chore: directory migration - gateway→server, web→client/electron
This commit is contained in:
@ -15,8 +15,8 @@ ETC=/etc/agentmail
|
||||
[[ $EUID -eq 0 ]] || { echo "需要 root:sudo $0" >&2; exit 1; }
|
||||
|
||||
echo "==> 构建前端"
|
||||
( cd "$REPO/web" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund )
|
||||
( cd "$REPO/web" && npm run typecheck && npm test && npm run build )
|
||||
( cd "$REPO/client/electron" && npm ci --no-audit --no-fund 2>/dev/null || npm install --no-audit --no-fund )
|
||||
( cd "$REPO/client/electron" && npm run typecheck && npm test && npm run build )
|
||||
|
||||
echo "==> 校验插件共用模块同源"
|
||||
# lib/ 下的纯函数模块在三个插件里逐字节相同(见 docs/PLUGIN-CONTRACT.md 第六节)。
|
||||
@ -34,7 +34,7 @@ fi
|
||||
# DSH 插件:先构建再跑测试。
|
||||
#
|
||||
# 它是 TypeScript 写的,package.json 的 main 指向 dist/index.js,而 dist/ 不进版本库
|
||||
# (与 web/dist 同理)—— 新克隆里不先 tsc,DSH 加载插件时会直接找不到入口。
|
||||
# (与 client/electron/dist 同理)—— 新克隆里不先 tsc,DSH 加载插件时会直接找不到入口。
|
||||
# 测试本身只碰 lib/ 下的纯函数(不依赖 dist),但先构建能把类型错误也当成门禁。
|
||||
#
|
||||
# 盖住的三类约定都是「错了不当场报错、只在深处炸一个无关错误」:
|
||||
@ -64,18 +64,18 @@ echo "==> 前端产物嵌入 Gateway"
|
||||
# 只清构建产物,不能 rm -rf 整个目录:
|
||||
# placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
|
||||
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库里带走了。
|
||||
rm -rf "$REPO/gateway/internal/static/static/assets"
|
||||
rm -f "$REPO/gateway/internal/static/static/index.html"
|
||||
cp -r "$REPO/web/dist/." "$REPO/gateway/internal/static/static/"
|
||||
rm -rf "$REPO/server/internal/static/static/assets"
|
||||
rm -f "$REPO/server/internal/static/static/index.html"
|
||||
cp -r "$REPO/client/electron/dist/." "$REPO/server/internal/static/static/"
|
||||
|
||||
echo "==> 构建 Gateway(单二进制,内含前端 + SQLite)"
|
||||
# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)
|
||||
rm -f "$REPO/gateway/agentmail-gateway"
|
||||
( cd "$REPO/gateway" && go vet ./... && go test ./... && go build -o "$REPO/gateway/agentmail-gateway" ./cmd/server )
|
||||
rm -f "$REPO/server/agentmail-gateway"
|
||||
( cd "$REPO/server" && go vet ./... && go test ./... && go build -o "$REPO/server/agentmail-gateway" ./cmd/server )
|
||||
|
||||
echo "==> 安装到 $PREFIX"
|
||||
install -d "$PREFIX" "$PREFIX/data" "$ETC"
|
||||
install -m 0755 "$REPO/gateway/agentmail-gateway" "$PREFIX/agentmail-gateway"
|
||||
install -m 0755 "$REPO/server/agentmail-gateway" "$PREFIX/agentmail-gateway"
|
||||
|
||||
# ---- env 文件:仅在缺失时生成,密码随机 ----
|
||||
if [[ ! -f "$ETC/gateway.env" ]]; then
|
||||
|
||||
@ -64,7 +64,7 @@ run() {
|
||||
eval "$@"
|
||||
}
|
||||
|
||||
[ -d "$REPO/gateway" ] || { bad "找不到 $REPO/gateway"; exit 2; }
|
||||
[ -d "$REPO/server" ] || { bad "找不到 $REPO/server"; exit 2; }
|
||||
command -v sqlite3 >/dev/null 2>&1 || { bad "缺少 sqlite3,无法安全备份数据库"; exit 2; }
|
||||
|
||||
say "0. 计划"
|
||||
@ -75,17 +75,17 @@ printf ' 服务 : %s\n' "$SERVICE"
|
||||
[ "$DRY_RUN" = 1 ] && printf ' 模式 : DRY-RUN(不落地)\n' || printf ' 模式 : 真实执行\n'
|
||||
|
||||
# ---------------------------------------------------------------- 1 前端产物
|
||||
if [ "$SYNC_WEB" = 1 ] && [ -d "$REPO/web/dist/assets" ]; then
|
||||
if [ "$SYNC_WEB" = 1 ] && [ -d "$REPO/client/electron/dist/assets" ]; then
|
||||
say "1. 同步前端产物进 go:embed 目录"
|
||||
# 只清构建产物:placeholder.html 在版本库里(让 go:embed 在新克隆里能编译),
|
||||
# 删掉它会让 git 看到一个本地删除,下一次 commit -a 就把它从仓库带走。
|
||||
run "rm -rf '$REPO/gateway/internal/static/static/assets'"
|
||||
run "rm -f '$REPO/gateway/internal/static/static/index.html'"
|
||||
run "cp -r '$REPO/web/dist/.' '$REPO/gateway/internal/static/static/'"
|
||||
run "rm -rf '$REPO/server/internal/static/static/assets'"
|
||||
run "rm -f '$REPO/server/internal/static/static/index.html'"
|
||||
run "cp -r '$REPO/client/electron/dist/.' '$REPO/server/internal/static/static/'"
|
||||
ok "前端产物已同步"
|
||||
else
|
||||
say "1. 跳过前端同步"
|
||||
[ "$SYNC_WEB" = 0 ] && ok "--skip-web" || warn "web/dist 不存在,先跑 cd web && npm run build"
|
||||
[ "$SYNC_WEB" = 0 ] && ok "--skip-web" || warn "client/electron/dist 不存在,先跑 cd client/electron && npm run build"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- 2 静态检查与测试
|
||||
@ -93,12 +93,12 @@ say "2. 构建前检查"
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
printf ' [dry-run] go vet ./... && go test ./...\n'
|
||||
else
|
||||
( cd "$REPO/gateway" && go vet ./... ) || { bad "go vet 不过,终止"; exit 1; }
|
||||
( cd "$REPO/server" && go vet ./... ) || { bad "go vet 不过,终止"; exit 1; }
|
||||
ok "go vet 通过"
|
||||
if [ "$SKIP_TESTS" = 1 ]; then
|
||||
warn "--skip-tests:跳过测试(急救模式,事后必须补跑)"
|
||||
else
|
||||
( cd "$REPO/gateway" && timeout 280 go test ./... -timeout 250s ) \
|
||||
( cd "$REPO/server" && timeout 280 go test ./... -timeout 250s ) \
|
||||
|| { bad "测试不过,终止部署"; exit 1; }
|
||||
ok "go test 全包通过"
|
||||
fi
|
||||
@ -110,9 +110,9 @@ STAGE="/tmp/agentmail-gateway-build-$TS"
|
||||
# 先删再建:go build -o 到已存在的路径时可能拿到 stale 二进制(此坑中过多次)
|
||||
run "rm -f '$STAGE'"
|
||||
if [ "$DRY_RUN" = 1 ]; then
|
||||
printf ' [dry-run] (cd gateway && go build -o %s ./cmd/server)\n' "$STAGE"
|
||||
printf ' [dry-run] (cd server && go build -o %s ./cmd/server)\n' "$STAGE"
|
||||
else
|
||||
( cd "$REPO/gateway" && go build -o "$STAGE" ./cmd/server ) \
|
||||
( cd "$REPO/server" && go build -o "$STAGE" ./cmd/server ) \
|
||||
|| { bad "构建失败"; exit 1; }
|
||||
ok "构建完成 $(du -h "$STAGE" | cut -f1)"
|
||||
fi
|
||||
|
||||
248
deploy/service-failure-notify.mjs
Normal file
248
deploy/service-failure-notify.mjs
Normal file
@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* systemd 服务异常退出邮件上报器。
|
||||
*
|
||||
* 两种调用:
|
||||
* service-failure-notify.mjs --report --service pi-mail-bridge.service
|
||||
* service-failure-notify.mjs --flush --service pi-mail-bridge.service
|
||||
*
|
||||
* `--report` 供 ExecStopPost 使用。systemd 会把 SERVICE_RESULT / EXIT_CODE /
|
||||
* EXIT_STATUS / INVOCATION_ID 注入命令环境;正常 stop/restart 不上报,只有
|
||||
* exit-code、signal、oom-kill、timeout、watchdog 等异常结果才发信。
|
||||
*
|
||||
* Gateway 暂时不可达时,报告只把**不含密钥**的邮件 payload 落到本地 spool;
|
||||
* 下次服务启动由 `--flush` 补发。relay_key 取 systemd invocation id,立即发送与
|
||||
* 补发即使竞态也只会入库一封。
|
||||
*/
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DEFAULT_GATEWAY = 'http://127.0.0.1:8180';
|
||||
const DEFAULT_RECIPIENT = 'jianf@';
|
||||
const DEFAULT_SPOOL = '/var/lib/agentmail/failure-spool';
|
||||
const MAX_SPOOL_FILES = 100;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
function argValue(argv, name) {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : '';
|
||||
}
|
||||
|
||||
export function isAbnormalExit(env = process.env) {
|
||||
const result = String(env.SERVICE_RESULT || '').trim();
|
||||
if (result) return result !== 'success';
|
||||
|
||||
const code = String(env.EXIT_CODE || '').trim();
|
||||
const status = String(env.EXIT_STATUS || '').trim();
|
||||
if (!code && !status) return false;
|
||||
return !(code === 'exited' && (status === '' || status === '0'));
|
||||
}
|
||||
|
||||
function cleanServiceName(value, agentName) {
|
||||
const raw = String(value || '').trim();
|
||||
if (raw) return basename(raw).replace(/[^A-Za-z0-9_.@-]/g, '-').slice(0, 96);
|
||||
return `${String(agentName || 'agent').replace(/[^A-Za-z0-9_.@-]/g, '-')}-bridge`;
|
||||
}
|
||||
|
||||
function redact(value, env) {
|
||||
let out = String(value || '');
|
||||
for (const secret of [env.AGENTMAIL_AGENT_KEY, env.AGENTMAIL_AGENT_SECRET]) {
|
||||
if (secret) out = out.split(String(secret)).join('[REDACTED]');
|
||||
}
|
||||
return out
|
||||
.replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]')
|
||||
.replace(/\b(?:ak_[A-Za-z0-9_-]+|sk-[A-Za-z0-9_-]{12,})\b/g, '[REDACTED]')
|
||||
.slice(0, 1_500);
|
||||
}
|
||||
|
||||
function relayKeyFor(env, serviceName) {
|
||||
const invocation = String(env.INVOCATION_ID || '').trim();
|
||||
if (invocation) return `service-failure:${invocation}`;
|
||||
const seed = `${serviceName}:${Date.now()}:${randomUUID()}`;
|
||||
return `service-failure:${createHash('sha256').update(seed).digest('hex')}`;
|
||||
}
|
||||
|
||||
export function buildFailurePayload({
|
||||
env = process.env,
|
||||
serviceName = '',
|
||||
reason = '',
|
||||
error = null,
|
||||
now = new Date(),
|
||||
} = {}) {
|
||||
const agentName = String(env.AGENTMAIL_AGENT_NAME || 'unknown-agent').trim();
|
||||
const service = cleanServiceName(serviceName || env.AGENTMAIL_SERVICE_NAME, agentName);
|
||||
const result = String(env.SERVICE_RESULT || reason || 'unexpected-exit');
|
||||
const exitCode = String(env.EXIT_CODE || 'unknown');
|
||||
const exitStatus = String(env.EXIT_STATUS || 'unknown');
|
||||
const detail = redact(error?.stack || error?.message || error || reason, env);
|
||||
|
||||
const lines = [
|
||||
`**${agentName}** 的宿主服务 **${service}** 异常终止。`,
|
||||
'',
|
||||
`- 时间:${now.toISOString()}`,
|
||||
`- systemd 结果:${result}`,
|
||||
`- 退出类型:${exitCode}`,
|
||||
`- 退出状态:${exitStatus}`,
|
||||
`- invocation:${String(env.INVOCATION_ID || 'unknown')}`,
|
||||
];
|
||||
if (detail) lines.push('', '**异常详情(已脱敏)**:', '```text', detail, '```');
|
||||
lines.push(
|
||||
'',
|
||||
'systemd 已按指数退避策略自动重启该服务;如果邮件连续出现,请检查:',
|
||||
`\`journalctl -u ${service} -n 100 --no-pager\``,
|
||||
);
|
||||
|
||||
return {
|
||||
to: String(env.AGENTMAIL_FAILURE_RECIPIENT || DEFAULT_RECIPIENT),
|
||||
subject: `[${agentName}] 桥服务异常终止`,
|
||||
body: lines.join('\n'),
|
||||
relay: 'summary',
|
||||
relay_key: relayKeyFor(env, service),
|
||||
};
|
||||
}
|
||||
|
||||
async function postPayload(payload, env = process.env) {
|
||||
const key = String(env.AGENTMAIL_AGENT_KEY || '').trim();
|
||||
if (!key) throw new Error('AGENTMAIL_AGENT_KEY 未配置');
|
||||
|
||||
const base = String(env.AGENTMAIL_GATEWAY_URL || DEFAULT_GATEWAY).replace(/\/+$/, '');
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/mail/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
'X-Agent-Name': String(env.AGENTMAIL_AGENT_NAME || ''),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const responseBody = await res.text();
|
||||
if (!res.ok) throw new Error(`Gateway HTTP ${res.status}: ${responseBody.slice(0, 300)}`);
|
||||
return responseBody;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function safeAgentName(env) {
|
||||
return String(env.AGENTMAIL_AGENT_NAME || 'unknown-agent')
|
||||
.replace(/[^A-Za-z0-9_.@-]/g, '-')
|
||||
.slice(0, 96);
|
||||
}
|
||||
|
||||
function spoolDirFor(env) {
|
||||
return join(String(env.AGENTMAIL_FAILURE_SPOOL || DEFAULT_SPOOL), safeAgentName(env));
|
||||
}
|
||||
|
||||
function safeSpoolName(payload) {
|
||||
return `${createHash('sha256').update(String(payload.relay_key)).digest('hex')}.json`;
|
||||
}
|
||||
|
||||
async function pruneSpool(spoolDir) {
|
||||
const names = (await readdir(spoolDir).catch(() => []))
|
||||
.filter((name) => name.endsWith('.json'));
|
||||
if (names.length <= MAX_SPOOL_FILES) return;
|
||||
|
||||
const entries = await Promise.all(names.map(async (name) => ({
|
||||
name,
|
||||
mtime: (await stat(join(spoolDir, name))).mtimeMs,
|
||||
})));
|
||||
entries.sort((a, b) => a.mtime - b.mtime);
|
||||
for (const entry of entries.slice(0, entries.length - MAX_SPOOL_FILES)) {
|
||||
await rm(join(spoolDir, entry.name), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function spoolPayload(payload, env = process.env) {
|
||||
// 每个 Agent 独立目录:否则 pi 启动时可能拿自己的密钥去补发 dsh 的报告,
|
||||
// 发件身份与正文主体会错位,relay_key 的幂等范围也变了。
|
||||
const spoolDir = spoolDirFor(env);
|
||||
await mkdir(spoolDir, { recursive: true, mode: 0o700 });
|
||||
const target = join(spoolDir, safeSpoolName(payload));
|
||||
const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
await writeFile(temp, `${JSON.stringify({ payload }, null, 2)}\n`, { mode: 0o600 });
|
||||
await rename(temp, target);
|
||||
await pruneSpool(spoolDir);
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function notifyCrash(reason, error, options = {}) {
|
||||
const env = options.env || process.env;
|
||||
const payload = buildFailurePayload({
|
||||
env,
|
||||
serviceName: options.serviceName,
|
||||
reason,
|
||||
error,
|
||||
now: options.now,
|
||||
});
|
||||
try {
|
||||
await postPayload(payload, env);
|
||||
return { sent: true, spooled: false, payload };
|
||||
} catch (sendError) {
|
||||
if (options.spool === false) throw sendError;
|
||||
const path = await spoolPayload(payload, env);
|
||||
return { sent: false, spooled: true, path, payload, error: sendError };
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushSpool(env = process.env) {
|
||||
const spoolDir = spoolDirFor(env);
|
||||
const names = (await readdir(spoolDir).catch(() => []))
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
.sort();
|
||||
let sent = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const name of names) {
|
||||
const path = join(spoolDir, name);
|
||||
try {
|
||||
const record = JSON.parse(await readFile(path, 'utf8'));
|
||||
await postPayload(record.payload, env);
|
||||
await rm(path, { force: true });
|
||||
sent++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
return { total: names.length, sent, failed };
|
||||
}
|
||||
|
||||
async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
const serviceName = argValue(argv, '--service');
|
||||
if (argv.includes('--flush')) {
|
||||
const result = await flushSpool(env);
|
||||
if (result.sent || result.failed) {
|
||||
console.error(`[agentmail-failure-notify] spool: sent=${result.sent} failed=${result.failed}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!argv.includes('--report')) throw new Error('需要 --report 或 --flush');
|
||||
if (!isAbnormalExit(env)) return;
|
||||
|
||||
if (argv.includes('--dry-run')) {
|
||||
console.log(JSON.stringify(buildFailurePayload({ env, serviceName }), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await notifyCrash(env.SERVICE_RESULT || 'unexpected-exit', null, {
|
||||
env,
|
||||
serviceName,
|
||||
});
|
||||
console.error(result.sent
|
||||
? `[agentmail-failure-notify] ${serviceName} 异常已上报`
|
||||
: `[agentmail-failure-notify] Gateway 不可达,报告已暂存 ${result.path}`);
|
||||
}
|
||||
|
||||
const isCLI = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCLI) {
|
||||
main().catch((error) => {
|
||||
console.error(`[agentmail-failure-notify] ${error?.message || error}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user