/** * Unit Tests — R-19.A 边上的数据契约:声明 → 落库 → 读回(27-P4 §4.6) * * 补的是 925d9db 之后**还空着的那一环**:`graph_files` / `graph_edge_contracts` 两张表、 * work-graph-db 的六个 API、graph-engine 两侧(describeInputs / indexNodeOutputs)都在了, * 但**没有任何地方声明边上的契约** —— `declareEdgeContract` 零调用方,于是 * `inboundContracts` 恒返回空,「沿边传递」这一整类数据流实际不生效(地基铺好了没人踩)。 * * 所以本文件锁的是三件事,缺一条这一环就又空了: * ① 规划侧写在边上的 `produces` / `needs`,经 createWorkGraph / writeGraph **真的落库** * (两层都验:work 边 id 是 TEXT、org 边 id 是 INTEGER,contracts 表里都存字符串形式) * ② 下游节点的 `inboundContracts` **真的非空**(这是 describeInputs 拼路径的唯一依据) * ③ R-19.4 的结构校验:声明要吃 kind=X 而全图没人产 X → 写入前就拒,一行都不落库 * * 为什么 `produces` / `needs` 是**两个**键(这层非对称是 ③ 的前提): * 合成一个键的话,同一行契约既是「上游产出」又是「下游要吃」, * 「声明要吃 X 但没人产 X」永远自洽 —— 校验会变成一条永远不报错的空跑规则。 * * 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 全程不碰; * 全部走服务层,不起 Claude CLI。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; const ROOT = path.resolve(__dirname, '../../..'); const TEST_DB_PATH = '/tmp/test-graph-edge-contracts.sqlite'; const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]; const MODULES = [ '../../services/graph-db', '../../services/graph-service', '../../services/org-graph-service', '../../services/work-graph-db', '../../services/graph-run-sse', '../../services/graph-engine', ]; describe('R-19.A 边上的数据契约:声明 → 落库 → 读回', () => { let wdb; let org; let engine; let runsDir; beforeAll(() => { for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } process.env.GRAPH_DB_PATH = TEST_DB_PATH; runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-contracts-runs-')); process.env.GRAPH_RUNS_DIR = runsDir; for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); org = require('../../services/org-graph-service'); engine = require('../../services/graph-engine'); }); afterAll(() => { for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} delete process.env.GRAPH_RUNS_DIR; for (const m of MODULES) delete require.cache[require.resolve(m)]; }); /** work_graph_nodes.system_agent_id 有外键指向 system_agents,先把本体行补上 */ function ensureAgents(ids) { const db = wdb.getDb(); const ts = new Date().toISOString(); const ins = db.prepare(` INSERT OR IGNORE INTO system_agents (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at) VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?) `); for (const id of new Set(ids)) ins.run(id, id, `system/agents/${id}`, ts, ts); } /** org 层:按 roleName 取 active 节点 id(org 边的 from/to 是数字 id) */ const orgNodeId = (companyId, roleName) => wdb.getDb() .prepare(`SELECT id FROM org_graph_nodes WHERE company_id = ? AND role_name = ? AND status = 'active'`) .get(companyId, roleName).id; /** org 层:把 getGraph 的边补上行 id,好交给 inboundContracts(它按 e.id 查契约) */ const orgEdges = (companyId) => wdb.getDb() .prepare(` SELECT id, from_node_id AS "from", to_node_id AS "to", edge_kind AS edgeKind FROM org_graph_edges WHERE company_id = ? AND status = 'active' ORDER BY id `) .all(companyId); const kindsOf = (rows) => rows.map((r) => r.kind).sort(); // ── ① + ② work 层(全链路)──────────────────────────────────── it('① work 层:边上的 produces 落进 graph_edge_contracts,下游 inboundContracts 非空', () => { const companyId = 'co-contract-work'; ensureAgents(['brd-planning', 'prd-planning', 'sdd-planning']); const g = wdb.createWorkGraph({ companyId, taskId: 'T-work', nodes: [ { nodeName: 'p-brd', systemAgentId: 'brd-planning' }, { nodeName: 'p-prd', systemAgentId: 'prd-planning' }, { nodeName: 'p-sdd', systemAgentId: 'sdd-planning' }, ], edges: [ // 字符串写法 + 数组写法都要收(模板里两种都可能出现) { from: 'p-brd', to: 'p-prd', condition: 'success', produces: 'brd' }, // needs:sdd 还要吃 brd,而 brd 的产出方**不是**紧邻上游(跨节点中继) { from: 'p-prd', to: 'p-sdd', condition: 'success', produces: ['prd'], needs: ['brd'] }, ], }); expect(g.errors || []).toEqual([]); expect(g.ok).toBe(true); const def = wdb.readDefGraph('work', g.id); const byName = new Map(def.nodes.map((n) => [n.name, n.defNodeId])); const edgeId = (from, to) => def.edges.find((e) => e.from === byName.get(from) && e.to === byName.get(to)).id; // 落库:契约挂在对应的那条边上(work 边 id 是 TEXT) expect(kindsOf(wdb.edgeContracts('work', edgeId('p-brd', 'p-prd')))).toEqual(['brd']); expect(kindsOf(wdb.edgeContracts('work', edgeId('p-prd', 'p-sdd')))).toEqual(['brd', 'prd']); // 读回:这是 describeInputs 拼上游路径的唯一依据,非空才算这一环通了 const inPrd = wdb.inboundContracts('work', def.edges, byName.get('p-prd')); expect(inPrd.map((c) => c.kind)).toEqual(['brd']); expect(inPrd[0].fromNodeId).toBe(byName.get('p-brd')); expect(inPrd[0].required).toBe(1); expect(kindsOf(wdb.inboundContracts('work', def.edges, byName.get('p-sdd')))).toEqual(['brd', 'prd']); // 入口节点没有入边 → 没有契约(不该凭空多出输入) expect(wdb.inboundContracts('work', def.edges, byName.get('p-brd'))).toEqual([]); }); it('① required:false 落成 0(describeInputs 靠它决定缺实例时报不报 ⚠️)', () => { const companyId = 'co-contract-optional'; ensureAgents(['coder', 'coder-validator']); const g = wdb.createWorkGraph({ companyId, taskId: 'T-optional', nodes: [ { nodeName: 'c-impl', systemAgentId: 'coder' }, { nodeName: 'c-val', systemAgentId: 'coder-validator' }, ], edges: [ { from: 'c-impl', to: 'c-val', condition: 'success', // 产出方在上一层 org graph / artifacts 里,本图管不着 → 可选 needs: [{ kind: 'acceptance', required: false }], }, ], }); expect(g.errors || []).toEqual([]); const def = wdb.readDefGraph('work', g.id); const rows = wdb.edgeContracts('work', def.edges[0].id); expect(rows.map((r) => [r.kind, r.required])).toEqual([['acceptance', 0]]); }); it('① kind=log 永远落成 required=0,哪怕调用方(模板/LLM 规划)写的是 required:true(33 号文档 + snakegame-co T-mtfh2kuf-7c31 两次复现)', () => { const companyId = 'co-contract-log-floor'; ensureAgents(['unit-tester', 'coder-validator']); const g = wdb.createWorkGraph({ companyId, taskId: 'T-log-floor', nodes: [ { nodeName: 'l-test', systemAgentId: 'unit-tester' }, { nodeName: 'l-val', systemAgentId: 'coder-validator' }, ], edges: [ // 裸数组写法 = normalizeContracts 默认 required:true,正是漂移复现的形状 { from: 'l-test', to: 'l-val', condition: 'success', produces: ['log'] }, ], }); expect(g.errors || []).toEqual([]); const def = wdb.readDefGraph('work', g.id); const rows = wdb.edgeContracts('work', def.edges[0].id); expect(rows.map((r) => [r.kind, r.required])).toEqual([['log', 0]]); }); it('① kind 大小写/空格被归一(" PRD " 与 "prd" 是同一个契约,不产生两行)', () => { const companyId = 'co-contract-case'; ensureAgents(['prd-planning', 'sdd-planning']); const g = wdb.createWorkGraph({ companyId, taskId: 'T-case', nodes: [ { nodeName: 'k-prd', systemAgentId: 'prd-planning' }, { nodeName: 'k-sdd', systemAgentId: 'sdd-planning' }, ], edges: [{ from: 'k-prd', to: 'k-sdd', condition: 'success', produces: [' PRD ', 'prd'] }], }); expect(g.errors || []).toEqual([]); const def = wdb.readDefGraph('work', g.id); expect(kindsOf(wdb.edgeContracts('work', def.edges[0].id))).toEqual(['prd']); }); // ── ② 全四环(这一条才证明「规划侧声明」真的接到了引擎输入侧)── it('② 四环闭合:createWorkGraph 声明 → 引擎登记实例 → 下游提示词里是上游产物的绝对路径', async () => { const companyId = 'co-contract-e2e'; const taskId = 'T-e2e'; ensureAgents(['brd-planning', 'prd-planning']); // ① 产生:规划侧只写契约(不写文件名——此刻文件还不存在) const g = wdb.createWorkGraph({ companyId, taskId, nodes: [ { nodeName: 'e-brd', systemAgentId: 'brd-planning' }, { nodeName: 'e-prd', systemAgentId: 'prd-planning' }, ], edges: [{ from: 'e-brd', to: 'e-prd', condition: 'success', produces: ['brd'] }], }); expect(g.errors || []).toEqual([]); const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId }); engine.markRunReady(run.id); engine.startRun(run.id, { mode: 'manual' }); // 上游节点落盘一份 brd.md(形状与 defaultAgentExecutor 一致:产物在节点私有目录,outputRef 指向它) const prompts = new Map(); engine.setAgentExecutor(async (ctx) => { prompts.set(ctx.nodeName, ctx.task); if (ctx.nodeName !== 'e-brd') return { status: 'success' }; const dir = path.join(runsDir, ctx.run.root_run_id, 'nodes', ctx.nodeRunId); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'brd.md'), '# BRD'); return { status: 'success', outputRef: dir }; }); try { await engine.advanceOne(run.id); // e-brd await engine.advanceOne(run.id); // e-prd } finally { engine.setAgentExecutor(null); } // ③ 落库:kind 取自**出边上的声明**(不是文件名兜底的 artifact) const rows = wdb.findFiles({ companyId, taskId, kinds: ['brd'] }); expect(rows).toHaveLength(1); expect(path.basename(rows[0].rel_path)).toBe('brd.md'); // ④ 读回:下游提示词里是能直接 open 的绝对路径,且没有「查不到实例」的告警 const downstream = prompts.get('e-prd'); expect(downstream).toBeTruthy(); expect(downstream).toContain('[上游输入'); const abs = path.join(runsDir, '_companies', companyId, rows[0].rel_path); expect(fs.existsSync(abs)).toBe(true); expect(downstream).toContain(`- brd:${abs}`); expect(downstream).not.toContain('⚠️ 上游声明会产出'); // 上游自己没有入边,不该凭空多一段输入 expect(prompts.get('e-brd')).not.toContain('[上游输入'); }); // ── ① + ② org 层 ───────────────────────────────────────────── it('① org 层:writeGraph 的边声明同样落库,inboundContracts 对 org 节点非空', () => { const companyId = 'co-contract-org'; const res = org.writeGraph(companyId, { nodes: ['org-planner', 'org-coder', 'org-validator'], edges: [ { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['prd', 'acceptance'] }, // R-9.3 的非相邻依赖:acceptance 由 planner 产、validator 吃,中间隔着 coder { from: 'org-coder', to: 'org-validator', condition: 'success', needs: ['acceptance'] }, { from: 'org-validator', to: 'org-coder', condition: 'fail', edgeKind: 'backward' }, ], }); expect(res.errors || []).toEqual([]); expect(res.ok).toBe(true); const edges = orgEdges(companyId); const find = (from, to) => edges.find((e) => e.from === orgNodeId(companyId, from) && e.to === orgNodeId(companyId, to)); // org 边 id 是 INTEGER,contracts 表里存字符串形式 —— 两种类型都要能查回来 const planned = find('org-planner', 'org-coder'); expect(kindsOf(wdb.edgeContracts('org', planned.id))).toEqual(['acceptance', 'prd']); expect(kindsOf(wdb.edgeContracts('org', String(planned.id)))).toEqual(['acceptance', 'prd']); // backward 边上没有契约(规则不允许声明,见 ③) expect(wdb.edgeContracts('org', find('org-validator', 'org-coder').id)).toEqual([]); const inbound = wdb.inboundContracts('org', edges, orgNodeId(companyId, 'org-validator')); expect(kindsOf(inbound)).toEqual(['acceptance']); // 入边是 org-coder 那条 forward 边;backward 入边不算数据来源 expect(String(inbound[0].fromNodeId)).toBe(String(orgNodeId(companyId, 'org-coder'))); }); it('① 同一 payload 里重复边被去重键吞掉时,契约仍挂到同一条边上(不静默蒸发)', () => { const companyId = 'co-contract-dupe'; const res = org.writeGraph(companyId, { nodes: ['org-planner', 'org-coder'], edges: [ { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['prd'] }, // 完全同一条边(撞 idx_org_graph_edges_dedupe_active,插入被 DO NOTHING 吞掉) { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['sdd'] }, ], }); expect(res.ok).toBe(true); expect(res.edgeCount).toBe(1); // 实际落库行数,不是入参长度 const edges = orgEdges(companyId); expect(edges).toHaveLength(1); expect(kindsOf(wdb.edgeContracts('org', edges[0].id))).toEqual(['prd', 'sdd']); }); // ── ③ R-19.4 写入前校验 ────────────────────────────────────── it('③ 声明要吃 brd 但全图没人产 brd → 两层都拒,rule=data-contract 且点名缺哪个 kind', () => { const nodes = [{ nodeName: 'w-impl' }, { nodeName: 'w-val' }]; const edges = [{ from: 'w-impl', to: 'w-val', condition: 'success', needs: ['brd'] }]; const workVerdict = wdb.validateWorkGraph(nodes, edges); expect(workVerdict.ok).toBe(false); expect(workVerdict.errors.map((e) => e.rule)).toContain('data-contract'); expect(workVerdict.errors.find((e) => e.rule === 'data-contract').detail).toContain('brd'); const orgVerdict = org.validateGraph( [{ roleName: 'w-impl' }, { roleName: 'w-val' }], edges, ); expect(orgVerdict.ok).toBe(false); expect(orgVerdict.errors.map((e) => e.rule)).toContain('data-contract'); }); it('③ 被拒的图一行都不落库(work / org 两条写入通道)', () => { const companyId = 'co-contract-rejected'; ensureAgents(['coder', 'coder-validator']); const created = wdb.createWorkGraph({ companyId, taskId: 'T-rejected', nodes: [ { nodeName: 'r-impl', systemAgentId: 'coder' }, { nodeName: 'r-val', systemAgentId: 'coder-validator' }, ], edges: [{ from: 'r-impl', to: 'r-val', condition: 'success', needs: ['sdd'] }], }); expect(created.ok).toBe(false); expect(created.errors.map((e) => e.rule)).toContain('data-contract'); expect( wdb.getDb().prepare(`SELECT COUNT(*) AS n FROM work_graphs WHERE company_id = ?`).get(companyId).n, ).toBe(0); const written = org.writeGraph(companyId, { nodes: ['org-planner', 'org-coder'], edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success', needs: ['sdd'] }], }); expect(written.ok).toBe(false); expect(written.errors.map((e) => e.rule)).toContain('data-contract'); expect(orgEdges(companyId)).toEqual([]); expect( wdb.getDb() .prepare(`SELECT COUNT(*) AS n FROM org_graph_nodes WHERE company_id = ? AND status = 'active'`) .get(companyId).n, ).toBe(0); }); it('③ required:false 的 needs 不要求本图内有产出方(显式逃生口,不是漏网)', () => { const verdict = wdb.validateWorkGraph( [{ nodeName: 'o-impl' }, { nodeName: 'o-val' }], [{ from: 'o-impl', to: 'o-val', condition: 'success', needs: [{ kind: 'acceptance', required: false }], }], ); expect(verdict).toEqual({ ok: true, errors: [] }); }); it('③ produces 自带产出方,正常链式声明不该被规则 6 误伤', () => { const verdict = wdb.validateWorkGraph( [{ nodeName: 'a-brd' }, { nodeName: 'a-prd' }, { nodeName: 'a-sdd' }], [ { from: 'a-brd', to: 'a-prd', condition: 'success', produces: ['brd'] }, { from: 'a-prd', to: 'a-sdd', condition: 'success', produces: ['prd'], needs: ['brd'] }, ], ); expect(verdict).toEqual({ ok: true, errors: [] }); }); it('③ 非法 kind 与「写在 backward 边上」都按 schema 拒(后者是静默失效的坑)', () => { const illegal = wdb.validateWorkGraph( [{ nodeName: 's-a' }, { nodeName: 's-b' }], [{ from: 's-a', to: 's-b', condition: 'success', produces: ['blueprint'] }], ); expect(illegal.ok).toBe(false); expect(illegal.errors[0].rule).toBe('schema'); expect(illegal.errors[0].detail).toContain('blueprint'); // 取值域要在报错里给出来,agent 才改得动 for (const kind of org.ARTIFACT_KINDS) expect(illegal.errors[0].detail).toContain(kind); const onBackward = wdb.validateWorkGraph( [{ nodeName: 's-a' }, { nodeName: 's-b' }], [ { from: 's-a', to: 's-b', condition: 'success' }, { from: 's-b', to: 's-a', condition: 'fail', edgeKind: 'backward', produces: ['prd'] }, ], ); expect(onBackward.ok).toBe(false); expect(onBackward.errors.map((e) => e.rule)).toEqual(['schema']); expect(onBackward.errors[0].detail).toMatch(/backward/); }); // ── 防漂移:模板教的键名必须是落库侧真读的那个 ──────────────── it('模板里写的边上声明键名,归一函数必须真的认(差一个字母就是静默失效)', () => { const dir = path.join(ROOT, 'system/agents/workgraph-planning'); const text = fs.readdirSync(dir) .filter((f) => f.endsWith('.md')) .map((f) => fs.readFileSync(path.join(dir, f), 'utf-8')) .join('\n'); // 模板必须两个键都教到(少了 produces 就没人产、少了 needs 就表达不了非相邻依赖) expect(text).toMatch(/"produces"/); expect(text).toMatch(/"needs"/); // 反向:模板里出现的每个「边上声明键」都得被 normalizeContracts 读进契约行 const keys = [...new Set([...text.matchAll(/"(produces|needs|carries|consumes|inputs|outputs)"/g)] .map((m) => m[1]))]; expect(keys.length).toBeGreaterThan(0); for (const key of keys) { const contracts = org.normalizeContracts({ from: 'x', to: 'y', [key]: ['brd'] }); expect(contracts.map((c) => c.kind), `模板教了 "${key}",但归一函数不读这个键`).toEqual(['brd']); } }); });