/** * Unit Tests — R-19.A2 契约的 role 落库 + PATCH 通道上的规则 6(27-P4 §4.6) * * 补的是 R-19.A 之后**还漏的那一环**,形状与前十三次完全一样: * `normalizeContracts` 产出 `role`(produces / needs),`declareEdgeContract` 的入参里没有它、 * 表也没有这一列 —— 产生 → **传递(丢)** → 落库 → 读回。 * * 为什么这一环非补不可:规则 6(data-contract)靠「有人 needs 但没人 produces」判错, * 只存 kind 的话从库里读回来就分不清谁该产、谁只是要吃,校验在库这一侧永远算不出来。 * 而 `patchGraph` 第 5 步「改完的整张图必须仍然合法」读的正是库 —— * 增量 PATCH(删节点 / 删边)又恰恰是「此前满足的 needs 失去产出方」的**唯一发生地**, * 于是校验偏偏在最该生效的那条通道上失效。全量 writeGraph 反而没事: * 规划 agent 刚写的声明还在内存里那份数组上。 * * 四条用例分别锁:① role 真的落库且两个值可区分 ② readGraph 把契约读回来 * ③ **PATCH 删掉产出方 → 整体回滚**(本次修复的全部意义)④ 旧行 NULL role 按 produces 读。 * * 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 全程不碰; * 全部走服务层真入口,不起 Claude CLI。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; const TEST_DB_PATH = '/tmp/test-graph-contract-role.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', ]; describe('R-19.A2 契约 role 落库 + PATCH 通道的 data-contract 校验', () => { let wdb; let org; beforeAll(() => { for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } process.env.GRAPH_DB_PATH = TEST_DB_PATH; for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); org = require('../../services/org-graph-service'); }); afterAll(() => { for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; for (const m of MODULES) delete require.cache[require.resolve(m)]; }); 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; const edgeIdOf = (companyId, from, to) => wdb.getDb() .prepare(` SELECT id FROM org_graph_edges WHERE company_id = ? AND from_node_id = ? AND to_node_id = ? AND status = 'active' `) .get(companyId, orgNodeId(companyId, from), orgNodeId(companyId, to)).id; /** 一张三节点的图:planner 产 prd+acceptance,validator 隔着 coder 吃 acceptance */ function seedRelayGraph(companyId) { const res = org.writeGraph(companyId, { nodes: ['org-planner', 'org-coder', 'org-validator'], edges: [ { from: 'org-planner', to: 'org-coder', condition: 'success', produces: ['prd', 'acceptance'] }, { 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); return res; } it('① role 落进 graph_edge_contracts,produces / needs 两个值可区分', () => { const companyId = 'co-role-store'; seedRelayGraph(companyId); const roleOf = (from, to, kind) => wdb.edgeContracts('org', edgeIdOf(companyId, from, to)).find((c) => c.kind === kind).role; // 同一个 kind(acceptance)在两条边上是两个不同的 role —— 只存 kind 时这层区别就没了 expect(roleOf('org-planner', 'org-coder', 'acceptance')).toBe('produces'); expect(roleOf('org-coder', 'org-validator', 'acceptance')).toBe('needs'); expect(roleOf('org-planner', 'org-coder', 'prd')).toBe('produces'); }); it('① work 层同样落 role(两层共用一张表、一个归一函数)', () => { const companyId = 'co-role-work'; 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 ['brd-planning', 'prd-planning', 'sdd-planning']) { ins.run(id, id, `system/agents/${id}`, ts, ts); } const g = wdb.createWorkGraph({ companyId, taskId: 'T-role', 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' }, { from: 'p-prd', to: 'p-sdd', condition: 'success', produces: ['prd'], needs: ['brd'] }, ], }); 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 relay = def.edges.find( (e) => e.from === byName.get('p-prd') && e.to === byName.get('p-sdd') ); const byKind = new Map(wdb.edgeContracts('work', relay.id).map((c) => [c.kind, c.role])); expect(byKind.get('prd')).toBe('produces'); expect(byKind.get('brd')).toBe('needs'); }); it('② readGraph / getGraph 把契约读回来(PATCH 校验与控制台都靠它)', () => { const companyId = 'co-role-read'; seedRelayGraph(companyId); const graph = org.getGraph(companyId); const edge = graph.edges.find( (e) => e.from === orgNodeId(companyId, 'org-coder') && e.to === orgNodeId(companyId, 'org-validator') ); // 缺了这一行,第 5 步的重新校验拿到的是一张没有契约的图 → 规则 6 恒真通过 expect(edge.contracts).toEqual([{ kind: 'acceptance', required: true, role: 'needs' }]); // 无声明的边上 contracts 整个字段不出现(与 failReason 同一个约定: // 绝大多数存量边一条声明都没有,恒空数组会让既有消费方凭空多出一个字段)。 // backward 边按规则不许声明契约,正好是这种形态。 const back = graph.edges.find( (e) => e.from === orgNodeId(companyId, 'org-validator') && e.to === orgNodeId(companyId, 'org-coder') ); expect(back).not.toHaveProperty('contracts'); // 对外形状不带内部行 id expect(edge).not.toHaveProperty('id'); }); it('③ PATCH 删掉产出方节点 → 规则 6 拒绝,整个 PATCH 回滚', () => { const companyId = 'co-role-patch'; seedRelayGraph(companyId); // org-planner 是 acceptance 的唯一产出方;删了它,validator 那条 needs 就悬空了。 // 这正是「跑到那一步才由 agent 说『我拿不到输入』」的结构版。 const res = org.patchGraph(companyId, { removeNodes: ['org-planner'] }); expect(res.ok).toBe(false); const contractErr = (res.errors || []).find((e) => e.rule === 'data-contract'); expect(contractErr).toBeTruthy(); expect(contractErr.detail).toContain('acceptance'); // 回滚:planner 和它的契约都还在,库是改动前的样子 expect(orgNodeId(companyId, 'org-planner')).toBeTruthy(); expect( wdb.edgeContracts('org', edgeIdOf(companyId, 'org-planner', 'org-coder')).map((c) => c.kind).sort() ).toEqual(['acceptance', 'prd']); }); it('③ 同一次 PATCH 里补上新产出方 → 通过(校验的是终态,不是删除动作本身)', () => { const companyId = 'co-role-patch-ok'; seedRelayGraph(companyId); // 删掉 planner 的同时让另一个节点接过 acceptance 的产出 —— 终态合法就该放行, // 否则这条校验会变成「任何删除都不许」的死规则。 const res = org.patchGraph(companyId, { addNodes: [{ roleName: 'org-brd' }], removeNodes: ['org-planner'], addEdges: [ { from: 'org-brd', to: 'org-coder', condition: 'success', produces: ['prd', 'acceptance'] }, ], }); expect(res.errors || []).toEqual([]); expect(res.ok).toBe(true); expect( wdb.edgeContracts('org', edgeIdOf(companyId, 'org-brd', 'org-coder')).map((c) => c.role) ).toEqual(['produces', 'produces']); }); it('④ 无契约声明的图(老库里的常态)PATCH 照旧放行,规则 6 不凭空报错', () => { const companyId = 'co-role-nocontract'; const res0 = org.writeGraph(companyId, { nodes: ['org-planner', 'org-coder'], edges: [{ from: 'org-planner', to: 'org-coder', condition: 'success' }], }); expect(res0.ok).toBe(true); // 一条契约都没声明时,producedKinds 与 needs 都是空集 —— 规则 6 该完全沉默。 // 存量公司的图全是这个形状,误报一条就是把所有老图的 PATCH 通道堵死。 // (节点必须连边加进来,否则撞的是 single-entry / reachable 那两条既有规则,与本条无关) const res = org.patchGraph(companyId, { addNodes: [{ roleName: 'org-extra' }], addEdges: [{ from: 'org-coder', to: 'org-extra', condition: 'success' }], }); expect(res.errors || []).toEqual([]); expect(res.ok).toBe(true); }); }); /** * 迁移路径单独一个库:新建库的 CREATE TABLE 里 role 是 NOT NULL, * 所以「role 为 NULL 的旧行」在新库里**结构上不可能出现** —— 往新库里硬塞 NULL * 测的是个不存在的状态。真造一个 R-19.A2 之前的表形态,让 ensureSchema 迁过去, * 测的才是 ALTER + 回填这条真实路径。 */ describe('R-19.A2 迁移:老库补 role 列并回填 produces', () => { const MIG_DB = '/tmp/test-graph-contract-role-mig.sqlite'; const MIG_FILES = [MIG_DB, `${MIG_DB}-shm`, `${MIG_DB}-wal`]; let wdb; beforeAll(() => { for (const f of MIG_FILES) { try { fs.unlinkSync(f); } catch {} } // 1. 造出 R-19.A2 之前的表形态(没有 role 列)并写入一行「旧行」 const Database = require('better-sqlite3'); const raw = new Database(MIG_DB); raw.exec(` CREATE TABLE graph_edge_contracts ( id TEXT PRIMARY KEY, edge_layer TEXT NOT NULL CHECK (edge_layer IN ('org','work')), edge_id TEXT NOT NULL, kind TEXT NOT NULL, required INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL ); CREATE UNIQUE INDEX idx_graph_edge_contracts_dedupe ON graph_edge_contracts(edge_layer, edge_id, kind); `); raw .prepare(` INSERT INTO graph_edge_contracts (id, edge_layer, edge_id, kind, required, created_at) VALUES ('gec_legacy', 'org', '7', 'acceptance', 1, '2026-01-01T00:00:00.000Z') `) .run(); raw.close(); // 2. 让 ensureSchema 对着这个库跑一遍(require 时触发) process.env.GRAPH_DB_PATH = MIG_DB; for (const m of MODULES) delete require.cache[require.resolve(m)]; wdb = require('../../services/work-graph-db'); }); afterAll(() => { for (const f of MIG_FILES) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; for (const m of MODULES) delete require.cache[require.resolve(m)]; }); it('旧行回填成 produces,而不是留空', () => { const row = wdb.edgeContracts('org', '7').find((c) => c.id === 'gec_legacy'); expect(row).toBeTruthy(); // 回填 produces 而不是 NULL:规则 6 里 NULL 既不算产出也不算需求, // 一条本该是 produces 的旧行留空,会让下游的 needs 突然找不到产出方 —— // 那是误拒一张合法的图。校验闸门宁可漏判不可错拦。 expect(row.role).toBe('produces'); expect(row.kind).toBe('acceptance'); expect(row.required).toBe(1); }); it('迁移后新写入的行照常带 role(迁移不是一次性补丁,是新形态的起点)', () => { wdb.declareEdgeContract({ edgeLayer: 'org', edgeId: '9', kind: 'prd', role: 'needs' }); expect(wdb.edgeContracts('org', '9')[0].role).toBe('needs'); }); });