work-graph-skeletons.test.js
8.77 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
/**
* system/work-graph-templates/*.json 的**数据契约**防线(R-19 / 27-P4 §4.6)。
*
* 挡的是这一类缺陷:骨架的 forward 边只带控制流(condition / edgeKind / failReason),
* 不带 produces / needs。照抄骨架得到的图于是**不传数据** —— 下游节点的提示词里
* 一条上游产物路径都没有,第四次真 CLI 冒烟里 org-planner-prd 的原话就是
* 「不是上游没产出,是它的产出结构上到不了我手上」。
* 补上声明前,那件事只能靠 workgraph-planning 的提示词施压来避免;换个模型就漏。
*
* 与 template-refs.test.js 的分工:那边管**引用**(systemAgentId 指不指得到真本体、
* 落不落得进库),这边管**数据流**(边上有没有声明、声明落库后读不读得回来)。
*
* 四条用例分别对应四环里的一环:
* ① 骨架过引擎真正用的那套校验(含规则 6 data-contract:声明要吃的 kind 必须有产出方)
* ② 每张骨架的**每条** forward 边都有非空声明(新加一张骨架漏写声明也会被抓)
* ③ 声明落库:createWorkGraph → graph_edge_contracts 真有行,且 role/kind/required 与 JSON 一致
* ④ 读回:inboundContracts(describeInputs 的数据源)对每个非入口节点都非空
*/
import { describe, it, expect, beforeAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
const ROOT = path.resolve(__dirname, '../../..');
const WORK_GRAPH_DIR = path.join(ROOT, 'system/work-graph-templates');
/**
* 占位符 → 真实存在的本体 id / 节点名。
* 落库那两条用例必须替换:work_graph_nodes.system_agent_id 有外键指向 system_agents(id),
* `{{implAgent}}` 会直接 FOREIGN KEY constraint failed —— 那样测的就不是契约而是外键了。
* 取值只要求「system/templates/ 下真实存在」,语义上贴近即可。
*/
const PLACEHOLDERS = {
'{{orgNode}}': 'org-coder',
'{{splitAgent}}': 'devloop-architect',
'{{implAgent}}': 'devloop-coder',
'{{testAgent}}': 'devloop-tester',
'{{mergeAgent}}': 'devloop-integrator',
'{{integrationAgent}}': 'devloop-tester',
'{{validatorAgent}}': 'coder-validator',
};
function readSkeletons() {
return fs
.readdirSync(WORK_GRAPH_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => ({
file: f,
json: JSON.parse(fs.readFileSync(path.join(WORK_GRAPH_DIR, f), 'utf-8')),
}));
}
/** 把骨架里的 {{占位符}} 全部替换成真实 id;替换后仍残留 {{ 就让它炸,不要静默落一张废图 */
function materialize(skeleton) {
let text = JSON.stringify(skeleton.json);
for (const [ph, real] of Object.entries(PLACEHOLDERS)) text = text.split(ph).join(real);
if (text.includes('{{')) throw new Error(`${skeleton.file} 有未覆盖的占位符,请补 PLACEHOLDERS`);
return JSON.parse(text);
}
const isForward = (e) => (e.edgeKind || e.edge_kind || 'forward') !== 'backward';
let wdb;
let orgGraphService;
let normalizeContracts;
beforeAll(async () => {
// 隔离手法与 template-refs.test.js / graph-engine.test.js 一致:先设 env 再 import。
// 仓库的 data/graph.sqlite 一行都不该被本文件碰到。
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'work-graph-skeletons-'));
process.env.GRAPH_DB_PATH = path.join(tmp, 'graph.sqlite');
process.env.GRAPH_RUNS_DIR = path.join(tmp, 'runs');
const graphDb = await import('../../services/graph-db.js');
const seed = await import('../../services/graph-seed-service.js');
wdb = (await import('../../services/work-graph-db.js')).default;
orgGraphService = (await import('../../services/org-graph-service.js')).default;
normalizeContracts = orgGraphService.normalizeContracts;
graphDb.default.getDb();
seed.default.seedAgents(); // system/agents/* → system_agents(外键的另一端)
});
describe('work graph 骨架的数据契约', () => {
const skeletons = readSkeletons();
it('本目录下有骨架可查(防止目录改名让整个文件静默空跑)', () => {
expect(skeletons.map((s) => s.file).sort()).toEqual([
'executor-fanout.json',
'executor.json',
'planner.json',
'validator.json',
]);
});
it('每张骨架都过引擎真正用的那套校验(含规则 6:声明要吃的 kind 必须有产出方;'
+ '规则 5 用 strict 模式——骨架代表 workgraph-planning 真实会提交的图,31 号文档决策 A)', () => {
const verdicts = skeletons.map((s) => {
const r = wdb.validateWorkGraph(s.json.nodes, s.json.edges, { strictFailFallback: true });
return `${s.file}: ${r.ok ? 'ok' : JSON.stringify(r.errors)}`;
});
expect(verdicts.filter((v) => !v.endsWith(': ok'))).toEqual([]);
});
it('每张骨架的每条 forward 边都有非空的 produces / needs 声明', () => {
// 「至少一张有」不够:新增骨架时漏写声明必须当场红。
// backward 边反过来一律不许声明(validateGraph 会报 rule=schema),上一条用例已覆盖。
const naked = [];
for (const s of skeletons) {
for (const e of s.json.edges || []) {
if (!isForward(e)) continue;
if (normalizeContracts(e).length === 0) naked.push(`${s.file}: ${e.from} -> ${e.to}`);
}
}
expect(naked).toEqual([]);
});
it('声明真的落库:createWorkGraph 之后 graph_edge_contracts 里 kind/role/required 与 JSON 逐条一致', () => {
// 这一步把「JSON 里写了」和「落库后读得到」分开:前者只是文本,后者才是四环闭合的第三环。
const mismatches = [];
let contractRows = 0;
for (const s of skeletons) {
const json = materialize(s);
const created = wdb.createWorkGraph({
companyId: 'work-graph-skeletons-test',
taskId: `T-${s.json.id}`,
nodes: json.nodes,
edges: json.edges,
});
expect(`${s.file}: ${created.ok ? 'ok' : JSON.stringify(created.errors)}`).toBe(`${s.file}: ok`);
const def = wdb.readDefGraph('work', created.id);
const nameOf = new Map(def.nodes.map((n) => [n.defNodeId, n.name]));
// 按端点名配对,**不能**按声明序:readDefGraph 是 ORDER BY created_at, id,
// 同一次事务里 created_at 全等,实际排序落到随机 id 上,落库序 ≠ 声明序。
const dbByEndpoints = new Map(
def.edges
.filter((e) => e.edgeKind !== 'backward')
.map((e) => [`${nameOf.get(e.from)} -> ${nameOf.get(e.to)}`, e])
);
const jsonEdges = json.edges.filter(isForward);
expect(dbByEndpoints.size).toBe(jsonEdges.length);
for (const je of jsonEdges) {
const label = `${s.file} ${je.from} -> ${je.to}`;
const dbEdge = dbByEndpoints.get(`${je.from} -> ${je.to}`);
if (!dbEdge) {
mismatches.push(`${label}: 落库后找不到这条边`);
continue;
}
const want = normalizeContracts(je)
.map((c) => `${c.role}:${c.kind}:${c.required}`)
.sort();
const got = wdb
.edgeContracts('work', dbEdge.id)
.map((r) => `${r.role}:${r.kind}:${r.required === 1}`)
.sort();
contractRows += got.length;
if (JSON.stringify(want) !== JSON.stringify(got)) {
mismatches.push(`${label}: JSON=${JSON.stringify(want)} DB=${JSON.stringify(got)}`);
}
}
}
expect(mismatches).toEqual([]);
expect(contractRows).toBeGreaterThan(0); // 全都对不上却"两边都空"时不许静默通过
});
it('读回:每个非入口节点的 inboundContracts 都非空(describeInputs 拼路径的数据源)', () => {
// 引擎给下游拼「上游输入」段落走的就是这个函数(graph-engine.describeInputs)。
// 它对某个节点返回空 = 那个节点的提示词里一条上游路径都没有。
const starved = [];
for (const s of skeletons) {
const json = materialize(s);
const created = wdb.createWorkGraph({
companyId: 'work-graph-skeletons-test',
taskId: `T-inbound-${s.json.id}`,
nodes: json.nodes,
edges: json.edges,
});
expect(created.ok).toBe(true);
const def = wdb.readDefGraph('work', created.id);
const hasForwardIn = new Set(
def.edges.filter((e) => e.edgeKind !== 'backward').map((e) => String(e.to))
);
for (const n of def.nodes) {
if (!hasForwardIn.has(String(n.defNodeId))) continue; // 入口节点没有入边,输入来自提示词
if (wdb.inboundContracts('work', def.edges, n.defNodeId).length === 0) {
starved.push(`${s.file}: ${n.name}`);
}
}
// 每张图至少有一个非入口节点,否则这条用例在"没节点可查"的情况下静默通过
expect(hasForwardIn.size).toBeGreaterThan(0);
}
expect(starved).toEqual([]);
});
});