graph-edge-contract-role.test.js
12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
/**
* 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, source_kind, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, 'global_agent', ?, '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');
});
});