/** * Unit Tests — 补种不许把「图基础设施本体」塞进 org_graph_nodes(28 三轮复验,问题 2) * * 锁的是一次**守卫漂移**:`company-service.decideOrgNodeRecording` 的判据①在 P3B/W7 从 * `{'meta-bootstrap'}` 放宽到 `{'meta-bootstrap','meta-planning'}`(要盖住按需雇的 * workgraph-planning,以及它自己雇的 prd/sdd/brd-planning、goal-benchmark), * 而 `graph-seed-service` 里那份**手抄**的 `isMetaBootstrap()` 停在只认 meta-bootstrap。 * 于是 `seedCompanyAgents()` 把 hire() 特意排除掉的规划本体重新塞回 org_graph_nodes —— * `authored_by=null` 的孤儿节点,正是 graph-engine.resolvePlanningAgentName 注释里点名要防的 * 「谁也点不亮的 subgraph 节点」;而 work-graph-db 起 org run 时读的就是这张表的 active 行, * 多出来的是一个**真的会被执行**的 org 节点。 * * 最坏的一半在对账:`verifyCounts()` 用**同一个**手抄函数算期望值 —— 期望和实际 * 一起错,`npm run graph:seed` exit 0、全程零告警。所以本文件的三条用例缺一不可: * ① 节点没被写进去(真实症状); * ② 对账的期望值跟着一致(否则「修好了 seed、对账反而变红」,或者反过来一起错还是绿); * ③ 存量孤儿节点被 reconcile 清掉且幂等(库里已经有 5 行,光修 seed 不会自己消失)。 * 外加一条漂移守卫:判据的取值域只能有一份。 * * 隔离手法同 graph-seed-meta-bootstrap.test.js:GRAPH_SEED_ROOT 指向临时目录自建最小夹具, * GRAPH_DB_PATH 指向一次性 sqlite,完全不碰真实仓库与生产库。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const MODULES = [ '../../services/graph-db', '../../services/graph-service', '../../services/graph-seed-service', ]; describe('seedCompanyAgents × 图基础设施本体守卫', () => { let seed; let root; const COMPANY = 'co-guard'; const ORG_ROLE = 'org-planner'; // type='executor' —— 正经 org 节点 const BOOTSTRAP_ROLE = 'orggraph-planning'; // type='meta-bootstrap' —— 老守卫就认识 const PLANNING_ROLE = 'workgraph-planning'; // type='meta-planning' —— W7 放宽后才该被拦 const nodeRoles = () => require('../../services/graph-db').getDb() .prepare(`SELECT role_name FROM org_graph_nodes WHERE company_id = ? ORDER BY role_name`) .all(COMPANY) .map((r) => r.role_name); beforeAll(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'seed-org-guard-')); for (const [id, type] of [ [ORG_ROLE, 'executor'], [BOOTSTRAP_ROLE, 'meta-bootstrap'], [PLANNING_ROLE, 'meta-planning'], ]) { const d = path.join(root, 'system', 'agents', id); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'agent.md'), `# ${id}\n`); fs.writeFileSync(path.join(d, 'config.json'), JSON.stringify({ type })); } fs.writeFileSync(path.join(root, 'system', 'agents', '_registry.json'), JSON.stringify({})); const companyDir = path.join(root, 'data', 'companies', COMPANY); fs.mkdirSync(companyDir, { recursive: true }); fs.writeFileSync( path.join(companyDir, 'company.json'), JSON.stringify({ id: COMPANY, name: COMPANY, agents: [ORG_ROLE, BOOTSTRAP_ROLE, PLANNING_ROLE] }) ); process.env.GRAPH_SEED_ROOT = root; process.env.GRAPH_DB_PATH = path.join(root, 'graph.sqlite'); for (const m of MODULES) delete require.cache[require.resolve(m)]; seed = require('../../services/graph-seed-service'); seed.seedAgents(); seed.seedCompanyAgents(); }); afterAll(() => { delete process.env.GRAPH_SEED_ROOT; delete process.env.GRAPH_DB_PATH; try { fs.rmSync(root, { recursive: true, force: true }); } catch {} for (const m of MODULES) delete require.cache[require.resolve(m)]; }); it('① meta-planning 本体不进 org_graph_nodes(meta-bootstrap 早就不进)', () => { // 守卫只认 meta-bootstrap 时这里是 ['org-planner', 'workgraph-planning']。 // 两侧都断言:只断言「没有 PLANNING_ROLE」的话,夹具一行都没种进去也会“通过”。 expect(nodeRoles()).toEqual([ORG_ROLE]); }); it('② verifyCounts() 的期望值跟着一致 —— 两边一致地错才是最难发现的那种', () => { const v = seed.verifyCounts(); expect(v.mismatches).toEqual([]); expect(v.ok).toBe(true); // 显式钉住期望值本身:光看 mismatches 为空,分不清「两边都对」和「两边一致地错」。 expect(v.expected.nodesPerCompany[COMPANY]).toBe(1); }); it('③ 库里已有的孤儿节点被 reconcile 清掉,且幂等', () => { const db = require('../../services/graph-db').getDb(); // 复刻漂移期间留下的存量行:authored_by IS NULL(hire 写 'hire'、writeGraph 写 'planner', // 只有补种脚本两个都不写)。生产库里 mycompany 就有 5 行这样的节点。 db.prepare(` INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at) VALUES (?, ?, ?, 'active', ?) `).run(COMPANY, PLANNING_ROLE, PLANNING_ROLE, new Date().toISOString()); expect(nodeRoles()).toEqual([ORG_ROLE, PLANNING_ROLE]); const r = seed.reconcileDanglingRows(); expect(r.orphanNonOrgNodesRemoved).toEqual([`${COMPANY}/${PLANNING_ROLE}`]); expect(nodeRoles()).toEqual([ORG_ROLE]); // 清完之后对账仍然干净(清理与期望值是同一条判据算出来的) expect(seed.verifyCounts().mismatches).toEqual([]); expect(seed.reconcileDanglingRows().orphanNonOrgNodesRemoved).toEqual([]); }); it('④ 漂移守卫:取值域只有一份 —— company-service 的字面量必须与 graph-service 一致', () => { // 判据的持有者是 graph-service.NON_ORG_AGENT_TYPES;company-service 眼下还留着一份 // 同名字面量(那是本轮之外的文件),所以这里直接读它的源码比对。 // 这条断言变红有两种正当读法:要么两边真的漂了,要么 company-service 已经改成 // `require('./graph-service').NON_ORG_AGENT_TYPES` —— 后者正是我们想要的终点, // 那时把这条用例删掉即可。两种都必须有人看见,所以不能只靠注释提醒。 const src = fs.readFileSync(path.resolve(__dirname, '../../services/company-service.js'), 'utf-8'); const m = src.match(/const NON_ORG_AGENT_TYPES = new Set\(\[([^\]]*)\]\)/); expect(m, 'company-service 里找不到 NON_ORG_AGENT_TYPES 的字面量了,请核对它是否已改为引用 graph-service').not.toBeNull(); const companyServiceTypes = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]).sort(); const graphServiceTypes = [...require('../../services/graph-service').NON_ORG_AGENT_TYPES].sort(); expect(companyServiceTypes).toEqual(graphServiceTypes); }); });