pi 读了 `5bc579f` 之后报了两条新的 + 三条小的,全部认下并落地。
## 一、"开关真的被认"那条判据在 /tmp 被清空后失去分辨力
上一版只在"真实测量不足"那个分支里断言(注入大数必须放行)。问题是:
**"不足"正是机器恢复健康后会消失的条件** —— 那天这条判据就退化成"只验
`--measure` 可用"的弱检查,而它守的恰恰是"开关别静默失效"。
两个方向是对偶的、各守一个机器状态,所以改成**按实测分叉、在两个分支里断言相反的方向**:
真实不足 ⇒ 注入大数必须放行 (开关被忽略则回退测量 ⇒ 2 ≠ 0 ⇒ 红)
真实充足 ⇒ 注入 0 必须 exit 2(开关被忽略则回退测量 ⇒ 0 ≠ 2 ⇒ 红)
量不到就 `assert.fail` 并说明"无法分叉"—— 不静默跳过(跳过会把"失去分辨力"
伪装成"验过了")。另把"端到端"那条的两个方向拆明白:只验"不足⇒2"时,
一个恒报不足的坏守卫也能绿。
## 二、"一处覆盖全部写点"成立的前提是"从 main() 进来"
`selfCheck()` 是**导出**的(用途就是被直接调),而兜住那三处裸写的 catch 在
`main()` 里 ⇒ 任何绕过 `main()` 的调用者撞上 ENOSPC 拿到的仍是原始英文堆栈。
**"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根**,所以把整段包一层
(`body()` + 统一 catch):与入口无关,`main()` 那个退化为冗余的第二道。
实测:`TMPDIR=/tmp node -e 'import("./deploy/check-deploy-drift.mjs").then(m=>m.selfCheck())'`
现在拿到的是「环境不足…这是环境问题,不是检查器的问题」。
## 三、`--inject-avail=abc` 静默放行(笔误 = 跳过守卫)
`Number('abc')` = NaN ⇒ 判据当"没测到" ⇒ 放行。现在按仓库约定处理:
**非法值 exit 2,未知参数也 exit 2**(`--measure` 少写 `=` 同样炸)。
`null` 仍是合法值("没测到 ⇒ 放行"是有意的),加了判据把这两个方向都钉住。
## 四、三条小的
- 两份实现(`lib/env-error.mjs` 的 `translateEnvError` 与 `deploy/` 的
`describeEnvError`)**不去重**,但两边各写一句"为什么不复用":
`deploy/` 的独立性比去重值钱(那份文件头整段在讲"服务不该依赖仓库是否存在")。
并写明**第三份拷贝出现时再考虑共用**。
- 写点计数口径写进注释:本函数 **6 处写** = `mkdtempSync`×2 + `mk()` 内 ×2
+ 三处裸写。免得与别处"五处"的说法对不上(上一封信里两个实测数字就是这么被误读的)。
- 变异自检的纪律补进 `lib/env-error.mjs` 头注释:**先证明能撤回来再注入变异,
且还原路径不能依赖被测对象**(那次把备份写进 `/tmp` —— 正是当时被占满的资源,
备份没写成而变异已覆盖源文件)。现在只对"已在 HEAD 干净提交"的文件做变异,
还原一律 `git checkout HEAD -- <file>`。
验证:`npm test` **475/475**;`--self-check` 18 条全过;
`TMPDIR=/tmp node deploy/check-deploy-drift.mjs --self-check` ⇒ exit 2 + 人话。
726 lines
34 KiB
JavaScript
726 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 部署漂移检查:**已部署的快照 vs 仓库 HEAD**,以及进程到底跑的是哪份代码。
|
||
*
|
||
* # 为什么需要它
|
||
*
|
||
* 「部署脚本跑过了」不等于「线上跑的是当前代码」。有几种各自独立的漂移方式,
|
||
* 而且都不会有人在意 —— 直到出问题时才发现线上是几天前的行为:
|
||
*
|
||
* 1. **仓库改了、快照没重新部署**。快照是独立副本,改仓库不会影响它。
|
||
* 2. **软链切换了、进程没重启**。`current` 只是个符号链接,切换它**不会**
|
||
* 重载已经在跑的进程 —— 进程持有的是启动那一刻加载进内存的代码。
|
||
* 3. **单元/配置改了、没 daemon-reload 或没重装**,于是进程仍按旧路径加载。
|
||
*
|
||
* # 判据必须按宿主真实的加载方式分开写(这一条是踩出来的)
|
||
*
|
||
* 第一版对四个宿主都用了同一套判据:「单元 ExecStart 指向 current」+
|
||
* 「进程 argv 里有快照路径」。结果 **opencode 与 dsh 双双假红** —— 它们根本
|
||
* 不是「自己起一个进程」那种宿主:
|
||
*
|
||
* | 宿主 | 谁加载插件 | 从哪里读 | 切换后需要重启吗 |
|
||
* |---|---|---|---|
|
||
* | pi | 自己的进程 | systemd unit 的 ExecStart | 需要(启动时加载) |
|
||
* | zcode | 自己的驱动进程 | systemd unit 的 ExecStart | 需要(启动时加载) |
|
||
* | dsh | dsh 宿主进程 | profile 的 `link:` → `node_modules` 软链 | 需要(服务启动时加载) |
|
||
* | opencode | opencode 宿主进程 | `opencode.jsonc` 的 `plugin:` | 不需要(会话创建时惰加载) |
|
||
*
|
||
* 用「进程 argv 里有快照路径」去量 opencode/dsh,永远为假 —— 它们的 argv 里
|
||
* 只有自己的可执行文件。**判据必须与它量的对象同一维度**,否则就是一条永假条件,
|
||
* 而永假条件在检查器里表现为「稳定的红灯」,人会学会忽略它。
|
||
*
|
||
* # 为什么不用 diff
|
||
*
|
||
* 本机 PATH 上的 `diff` 是鸿蒙 SDK 工具链里的那个:不认 `-q`,而且对
|
||
* **内容不同的文件仍然返回 0**(`deploy/check-shared-libs.sh` 的头注释记了这件事)。
|
||
* 所以这里一律自己算 sha256 比对。
|
||
*
|
||
* # 用法
|
||
*
|
||
* node deploy/check-deploy-drift.mjs # 人类可读
|
||
* node deploy/check-deploy-drift.mjs --json # 机器可读
|
||
* node deploy/check-deploy-drift.mjs --self-check # 先证明判据本身能发现差异
|
||
*
|
||
* 退出码:0 无运行文件漂移;1 有漂移(或判据自检失败)。
|
||
*/
|
||
|
||
import { createHash } from 'node:crypto';
|
||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||
import { join, relative, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { tmpdir } from 'node:os';
|
||
import { execFileSync } from 'node:child_process';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const REPO = join(HERE, '..');
|
||
const DEPLOY_ROOT = '/opt/agentmail/plugins';
|
||
|
||
/** 与 deploy/redeploy-plugin.sh 的排除清单**逐条对齐**。
|
||
* 对不齐就会出现「脚本说一致、部署脚本却拷了别的」这种假绿。 */
|
||
const EXCLUDE_DIRS = new Set(['test', '.git', 'node_modules', 'coverage']);
|
||
const EXCLUDE_FILES = new Set(['.DS_Store']);
|
||
const EXCLUDE_SUFFIX = ['.log'];
|
||
|
||
/**
|
||
* 宿主表。`load` 决定用哪套判据(见文件头那张表)。
|
||
*
|
||
* - `own-process`:插件自己是个进程,argv 里应当有快照路径
|
||
* - `host-package`:宿主经 node_modules 解析插件;服务启动时加载 ⇒ 切换后必须重启
|
||
* - `host-config`:宿主从配置文件读插件路径;会话创建时惰加载 ⇒ 不强制重启
|
||
*/
|
||
const HOSTS = [
|
||
{ host: 'pi', plugin: 'pi-mail-bridge', unit: 'pi-mail-bridge.service', load: 'own-process' },
|
||
{ host: 'zcode', plugin: 'zcode-mail-bridge', unit: 'zcode-mail-bridge.service', load: 'own-process' },
|
||
{
|
||
host: 'dsh',
|
||
plugin: 'dsh-mail-bridge',
|
||
unit: 'dsh.service',
|
||
load: 'host-package',
|
||
configFile: '/root/.dsh/profiles/web/package.json',
|
||
configNeedle: `link:/opt/agentmail/plugins/dsh-mail-bridge/current`,
|
||
// 真正被 Node 解析的那条:profile 的 node_modules 软链
|
||
resolvePath: '/root/.dsh/profiles/web/node_modules/dsh-mail-bridge'
|
||
},
|
||
{
|
||
host: 'opencode',
|
||
plugin: 'opencode-mail-bridge',
|
||
unit: 'opencode-serve.service',
|
||
load: 'host-config',
|
||
configFile: '/root/.config/opencode/opencode.jsonc',
|
||
configNeedle: 'file:///opt/agentmail/plugins/opencode-mail-bridge/current'
|
||
}
|
||
];
|
||
|
||
/** 文档类扩展名:它们的差异是「快照里的说明比仓库旧」,不影响运行行为。 */
|
||
const DOC_EXT = /\.(md|txt)$/i;
|
||
|
||
export function collectFiles(root) {
|
||
const out = new Map();
|
||
const walk = dir => {
|
||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||
const full = join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
||
walk(full);
|
||
} else if (entry.isFile()) {
|
||
if (EXCLUDE_FILES.has(entry.name)) continue;
|
||
if (EXCLUDE_SUFFIX.some(s => entry.name.endsWith(s))) continue;
|
||
const rel = relative(root, full);
|
||
if (rel.split('/').includes('.cache')) continue;
|
||
out.set(rel, createHash('sha256').update(readFileSync(full)).digest('hex'));
|
||
}
|
||
}
|
||
};
|
||
walk(root);
|
||
return out;
|
||
}
|
||
|
||
/** 比较两棵树,返回分类后的差异。导出是为了让自检能直接调它。 */
|
||
export function diffTrees(repoFiles, snapFiles) {
|
||
const onlyRepo = [...repoFiles.keys()].filter(k => !snapFiles.has(k)).sort();
|
||
const onlySnap = [...snapFiles.keys()].filter(k => !repoFiles.has(k)).sort();
|
||
const changed = [...repoFiles.keys()]
|
||
.filter(k => snapFiles.has(k) && repoFiles.get(k) !== snapFiles.get(k))
|
||
.sort();
|
||
const classify = list =>
|
||
list.reduce((acc, k) => (acc[DOC_EXT.test(k) ? 'doc' : 'runtime'].push(k), acc), { doc: [], runtime: [] });
|
||
const a = classify(onlyRepo);
|
||
const b = classify(onlySnap);
|
||
const c = classify(changed);
|
||
return {
|
||
onlyRepo: { doc: a.doc, runtime: a.runtime },
|
||
onlySnap: { doc: b.doc, runtime: b.runtime },
|
||
changed: { doc: c.doc, runtime: c.runtime }
|
||
};
|
||
}
|
||
|
||
export function diffSummary(d) {
|
||
return {
|
||
runtimeDrift: d.onlyRepo.runtime.length + d.onlySnap.runtime.length + d.changed.runtime.length,
|
||
docDrift: d.onlyRepo.doc.length + d.onlySnap.doc.length + d.changed.doc.length
|
||
};
|
||
}
|
||
|
||
function processArgv(pid) {
|
||
try {
|
||
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean).join(' ');
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function unitMainPid(unit) {
|
||
try {
|
||
return execFileSync('systemctl', ['show', '-p', 'MainPID', '--value', unit], { encoding: 'utf8' }).trim();
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function unitExecStart(unit) {
|
||
try {
|
||
const v = execFileSync('systemctl', ['show', '-p', 'ExecStart', '--value', unit], { encoding: 'utf8' });
|
||
const m = v.match(/argv\[\]=([^;]+)/);
|
||
return m ? m[1] : v.trim();
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/** 进程启动时刻(墙钟,ms)。
|
||
*
|
||
* 用 `/proc/<pid>/stat` 的第 22 字段(自开机起的时钟滴答)+ `/proc/stat` 的 `btime`
|
||
* 换算,而不是 `/proc/<pid>` 目录的 mtime —— 后者只是个碰巧相近的代理,
|
||
* 它的语义是「这个目录最后一次变动」,不是「进程何时启动」。
|
||
*/
|
||
export function procStartMs(pid) {
|
||
try {
|
||
const fields = readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ');
|
||
const startTicks = Number(fields[21]);
|
||
const hz = Number(execFileSync('getconf', ['CLK_TCK'], { encoding: 'utf8' }).trim()) || 100;
|
||
const btime = Number(
|
||
readFileSync('/proc/stat', 'utf8')
|
||
.split('\n')
|
||
.find(l => l.startsWith('btime '))
|
||
.split(' ')[1]
|
||
);
|
||
if (!Number.isFinite(startTicks) || !Number.isFinite(btime)) throw new Error('字段解析失败');
|
||
return (btime + startTicks / hz) * 1000;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 判据 ④:进程是不是比快照还旧(= 线上跑的还是旧代码)。
|
||
*
|
||
* 抽成纯函数是因为**第一版写错过**,而且自检没接住:`restartOnSwitch` 只有
|
||
* dsh 显式设了 `true`,pi/zcode 是 `undefined` ⇒ 落进「惰加载」分支 ⇒ **永真**。
|
||
* 当时自检用的是 `h.restartOnSwitch !== false`,`undefined` 也被放过。
|
||
* 下面改成行为用例:自带一个「旧进程 + 自身进程宿主」必须判红。
|
||
*
|
||
* @param {object} o
|
||
* @param {string} o.load 宿主加载方式(见文件头的表)
|
||
* @param {number} o.startedAt 进程启动时刻(ms,0 = 读不到)
|
||
* @param {number} o.switchAt 软链切换时刻(ms)
|
||
* @param {number} [o.toleranceMs] 容差:同一次部署里先切后重启,两者相差一两秒
|
||
*/
|
||
export function judgeRestart({ load, startedAt, switchAt, toleranceMs = 2000 }) {
|
||
if (!startedAt) {
|
||
// 读不到就不要据此判红:不知道不能当成「不对」
|
||
return { ok: true, note: '读不到进程启动时刻(不据此判定)' };
|
||
}
|
||
if (startedAt >= switchAt - toleranceMs) return { ok: true, note: '切换之后才启动' };
|
||
// 惰加载的宿主(opencode:会话创建时才读配置)不强制重启;
|
||
// 其余宿主在启动时就加载了插件,软链切换不会重载它 ⇒ 必须重启。
|
||
if (load === 'host-config') {
|
||
return { ok: true, note: '惰加载:会话创建时才读配置,不强制重启' };
|
||
}
|
||
return {
|
||
ok: false,
|
||
note:
|
||
`进程启动于 ${new Date(startedAt).toISOString()},切换发生在 ${new Date(switchAt).toISOString()} —— ` +
|
||
'软链切换不会重载已在跑的进程,需要重启该服务'
|
||
};
|
||
}
|
||
|
||
function checkHost(spec) {
|
||
const { host, plugin, unit, load } = spec;
|
||
const repoDir = join(REPO, 'plugins', plugin);
|
||
const linkPath = join(DEPLOY_ROOT, plugin, 'current');
|
||
const result = { host, plugin, unit, load, checks: [], stale: false };
|
||
const fail = (name, note) => {
|
||
result.checks.push({ name, ok: false, note });
|
||
result.stale = true;
|
||
};
|
||
const pass = (name, note = '') => result.checks.push({ name, ok: true, note });
|
||
|
||
if (!existsSync(repoDir)) return fail('仓库插件目录存在', repoDir), result;
|
||
if (!existsSync(linkPath)) return fail('已部署(current 存在)', `未部署:${linkPath}`), result;
|
||
|
||
// ① 内容:快照 vs **仓库工作区当前内容**(四个宿主同一套)
|
||
//
|
||
// ★ 比的是工作区**当前**内容,不是 git HEAD —— 仓库脏(未提交的改动)时
|
||
// 这条判据照样会绿。实证(2026-09-14):`pool.mjs` 里有一行未提交的
|
||
// `let missingSessionCount = 0;`(模块顶层、无人读),被 17:20 那次
|
||
// 「从脏工作区做的」快照原样带进了生产,而这条判据当时报的是「逐字节一致」
|
||
// —— 工作区与快照确实一致,只是两者都不等于 HEAD。判据没错,是它的**量纲**
|
||
// 只覆盖「仓库→快照」这一跳,覆盖不到「HEAD→工作区」那一跳。
|
||
// 想连那一跳一起判,得单独比 `git diff --stat`,别指望这一条。
|
||
const d = diffTrees(collectFiles(repoDir), collectFiles(linkPath));
|
||
const { runtimeDrift, docDrift } = diffSummary(d);
|
||
if (runtimeDrift === 0) {
|
||
pass('① 运行文件与仓库一致', docDrift ? `一致(另有 ${docDrift} 个文档差异,不影响运行)` : '逐字节一致');
|
||
} else {
|
||
fail(
|
||
'① 运行文件与仓库一致',
|
||
`漂移 ${runtimeDrift} 处:${[
|
||
...d.onlyRepo.runtime.map(f => `仓库独有 ${f}`),
|
||
...d.onlySnap.runtime.map(f => `快照独有 ${f}`),
|
||
...d.changed.runtime.map(f => `内容不同 ${f}`)
|
||
]
|
||
.slice(0, 5)
|
||
.join(';')}`
|
||
);
|
||
}
|
||
|
||
// ② 加载路径指向快照 —— **按宿主真实的加载方式**
|
||
if (load === 'own-process') {
|
||
const execStart = unitExecStart(unit);
|
||
const atSnapshot = execStart.includes(`/opt/agentmail/plugins/${plugin}/current`);
|
||
const atRepo = execStart.includes(`/home/program/agentmail/plugins/${plugin}`);
|
||
if (!execStart) fail('② 单元入口指向快照', `读不到 ${unit} 的 ExecStart`);
|
||
else if (atRepo) fail('② 单元入口指向快照', `仍指向仓库工作区:${execStart}`);
|
||
else if (atSnapshot) pass('② 单元入口指向快照', '指向 current');
|
||
else fail('② 单元入口指向快照', `指向别处:${execStart}`);
|
||
} else {
|
||
// host-package / host-config:查配置文件里那条引用,并尽量查它**实际解析到哪**
|
||
const cfg = spec.configFile;
|
||
let cfgText = '';
|
||
try {
|
||
cfgText = readFileSync(cfg, 'utf8');
|
||
} catch (e) {
|
||
fail('② 宿主配置指向快照', `读不到 ${cfg}:${e?.message || e}`);
|
||
}
|
||
if (cfgText) {
|
||
if (cfgText.includes(spec.configNeedle)) pass('② 宿主配置指向快照', `${cfg} 含 current`);
|
||
else fail('② 宿主配置指向快照', `${cfg} 里没有 ${spec.configNeedle}`);
|
||
}
|
||
if (spec.resolvePath) {
|
||
// 真正决定加载哪份代码的是这条软链(Node 按 node_modules 解析)
|
||
try {
|
||
const real = realpathSync(spec.resolvePath);
|
||
if (real.startsWith(`/opt/agentmail/plugins/${plugin}/`)) {
|
||
pass('② 实际解析到快照', `${real.split('/').slice(-2).join('/')}`);
|
||
} else {
|
||
fail('② 实际解析到快照', `${spec.resolvePath} → ${real}`);
|
||
}
|
||
} catch (e) {
|
||
fail('② 实际解析到快照', `${spec.resolvePath} 不可用:${e?.message || e}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ③ 进程身份(own-process 才看 argv;别的宿主 argv 里本来就没有插件路径)
|
||
const pid = unitMainPid(unit);
|
||
if (!pid || pid === '0') {
|
||
fail('③ 宿主进程在跑', `${unit} 没有主进程(未运行?)`);
|
||
return result;
|
||
}
|
||
if (load === 'own-process') {
|
||
const argv = processArgv(pid);
|
||
if (argv.includes(`/opt/agentmail/plugins/${plugin}/current`)) pass('③ 进程在跑快照里的代码', `pid=${pid}`);
|
||
else fail('③ 进程在跑快照里的代码', `pid=${pid} 的 argv 里没有快照路径:${argv.slice(0, 90)}`);
|
||
} else {
|
||
pass('③ 宿主进程在跑', `pid=${pid}(插件由宿主加载,argv 里本就没有插件路径)`);
|
||
}
|
||
|
||
// ④ 进程启动时刻 vs 软链切换时刻。
|
||
// 软链切换**不会**重载已在跑的进程 ⇒ 对「启动时加载」的宿主,这一条是硬判据。
|
||
// 判定逻辑在纯函数 judgeRestart 里(它被自检的行为用例盖住)。
|
||
const switchAt = lstatSync(linkPath).mtimeMs;
|
||
const startedAt = procStartMs(pid);
|
||
const j = judgeRestart({ load, startedAt, switchAt });
|
||
result.checks.push({ name: '④ 进程启动不早于快照切换', ok: j.ok, note: j.note });
|
||
if (!j.ok) result.stale = true;
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 把「临时目录写不进去」翻译成人看得懂的话。
|
||
*
|
||
* ★ 为什么要有这个函数(不是为了好看,是为了**覆盖全部写点**):
|
||
*
|
||
* `selfCheck()` 里要在临时目录造两棵小树,写点有**五处**(`mk()` 里两处、
|
||
* 后面 `README.md`/`extra.mjs`/`test/t.mjs` 三处)。第一版只在 `mk()` 里包了
|
||
* try/catch —— 于是后面那几处撞上 ENOSPC 时,异常冒到 `main()` 的 catch,
|
||
* 退出码是对的(2),但打印的是**原始英文 `ENOSPC: no space left on device, write`
|
||
* 加一段指向本文件的堆栈** —— 也就是"看起来像检查器坏了"这个信号,**恰恰在
|
||
* 最需要它的那些路径上还在**。集中在这里换一次,以后再加写点也不用管。
|
||
*/
|
||
function describeEnvError(e, what) {
|
||
const isEnospc = e && (e.code === 'ENOSPC' || /no space left on device/i.test(String(e.message)));
|
||
if (!isEnospc) return e;
|
||
const err = new Error(
|
||
`环境不足:临时目录 ${tmpdir()} 写不进去(ENOSPC)—— ${what}。` +
|
||
'这是环境问题,不是检查器的问题。药方:TMPDIR=<有空间的目录> 后再跑。'
|
||
);
|
||
err.code = 'ENOSPC';
|
||
err.cause = e;
|
||
return err;
|
||
}
|
||
|
||
/**
|
||
* 判据自检:**先证明这个检查器能发现差异**,再用它下结论。
|
||
* 一个永远说「一致」的比较器看起来同样令人放心。
|
||
*
|
||
* ★ **本函数自己保证"环境不足说人话",与从哪个入口调它无关。**
|
||
*
|
||
* 上一版把"覆盖全部写点"那一层放在 `main()` 的 catch 里 —— 但本函数是**导出的**
|
||
* (导出的用途就是被别人直接调,例如 `--self-check` 之外的自检脚本)。任何绕过
|
||
* `main()` 的调用者,在那几处裸写撞上 ENOSPC 时拿到的仍是原始英文 + 本文件堆栈。
|
||
* pi 评审时指出:**"一处覆盖全部写点"成立的前提是"从 main() 进来"** ——
|
||
* 而"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根。
|
||
*
|
||
* 所以这里把整段包一层(`body()` + 统一 catch),主进程那个 catch 退化为
|
||
* 冗余的第二道 —— 冗余是故意的。
|
||
*
|
||
* 为什么不去用 `plugins/pi-mail-bridge/lib/env-error.mjs` 的 `translateEnvError`
|
||
* (判据相同、措辞不同,看起来该合并):**`deploy/` 的独立性比去重值钱**。
|
||
* 本文件头整段就在讲"服务不该依赖仓库是否存在",`deploy/` 下的工具同理 ——
|
||
* 让它 import 插件目录里的模块,等于把部署工具绑死在插件的目录结构上。
|
||
* 判据(`code === 'ENOSPC'` / 消息里含 no space left)是稳定的,措辞各自合适即可。
|
||
* **若哪天出现第三份拷贝,再考虑共用** —— 两份还撑得住。
|
||
*
|
||
* 写点计数口径(免得与别处的说法对不上):本函数共 **6 处写**
|
||
* = `mkdtempSync` ×2 + `mk()` 内 `writeFileSync` ×2 + 三处裸写
|
||
* (`b/README.md`、`b/lib/extra.mjs`、`a/test/t.mjs`)。
|
||
*/
|
||
export function selfCheck() {
|
||
try {
|
||
return body();
|
||
} catch (e) {
|
||
throw describeEnvError(e, '判据自检要在临时目录里造样本树');
|
||
}
|
||
|
||
function body() {
|
||
// `mkdtempSync` 本身就是一个写点:临时目录满的时候(2026-09-14 实测 `bavail`
|
||
// 一度真是 0)它会抛 ENOSPC。上一版它在 try 之外,于是那条路径连退出码 2 都拿不到。
|
||
const a = mkdtempSync(join(tmpdir(), 'drift-a-'));
|
||
const b = mkdtempSync(join(tmpdir(), 'drift-b-'));
|
||
const mk = (root, content) => {
|
||
mkdirSync(join(root, 'lib'), { recursive: true });
|
||
// ★ 自检要在临时目录里造两棵小树。临时目录满了时这里会抛 ENOSPC ——
|
||
// 而它是一个**未捕获的异常**,堆栈指向本文件的 `mk()`,看起来像检查器自己坏了。
|
||
// 真相是环境不足。翻译成说得清的错,交给 main() 报 2(环境问题)而不是崩栈。
|
||
try {
|
||
writeFileSync(join(root, 'lib', 'x.mjs'), content);
|
||
writeFileSync(join(root, 'README.md'), 'doc');
|
||
} catch (e) {
|
||
// 交给最外层那个统一 catch(见函数头注释)——**不要**在这里单独翻译,
|
||
// 否则"覆盖范围"又变成"取决于哪一处写",就是这套 bug 的病根。
|
||
throw e;
|
||
}
|
||
};
|
||
const out = [];
|
||
try {
|
||
mk(a, 'same');
|
||
mk(b, 'same');
|
||
const same = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
|
||
out.push({ name: '相同的树判为一致', ok: same.runtimeDrift === 0 && same.docDrift === 0 });
|
||
|
||
mk(b, 'DIFFERENT');
|
||
const diff = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
|
||
out.push({ name: '内容不同必须被发现', ok: diff.runtimeDrift === 1 });
|
||
|
||
mk(b, 'same');
|
||
writeFileSync(join(b, 'README.md'), 'doc-changed');
|
||
const docOnly = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
|
||
out.push({ name: '文档差异不算运行漂移', ok: docOnly.runtimeDrift === 0 && docOnly.docDrift === 1 });
|
||
|
||
writeFileSync(join(b, 'lib', 'extra.mjs'), 'x');
|
||
const extra = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
|
||
out.push({ name: '快照多出运行文件必须被发现', ok: extra.runtimeDrift === 1 });
|
||
|
||
mkdirSync(join(a, 'test'), { recursive: true });
|
||
writeFileSync(join(a, 'test', 't.mjs'), 'only-in-repo');
|
||
const withTest = diffSummary(diffTrees(collectFiles(a), collectFiles(b)));
|
||
out.push({ name: 'test/ 不参与比较(与部署脚本一致)', ok: withTest.runtimeDrift === 1 });
|
||
|
||
// 判据 ④ 的行为用例。
|
||
//
|
||
// 这一组来自一次**自检没接住的真错**:`restartOnSwitch` 只有 dsh 显式设了 true,
|
||
// pi/zcode 是 undefined ⇒ 落进「惰加载」分支 ⇒ 判据永真;
|
||
// 而当时自检写的是 `h.restartOnSwitch !== false`,undefined 也被放过了。
|
||
// 所以现在不查字段,直接查**判定结果**。
|
||
const SWITCH = 1_000_000_000_000;
|
||
const older = SWITCH - 60_000; // 进程比切换早一分钟
|
||
const newer = SWITCH + 60_000; // 进程比切换晚一分钟
|
||
out.push({
|
||
name: '④ 启动时加载的宿主:旧进程必须判红',
|
||
ok:
|
||
judgeRestart({ load: 'own-process', startedAt: older, switchAt: SWITCH }).ok === false &&
|
||
judgeRestart({ load: 'host-package', startedAt: older, switchAt: SWITCH }).ok === false
|
||
});
|
||
out.push({
|
||
name: '④ 惰加载的宿主:旧进程不判红(但也不是「已验过」)',
|
||
ok: judgeRestart({ load: 'host-config', startedAt: older, switchAt: SWITCH }).ok === true
|
||
});
|
||
out.push({
|
||
name: '④ 切换之后才启动的一律放行',
|
||
ok: ['own-process', 'host-package', 'host-config'].every(
|
||
l => judgeRestart({ load: l, startedAt: newer, switchAt: SWITCH }).ok === true
|
||
)
|
||
});
|
||
out.push({
|
||
name: '④ 读不到启动时刻时不据此判红',
|
||
ok: judgeRestart({ load: 'own-process', startedAt: 0, switchAt: SWITCH }).ok === true
|
||
});
|
||
out.push({
|
||
name: '④ 容差内(同一次部署先切后重启)不判红',
|
||
ok: judgeRestart({ load: 'own-process', startedAt: SWITCH - 1000, switchAt: SWITCH }).ok === true
|
||
});
|
||
|
||
// 宿主表本身的自检:每个宿主的判据必须落在它能观测到的地方。
|
||
const badSpec = HOSTS.filter(h => h.load !== 'own-process' && !h.configNeedle);
|
||
out.push({ name: '宿主表:非自有进程的宿主必须给出配置判据', ok: badSpec.length === 0 });
|
||
const badOwn = HOSTS.filter(h => h.load === 'own-process' && h.configNeedle);
|
||
out.push({ name: '宿主表:自有进程的宿主不该用配置判据', ok: badOwn.length === 0 });
|
||
} finally {
|
||
rmSync(a, { recursive: true, force: true });
|
||
rmSync(b, { recursive: true, force: true });
|
||
}
|
||
return out;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 标准目录部署检查(2026-09-14)。
|
||
*
|
||
* 用户注意到:「当前 agentmail 是在源码目录部署的,应当改为标准目录部署」。
|
||
* 当时有三处实证:① 失败通知钩子执行的是**仓库里**的脚本(仓库一挪,故障通知静默
|
||
* 失效);② opencode 服务的 cwd 就是源码目录;③ 仓库里的 `deploy/*.service` 是旧的
|
||
* 源码目录版本,而机器上的已被改过 —— 谁跑一次 install.sh 就把部署退回源码目录。
|
||
*
|
||
* 现在:单元与 drop-in 的唯一真相是 `deploy/systemd/`(镜像 systemd 目录结构),
|
||
* 运行时脚本装在 `/opt/agentmail/bin/`,服务不依赖仓库是否存在。
|
||
*
|
||
* @param {object} [inject] 注入点(判据自检时喂假文件系统)
|
||
*/
|
||
export function checkLayout(inject = {}) {
|
||
const readdir = inject.readdir ?? readdirSync;
|
||
const readFile = inject.readFile ?? readFileSync;
|
||
const exists = inject.exists ?? existsSync;
|
||
const stat = inject.stat ?? statSync;
|
||
const out = [];
|
||
// 每条检查带一个**显式 id**。原先只有第 ⑥ 条自带编号前缀,于是读者会去找
|
||
// 一个不存在的第 ⑤ 条 —— 编号要么全有、要么全无,没有第三条路。
|
||
// 现在编号只出现在 id 里,`name` 是纯人名的展示串。
|
||
let checkSeq = 0;
|
||
const push = (name, ok, note = '') => out.push({ id: String(++checkSeq), name, ok, note });
|
||
|
||
const SYS = '/etc/systemd/system';
|
||
const REPO = '/home/program/agentmail';
|
||
// 自有进程必须住在安装根下;其余宿主有它们自己的标准位置(不是本项目的源码目录)
|
||
const HOST_ALLOW = {
|
||
'homeagent.service': '/home/newqqagent',
|
||
'dsh.service': '/usr/bin/dsh',
|
||
'zcode.service': '/opt/ZCode'
|
||
};
|
||
|
||
// ① 任何 unit/drop-in 都不得引用源码目录
|
||
const offenders = [];
|
||
const walk = dir => {
|
||
let entries = [];
|
||
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
|
||
for (const e of entries) {
|
||
const full = `${dir}/${e.name}`;
|
||
if (e.isDirectory()) walk(full);
|
||
else if (/\.(conf|service|timer)$/.test(e.name) && !e.name.includes('.bak')) {
|
||
let text = '';
|
||
try { text = String(readFile(full, 'utf8')); } catch { continue; }
|
||
if (text.includes(REPO)) offenders.push(full);
|
||
}
|
||
}
|
||
};
|
||
walk(SYS);
|
||
push('没有任何 unit/drop-in 引用源码目录', offenders.length === 0, offenders.join(' '));
|
||
|
||
// ② 已安装单元与仓库副本一致(仓库是唯一真相)
|
||
const drift = [];
|
||
const repoUnits = inject.repoUnits ?? new URL('../systemd', import.meta.url).pathname.replace(/\/$/, '');
|
||
const compare = dir => {
|
||
let entries = [];
|
||
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
|
||
for (const e of entries) {
|
||
const full = `${dir}/${e.name}`;
|
||
if (e.isDirectory()) compare(full);
|
||
else {
|
||
const rel = full.slice(repoUnits.length + 1);
|
||
const live = `${SYS}/${rel}`;
|
||
if (!exists(live)) { drift.push(`${rel}(缺)`); continue; }
|
||
let a = '', b = '';
|
||
try { a = String(readFile(full, 'utf8')); b = String(readFile(live, 'utf8')); } catch { continue; }
|
||
if (a !== b) drift.push(rel);
|
||
}
|
||
}
|
||
};
|
||
compare(repoUnits);
|
||
push('已安装单元与 deploy/systemd/ 一致', drift.length === 0, drift.join(' '));
|
||
|
||
// ③ 通知脚本在标准位置且可执行
|
||
const script = '/opt/agentmail/bin/service-failure-notify.mjs';
|
||
let scriptOk = false;
|
||
let note = script;
|
||
try {
|
||
const st = stat(script);
|
||
scriptOk = st.isFile() && (st.mode & 0o111) !== 0;
|
||
if (!scriptOk) note = `${script} 缺执行位`;
|
||
} catch { note = `${script} 不存在`; }
|
||
push('故障通知脚本装在 /opt/agentmail/bin/ 且可执行', scriptOk, note);
|
||
|
||
// ④ 自有服务的 cwd / ExecStart 不得落在源码目录
|
||
const badHosts = [];
|
||
for (const unit of ['agentmail-gateway.service', 'pi-mail-bridge.service', 'opencode-serve.service',
|
||
'zcode-mail-bridge.service', 'homeagent.service', 'dsh.service', 'zcode.service']) {
|
||
let text = '';
|
||
try { text = String(readFile(`${SYS}/${unit}`, 'utf8')); } catch { continue; }
|
||
const cwd = (text.match(/WorkingDirectory=(.+)/) || [])[1]?.trim() ?? '';
|
||
const exec = (text.match(/ExecStart=(.+)/) || [])[1]?.trim() ?? '';
|
||
const allow = HOST_ALLOW[unit];
|
||
if (allow) {
|
||
// 用 includes 而不是 startsWith:zcode 的 ExecStart 是
|
||
// `dbus-run-session -- xvfb-run … /opt/ZCode/zcode …`,宿主路径在中间。
|
||
if (!text.includes(allow)) badHosts.push(`${unit}(不在 ${allow})`);
|
||
continue;
|
||
}
|
||
if (cwd.startsWith(REPO) || exec.startsWith(REPO)) badHosts.push(`${unit}(${cwd || exec})`);
|
||
}
|
||
push('各服务的工作目录/可执行文件不在源码目录', badHosts.length === 0, badHosts.join(' '));
|
||
|
||
// ⑥ 工作区干净度 —— **WARN,不参与退出码**。
|
||
//
|
||
// 判据 ① 比的是「仓库工作区 → 快照」这一跳。它覆盖不到「HEAD → 工作区」
|
||
// 那一跳:工作区脏(有未提交改动)时,① 照样会绿 —— 工作区与快照一致,
|
||
// 只是两者都不等于 HEAD。2026-09-14 实证:`pool.mjs` 里一行未提交的
|
||
// `let missingSessionCount = 0;` 被 17:20 那次「从脏工作区做的」快照原样
|
||
// 带进了生产,而 ① 报的是「逐字节一致」。
|
||
//
|
||
// 为什么只 WARN:开发中间态脏是正常的。把它做成红灯就造出一条**总在亮**的
|
||
// 判据 —— 正是本文件头注释骂过的病("人会学会忽略它")。所以:说出来,
|
||
// 但不改变结论、不影响 exit code。
|
||
const gitOut = (() => {
|
||
try {
|
||
const run = inject.git ?? ((args) => execFileSync('git', args, { cwd: REPO, encoding: 'utf8' }));
|
||
return run(['status', '--porcelain']);
|
||
} catch { return null; } // 不是 git 仓库/没有 git:不判
|
||
})();
|
||
if (gitOut === null) {
|
||
push('工作区干净(仅提示,不影响结论)', true, '读不到 git 状态,不据此判定');
|
||
} else {
|
||
const dirty = gitOut.split('\n').map(l => l.trim()).filter(Boolean);
|
||
push(
|
||
'工作区干净(仅提示,不影响结论)',
|
||
true,
|
||
dirty.length === 0
|
||
? '干净 —— 快照就是 HEAD 的内容'
|
||
: `脏 ${dirty.length} 处(快照会是"工作区 + HEAD 都不是"的第三种东西):${dirty.slice(0, 5).join(';')}`
|
||
);
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
/** 标准目录那组自检:坏样本必须红、干净样本必须绿(证明它不是恒真)。 */
|
||
export function layoutSelfCheck() {
|
||
const fake = map => ({
|
||
readdir: dir => map[dir] ?? [],
|
||
readFile: p => {
|
||
if (!(p in map)) throw new Error('ENOENT');
|
||
return map[p];
|
||
},
|
||
exists: p => p in map,
|
||
stat: () => ({ isFile: () => true, mode: 0o755 }),
|
||
repoUnits: '/repo/systemd'
|
||
});
|
||
// ⑥ 的 git 读取也要能被喂样本,否则它是一条测不到的判据(读不到就放行 ⇒ 恒绿)。
|
||
const fakeGit = porcelain => ({ git: () => porcelain });
|
||
const bad = checkLayout(fake({
|
||
'/etc/systemd/system': [{ name: 'x.service', isDirectory: () => false }],
|
||
'/etc/systemd/system/x.service': 'ExecStart=/home/program/agentmail/bin/x',
|
||
'/repo/systemd': [],
|
||
'/opt/agentmail/bin/service-failure-notify.mjs': 'x'
|
||
}));
|
||
const good = checkLayout(fake({
|
||
'/etc/systemd/system': [{ name: 'y.service', isDirectory: () => false }],
|
||
'/etc/systemd/system/y.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
|
||
'/repo/systemd': [],
|
||
'/opt/agentmail/bin/service-failure-notify.mjs': 'x'
|
||
}));
|
||
const dirty = checkLayout({ ...fake({
|
||
'/etc/systemd/system': [],
|
||
'/repo/systemd': [],
|
||
'/opt/agentmail/bin/service-failure-notify.mjs': 'x'
|
||
}), ...fakeGit(' M src/pool.mjs\n') });
|
||
const clean = checkLayout({ ...fake({
|
||
'/etc/systemd/system': [],
|
||
'/repo/systemd': [],
|
||
'/opt/agentmail/bin/service-failure-notify.mjs': 'x'
|
||
}), ...fakeGit('') });
|
||
const unreadable = checkLayout({ ...fake({
|
||
'/etc/systemd/system': [],
|
||
'/repo/systemd': [],
|
||
'/opt/agentmail/bin/service-failure-notify.mjs': 'x'
|
||
}), git: () => { throw new Error('not a git repo'); } });
|
||
const sixth = o => o.find(c => c.name.startsWith('工作区干净'));
|
||
return [
|
||
{ name: '标准目录:引用源码目录的样本必须判红', ok: bad[0].ok === false },
|
||
{ name: '标准目录:干净样本必须判绿', ok: good[0].ok === true },
|
||
// 编号:id 从 1 连续排到 N,不许跳号(原先只有 ⑥ 带编号,读者会去找不存在的 ⑤)。
|
||
{ name: '标准目录:id 连续编号,不跳号', ok: good.every((c, i) => c.id === String(i + 1)) },
|
||
// 「工作区干净」是"说出来但不改结论"的提示:脏 / 干净 / 读不到三种都被报出来,
|
||
// 且**都不判红**(做成红灯就是一条总在亮的判据,本文件头骂过这个病)。
|
||
{ name: '工作区干净:脏 → 说出来(且不判红)', ok: sixth(dirty)?.note.includes('脏 1 处') === true && sixth(dirty)?.ok === true },
|
||
{ name: '工作区干净:干净 → 明说干净', ok: sixth(clean)?.note.includes('干净') === true },
|
||
{ name: '工作区干净:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true }
|
||
];
|
||
}
|
||
|
||
function main() {
|
||
const json = process.argv.includes('--json');
|
||
const wantSelfCheck = process.argv.includes('--self-check');
|
||
|
||
if (wantSelfCheck) {
|
||
// 环境不足(临时目录写不进去 ⇒ 自检造不出样本树)必须报 2,不是崩栈、也不是 1。
|
||
// 1 会让人去查"是不是判据坏了",2 才说得清是机器的问题。
|
||
let checks;
|
||
try {
|
||
checks = selfCheck().concat(layoutSelfCheck());
|
||
} catch (e) {
|
||
// ★ 覆盖 **全部** 写点:上面 `mk()` 自己会翻译,但后面还有三处裸写
|
||
// (README.md / extra.mjs / test/t.mjs)。第一版只包了 `mk()`,那三处
|
||
// 撞上 ENOSPC 会打出原始英文 + 本文件堆栈 —— 退出码对、信号错。
|
||
const translated = describeEnvError(e, '判据自检要在临时目录里造样本树');
|
||
if (translated.code === 'ENOSPC') {
|
||
console.error(`\n${translated.message}\n`);
|
||
process.exit(2);
|
||
}
|
||
throw e;
|
||
}
|
||
if (json) console.log(JSON.stringify({ selfCheck: checks }, null, 2));
|
||
else {
|
||
console.log('判据自检(先证明检查器能发现差异):');
|
||
for (const c of checks) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}`);
|
||
}
|
||
const bad = checks.filter(c => !c.ok);
|
||
if (bad.length) {
|
||
console.error(`判据自检失败 ${bad.length} 项 —— 检查器本身不可信,不能用它的结论`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
const layout = checkLayout();
|
||
const layoutBad = layout.filter(c => !c.ok);
|
||
|
||
const results = HOSTS.map(checkHost);
|
||
if (json) {
|
||
console.log(JSON.stringify({ hosts: results, layout }, null, 2));
|
||
} else {
|
||
console.log('\n部署漂移检查(快照 vs 仓库 HEAD,以及进程到底在跑哪份代码):');
|
||
for (const r of results) {
|
||
console.log(`\n ${r.host}(${r.plugin},加载方式 ${r.load})${r.stale ? ' ⚠️ 漂移' : ''}`);
|
||
for (const c of r.checks) console.log(` ${c.ok ? '通过' : '失败'} ${c.name}${c.note ? ' — ' + c.note : ''}`);
|
||
}
|
||
console.log('\n 标准目录部署:');
|
||
for (const c of layout) console.log(` ${c.ok ? '通过' : '失败'} ${c.id} ${c.name}${c.note ? ' — ' + c.note : ''}`);
|
||
const stale = results.filter(r => r.stale);
|
||
console.log(`\n 结论:${stale.length === 0 ? '四个宿主都在跑当前代码' : `${stale.length} 个宿主需要重新部署/重启`}`);
|
||
for (const r of stale) console.log(` ✗ ${r.host}:bash deploy/redeploy-plugin.sh ${r.host}`);
|
||
}
|
||
process.exit(results.some(r => r.stale) || layoutBad.length ? 1 : 0);
|
||
}
|
||
|
||
// 仅在被直接执行时跑 main(被 import 时只导出,供测试调用)
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();
|