Files

253 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* selfcheck.mjs — ai-group-chat 的离线端到端自检。
*
* 全程使用临时 AIGROUP_HOME,**不触碰真实房间、不调用任何模型**(唤醒只走 --dry-run
* 和故意失败的分支)。任何一条不过就以非零码退出。
*
* 用法:node selfcheck.mjs [--keep]
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const AIG = path.join(HERE, 'aig.mjs');
const ROOM = 'selfcheck';
const HOME_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'aig-selfcheck-'));
const KEEP = process.argv.includes('--keep');
const ENV = { ...process.env, AIGROUP_HOME: HOME_ROOT, AIGROUP_ROOM: ROOM, NO_COLOR: '1' };
delete ENV.AIGROUP_DEPTH;
delete ENV.AIGROUP_NO_WAKE;
let pass = 0;
const failures = [];
function record(ok, name, detail = '') {
if (ok) { pass++; process.stdout.write(` ✔ ${name}${detail ? ` ${detail}` : ''}\n`); }
else { failures.push({ name, detail }); process.stdout.write(` ✘ ${name}${detail ? ` ${detail}` : ''}\n`); }
}
function aig(args, env = {}) {
const r = spawnSync(process.execPath, [AIG, ...args], { encoding: 'utf8', windowsHide: true, env: { ...ENV, ...env }, timeout: 60000 });
let json = null;
try { json = JSON.parse(r.stdout); } catch {}
return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), json };
}
function aigAsync(args, env = {}) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [AIG, ...args], { windowsHide: true, env: { ...ENV, ...env } });
let out = '', err = '';
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { err += d; });
child.on('close', (code) => resolve({ code, out: out.trim(), err: err.trim() }));
});
}
// 直接读日志文件,绕开 CLI —— 否则 `read --all --as dsh` 会顺手推进 dsh 的游标,
// 把后面「唤醒 dsh 应该有未读」的用例打空。
const msgs = () => {
const file = path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl');
let raw = '';
try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; }
return raw.split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
};
const unreadOf = (m) => aig(['read', '--json', '--as', m]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ 各阶段 */
function phaseRooms() {
process.stdout.write('【房间与成员】\n');
const r = aig(['init']);
record(r.code === 0, 'init 建房间', r.out.split('\n')[0]);
const members = JSON.parse(fs.readFileSync(path.join(HOME_ROOT, 'rooms', ROOM, 'members.json'), 'utf8'));
record(['dsh', 'opencode', 'workbuddy'].every((m) => members[m]), '预置三个成员', Object.keys(members).join(','));
record(fs.existsSync(path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl')), '消息日志已创建');
const m = aig(['members', '--json']);
record(m.code === 0 && Array.isArray(m.json) && m.json.length === 3, 'members --json 可用', `${m.json?.length} 个成员`);
const wakable = (m.json || []).filter((x) => x.wakable).map((x) => x.member);
record(wakable.length >= 2, '至少两个成员的唤醒通道能解析', `可唤醒:${wakable.join(',') || '无'}`);
}
function phaseMessages() {
process.stdout.write('\n【发消息 / 读消息 / 游标】\n');
const s = aig(['send', '大家好,我是 DSH @opencode 先看下这个', '--as', 'dsh']);
record(s.code === 0, 'send 成功');
const all = msgs();
record(all.length === 1 && all[0].from === 'dsh', '消息落库', `#${all[0]?.seq} ${all[0]?.from}`);
record(['id', 'seq', 'ts', 'from', 'to', 'text'].every((k) => all[0][k] !== undefined), '消息 schema 齐全');
record(all[0].to.includes('opencode'), '正文里的 @opencode 自动定向', JSON.stringify(all[0].to));
const u1 = unreadOf('opencode');
record(u1.json?.length === 1, 'opencode 有 1 条未读', String(u1.json?.length));
const u2 = unreadOf('opencode');
record(u2.json?.length === 0, '读完后游标推进(第二次读为空)', String(u2.json?.length));
aig(['send', '给 workbuddy 的一条,用来验 --peek', '--to', 'workbuddy', '--as', 'dsh']);
const peek = aig(['read', '--peek', '--json', '--as', 'workbuddy']);
record(peek.json?.length === 1, '--peek 能看到未读', `peek=${peek.json?.length}`);
const peek2 = aig(['read', '--peek', '--json', '--as', 'workbuddy']);
record(peek2.json?.length === 1, '--peek 不推进游标(连读两次都有)', `第二次 peek=${peek2.json?.length}`);
const consumed = aig(['read', '--json', '--as', 'workbuddy']);
const after = aig(['read', '--json', '--as', 'workbuddy']);
record(consumed.json?.length === 1 && after.json?.length === 0, '真读一次后游标才推进', `${consumed.json?.length} → ${after.json?.length}`);
aig(['read', '--as', 'dsh']);
}
function phaseTargeting() {
process.stdout.write('\n【定向与广播隔离】\n');
aig(['send', '这条只给 opencode', '--to', 'opencode', '--as', 'dsh']);
const dub = unreadOf('workbuddy');
record(dub.json?.length === 0, '定向消息不会漏给第三方', `workbuddy 未读 ${dub.json?.length}`);
const dob = unreadOf('opencode');
record(dob.json?.length === 1 && dob.json[0].text.includes('只给 opencode'), '定向消息送达本人');
aig(['send', '这条是广播', '--as', 'dsh']);
const b1 = unreadOf('opencode');
const b2 = unreadOf('workbuddy');
record(b1.json?.length === 1 && b2.json?.length === 1, '广播双方都收到', `opencode=${b1.json?.length} workbuddy=${b2.json?.length}`);
}
async function phaseConcurrency() {
process.stdout.write('\n【并发写入】\n');
const before = msgs().length;
const jobs = [];
for (let i = 0; i < 8; i++) jobs.push(aigAsync(['send', `并发消息 ${i}`, '--as', i % 2 ? 'opencode' : 'workbuddy']));
const results = await Promise.all(jobs);
const okCount = results.filter((r) => r.code === 0).length;
const all = msgs();
const seqs = all.map((m) => m.seq);
record(okCount === 8, '8 个并发 send 全部成功', `成功 ${okCount}/8`);
record(all.length === before + 8, '并发后消息数正确', `${before} → ${all.length}`);
record(new Set(seqs).size === seqs.length, '序号无重复(锁有效)', `唯一 ${new Set(seqs).size}/${seqs.length}`);
record(seqs.every((s, i) => i === 0 || s > seqs[i - 1]), '序号严格递增');
}
async function phaseWait() {
process.stdout.write('\n【wait 阻塞等待】\n');
aig(['read', '--as', 'workbuddy']);
const waiting = aigAsync(['wait', '--timeout', '20', '--interval', '0.3', '--as', 'workbuddy']);
await sleep(900);
aig(['send', '给等待中的 workbuddy 的一条', '--as', 'dsh']);
const w = await waiting;
record(w.code === 0, 'wait 被新消息唤醒', `exit=${w.code}`);
record(w.out.includes('给等待中的 workbuddy'), 'wait 打印了消息内容');
const t = aig(['wait', '--timeout', '1', '--interval', '0.3', '--as', 'workbuddy']);
record(t.code === 3, '无消息时 wait 超时返回 3', `exit=${t.code}`);
}
function phaseWakeGuards() {
process.stdout.write('\n【唤醒:dry-run 与护栏】\n');
// 由 human 发广播:这样 dsh 自己也有未读(自己发的消息对自己是不可见的)
aig(['send', '广播给所有人的一条,用于唤醒测试', '--as', 'human']);
for (const [member, needle] of [['dsh', '--profile'], ['opencode', 'run'], ['workbuddy', '-p']]) {
const r = aig(['wake', member, '--dry-run']);
record(r.code === 0 && r.out.includes(needle), `wake ${member} --dry-run 组装命令`, r.code === 0 ? '' : r.err);
}
const selfWake = aig(['wake', 'human', '--dry-run']);
record(selfWake.code === 4 || selfWake.code === 0, 'human 没有唤醒通道时不崩', `exit=${selfWake.code}`);
const deep = aig(['wake', 'opencode'], { AIGROUP_DEPTH: '3', AIGROUP_MAX_DEPTH: '3' });
record(deep.code === 5, '超过最大深度时拒绝唤醒(防死循环)', `exit=${deep.code}`);
const nowake = aig(['wake', 'opencode'], { AIGROUP_NO_WAKE: '1' });
record(nowake.code === 5, 'NO_WAKE 链条里拒绝再唤醒', `exit=${nowake.code}`);
const model = aig(['wake', 'opencode', '--dry-run', '--model', 'opencode/muse-spark-1.3-contributor-free']);
record(model.out.includes('muse-spark-1.3-contributor-free'), '--model 覆盖生效');
}
function phaseFailurePath() {
process.stdout.write('\n【失败必须进群 / 备用模型】\n');
// 用「一定能失败」的通道:把一个必定非零退出的 JS 文件当成 dsh 的入口
const broken = path.join(HOME_ROOT, 'broken-transport.js');
fs.writeFileSync(broken, 'process.stderr.write("boom: 通道故意失败\\n"); process.exit(3);\n', 'utf8');
aig(['send', '这条会触发一次注定失败的唤醒', '--as', 'human']);
const before = msgs().length;
const bad = aig(['wake', 'dsh', '--timeout', '30'], { AIGROUP_DSH_BIN: broken });
record(bad.code === 4, '唤起失败返回 4', `exit=${bad.code}`);
const after = msgs();
const lastMsg = after[after.length - 1];
record(after.length === before + 1 && lastMsg.kind === 'dispatch-error', '失败被写成 dispatch-error 消息进群', String(lastMsg?.kind));
record(String(lastMsg?.text || '').includes('boom'), '失败原因带上真实 stderr', String(lastMsg?.text || '').slice(0, 60).replace(/\n/g, ' '));
// 备用模型:第一次调用失败、第二次成功 —— 模拟「隐身模型下线后自动退档」
const flaky = path.join(HOME_ROOT, 'flaky-transport.js');
const flagFile = path.join(HOME_ROOT, 'flaky.flag');
fs.writeFileSync(flaky, [
"import fs from 'node:fs';",
`const flag = ${JSON.stringify(flagFile)};`,
"if (!fs.existsSync(flag)) { fs.writeFileSync(flag, '1'); process.stderr.write('model unavailable\\n'); process.exit(3); }",
"process.stdout.write('来自备用模型的回答');",
].join('\n'), 'utf8');
aig(['join', 'dsh', '--model', 'opencode/primary-x', '--fallback-model', 'opencode/backup-y']);
aig(['send', '这条模拟主模型下线', '--as', 'human']);
const fb = aig(['wake', 'dsh', '--timeout', '30', '--json'], { AIGROUP_DSH_BIN: flaky });
const fbReply = fb.json?.reply;
record(fb.code === 0 && String(fbReply?.text || '').includes('备用模型'), '主模型失败后自动退到备用模型', `exit=${fb.code}`);
record(fbReply?.meta?.model === 'opencode/backup-y' && fbReply?.meta?.fallback_note, '消息里记录了换档事实', String(fbReply?.meta?.fallback_note || '').slice(0, 50));
}
function phaseDiagnostics() {
process.stdout.write('\n【诊断命令】\n');
for (const [cmd, args] of [['status', ['--json']], ['doctor', ['--json']], ['detect', ['--json']], ['members', ['--json']], ['tail', ['--json', '--limit', '3']], ['rooms', ['--json']]]) {
const r = aig([cmd, ...args]);
record(r.code === 0 && r.json !== null, `${cmd} --json 可取`, `exit=${r.code}`);
}
const d = aig(['detect', '--json']);
record(typeof d.json?.resolved === 'string' && d.json.resolved.length > 0, 'detect 给出身份判断', String(d.json?.resolved));
}
function phaseBadLines() {
process.stdout.write('\n【坏行不静默(workbuddy 在群里的建议)】\n');
const log = path.join(HOME_ROOT, 'rooms', ROOM, 'messages.jsonl');
const marker = '{"seq": 这个不是合法 JSON';
fs.appendFileSync(log, `${marker}\n`, 'utf8');
const r = aig(['read', '--all', '--as', 'dsh']);
record(r.code === 0 && r.out.includes('已发送') === false && r.out.length > 0, '有坏行时仍然读得出好消息', `exit=${r.code}`);
record(r.err.includes('跳过') && r.err.includes('1 行'), 'stderr 明确警告跳过了坏行', r.err.split('\n').filter((l) => l.includes('跳过'))[0] || '(无警告)');
const badLog = path.join(HOME_ROOT, 'rooms', ROOM, 'bad-lines.log');
const dumped = fs.existsSync(badLog) ? fs.readFileSync(badLog, 'utf8') : '';
record(dumped.includes('这个不是合法 JSON'), '坏行原文落盘到 bad-lines.log', `${dumped.length}B`);
// 清掉坏行,免得影响后面的用例
fs.writeFileSync(log, fs.readFileSync(log, 'utf8').split('\n').filter((l) => !l.includes('这个不是合法 JSON')).join('\n'), 'utf8');
}
function phaseReset() {
process.stdout.write('\n【默认房间与清理】\n');
aig(['use', ROOM]);
const s = aig(['send', '不指定 --room 时进默认房间', '--as', 'dsh']);
record(s.code === 0 && s.out.includes('已发送'), 'use 设的默认房间生效');
const resetNo = aig(['reset']);
record(resetNo.code === 2, 'reset 缺 --yes 时拒绝', `exit=${resetNo.code}`);
const resetYes = aig(['reset', '--yes']);
record(resetYes.code === 0 && !fs.existsSync(path.join(HOME_ROOT, 'rooms', ROOM)), 'reset --yes 清空房间');
}
/* ------------------------------------------------------------------ main */
async function main() {
process.stdout.write(`\nai-group-chat 自检 · 临时总线 ${HOME_ROOT}\n\n`);
phaseRooms();
phaseMessages();
phaseTargeting();
await phaseConcurrency();
await phaseWait();
phaseWakeGuards();
phaseFailurePath();
phaseDiagnostics();
phaseBadLines();
phaseReset();
if (!KEEP) { try { fs.rmSync(HOME_ROOT, { recursive: true, force: true }); } catch {} }
const total = pass + failures.length;
process.stdout.write(`\n${'─'.repeat(52)}\n`);
process.stdout.write(failures.length ? `✘ ${pass}/${total} 通过,失败 ${failures.length} 项:\n` : `✔ 全部通过(${pass}/${total})\n`);
for (const f of failures) process.stdout.write(` · ${f.name} ${f.detail}\n`);
if (KEEP) process.stdout.write(`临时总线保留在 ${HOME_ROOT}\n`);
process.exitCode = failures.length ? 1 : 0;
}
await main();