graph-edge-contracts.test.js
18.5 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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
/**
* 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, source_kind, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, 'global_agent', ?, '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 大小写/空格被归一(" 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/templates/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']);
}
});
});