/** * Unit Tests — work graph 骨架的悬空本体引用防线(R6-F1) * * 挡的是这一类缺陷:`system/work-graph-templates/*.json` 里写了一个 `systemAgentId`, * 而 `system/templates/` 下根本没有这个 agent。它不会在任何静态检查里报错, * 一路要跑到生产才炸——`work_graph_nodes.system_agent_id` 有外键指向 `system_agents(id)`, * `createWorkGraph` 落库时抛 `FOREIGN KEY constraint failed`, * `createWorkGraphSafely` 把它翻成 `rule=reference` 打回 agent,连打三轮后抛错, * 节点按「无 fail_reason 的 fail」走兜底边。而 `org-validator` 的模板里写死了 * 「必须用 validator.json 这张骨架」,所以这条路径是 100% 会踩的。 * * 实际发生过:validator.json 引用 benchmark-validator / testdata-validator, * planner.json 引用 brd-planning / prd-planning / sdd-planning / goal-benchmark, * 六个全是空引用(R6-F1)。 * * ── 为什么校验的是「目录」而不只是「_registry.json 条目」────────────── * `system_agents` 的数据源头是**目录扫描**:`graph-seed-service.seedTemplates()` * 读 `system/templates/*` 的子目录逐个 upsert,`_registry.json` 只用来补充可选的 * `benchmarkScore`(匹配不上就留 NULL)。所以决定外键成不成立的是**目录在不在**。 * 但 registry 是这批 agent 的人类可读清单,漏登记会让 `_registry.json` 与真实本体集 * 悄悄发散——两条都查,一条挡外键、一条挡元数据漂移。 * * ── 为什么不顺带查 `system/org-graph-templates/` ──────────────────── * 那边的节点是 org node 角色名(`org-planner` / `org-coder` …), * 它们不是 `system/templates/` 下的模板,而是建公司时由 * `graph-seed-service.seedCompanyAgents()` 以 `source_kind='company_hired'` 落库的。 * 拿本文件这套判据去查它,会把正确的东西判成错的。 */ import { describe, it, expect } from 'vitest'; import fs from 'fs'; import path from 'path'; const ROOT = path.resolve(__dirname, '../../..'); const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates'); const TEMPLATES_DIR = path.join(ROOT, 'system/templates'); const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); /** `{{orgNode}}` 这类占位符不是合法本体名,由使用方替换(README「`{{占位符}}` 必须替换」) */ const isPlaceholder = (s) => /\{\{.*\}\}/.test(s); function readSkeletons() { return fs .readdirSync(WORK_GRAPH_DIR) .filter((f) => f.endsWith('.json')) .map((f) => ({ file: f, json: JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_DIR, f), 'utf-8')) })); } /** * 骨架里所有被引用的本体 id。 * `normalizeNodes`(work-graph-db.js)在缺 systemAgentId 时回退到 nodeName—— * 这里照同一条规则取,否则漏掉的正是最容易写错的那种节点。 */ function referencedAgentIds(skeleton) { const out = []; for (const n of skeleton.json.nodes || []) { const nodeName = typeof n === 'string' ? n : n && (n.nodeName || n.node_name); const id = (typeof n === 'object' && n && (n.systemAgentId || n.system_agent_id)) || nodeName; if (id && !isPlaceholder(id)) out.push({ id, nodeName, file: skeleton.file }); } return out; } describe('work graph 骨架的本体引用', () => { const skeletons = readSkeletons(); it('本目录下有骨架可查(防止测试因目录改名而静默变成空跑)', () => { expect(skeletons.length).toBeGreaterThan(0); }); it('每个 systemAgentId 在 system/templates/ 下都有目录(决定外键成不成立的那一环)', () => { const dangling = []; for (const s of skeletons) { for (const ref of referencedAgentIds(s)) { if (!fs.existsSync(path.join(TEMPLATES_DIR, ref.id))) dangling.push(ref); } } expect( dangling.map((d) => `${d.file}: 节点 ${d.nodeName} → system/templates/${d.id} 不存在`), ).toEqual([]); }); it('每个 systemAgentId 在 _registry.json 里都有条目', () => { const registry = JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf-8')); expect(Array.isArray(registry)).toBe(true); const known = new Set(registry.map((e) => e.id)); const missing = []; for (const s of skeletons) { for (const ref of referencedAgentIds(s)) { if (!known.has(ref.id)) missing.push(ref); } } expect( missing.map((m) => `${m.file}: 节点 ${m.nodeName} → _registry.json 里没有 ${m.id}`), ).toEqual([]); }); it('引用的本体确实能落库:真的建一次库并写入无占位符的骨架', async () => { // 隔离手法与 graph-engine.test.js 一致:先设 GRAPH_DB_PATH 再 import,真实 data/graph.sqlite 不被碰。 const os = await import('os'); const dbPath = path.join( fs.mkdtempSync(path.join(os.default.tmpdir(), 'template-refs-')), 'graph.sqlite', ); process.env.GRAPH_DB_PATH = dbPath; const graphDb = await import('../../services/graph-db.js'); const seed = await import('../../services/graph-seed-service.js'); const wdb = await import('../../services/work-graph-db.js'); graphDb.default.getDb(); seed.default.seedTemplates(); // system/templates/* → system_agents(外键的另一端) const landed = []; for (const s of skeletons) { if (JSON.stringify(s.json).includes('{{')) continue; // 占位符骨架落库前必须先替换,不在本条覆盖范围 const res = wdb.default.createWorkGraph({ companyId: 'template-refs-test', nodes: s.json.nodes, edges: s.json.edges, }); landed.push(`${s.file}: ${res.ok ? 'ok' : JSON.stringify(res.errors)}`); } // 无占位符的骨架必须至少有一张(validator.json 是 org-validator 强制使用的那张), // 否则这条用例会在"没有任何图被落库"的情况下静默通过。 expect(landed.length).toBeGreaterThan(0); expect(landed.filter((l) => !l.endsWith(': ok'))).toEqual([]); }); });