workgraph-planning-endpoints.test.js
15 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
/**
* Unit Tests — workgraph-planning 模板的防漂移闸:**模板教的端点必须真的存在**
*
* 挡的是已经实际发生过的这一类缺陷:模板(`system/templates/workgraph-planning/*.md`)
* 教 agent `POST /api/company/<公司ID>/work-graphs` 去落库,而这个路由**从来没有实现过**
* (`server/index.js` 只挂了 `/api/graph-runs` 与 `/api/company` 的 org-graph 系列,
* 设计文档里也查不到这个端点)。后果不是功能坏掉 —— `graph-engine.js` 的
* `extractPlannedGraph` 那条「读回复取图」的分支本来就是主路径,图照样落得进去 ——
* 而是**每次真跑都白烧一轮 agent 调用**去 curl 一个必然 404 的地址(第五次真 CLI 冒烟实测)。
* 这种缺陷在单测里天然无感:prompt 文本没有任何编译期检查,端点没了也不会有人红。
*
* 所以这里把「模板文本」和「真实路由表」对起来查:
* ① 模板里出现的每个 `/api/...` 路径,都必须能在 `server/routes/` 里找到注册;
* ② 反向:被显式标注为反例(`NO-SUCH-ENDPOINT`)的路径,必须**确实不存在** ——
* 哪天这个端点真上线了,这条会红,提醒把警示文本删掉,而不是留一句过时的谣言;
* ③ 模板教 agent 交回的那份图示例,本身要是**严格 JSON** 且过 `validateWorkGraph`:
* 引擎用 `JSON.parse` 取图,示例里带注释/尾逗号就等于教它白烧一轮。
*
* ── 路由表怎么来 ────────────────────────────────────────────────
* **静态解析** `server/index.js` 的 `app.use('<前缀>', require('./routes/<文件>')[.<导出>])`
* + 路由文件里的 `<router>.get/post/...('<路径>')`,不 require 任何路由模块:
* 那些模块一 require 就会拉起 service 层(sqlite 连接等),而这条 guard 只需要文本事实。
*
* ponytail: 路径匹配按「段」严格比:占位符段(`:id` / `<公司ID>` / `{公司ID}`)两侧都归一成
* `*`,且**只与占位符相等** —— 字面量位置上出现占位符也算不匹配(松一点就会出现
* `/api/company/{公司ID}/work-graphs` 被 `PATCH /api/company/templates/:templateId` 匹配上,
* 实测过)。上限:模板把一族端点写成 `{start,next,...}` 时靠展开逗号处理,
* 而**动词(GET/POST)不参与比对** —— 端点存在但方法写错,这条查不出来。
* 要更紧就得真起 express app 逐条 match,代价是把 service 层全拉起来,不值当。
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
const ROOT = path.resolve(__dirname, '../../..');
const TEMPLATE_DIR = path.join(ROOT, 'system/templates/workgraph-planning');
const ROUTES_DIR = path.join(ROOT, 'server/routes');
const INDEX_JS = path.join(ROOT, 'server/index.js');
/**
* 反例标记:同一行里出现它,这一行的端点就**不要求**注册(反而要求它不存在,见用例 ②)。
* 选「同一行」而不是「同一段落」是为了让判据肉眼可查 —— 加一句无关的警示文字不会顺手
* 豁免掉隔壁那条真端点。
*/
const NO_SUCH = 'NO-SUCH-ENDPOINT';
/**
* `{start,next,...}` 这种「一族端点写成一行」的写法展开成逐条候选(`...` 省略号丢掉)。
* 只展开**带逗号**的花括号 —— `{公司ID}` 是占位符,不是选择集。
*/
function expandAlternatives(p) {
const m = /\{([^{}]*,[^{}]*)\}/.exec(p);
if (!m) return [p];
return m[1]
.split(',').map((s) => s.trim()).filter((s) => s && s !== '...')
.flatMap((alt) => expandAlternatives(p.slice(0, m.index) + alt + p.slice(m.index + m[0].length)));
}
/** 段级归一:占位符(`:x` / `<x>` / `{x}`)一律 `*`,字面量原样保留 */
function segments(p) {
return String(p)
.split('/')
.filter((s) => s !== '')
.map((s) => (/[:<>{}*]/.test(s) ? '*' : s));
}
/**
* 严格逐段相等(占位符只与占位符相等)。
*
* 早先写成「`*` 匹配任意一段」,结果 `/api/company/{公司ID}/work-graphs` 被
* `PATCH /api/company/templates/:templateId` 匹配上了(占位符吃掉 `templates`、
* `work-graphs` 又被 `:templateId` 吃掉)—— 那样这条 guard 对本次要挡的缺陷直接失效。
* 所以「字面量位置上出现占位符」也算不匹配。
*/
function pathMatches(a, b) {
const x = segments(a);
const y = segments(b);
return x.length === y.length && x.every((s, i) => s === y[i]);
}
/** 一个模板路径「已注册」= 它展开出的每一条候选都能找到注册 */
function isRegistered(p, registered) {
return expandAlternatives(p).every((cand) => registered.some((r) => pathMatches(cand, r.full)));
}
/** server/index.js 的挂载表:`app.use('/api/x', require('./routes/y').z)` */
function readMounts() {
const src = fs.readFileSync(INDEX_JS, 'utf-8');
const re = /app\.use\(\s*'([^']+)'\s*,\s*require\('\.\/routes\/([\w-]+)'\)(?:\.(\w+))?\s*\)/g;
const out = [];
let m;
while ((m = re.exec(src))) out.push({ prefix: m[1], file: `${m[2]}.js`, exportName: m[3] || null });
return out;
}
/** 这个挂载点对应的 Router 变量名(`module.exports = router` / `module.exports.companyRouter = x`) */
function routerIdentifier(src, exportName) {
const re = exportName
? new RegExp(`module\\.exports\\.${exportName}\\s*=\\s*(\\w+)`)
: /module\.exports\s*=\s*(\w+)\s*;/;
const m = re.exec(src);
return m ? m[1] : null;
}
/** 真实注册的完整路径集合(前缀 + 声明路径),只收字面量路径 */
function registeredPaths() {
const out = [];
for (const mount of readMounts()) {
const file = path.join(ROUTES_DIR, mount.file);
if (!fs.existsSync(file)) continue;
const src = fs.readFileSync(file, 'utf-8');
const ident = routerIdentifier(src, mount.exportName);
if (!ident) continue;
const re = new RegExp(`\\b${ident}\\.(?:get|post|put|patch|delete)\\(\\s*'([^']*)'`, 'g');
let m;
while ((m = re.exec(src))) {
out.push({ full: `${mount.prefix}/${m[1]}`.replace(/\/+/g, '/'), file: mount.file });
}
}
return out;
}
/**
* 模板里提到的每个 `/api/...` 路径(带出处行号,便于定位)。
*
* 占位符里有中文(`<公司ID>`),所以 ASCII 字符类**不够** —— 第一版写成
* `[A-Za-z0-9_\-./:<>{},]*` 时,`/api/company/<公司ID>/agents` 被截成 `/api/company/<`,
* 段数从 4 塌成 3,正好被 `GET /api/company/:companyId` 匹配上:整条 guard 对模板里
* **每一条**真实路径都变成了空跑(变异测试实测撞到)。CJK 只在 `<>` / `{}` 括号里放行,
* 括号外仍限 ASCII —— 否则紧跟路径的中文正文会被吞进路径里,变成误报。
*/
const ENDPOINT_RE = /\/api\/(?:<[^<>\s]*>|\{[^{}\s]*\}|[A-Za-z0-9_\-.:]|\/)+/g;
function templateEndpoints() {
const out = [];
for (const name of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) {
const lines = fs.readFileSync(path.join(TEMPLATE_DIR, name), 'utf-8').split('\n');
lines.forEach((line, i) => {
for (const raw of line.match(ENDPOINT_RE) || []) {
const cleaned = raw.replace(/[.,:;/]+$/, '');
out.push({ path: cleaned, file: name, line: i + 1, exempt: line.includes(NO_SUCH) });
}
});
}
return out;
}
/** ```json 围栏块里能解析出 `nodes` 数组的那些(引擎 extractPlannedGraph 同一取法) */
function graphExamples(fileName) {
const text = fs.readFileSync(path.join(TEMPLATE_DIR, fileName), 'utf-8');
const out = [];
const fence = /```json\s*([\s\S]*?)```/g;
let m;
while ((m = fence.exec(text))) {
try {
const doc = JSON.parse(m[1]);
if (doc && Array.isArray(doc.nodes)) out.push(doc);
} catch { /* 不是图的 json 块(或写坏了),交给下面的断言去管 */ }
}
return out;
}
describe('workgraph-planning 模板:教的端点必须真的存在', () => {
const registered = registeredPaths();
const endpoints = templateEndpoints();
it('两侧都不是空的(防止 guard 因为改名/正则失效而静默变成空跑)', () => {
expect(endpoints.length).toBeGreaterThan(0);
expect(registered.length).toBeGreaterThan(20);
// 抽取必须**整条**抽出来(含中文占位符):截断成 `/api/company/<` 会被
// `GET /api/company/:companyId` 匹配上,guard 就整体空跑了
for (const tail of ['/hire', '/agents']) {
const hit = endpoints.find((e) => e.path.endsWith(tail));
expect(hit, `模板里抽不到以 ${tail} 结尾的端点,抽取正则多半又被截断了`).toBeTruthy();
expect(segments(hit.path).length).toBe(4);
}
// 已知一定注册着的几条,用来验「解析链路本身没坏」(含挂在 companyRouter 上的那条)
for (const known of ['/api/company/<id>/hire', '/api/company/<id>/agents',
'/api/graph-runs/<id>/{start,next}', '/api/company/<id>/graph-runs']) {
expect(isRegistered(known, registered), `解析不到 ${known}`).toBe(true);
}
// 反面自检:一个明摆着不存在的路径不许被判成已注册(挡住"匹配太松 → guard 空跑")
expect(isRegistered('/api/company/<id>/definitely-not-a-route', registered)).toBe(false);
});
it('① 模板里的每个端点都在 server/routes/ 里真实注册', () => {
const dangling = endpoints
.filter((e) => !e.exempt)
.filter((e) => !isRegistered(e.path, registered))
.map((e) => `${e.file}:${e.line} → ${e.path} 没有任何路由注册它`
+ `(真要写成反例,同一行加上 ${NO_SUCH} 标记并写明"不存在,不要调用")`);
expect(dangling).toEqual([]);
});
it(`② 标了 ${NO_SUCH} 的端点必须确实不存在(端点真上线了就该删掉这句警示)`, () => {
const exempt = endpoints.filter((e) => e.exempt);
// 反例本身也要在(否则这条用例是空跑):删掉那条警示行时这里会红,提醒别把它悄悄弄没了
expect(exempt.length).toBeGreaterThan(0);
const stale = exempt
.filter((e) => isRegistered(e.path, registered))
.map((e) => `${e.file}:${e.line} → ${e.path} 已经真的注册了,${NO_SUCH} 这句话过时了`);
expect(stale).toEqual([]);
});
it('③ 模板教 agent 交回的图示例:严格 JSON + 过 validateWorkGraph(引擎用 JSON.parse 取它)', () => {
// 隔离:先设 GRAPH_DB_PATH 再 require(清 require.cache),仓库 data/graph.sqlite 一行不碰。
// validateWorkGraph 是纯函数,但 require 链上有 getDb,保险起见照铁律走。
process.env.GRAPH_DB_PATH = '/tmp/test-workgraph-planning-endpoints.sqlite';
for (const m of ['../../services/graph-db', '../../services/work-graph-db']) {
delete require.cache[require.resolve(m)];
}
const wdb = require('../../services/work-graph-db');
// agent.md 是**唯一进提示词的那份**(agent-manager.getAgentConfig 只把 agent.md 当 prompt),
// 所以它里面必须有一份可直接照抄的图示例。
const examples = graphExamples('agent.md');
expect(examples.length).toBeGreaterThan(0);
const bad = [];
for (const dir of fs.readdirSync(TEMPLATE_DIR).filter((f) => f.endsWith('.md'))) {
for (const g of graphExamples(dir)) {
const verdict = wdb.validateWorkGraph(g.nodes, g.edges || []);
if (!verdict.ok) bad.push(`${dir}: ${JSON.stringify(verdict.errors)}`);
}
}
expect(bad).toEqual([]);
});
});
/**
* ④ 引擎自己拼的规划提示词,不许与模板对立。
*
* 第六次真 CLI 冒烟挖出来的:同一条提示词里,模板部分(系统提示词)写着
* 「不许出现 wg_ + 12 位十六进制,你永远拿不到真的 wg_ id」,而引擎拼的任务描述部分
* 写着「已经自行落库成功时,改为回一行 workGraphId: wg_xxx」——**两边直接对立**,
* agent 得赌听谁的。那一轮它选择听模板,是运气不是保证。
*
* 同型的还有两条:引擎的内联图示例一个 produces 都没有(agent 唯一见过的「边长什么样」
* 就是零声明的),以及引擎说端点「尚未上线」(暗示将来会上线,于是它还想去试一次)。
*
* 手法与上面一致:**静态读源码文本**,不 require graph-engine(一 require 就拉起 sqlite
* 与 AgentManager 依赖链)。buildPlanningPrompt 不导出,也不为测试新开导出面。
* ponytail: 按「## 交回方式」到数组收尾 `];` 之间的文本切段。上限是节标题一改就切不到 ——
* 所以下面第一条断言先钉住「这段必须切得到且非空」,切不到时是它红而不是静默空跑。
*/
describe('引擎的规划提示词:与 workgraph-planning 模板口径一致', () => {
const ENGINE_SRC = path.join(ROOT, 'server/services/graph-engine.js');
const src = fs.readFileSync(ENGINE_SRC, 'utf-8');
/**
* buildPlanningPrompt 里「## 交回方式」那一节里**真正会进提示词的文本**。
*
* 必须剔掉 `//` 注释行:这一节的注释本身就在解释「为什么不许说某句话」,
* 连注释一起扫的话,写下禁令理由的那条注释会让 guard 自己红
* (第一版就是这样红的 —— 它量的是源码文本,而该量的是发给 agent 的文本)。
*/
function handoffSection() {
const start = src.indexOf('## 交回方式');
if (start < 0) return '';
const end = src.indexOf('\n ];', start);
if (end < 0) return '';
return src.slice(start, end)
.split('\n')
.filter((l) => !l.trim().startsWith('//'))
.join('\n');
}
it('这一节切得到(切不到时这条红,而不是让下面三条静默空跑)', () => {
const sec = handoffSection();
expect(sec.length).toBeGreaterThan(100);
// 它必须确实是在讲交回图这件事,否则说明切到了别的地方
expect(sec).toContain('json');
});
it('不邀请 agent 回 workGraphId(模板明令禁止写 wg_ id,两边不许对立)', () => {
const sec = handoffSection();
// 找「教它输出 wg_」的措辞。引擎侧仍可以保留**解析**真实 wg_ id 的能力,
// 禁的只是在提示词里邀请它写一个 —— 它没有任何受支持的落库途径,写出来必是编的。
expect(sec).not.toMatch(/workGraphId\s*[::]\s*wg_/);
expect(sec).not.toContain('自行落库成功');
});
it('内联图示例的边带 produces(示例是 agent 唯一见过的「边长什么样」)', () => {
const sec = handoffSection();
const edges = sec.match(/"edges"\s*:\s*\[[^\]]*\]/);
expect(edges).not.toBeNull();
expect(edges[0]).toContain('produces');
});
it('提到 work-graphs 端点时不说「尚未上线」(暗示将来会上线,agent 就还会去试)', () => {
const sec = handoffSection();
if (!sec.includes('work-graphs')) return; // 不提它也行,那就没这个问题
expect(sec).not.toContain('尚未上线');
expect(sec).not.toContain('还没上线');
});
});