graph-run-route-io.test.js
9.11 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
/**
* Unit Tests — GET /api/graph-runs/:id 的 R-19 输入输出三字段(27-P4 §4.6)
*
* 控制台的「输入输出」tab 读三个字段:`edges[].contracts` / `companyRoot` / `taskFiles`。
* 缺任何一个它整块降级 —— 契约声明和产物索引都在库里,界面上却看不见,
* 就是这一路踩了十几次的「实现在、零调用方」换个方向再来一次(这次是前端在、后端没喂)。
*
* 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch(与 devloop/routes.test.js 同法),
* 不导出内部函数、不打桩 res.json。
*
* 顺带钉住两条形状约定:
* - `contracts` 只在非空时出现(与 failReason 一致,存量边不该凭空多字段)
* - `companyRoot` 是绝对路径,且与 recordFile 存的 rel_path 拼起来就是真实文件
* —— 「拼得回去」才是这个字段唯一的用处,光断言它非空等于没测
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import express from 'express';
const TEST_DB_PATH = '/tmp/test-graph-run-route-io.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/company-paths',
'../../services/graph-run-sse',
'../../services/graph-engine',
'../../routes/graph-run',
];
describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => {
let wdb;
let srv;
let base;
let runsDir;
let runId;
let companyId = 'co-route-io';
const taskId = 'T-route-io';
beforeAll(async () => {
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(), 'route-io-runs-'));
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of MODULES) delete require.cache[require.resolve(m)];
wdb = require('../../services/work-graph-db');
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']) ins.run(id, id, `system/agents/${id}`, ts, ts);
const g = wdb.createWorkGraph({
companyId,
taskId,
nodes: [
{ nodeName: 'p-brd', systemAgentId: 'brd-planning' },
{ nodeName: 'p-prd', systemAgentId: 'prd-planning' },
// 第三个节点只为了造一条**没有任何声明**的边:全是有声明的边时,
// 「无声明的边上不出现 contracts 字段」那条断言在这份 fixture 上永远自洽
{ nodeName: 'p-tail', systemAgentId: 'prd-planning' },
],
edges: [
{ from: 'p-brd', to: 'p-prd', condition: 'success', produces: ['brd'] },
{ from: 'p-prd', to: 'p-tail', condition: 'success' },
],
});
expect(g.ok).toBe(true);
const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId });
runId = run.id;
// 真的把文件写到公司根下再登记 —— 「拼得回去」这条断言要求文件真实存在
const { companyRootDir, taskDirRel } = require('../../services/company-paths');
const relPath = path.join(taskDirRel(taskId), 'brd.md');
const abs = path.join(companyRootDir(companyId), relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, '# brd\n');
wdb.recordFile({ companyId, taskId, relPath, kind: 'brd' });
const app = express();
app.use(express.json());
app.use('/api/graph-runs', require('../../routes/graph-run'));
await new Promise((resolve) => {
srv = app.listen(0, '127.0.0.1', () => {
base = `http://127.0.0.1:${srv.address().port}`;
resolve();
});
});
});
afterAll(async () => {
if (srv) await new Promise((r) => srv.close(r));
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)];
});
it('三个字段都在,且 companyRoot + rel_path 拼回真实文件', async () => {
const r = await fetch(`${base}/api/graph-runs/${runId}`);
expect(r.status).toBe(200);
const d = await r.json();
// ① 边上的契约(哪一层的边查哪一层的契约;work run 查 work 层)
const declared = d.edges.filter((e) => e.contracts);
expect(declared).toHaveLength(1);
expect(declared[0].contracts.map((c) => c.kind)).toEqual(['brd']);
expect(declared[0].contracts[0].role).toBe('produces');
// ② 产物索引:本 task 已登记的实例
expect(d.taskFiles.map((f) => f.kind)).toEqual(['brd']);
// ③ companyRoot 的唯一用处是让前端把相对路径拼成可点的绝对路径
expect(path.isAbsolute(d.companyRoot)).toBe(true);
const abs = path.join(d.companyRoot, d.taskFiles[0].rel_path);
expect(fs.existsSync(abs)).toBe(true);
expect(fs.readFileSync(abs, 'utf8')).toContain('# brd');
});
it('无声明的边上不出现 contracts 字段(与 failReason 同一个约定)', async () => {
const r = await fetch(`${base}/api/graph-runs/${runId}`);
const d = await r.json();
// 三个节点两条边:一条有声明、一条没有。没有那条必须整个字段不出现,
// 而不是回一个空数组(存量边绝大多数都是这个形态,恒空字段会让既有消费方凭空多东西)。
expect(d.edges).toHaveLength(2);
expect(d.edges.filter((e) => 'contracts' in e)).toHaveLength(1);
for (const e of d.edges) {
if ('contracts' in e) expect(e.contracts.length).toBeGreaterThan(0);
}
});
it('run 不存在仍是 404(新字段不该把错误路径也一起改掉)', async () => {
const r = await fetch(`${base}/api/graph-runs/run_nope`);
expect(r.status).toBe(404);
});
// 34 号文档:graph_files 只索引节点私有目录,真实交付物写在这个任务共享的
// 项目根(W2 ensureProjectRoot)——控制台「产物」面板要同时看到两处,缺一个字段
// 就是同一路「实现在、零调用方」再来一次。
it('projectRoot:没分配过就是 null(不假装有)', async () => {
const r = await fetch(`${base}/api/graph-runs/${runId}`);
const d = await r.json();
expect(d.projectRoot).toBeNull();
});
it('projectRoot:分配过之后原样透传(root_path/pid 与落库的记录一致)', async () => {
const engine = require('../../services/graph-engine');
const run = wdb.getRun(runId);
const rec = engine.ensureProjectRoot({ run, companyId, nodeRunId: 'rn-fake' });
expect(rec).toBeTruthy();
const r = await fetch(`${base}/api/graph-runs/${runId}`);
const d = await r.json();
expect(d.projectRoot).toBeTruthy();
expect(d.projectRoot.pid).toBe(rec.pid);
expect(d.projectRoot.root_path).toBe(rec.root_path);
// 幂等:再查一次不会重新分配一个不同的项目根
expect(engine.ensureProjectRoot({ run, companyId, nodeRunId: 'rn-fake-2' }).root_path).toBe(rec.root_path);
});
// 35 号文档提案 B:控制台「这份验收有没有证据支撑」要读的数据——只读
// goal-contract.js 已经落盘的判决,不重新计算。
it('benchmarkVerdicts:没有任何判决落盘时是空数组(不是整块降级)', async () => {
const r = await fetch(`${base}/api/graph-runs/${runId}`);
const d = await r.json();
expect(d.benchmarkVerdicts).toEqual([]);
});
it('benchmarkVerdicts:落过判决之后,逐维度的 evidence_required/ok 原样透传给前端', async () => {
const gc = require('../../services/goal-contract');
const run = wdb.getRun(runId);
const runDir = gc.resolveRunDir(companyId, run.root_run_id || run.id);
const verdict = {
verdict_id: 'vd-test-1', ok: false, fail_reason: 'acceptance_not_met',
goal_id: 'G-1', run_id: run.id, node_run_id: 'rn-benchmark-1', attempt: 0,
contract_hash: 'x', target: 'x', created_at: new Date().toISOString(),
sections: {
benchmark: {
ok: false,
dimensions: [
{ name: 'hero_first_impression', score: 100, ok: false, evidence_required: true },
{ name: 'code_quality', score: 90, ok: true },
],
},
},
};
gc.persistVerdict(runDir, verdict);
const r = await fetch(`${base}/api/graph-runs/${runId}`);
const d = await r.json();
expect(d.benchmarkVerdicts).toHaveLength(1);
const v = d.benchmarkVerdicts[0];
expect(v.nodeRunId).toBe('rn-benchmark-1');
expect(v.ok).toBe(false);
expect(v.dimensions).toEqual([
{ name: 'hero_first_impression', ok: false, score: 100, evidenceRequired: true },
{ name: 'code_quality', ok: true, score: 90, evidenceRequired: false },
]);
});
});