253 lines
14 KiB
JavaScript
253 lines
14 KiB
JavaScript
#!/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();
|