/** * Unit Tests — graph-seed-service.js * * 两块: * * A. **hermetic 夹具根**(P3B W1 起):`GRAPH_SEED_ROOT` 指向 /tmp 下现造的 * system/agents + data/companies,配一个一次性 GRAPH_DB_PATH。 * 行数因此是**写死的精确值**而不是「跟着仓库现状走」——原来的写法直接对着生产 * `data/graph.sqlite` 与真实 `data/companies/` 断言,任何人手建一家公司都会让它变红。 * 悬空行回收 / 占位接管 / 防误伤 / `_` 保留 id 过滤四条规则都在这里锁。 * * 28 批次 1 起数据源只剩 `system/agents/`(`system/templates/` 已并入), * `source_kind` 的三分收成 `origin` 二分,断言随之改口径 —— 不是放松。 * * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实 * `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok, * 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。 * 读的仍然是真实仓库的内容,但 `data/companies/` 走一次**冻结快照**(见 freezeRepoRoot)—— * 原因写在那个函数上。 */ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; /** 换掉 GRAPH_SEED_ROOT / GRAPH_DB_PATH 之后必须重新 require,两个模块都在顶层读了常量 */ function reloadServices() { delete require.cache[require.resolve('../../services/graph-db')]; delete require.cache[require.resolve('../../services/graph-seed-service')]; return { graphDb: require('../../services/graph-db'), seedService: require('../../services/graph-seed-service'), }; } function write(file, content) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, content); } const REPO_ROOT = path.resolve(__dirname, '../../..'); /** * 把「真实仓库根」冻成一个不会在用例中途变形的 GRAPH_SEED_ROOT。 * * 为什么需要冻:verifyCounts() 的价值在于**独立重扫一遍目录**再跟库里的行数对, * 于是 seedCompanyAgents() 那次扫描和 verifyCounts() 那次扫描之间只要磁盘变了一次, * 期望值和实际值就必然不等 —— 而 `data/companies/` 是运行期状态,同一轮 `vitest run` 里 * 至少有 company-hire-org-layer.test.js / graph-agent-p1-{hire,acceptance}.test.js * 三个文件在真实 `data/companies/` 下建公司再 hire()(company.json 的 agents[] 逐个变长), * afterAll 又整目录删掉;用户开着 dev server 时还会有人从 UI 建公司。 * 实测:拿一个反复建/删的公司目录撞这个窗口,12 次单跑里 3 次红在 * `org_graph_nodes COUNT(*) WHERE company_id=...`。这就是它在全套里偶发变红的全部原因。 * * 冻的是「一致性」而不是「新鲜度」:快照本身可能抓到某家公司写一半的样子, * 但两次扫描读的是同一份,结论照旧成立。所以断言一条没放松 —— 真实仓库里多一个 * 模板目录 / 多一家公司带着 seeder 会跳过的角色,照样红(下面有验证)。 * * `system/` 不快照,直接软链:它是签入代码,跑测试期间没人往里加目录 * (hire() 的使用计数写在 data/template-usage.json,不碰 `_registry.json`, * 而 _registry.json 只影响 benchmark_score,不影响任何行数)。软链还顺带让 * 「every system/agents/* body dir …」那条用例仍然对着真实目录清单比。 */ function freezeRepoRoot(dest) { fs.mkdirSync(dest, { recursive: true }); fs.symlinkSync(path.join(REPO_ROOT, 'system'), path.join(dest, 'system')); const src = path.join(REPO_ROOT, 'data', 'companies'); const dst = path.join(dest, 'data', 'companies'); if (!fs.existsSync(src)) return; // 连目录都没有 → 快照里也不该有,别把安全阀的判定条件改掉 fs.mkdirSync(dst, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { if (!entry.isDirectory()) continue; try { // 没有 company.json → seedCompanyAgents 与 verifyCounts 都跳过它,不必进快照 const companyJson = fs.readFileSync(path.join(src, entry.name, 'company.json')); write(path.join(dst, entry.name, 'company.json'), companyJson); let roles = []; try { const parsed = JSON.parse(companyJson); if (Array.isArray(parsed.agents)) roles = parsed.agents; } catch { // 坏掉的 company.json:两个实现都 continue,快照里留着原样即可 } for (const role of roles) { const roleDir = path.join(src, entry.name, 'agents', role); if (!fs.existsSync(roleDir)) continue; // legacy_path 存在性也要一并复制 fs.mkdirSync(path.join(dst, entry.name, 'agents', role), { recursive: true }); // isMetaBootstrap() 只看这一个文件,而它决定 seedCompanyAgents 建不建 org 节点 —— 必须带上 try { fs.copyFileSync( path.join(roleDir, 'config.json'), path.join(dst, entry.name, 'agents', role, 'config.json') ); } catch {} } } catch { // 这家公司正被别的测试 / dev server 删掉 → 跳过。快照要的是自洽,不是全集。 } } } describe('graph-seed-service (hermetic fixture root)', () => { let root; let dbPath; let seedService; let graphDb; /** 夹具:3 个本体目录 + 1 个保留 id + 1 个无 agent.md 的杂目录、1 家活着的公司 */ function buildFixture() { root = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-fixture-')); dbPath = path.join(root, 'graph.sqlite'); write(path.join(root, 'system/agents/_registry.json'), JSON.stringify([{ id: 'tmpl-a', benchmarkScore: 7.5 }])); write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha'); write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha' })); write(path.join(root, 'system/agents/tmpl-a/agent.md'), '# tmpl-a'); write(path.join(root, 'system/agents/tmpl-a/config.json'), JSON.stringify({ name: 'Template A' })); write(path.join(root, 'system/agents/tmpl-b/agent.md'), '# tmpl-b'); // 无 config.json write(path.join(root, 'system/agents/_base/agent.md'), '# 元 agent 模板'); // 保留 id:有 agent.md 也不该入库 fs.mkdirSync(path.join(root, 'system/agents/no-agent-md'), { recursive: true }); // 数据碎片,不算本体 write( path.join(root, 'data/companies/live-co/company.json'), JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role'] }) ); // 28 批次 4 起公司侧没有 agent 目录了 —— private-role 这行靠「活着的公司还点名它」 // 保住,不再靠 legacy_path 指向的目录真实存在(那条闸门只对存量行还有意义)。 process.env.GRAPH_SEED_ROOT = root; process.env.GRAPH_DB_PATH = dbPath; ({ graphDb, seedService } = reloadServices()); } function rows() { return graphDb.getDb().prepare('SELECT id, origin, scope, owner_company_id, legacy_path FROM system_agents ORDER BY id').all(); } function rowOf(id) { return rows().find(r => r.id === id) || null; } function orgNodes() { return graphDb.getDb().prepare('SELECT company_id, system_agent_id, role_name FROM org_graph_nodes ORDER BY company_id, role_name').all(); } /** 直接造一行 origin='created'(模拟历史遗留 / hire() 写下的公司私有 agent 行) */ function insertHired(id, ownerCompanyId, legacyPath) { const now = new Date().toISOString(); graphDb.getDb().prepare(` INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, origin, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (?, ?, 'company', ?, 'company_hired', 'created', ?, 'active', NULL, ?, ?) `).run(id, id, ownerCompanyId, legacyPath, now, now); } beforeEach(() => buildFixture()); afterEach(() => { delete process.env.GRAPH_SEED_ROOT; delete process.env.GRAPH_DB_PATH; try { fs.rmSync(root, { recursive: true, force: true }); } catch {} }); it('seeds exact row counts from the fixture and passes verifyCounts', () => { seedService.seedAgents(); seedService.seedCompanyAgents(); expect(rows().map(r => [r.id, r.origin])).toEqual([ ['alpha', 'builtin'], ['private-role', 'created'], // live-co 独有角色 ['tmpl-a', 'builtin'], ['tmpl-b', 'builtin'], ]); // `_base`(保留 id,有 agent.md)与 no-agent-md(数据碎片)都不是可调用 agent expect(rowOf('_base')).toBeNull(); expect(rowOf('no-agent-md')).toBeNull(); // 路径恒由 id 推导,不再有第二个本体目录可选 expect(rowOf('alpha').legacy_path).toBe('system/agents/alpha'); expect(orgNodes()).toEqual([ { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' }, { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' }, ]); const v = seedService.verifyCounts(); expect(v.mismatches).toEqual([]); expect(v.ok).toBe(true); expect(v.expected).toMatchObject({ builtin: 3, created: 1 }); }); it('picks up an agent directory added after the first seed (W1: 新本体目录自动进表)', () => { seedService.runStartupSeed(); expect(rowOf('tmpl-new')).toBeNull(); write(path.join(root, 'system/agents/tmpl-new/agent.md'), '# brand new'); write(path.join(root, 'system/agents/tmpl-new/config.json'), JSON.stringify({ name: 'Brand New' })); seedService.runStartupSeed(); expect(rowOf('tmpl-new')).toEqual({ id: 'tmpl-new', origin: 'builtin', scope: 'public', owner_company_id: null, legacy_path: 'system/agents/tmpl-new', }); }); it('reclaims a dangling created row that squats on a body id', () => { // 历史遗留:公司 ghost-co 已被删,行还占着 tmpl-b 这个本体 id insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b'); expect(rowOf('tmpl-b').origin).toBe('created'); const summary = seedService.runStartupSeed(); // reconcile 先删掉它(没有任何外键引着),seedAgents 再按目录重新种回来 expect(summary.reconciled.agentsRemoved).toContain('tmpl-b'); expect(rowOf('tmpl-b')).toEqual({ id: 'tmpl-b', origin: 'builtin', scope: 'public', owner_company_id: null, legacy_path: 'system/agents/tmpl-b', }); }); it('takes over a squatting row in-place when a foreign key still points at it', () => { insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b'); // 还活着的公司 live-co 有一个 org 节点引着这行 → reconcile 删不掉(外键) graphDb.getDb().prepare(` INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at) VALUES ('live-co', 'tmpl-b', 'tmpl-b', ?) `).run(new Date().toISOString()); const summary = seedService.runStartupSeed(); expect(summary.reconciled.agentsRemoved).not.toContain('tmpl-b'); expect(summary.reconciled.retained.map(r => r.id)).toContain('tmpl-b'); // 删不掉就地转正:同一行 id 保留(外键继续有效),origin / scope / legacy_path 全部改写 expect(summary.agents.reclaimed).toContain('tmpl-b'); expect(rowOf('tmpl-b')).toEqual({ id: 'tmpl-b', origin: 'builtin', scope: 'public', owner_company_id: null, legacy_path: 'system/agents/tmpl-b', }); // 引着它的节点必须还在(转正而不是删了重建的全部意义所在) expect(orgNodes().some(n => n.system_agent_id === 'tmpl-b')).toBe(true); }); it('never reclaims rows a live company still backs (防误伤)', () => { seedService.seedAgents(); seedService.seedCompanyAgents(); // ① 没有本体目录、但 live-co 的 company.json 仍在 agents[] 里点名它(兼容名册那一路) expect(rowOf('private-role').origin).toBe('created'); // ② 存量行:legacy_path 还是批次 4 之前的公司侧路径(目录早没了),靠 agents[] 点名保住 insertHired('tmpl-a-clone', 'live-co', 'data/companies/live-co/agents/tmpl-a-clone'); write( path.join(root, 'data/companies/live-co/company.json'), JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role', 'tmpl-a-clone'] }) ); // ③ 只在 company_agents 里有行、company.json 一个字都没提的(28 批次 4-19 起的真相来源)。 // 少了这一路,凡是迁移后新雇的人都会在下一次启动 reconcile 时被当悬空行删掉。 insertHired('table-only-role', 'live-co', 'system/agents/table-only-role'); graphDb.getDb().prepare(` INSERT INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) VALUES ('live-co', 'table-only-role', 'table-only-role', 'active', ?) `).run(new Date().toISOString()); const r = seedService.reconcileDanglingRows(); expect(r.agentsRemoved).toEqual([]); expect(rowOf('private-role')).not.toBeNull(); expect(rowOf('tmpl-a-clone')).not.toBeNull(); expect(rowOf('tmpl-a-clone').origin).toBe('created'); expect(rowOf('table-only-role')).not.toBeNull(); }); it('③ 那一路不是无脑放行:公司目录没了的 company_agents 行不算认领', () => { // 与上一条同形态,唯一差别是公司目录不存在。company_agents 的行不会随公司目录消失, // 拿它当「还活着」的信号会让回收永远不生效 —— 所以 claim 的存活判定仍走磁盘。 insertHired('ghost-table-role', 'ghost-co', 'system/agents/ghost-table-role'); graphDb.getDb().prepare(` INSERT INTO company_agents (company_id, system_agent_id, role_name, status, hired_at) VALUES ('ghost-co', 'ghost-table-role', 'ghost-table-role', 'active', ?) `).run(new Date().toISOString()); const r = seedService.reconcileDanglingRows(); // 它被判成悬空行并真的删掉了。以前这里断言的是「落进 retained」——因为幽灵公司的 // company_agents 行还在,外键把 system_agents 那行拦住了。步骤 A 现在会先清掉 // 幽灵公司的名册行(否则 heartbeat-service.start() 会给已删公司挂上定时心跳), // 拦路的外键随之消失,这一路才走完整。 expect(r.companyAgentsRemoved).toBeGreaterThanOrEqual(1); expect(r.agentsRemoved).toContain('ghost-table-role'); expect(r.retained.map((x) => x.id)).not.toContain('ghost-table-role'); }); it('never clobbers a created row a live company is still using (合并后剩下的唯一冲突面)', () => { // tmpl-a 既有本体目录、又被活着的 live-co 在 company.json.agents[] 里点名。 // 这种行是公司自己雇的那份私有拷贝在跑,转成 builtin 会让 lookupSharedAgentMd // 突然返回本体路径 —— 等于在公司毫不知情的情况下换掉它的 agent.md。 insertHired('tmpl-a', 'live-co', 'data/companies/live-co/agents/tmpl-a'); const summary = seedService.runStartupSeed(); expect(summary.agents.skipped).toEqual([{ id: 'tmpl-a', existingOrigin: 'created' }]); expect(summary.agents.reclaimed).toEqual([]); expect(rowOf('tmpl-a')).toMatchObject({ origin: 'created', legacy_path: 'data/companies/live-co/agents/tmpl-a', }); }); it('drops org nodes of deleted companies but keeps live ones, and is idempotent', () => { seedService.seedAgents(); seedService.seedCompanyAgents(); insertHired('ghost-role', 'ghost-co', 'data/companies/ghost-co/agents/ghost-role'); const now = new Date().toISOString(); graphDb.getDb().prepare(` INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at) VALUES ('ghost-co', 'ghost-role', 'ghost-role', ?) `).run(now); const first = seedService.reconcileDanglingRows(); expect(first.ghostCompanyIds).toEqual(['ghost-co']); expect(first.orgNodesRemoved).toBe(1); expect(first.agentsRemoved).toEqual(['ghost-role']); expect(first.retained).toEqual([]); // live-co 的两个节点一个没少 expect(orgNodes()).toEqual([ { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' }, { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' }, ]); const second = seedService.reconcileDanglingRows(); expect(second).toMatchObject({ orgNodesRemoved: 0, orgEdgesRemoved: 0, ghostCompanyIds: [], agentsRemoved: [], retained: [] }); }); it('skips org-node reconcile entirely when data/companies is absent (安全阀)', () => { seedService.seedAgents(); seedService.seedCompanyAgents(); const before = orgNodes(); fs.rmSync(path.join(root, 'data/companies'), { recursive: true, force: true }); const r = seedService.reconcileDanglingRows(); expect(r.ghostCompanyIds).toEqual([]); expect(r.orgNodesRemoved).toBe(0); expect(orgNodes()).toEqual(before); }); it('runStartupSeed leaves company-derived rows alone (it must not run seedCompanyAgents)', () => { seedService.runStartupSeed(); // company.json 里的 private-role 没有被启动流程带进表,org 图也一行没写—— // 那是 hire()/fire() 与 scripts/graph-seed.js 的地盘 expect(rowOf('private-role')).toBeNull(); expect(orgNodes()).toEqual([]); }); it('runStartupSeedSafely swallows failures and returns null', () => { process.env.GRAPH_DB_PATH = path.join(root, 'no-such-dir/nested/graph.sqlite'); const { seedService: broken } = reloadServices(); expect(broken.runStartupSeedSafely()).toBeNull(); }); }); describe('graph-seed-service verifyCounts (real repo root)', () => { let seedService; let graphDb; let snapshotRoot; beforeAll(() => { // mkdtemp 而不是固定 /tmp/test-graph-seed-service.sqlite:同一台机器上并行跑两遍 // 这个文件时不该共用一个库 snapshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-realroot-')); freezeRepoRoot(snapshotRoot); process.env.GRAPH_SEED_ROOT = snapshotRoot; process.env.GRAPH_DB_PATH = path.join(snapshotRoot, 'graph.sqlite'); ({ graphDb, seedService } = reloadServices()); }); afterAll(() => { delete process.env.GRAPH_SEED_ROOT; delete process.env.GRAPH_DB_PATH; try { fs.rmSync(snapshotRoot, { recursive: true, force: true }); } catch {} }); it('reports ok:true against a freshly-seeded DB (no drift)', () => { seedService.seedAgents(); seedService.seedCompanyAgents(); const v = seedService.verifyCounts(); expect(v.mismatches).toEqual([]); expect(v.ok).toBe(true); }); it('every system/agents/* body dir lands as origin=builtin, and reserved ids never do', () => { const db = graphDb.getDb(); const agentsDir = path.resolve(__dirname, '../../../system/agents'); // 先取目录清单、**再**补种一次,顺序不能反:`system/` 是软链进快照的(见 freezeRepoRoot), // 同一轮 vitest 里别的用例会往真实 system/agents/ 下毕业出新本体 // (28 号裁决:测试建的 agent 是正式注册表条目,留在盘上)。 // 反过来写的话,「上一条 it 补种之后、本条 readdir 之前」新出现的那个本体 // 会被判成「没落库」——那是竞态,不是漂移。 const dirs = fs .readdirSync(agentsDir, { withFileTypes: true }) .filter(d => d.isDirectory()) .map(d => d.name); const bodyDirs = dirs.filter( id => !id.startsWith('_') && fs.existsSync(path.join(agentsDir, id, 'agent.md')) ); seedService.seedAgents(); // 真实仓库里确实有保留 id(`_base`),否则下面那条负向断言是空跑 expect(dirs.some(id => id.startsWith('_'))).toBe(true); const seeded = new Set( db.prepare(`SELECT id FROM system_agents WHERE origin = 'builtin'`).all().map(r => r.id) ); expect(bodyDirs.filter(id => !seeded.has(id))).toEqual([]); expect([...seeded].filter(id => id.startsWith('_'))).toEqual([]); }); it('catches a mismatch when the DB has a row the filesystem does not back', () => { const db = graphDb.getDb(); const now = new Date().toISOString(); db.prepare(` INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run('bogus-agent-no-fs-backing', 'Bogus', 'public', null, 'global_agent', 'system/agents/bogus-agent-no-fs-backing', 'active', null, now, now); const v = seedService.verifyCounts(); expect(v.ok).toBe(false); expect(v.mismatches.some(m => m.check.includes("origin='builtin'"))).toBe(true); db.prepare('DELETE FROM system_agents WHERE id = ?').run('bogus-agent-no-fs-backing'); }); });