/** * Unit Test — `npm run graph:seed` 的对账必须能在**存量形态**上跑绿(28 二轮修复 问题 1) * * 背景:`scripts/graph-seed.js` 的对账在生产库上确定性 exit 1,而且反复跑都红 —— * 一个用来发现真实漂移的告警永久性地喊狼来了。两个成因都是 28 号改造留下的, * 本用例把它们各造一遍: * * ① **目录改名残渣**:批次 1 把 `system/templates/base` 改名成 `system/agents/_base`, * 库里那行 `id='base'`(legacy_path 指着已不存在的旧目录)没人删也没人改名 —— * reconcile 只回收 `origin='created'` 行,够不着它;seedAgents 只按目录扫,也碰不到它。 * 于是它永远多算一行 builtin。 * ② **迁移契约 vs 对账契约打架**:§七 M2 要求毕业出来的 org-coder / org-planner * 保持 `origin='created'`(未来 create-agent 造的本体同样是 created), * 而 verifyCounts() 原来的期望是「created 必须为 0」。裁决是**改对账口径**: * 验「created 行在磁盘上都有对应本体目录」,而不是验它为零。 * * 为什么跑真的子进程而不是直接调 verifyCounts():要证的就是**退出码**。 * 脚本里 `process.exitCode = 1` 那段和对账结果之间还隔着一层拼装,直接调函数证不到它。 * 夹具走 GRAPH_SEED_ROOT / GRAPH_DB_PATH(与 graph-seed-service.test.js 同一套落地缝), * 真实仓库的 `data/graph.sqlite` 一个字节都不碰。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { execFileSync } from 'child_process'; import Database from 'better-sqlite3'; const REPO_ROOT = path.resolve(__dirname, '../../..'); const SCRIPT = path.join(REPO_ROOT, 'scripts/graph-seed.js'); function write(file, content) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); } describe('graph-seed 对账在存量形态上退出码为 0', () => { let root; let dbPath; let stdout; beforeAll(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-reconcile-')); dbPath = path.join(root, 'graph.sqlite'); // ── 磁盘:一个保留 id + 一个普通本体 + 一个「已毕业」的本体 ── write(path.join(root, 'system/agents/_registry.json'), JSON.stringify([{ id: 'alpha', benchmarkScore: 9 }])); write(path.join(root, 'system/agents/_base/agent.md'), '# 元 agent 模板'); write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha'); write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha' })); // M2 的「毕业」产物:本体已经在 system/agents/ 下,库里那行按契约保持 created write(path.join(root, 'system/agents/graduated/agent.md'), '# graduated'); write( path.join(root, 'data/companies/live-co/company.json'), JSON.stringify({ id: 'live-co', agents: ['alpha', 'graduated'] }) ); // ── 库:先建表,再塞进两行「存量」── process.env.GRAPH_SEED_ROOT = root; process.env.GRAPH_DB_PATH = dbPath; delete require.cache[require.resolve('../../services/graph-db')]; const graphDb = require('../../services/graph-db'); const db = graphDb.getDb(); const now = new Date().toISOString(); const insert = db.prepare(` INSERT INTO system_agents (id, name, scope, owner_company_id, origin, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (@id, @name, @scope, @owner, @origin, @legacy_path, 'active', NULL, @now, @now) `); insert.run({ id: 'base', name: 'base', scope: 'public', owner: null, origin: 'builtin', legacy_path: 'system/templates/base', now }); insert.run({ id: 'graduated', name: 'graduated', scope: 'company', owner: 'live-co', origin: 'created', legacy_path: 'system/agents/graduated', now }); // ── 幽灵公司的名册行:目录(连同 company.json)已经不在,company_agents 那行还在 ── // 这张表批次 4-19 之后是**唯一被读的名册**,heartbeat-service.start() 拿它挂心跳时 // 不查公司还在不在。漏清的话,一家删掉的公司每次服务启动都会把员工重新挂上定时心跳, // 到点真起一次 Claude CLI —— 全程零报错。实测生产库里已经积了 10 行。 // 注意 ghost-co **没有** org_graph_nodes 行:基础设施角色不入图,只扫 org 表会整家漏掉。 const hire = db.prepare(` INSERT INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) VALUES (?, ?, ?, 'active', ?) `); hire.run('live-co', 'graduated', 'graduated', now); hire.run('ghost-co', 'graduated', 'graduated', now); db.close(); // 子进程要自己开一次连接,别拿着写锁不放 stdout = execFileSync(process.execPath, [SCRIPT], { cwd: REPO_ROOT, encoding: 'utf-8', env: { ...process.env, GRAPH_SEED_ROOT: root, GRAPH_DB_PATH: dbPath }, }); // execFileSync 在非零退出码时抛异常 —— 这就是「exit 0」那条断言本身 }); afterAll(() => { delete process.env.GRAPH_SEED_ROOT; delete process.env.GRAPH_DB_PATH; delete require.cache[require.resolve('../../services/graph-db')]; try { fs.rmSync(root, { recursive: true, force: true }); } catch {} }); function rows() { const db = new Database(dbPath, { readonly: true }); try { return db.prepare('SELECT id, origin, legacy_path FROM system_agents ORDER BY id').all(); } finally { db.close(); } } it('脚本跑完对账通过(退出码 0,报告里没有失败段)', () => { expect(stdout).toContain('行数对账通过'); expect(stdout).not.toContain('行数对账失败'); }); it('目录改名残渣 id=base 被清掉(reconcile 够得着 builtin 行了)', () => { expect(rows().find((r) => r.id === 'base')).toBeUndefined(); expect(stdout).toContain('磁盘上已无本体的 builtin 行: 1 条 (base)'); // 保留 id 本身也从来不该入库 expect(rows().find((r) => r.id === '_base')).toBeUndefined(); }); it("origin='created' 的行都有对应磁盘本体目录(M2 契约不再和对账打架)", () => { const created = rows().filter((r) => r.origin === 'created'); expect(created.map((r) => r.id)).toEqual(['graduated']); // 毕业行还在,没被当悬空行回收 for (const row of created) { expect( fs.existsSync(path.join(root, 'system/agents', row.id, 'agent.md')), `created 行 ${row.id} 在 system/agents/ 下没有本体目录` ).toBe(true); } }); it('幽灵公司的名册行被清掉,活着公司的一行不动(否则心跳会给已删公司挂定时器)', () => { const db = new Database(dbPath, { readonly: true }); try { const all = db.prepare('SELECT company_id, role_name FROM company_agents ORDER BY company_id').all(); expect(all).toEqual([{ company_id: 'live-co', role_name: 'graduated' }]); } finally { db.close(); } expect(stdout).toContain('已删公司的名册行: 1 行'); }); it('再跑一遍仍然 exit 0 且不再有可清理项(幂等:告警不会隔天又喊狼来了)', () => { const second = execFileSync(process.execPath, [SCRIPT], { cwd: REPO_ROOT, encoding: 'utf-8', env: { ...process.env, GRAPH_SEED_ROOT: root, GRAPH_DB_PATH: dbPath }, }); expect(second).toContain('行数对账通过'); expect(second).toContain('磁盘上已无本体的 builtin 行: 0 条'); }); });