/** * system/work-graph-templates/*.json 的**数据契约**防线(R-19 / 27-P4 §4.6)。 * * 挡的是这一类缺陷:骨架的 forward 边只带控制流(condition / edgeKind / failReason), * 不带 produces / needs。照抄骨架得到的图于是**不传数据** —— 下游节点的提示词里 * 一条上游产物路径都没有,第四次真 CLI 冒烟里 org-planner-prd 的原话就是 * 「不是上游没产出,是它的产出结构上到不了我手上」。 * 补上声明前,那件事只能靠 workgraph-planning 的提示词施压来避免;换个模型就漏。 * * 与 template-refs.test.js 的分工:那边管**引用**(systemAgentId 指不指得到真本体、 * 落不落得进库),这边管**数据流**(边上有没有声明、声明落库后读不读得回来)。 * * 四条用例分别对应四环里的一环: * ① 骨架过引擎真正用的那套校验(含规则 6 data-contract:声明要吃的 kind 必须有产出方) * ② 每张骨架的**每条** forward 边都有非空声明(新加一张骨架漏写声明也会被抓) * ③ 声明落库:createWorkGraph → graph_edge_contracts 真有行,且 role/kind/required 与 JSON 一致 * ④ 读回:inboundContracts(describeInputs 的数据源)对每个非入口节点都非空 */ import { describe, it, expect, beforeAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const ROOT = path.resolve(__dirname, '../../..'); const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates'); /** * 占位符 → 真实存在的本体 id / 节点名。 * 落库那两条用例必须替换:work_graph_nodes.system_agent_id 有外键指向 system_agents(id), * `{{implAgent}}` 会直接 FOREIGN KEY constraint failed —— 那样测的就不是契约而是外键了。 * 取值只要求「system/templates/ 下真实存在」,语义上贴近即可。 */ const PLACEHOLDERS = { '{{orgNode}}': 'org-coder', '{{splitAgent}}': 'devloop-architect', '{{implAgent}}': 'devloop-coder', '{{testAgent}}': 'devloop-tester', '{{mergeAgent}}': 'devloop-integrator', '{{integrationAgent}}': 'devloop-tester', '{{validatorAgent}}': 'coder-validator', }; 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;替换后仍残留 {{ 就让它炸,不要静默落一张废图 */ function materialize(skeleton) { let text = JSON.stringify(skeleton.json); for (const [ph, real] of Object.entries(PLACEHOLDERS)) text = text.split(ph).join(real); if (text.includes('{{')) throw new Error(`${skeleton.file} 有未覆盖的占位符,请补 PLACEHOLDERS`); return JSON.parse(text); } const isForward = (e) => (e.edgeKind || e.edge_kind || 'forward') !== 'backward'; let wdb; let orgGraphService; let normalizeContracts; beforeAll(async () => { // 隔离手法与 template-refs.test.js / graph-engine.test.js 一致:先设 env 再 import。 // 仓库的 data/graph.sqlite 一行都不该被本文件碰到。 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'work-graph-skeletons-')); process.env.GRAPH_DB_PATH = path.join(tmp, 'graph.sqlite'); process.env.GRAPH_RUNS_DIR = path.join(tmp, 'runs'); const graphDb = await import('../../services/graph-db.js'); const seed = await import('../../services/graph-seed-service.js'); wdb = (await import('../../services/work-graph-db.js')).default; orgGraphService = (await import('../../services/org-graph-service.js')).default; normalizeContracts = orgGraphService.normalizeContracts; graphDb.default.getDb(); seed.default.seedTemplates(); // system/templates/* → system_agents(外键的另一端) }); describe('work graph 骨架的数据契约', () => { const skeletons = readSkeletons(); it('本目录下有骨架可查(防止目录改名让整个文件静默空跑)', () => { expect(skeletons.map((s) => s.file).sort()).toEqual([ 'executor-fanout.json', 'executor.json', 'planner.json', 'validator.json', ]); }); it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方)', () => { const verdicts = skeletons.map((s) => { const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges); return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`; }); expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]); }); it('每张骨架的每条 forward 边都有非空的 produces / needs 声明', () => { // 「至少一张有」不够:新增骨架时漏写声明必须当场红。 // backward 边反过来一律不许声明(validateGraph 会报 rule=schema),上一条用例已覆盖。 const naked = []; for (const s of skeletons) { for (const e of s.json.edges || []) { if (!isForward(e)) continue; if (normalizeContracts(e).length === 0) naked.push(`${s.file}: ${e.from} -> ${e.to}`); } } expect(naked).toEqual([]); }); it('声明真的落库:createWorkGraph 之后 graph_edge_contracts 里 kind/role/required 与 JSON 逐条一致', () => { // 这一步把「JSON 里写了」和「落库后读得到」分开:前者只是文本,后者才是四环闭合的第三环。 const mismatches = []; let contractRows = 0; for (const s of skeletons) { const json = materialize(s); const created = wdb.createWorkGraph({ companyId: 'work-graph-skeletons-test', taskId: `T-${s.json.id}`, nodes: json.nodes, edges: json.edges, }); expect(`${s.file}: ${created.ok ? 'ok' : JSON.stringify(created.errors)}`).toBe(`${s.file}: ok`); const def = wdb.readDefGraph('work', created.id); const nameOf = new Map(def.nodes.map((n) => [n.defNodeId, n.name])); // 按端点名配对,**不能**按声明序:readDefGraph 是 ORDER BY created_at, id, // 同一次事务里 created_at 全等,实际排序落到随机 id 上,落库序 ≠ 声明序。 const dbByEndpoints = new Map( def.edges .filter((e) => e.edgeKind !== 'backward') .map((e) => [`${nameOf.get(e.from)} -> ${nameOf.get(e.to)}`, e]) ); const jsonEdges = json.edges.filter(isForward); expect(dbByEndpoints.size).toBe(jsonEdges.length); for (const je of jsonEdges) { const label = `${s.file} ${je.from} -> ${je.to}`; const dbEdge = dbByEndpoints.get(`${je.from} -> ${je.to}`); if (!dbEdge) { mismatches.push(`${label}: 落库后找不到这条边`); continue; } const want = normalizeContracts(je) .map((c) => `${c.role}:${c.kind}:${c.required}`) .sort(); const got = wdb .edgeContracts('work', dbEdge.id) .map((r) => `${r.role}:${r.kind}:${r.required === 1}`) .sort(); contractRows += got.length; if (JSON.stringify(want) !== JSON.stringify(got)) { mismatches.push(`${label}: JSON=${JSON.stringify(want)} DB=${JSON.stringify(got)}`); } } } expect(mismatches).toEqual([]); expect(contractRows).toBeGreaterThan(0); // 全都对不上却"两边都空"时不许静默通过 }); it('读回:每个非入口节点的 inboundContracts 都非空(describeInputs 拼路径的数据源)', () => { // 引擎给下游拼「上游输入」段落走的就是这个函数(graph-engine.describeInputs)。 // 它对某个节点返回空 = 那个节点的提示词里一条上游路径都没有。 const starved = []; for (const s of skeletons) { const json = materialize(s); const created = wdb.createWorkGraph({ companyId: 'work-graph-skeletons-test', taskId: `T-inbound-${s.json.id}`, nodes: json.nodes, edges: json.edges, }); expect(created.ok).toBe(true); const def = wdb.readDefGraph('work', created.id); const hasForwardIn = new Set( def.edges.filter((e) => e.edgeKind !== 'backward').map((e) => String(e.to)) ); for (const n of def.nodes) { if (!hasForwardIn.has(String(n.defNodeId))) continue; // 入口节点没有入边,输入来自提示词 if (wdb.inboundContracts('work', def.edges, n.defNodeId).length === 0) { starved.push(`${s.file}: ${n.name}`); } } // 每张图至少有一个非入口节点,否则这条用例在"没节点可查"的情况下静默通过 expect(hasForwardIn.size).toBeGreaterThan(0); } expect(starved).toEqual([]); }); });