graph-files-index.test.js
20.2 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
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
/**
* Unit Tests — R-19 产物索引(27-P4 §4.6)
*
* 锁的是第四次真 CLI 冒烟那个断点的正解。当时 org-planner-prd 的归因原话是
* 「不是上游没产出,是它的产出**结构上到不了我手上**」—— 边只承载控制流
* (condition / edge_kind / fail_reason),上游 brd.md 躺在节点私有目录,
* 下游提示词里没有任何可读路径。
*
* 每条用例都按「产生 → 传递 → 落库 → 读回」四环走真入口:
* ① 版本链 同路径二次产出标 superseded_by,不删行(落库)
* ② 部分唯一索引 「同一键只有一个活的 + 留历史」这个选择本身
* ③a 沿边传递 出边声明 kind → 引擎登记实例 → 下游提示词里是绝对路径(全四环)
* ③b 任务级共享 acceptance 对**非相邻**节点也可见(R-9.3 planner→(coder)→validator)
* ④ 缺输入告警 声明了却查不到实例时提示词里出现 ⚠️,不静默省略(R-19.4 读回端)
* ⑤ 产出侧过滤 attempt-*.json 不登记(引擎自读的归因文件);节点 fail 不登记
* ⑥ 索引重建 删掉 graph_files 后能从 graph_run_nodes 重建
*
* describeInputs / indexNodeOutputs / companyRootDir **故意不导出**(graph-engine.js
* module.exports 那段注释写明了口径:不为测试新开公开面),所以这里全部通过 advanceOne
* 这个真入口验 —— 输入侧读 executor 收到的 ctx.task,产出侧读 graph_files 落库行。
*
* 隔离手法同 graph-boundary.test.js:先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,
* 仓库 data/ 全程不被碰;executor 全用注入的假货,不起 Claude CLI。
* (注入 executor 还顺带关掉了边界守卫 —— boundaryGuardApplicable 只在
* agentExecutor === defaultAgentExecutor 时成立,所以这些用例不扫宿主仓库。)
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
describe('R-19 产物索引:graph_files / graph_edge_contracts', () => {
let wdb;
let engine;
let runsDir;
const TEST_DB_PATH = '/tmp/test-graph-files-index.sqlite';
beforeAll(() => {
for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
try { fs.unlinkSync(f); } catch {}
}
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-files-runs-'));
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of [
'../../services/graph-db',
'../../services/graph-service',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/graph-run-sse',
'../../services/graph-engine',
]) {
delete require.cache[require.resolve(m)];
}
wdb = require('../../services/work-graph-db');
engine = require('../../services/graph-engine');
});
afterAll(() => {
for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
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 ['../../services/graph-run-sse', '../../services/graph-engine']) {
delete require.cache[require.resolve(m)];
}
});
// ── 测试脚手架 ────────────────────────────────────────────────
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);
}
/** 建图 + 建 run + 置 ready + start,并给出「按节点名拿 defNodeId / 边 id」的取值器 */
function buildRun({ companyId, taskId, nodes, edges = [] }) {
ensureAgents(nodes.map((n) => n.systemAgentId || n.nodeName));
const g = wdb.createWorkGraph({ companyId, taskId, nodes, edges });
expect(g.errors || []).toEqual([]);
const def = wdb.readDefGraph('work', g.id);
const byName = new Map(def.nodes.map((n) => [n.name, n.defNodeId]));
const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId });
engine.markRunReady(run.id);
engine.startRun(run.id, { mode: 'manual' });
return {
def,
runId: run.id,
defNodeId: (name) => byName.get(name),
edgeId: (from, to) =>
def.edges.find((e) => e.from === byName.get(from) && e.to === byName.get(to)).id,
};
}
/** 节点私有产物目录 —— 与 graph-engine.nodeArtifactDir 同源(GRAPH_RUNS_DIR 覆盖分支) */
const nodeDir = (rootRunId, nodeRunId) => path.join(runsDir, rootRunId, 'nodes', nodeRunId);
/** 公司根 —— 与 graph-engine.companyRootDir 同源(同一个 override 分支) */
const companyRoot = (companyId) => path.join(runsDir, '_companies', companyId);
/**
* 跑 n 步,收集每个节点看到的提示词。writes[nodeName] 给出这个节点要落盘的文件,
* 落在它自己的私有目录里、outputRef 指向该目录 —— 与 defaultAgentExecutor 的形状一致。
*/
async function advance(runId, steps, { writes = {}, status = () => 'success' } = {}) {
const tasks = new Map();
engine.setAgentExecutor(async (ctx) => {
tasks.set(ctx.nodeName, ctx.task);
const files = writes[ctx.nodeName];
if (!files) return { status: status(ctx.nodeName) };
const dir = nodeDir(ctx.run.root_run_id, ctx.nodeRunId);
fs.mkdirSync(dir, { recursive: true });
for (const [name, body] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), body);
}
return { status: status(ctx.nodeName), outputRef: dir };
});
try {
for (let i = 0; i < steps; i++) await engine.advanceOne(runId);
} finally {
engine.setAgentExecutor(null);
}
return tasks;
}
const allFiles = (companyId) =>
wdb.getDb()
.prepare(`SELECT * FROM graph_files WHERE company_id = ? ORDER BY created_at, id`)
.all(companyId);
// ── ① 版本链 ─────────────────────────────────────────────────
it('① 同一 (company, task, relPath) 二次产出:旧行标 superseded_by 而不是被删,findFiles 只给最新那份', () => {
const companyId = 'co-files-ver';
const taskId = 'task-ver';
const rel = 'tasks/task-ver/brd.md';
const v1 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' });
const v2 = wdb.recordFile({ companyId, taskId, relPath: rel, kind: 'brd' });
expect(v2).not.toBe(v1);
const rows = allFiles(companyId);
// 历史不被删:两行都在
expect(rows.map((r) => r.id).sort()).toEqual([v1, v2].sort());
// 旧行指向新行
expect(rows.find((r) => r.id === v1).superseded_by).toBe(v2);
// 当前版本恰好一行
expect(rows.filter((r) => r.superseded_by === null).map((r) => r.id)).toEqual([v2]);
// 读回:findFiles 只返回最新那份
const found = wdb.findFiles({ companyId, taskId, kinds: ['brd'] });
expect(found.map((f) => f.id)).toEqual([v2]);
});
// ── ② 部分唯一索引 ───────────────────────────────────────────
it('② 部分唯一索引:两行同路径且都 superseded_by IS NULL 被拒;历史行同路径可共存', () => {
const db = wdb.getDb();
const companyId = 'co-files-idx';
const rel = 'tasks/task-idx/prd.md';
const ins = db.prepare(`
INSERT INTO graph_files (id, company_id, task_id, rel_path, kind, created_at, superseded_by)
VALUES (?, ?, 'task-idx', ?, 'prd', ?, ?)
`);
const now = new Date().toISOString();
ins.run('gf-live-1', companyId, rel, now, null);
// 第二个「当前版本」必须被索引挡住 —— 否则 findFiles 会同时返回两份,
// 下游拿不准该读哪个(这条锁的是「用部分唯一索引」这个选择本身)
expect(() => ins.run('gf-live-2', companyId, rel, now, null)).toThrow(/UNIQUE/i);
// 而历史行(superseded_by 非空)同路径可以随便共存 —— 索引是**部分**的,不是全量的
expect(() => ins.run('gf-old-1', companyId, rel, now, 'gf-live-1')).not.toThrow();
expect(() => ins.run('gf-old-2', companyId, rel, now, 'gf-live-1')).not.toThrow();
expect(allFiles(companyId).filter((r) => r.superseded_by === null)).toHaveLength(1);
});
// ── ③a 沿边传递(全四环)+ ⑤ attempt-*.json 不登记 ──────────
it('③a 沿边传递:出边声明 brd → 引擎登记实例 → 下游提示词里是绝对路径;attempt-*.json 不登记', async () => {
const companyId = 'co-files-edge';
const taskId = 'task-edge';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
});
// 产生:规划时就在边上声明「这条边传一份 brd」
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
});
const tasks = await advance(b.runId, 2, {
writes: {
'brd-writer': {
'brd.md': '# BRD',
// 引擎自读的归因文件,不是给下游的产物
'attempt-1.json': JSON.stringify({ fail_reason: null }),
},
},
});
// 落库:只登记 brd.md,kind 取出边声明的 brd(不是兜底的 artifact)
const rows = allFiles(companyId);
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('brd');
expect(path.basename(rows[0].rel_path)).toBe('brd.md');
// attempt-*.json 一行都不该有
expect(rows.filter((r) => /attempt-/.test(r.rel_path))).toEqual([]);
// 传递:入边契约查得到
const inbound = wdb.inboundContracts('work', b.def.edges, b.defNodeId('prd-writer'));
expect(inbound.map((c) => c.kind)).toEqual(['brd']);
// 读回:下游提示词里是能直接 open 的绝对路径
const abs = path.join(companyRoot(companyId), rows[0].rel_path);
expect(fs.existsSync(abs)).toBe(true);
const prompt = tasks.get('prd-writer');
expect(prompt).toContain('[上游输入');
expect(prompt).toContain(`- brd:${abs}`);
// 查得到实例就不该出现缺输入告警(段尾那句「标 ⚠️ 的表示…」是固定说明,不算告警)
expect(prompt).not.toContain('⚠️ 上游声明会产出');
// 上游自己那一步没有入边,不该凭空多一段
expect(tasks.get('brd-writer')).not.toContain('[上游输入');
});
// ── ③b 任务级共享:非相邻可见 ────────────────────────────────
it('③b 任务级共享:planner 产的 acceptance,隔着 coder 的 validator 也查得到(R-9.3 的形状)', async () => {
const companyId = 'co-files-shared';
const taskId = 'task-shared';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'planner' }, { nodeName: 'coder' }, { nodeName: 'validator' }],
edges: [
{ from: 'planner', to: 'coder', condition: 'success' },
{ from: 'coder', to: 'validator', condition: 'success' },
],
});
// 刻意**不**声明任何边契约:planner→validator 根本没有边,纯边绑定表达不了这条依赖
const tasks = await advance(b.runId, 3, {
writes: { planner: { 'acceptance.json': JSON.stringify({ checks: ['a'] }) } },
});
const rows = allFiles(companyId);
expect(rows).toHaveLength(1);
expect(rows[0].kind).toBe('acceptance'); // 无声明时按文件名定 kind
const abs = path.join(companyRoot(companyId), rows[0].rel_path);
// 非相邻的 validator 拿到了路径 —— 这才是「两类数据流都要」的理由
const prompt = tasks.get('validator');
expect(prompt).toContain('[上游输入');
expect(prompt).toContain(`- acceptance(任务级共享):${abs}`);
// 相邻的 coder 同样看得到(任务级共享不受相邻性限制)
expect(tasks.get('coder')).toContain(abs);
// planner 跑的时候索引里还没有实例,不该有段落
expect(tasks.get('planner')).not.toContain('[上游输入');
});
// ── ④ 缺输入告警(R-19.4 读回端)────────────────────────────
it('④ 声明了 required 输入却查不到实例:提示词里出现 ⚠️,不静默省略', async () => {
const companyId = 'co-files-missing';
const taskId = 'task-missing';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'sdd-writer' }, { nodeName: 'impl' }],
edges: [{ from: 'sdd-writer', to: 'impl', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('sdd-writer', 'impl'), kind: 'sdd', required: true,
});
// 上游什么都不写 → 声明在、实例不在
const tasks = await advance(b.runId, 2);
expect(allFiles(companyId)).toEqual([]);
const prompt = tasks.get('impl');
expect(prompt).toContain('[上游输入');
expect(prompt).toContain('- sdd:⚠️');
expect(prompt).toContain('索引里查不到实例');
});
// ── ⑤ 节点 fail 不登记 ──────────────────────────────────────
it('⑤ 节点 fail 时落盘的半成品不进索引(下游不该把它当输入读)', async () => {
const companyId = 'co-files-fail';
const taskId = 'task-fail';
const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'flaky' }] });
await advance(b.runId, 1, {
writes: { flaky: { 'brd.md': '# 半成品' } },
status: () => 'fail',
});
// 文件真的落盘了(不是没写),但索引里一行都没有
const state = wdb.getRunState(b.runId);
// 图上没有任何 fail 边,失败节点最终落 blocked(fail → blocked 是 FSM 的终态转换)
expect(state.nodes[0].status).toBe('blocked');
expect(fs.existsSync(path.join(state.nodes[0].outputRef, 'brd.md'))).toBe(true);
expect(allFiles(companyId)).toEqual([]);
});
// ── ⑥ 索引重建 ─────────────────────────────────────────────
it('⑥ graph_files 被清掉后能从 graph_run_nodes 重建(有损:kind 退成 artifact)', async () => {
const companyId = 'co-files-rebuild';
const taskId = 'task-rebuild';
const b = buildRun({ companyId, taskId, nodes: [{ nodeName: 'brd-writer' }] });
await advance(b.runId, 1, { writes: { 'brd-writer': { 'brd.md': '# BRD' } } });
expect(allFiles(companyId)).toHaveLength(1);
// 索引坏了 / 被清空
wdb.getDb().prepare(`DELETE FROM graph_files WHERE company_id = ?`).run(companyId);
expect(wdb.findFiles({ companyId, taskId })).toEqual([]);
// 节点行还在 → 能重建出「这个节点产出过哪个目录」
expect(wdb.rebuildFilesFromNodes(companyId)).toBe(1);
const rebuilt = wdb.findFiles({ companyId, taskId });
expect(rebuilt).toHaveLength(1);
expect(rebuilt[0].kind).toBe('artifact'); // 重建恢复不了语义,只恢复位置
expect(rebuilt[0].rel_path).toBe(wdb.getRunState(b.runId).nodes[0].outputRef);
});
// ── ⑦ 文件名认不出 kind 时靠声明认领 ────────────────────────
//
// 第五次真 CLI 冒烟里这一环成立**纯属侥幸**:brd 节点恰好把文件命名成 `brd.md`。
// agent 给产物起中文名是完全正常的行为,不该由文件名决定索引对不对。
it('⑦ 文件名认不出 kind 时按出边声明认领(一对一无歧义),下游照样拿到路径', async () => {
const companyId = 'co-files-claim';
const taskId = 'task-claim';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
});
const tasks = await advance(b.runId, 2, {
writes: {
'brd-writer': {
// 文件名里一个 "brd" 字都没有 —— 老逻辑会落成 kind=artifact
'业务需求文档.md': '# 业务需求',
// 同目录还有一个能按文件名认出来的(真跑里就是这个形状),
// 它必须先被 contract 认领掉,否则「还剩一个认不出的文件」不成立
'contract.json': JSON.stringify({ outputs: [] }),
'attempt-1.json': JSON.stringify({ fail_reason: null }),
},
},
});
const rows = allFiles(companyId);
const byKind = new Map(rows.map((r) => [r.kind, r]));
expect([...byKind.keys()].sort()).toEqual(['brd', 'contract']);
expect(path.basename(byKind.get('brd').rel_path)).toBe('业务需求文档.md');
// 真正要防的是下游拿不到输入 —— 落成 artifact 时这一行会变成 ⚠️
const prompt = tasks.get('prd-writer');
expect(prompt).toContain(`- brd:${path.join(companyRoot(companyId), byKind.get('brd').rel_path)}`);
expect(prompt).not.toContain('⚠️ 上游声明会产出');
});
it('⑦ 有歧义时不猜:两个认不出的文件配一个声明 → 都落 artifact', async () => {
const companyId = 'co-files-ambiguous';
const taskId = 'task-ambiguous';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'brd-writer' }, { nodeName: 'prd-writer' }],
edges: [{ from: 'brd-writer', to: 'prd-writer', condition: 'success' }],
});
wdb.declareEdgeContract({
edgeLayer: 'work', edgeId: b.edgeId('brd-writer', 'prd-writer'), kind: 'brd',
});
const tasks = await advance(b.runId, 2, {
writes: { 'brd-writer': { '文档一.md': 'a', '文档二.md': 'b' } },
});
// 猜错比留 artifact 更坏:错的 kind 会让下游读到一份不是它要的文件,
// 而 artifact 只是查不到 —— 后者会如实报 ⚠️,前者是静默的错。
const rows = allFiles(companyId);
expect(rows.map((r) => r.kind)).toEqual(['artifact', 'artifact']);
expect(tasks.get('prd-writer')).toContain('- brd:⚠️');
});
// ── ⑧ 任务级共享按 kind 去重 ────────────────────────────────
it('⑧ 两个节点各写一份同 kind 的任务级产物:提示词里只列最新那一份,不逐个累加', async () => {
const companyId = 'co-files-dedupe';
const taskId = 'task-dedupe';
const b = buildRun({
companyId, taskId,
nodes: [{ nodeName: 'first' }, { nodeName: 'second' }, { nodeName: 'third' }],
edges: [
{ from: 'first', to: 'second', condition: 'success' },
{ from: 'second', to: 'third', condition: 'success' },
],
});
// 真跑里的形状:brd 与 prd 两个节点各写了一份 contract.json,都是 kind=contract
const tasks = await advance(b.runId, 3, {
writes: {
first: { 'contract.json': JSON.stringify({ v: 1 }) },
second: { 'contract.json': JSON.stringify({ v: 2 }) },
},
});
// 两份都在索引里(各自路径不同,都是当前版本)——去重只发生在拼提示词那一步
const rows = allFiles(companyId).filter((r) => r.kind === 'contract');
expect(rows).toHaveLength(2);
const prompt = tasks.get('third');
const listed = prompt.split('\n').filter((l) => l.includes('(任务级共享)'));
expect(listed).toHaveLength(1);
// 最新的赢(findFiles 是 created_at 升序,与沿边那一半的 byKind.set 同口径)
expect(listed[0]).toContain(path.join(companyRoot(companyId), rows[1].rel_path));
expect(listed[0]).not.toContain(rows[0].rel_path);
});
});