graph-seed-service.test.js
20.6 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
431
432
433
434
435
436
437
438
439
440
441
442
/**
* Unit Tests — graph-seed-service.js
*
* 两块:
*
* A. **hermetic 夹具根**(P3B W1 起):`GRAPH_SEED_ROOT` 指向 /tmp 下现造的
* system/agents + data/companies,配一个一次性 GRAPH_DB_PATH。
* 行数因此是**写死的精确值**而不是「跟着仓库现状走」——原来的写法直接对着生产
* `data/graph.sqlite` 与真实 `data/companies/` 断言,任何人手建一家公司都会让它变红。
* 悬空行回收 / 占位接管 / 防误伤 / `_` 保留 id 过滤四条规则都在这里锁。
*
* 28 批次 1 起数据源只剩 `system/agents/`(`system/templates/` 已并入),
* `source_kind` 的三分收成 `origin` 二分,断言随之改口径 —— 不是放松。
*
* B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实
* `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok,
* 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。
* 读的仍然是真实仓库的内容,但 `data/companies/` 走一次**冻结快照**(见 freezeRepoRoot)——
* 原因写在那个函数上。
*/
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
/** 换掉 GRAPH_SEED_ROOT / GRAPH_DB_PATH 之后必须重新 require,两个模块都在顶层读了常量 */
function reloadServices() {
delete require.cache[require.resolve('../../services/graph-db')];
delete require.cache[require.resolve('../../services/graph-seed-service')];
return {
graphDb: require('../../services/graph-db'),
seedService: require('../../services/graph-seed-service'),
};
}
function write(file, content) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
const REPO_ROOT = path.resolve(__dirname, '../../..');
/**
* 把「真实仓库根」冻成一个不会在用例中途变形的 GRAPH_SEED_ROOT。
*
* 为什么需要冻:verifyCounts() 的价值在于**独立重扫一遍目录**再跟库里的行数对,
* 于是 seedCompanyAgents() 那次扫描和 verifyCounts() 那次扫描之间只要磁盘变了一次,
* 期望值和实际值就必然不等 —— 而 `data/companies/` 是运行期状态,同一轮 `vitest run` 里
* 至少有 company-hire-org-layer.test.js / graph-agent-p1-{hire,acceptance}.test.js
* 三个文件在真实 `data/companies/` 下建公司再 hire()(company.json 的 agents[] 逐个变长),
* afterAll 又整目录删掉;用户开着 dev server 时还会有人从 UI 建公司。
* 实测:拿一个反复建/删的公司目录撞这个窗口,12 次单跑里 3 次红在
* `org_graph_nodes COUNT(*) WHERE company_id=...`。这就是它在全套里偶发变红的全部原因。
*
* 冻的是「一致性」而不是「新鲜度」:快照本身可能抓到某家公司写一半的样子,
* 但两次扫描读的是同一份,结论照旧成立。所以断言一条没放松 —— 真实仓库里多一个
* 模板目录 / 多一家公司带着 seeder 会跳过的角色,照样红(下面有验证)。
*
* `system/` 不快照,直接软链:它是签入代码,跑测试期间没人往里加目录
* (hire() 的使用计数写在 data/template-usage.json,不碰 `_registry.json`,
* 而 _registry.json 只影响 benchmark_score,不影响任何行数)。软链还顺带让
* 「every system/agents/* body dir …」那条用例仍然对着真实目录清单比。
*/
function freezeRepoRoot(dest) {
fs.mkdirSync(dest, { recursive: true });
fs.symlinkSync(path.join(REPO_ROOT, 'system'), path.join(dest, 'system'));
const src = path.join(REPO_ROOT, 'data', 'companies');
const dst = path.join(dest, 'data', 'companies');
if (!fs.existsSync(src)) return; // 连目录都没有 → 快照里也不该有,别把安全阀的判定条件改掉
fs.mkdirSync(dst, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
try {
// 没有 company.json → seedCompanyAgents 与 verifyCounts 都跳过它,不必进快照
const companyJson = fs.readFileSync(path.join(src, entry.name, 'company.json'));
write(path.join(dst, entry.name, 'company.json'), companyJson);
let roles = [];
try {
const parsed = JSON.parse(companyJson);
if (Array.isArray(parsed.agents)) roles = parsed.agents;
} catch {
// 坏掉的 company.json:两个实现都 continue,快照里留着原样即可
}
for (const role of roles) {
const roleDir = path.join(src, entry.name, 'agents', role);
if (!fs.existsSync(roleDir)) continue; // legacy_path 存在性也要一并复制
fs.mkdirSync(path.join(dst, entry.name, 'agents', role), { recursive: true });
// isMetaBootstrap() 只看这一个文件,而它决定 seedCompanyAgents 建不建 org 节点 —— 必须带上
try {
fs.copyFileSync(
path.join(roleDir, 'config.json'),
path.join(dst, entry.name, 'agents', role, 'config.json')
);
} catch {}
}
} catch {
// 这家公司正被别的测试 / dev server 删掉 → 跳过。快照要的是自洽,不是全集。
}
}
}
describe('graph-seed-service (hermetic fixture root)', () => {
let root;
let dbPath;
let seedService;
let graphDb;
/** 夹具:3 个本体目录 + 1 个保留 id + 1 个无 agent.md 的杂目录、1 家活着的公司 */
function buildFixture() {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-fixture-'));
dbPath = path.join(root, 'graph.sqlite');
write(path.join(root, 'system/agents/_registry.json'), JSON.stringify([{ id: 'tmpl-a', benchmarkScore: 7.5 }]));
write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha');
write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha' }));
write(path.join(root, 'system/agents/tmpl-a/agent.md'), '# tmpl-a');
write(path.join(root, 'system/agents/tmpl-a/config.json'), JSON.stringify({ name: 'Template A' }));
write(path.join(root, 'system/agents/tmpl-b/agent.md'), '# tmpl-b'); // 无 config.json
write(path.join(root, 'system/agents/_base/agent.md'), '# 元 agent 模板'); // 保留 id:有 agent.md 也不该入库
fs.mkdirSync(path.join(root, 'system/agents/no-agent-md'), { recursive: true }); // 数据碎片,不算本体
write(
path.join(root, 'data/companies/live-co/company.json'),
JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role'] })
);
// 28 批次 4 起公司侧没有 agent 目录了 —— private-role 这行靠「活着的公司还点名它」
// 保住,不再靠 legacy_path 指向的目录真实存在(那条闸门只对存量行还有意义)。
process.env.GRAPH_SEED_ROOT = root;
process.env.GRAPH_DB_PATH = dbPath;
({ graphDb, seedService } = reloadServices());
}
function rows() {
return graphDb.getDb().prepare('SELECT id, origin, scope, owner_company_id, legacy_path FROM system_agents ORDER BY id').all();
}
function rowOf(id) {
return rows().find(r => r.id === id) || null;
}
function orgNodes() {
return graphDb.getDb().prepare('SELECT company_id, system_agent_id, role_name FROM org_graph_nodes ORDER BY company_id, role_name').all();
}
/** 直接造一行 origin='created'(模拟历史遗留 / hire() 写下的公司私有 agent 行) */
function insertHired(id, ownerCompanyId, legacyPath) {
const now = new Date().toISOString();
graphDb.getDb().prepare(`
INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, origin, legacy_path, status, benchmark_score, created_at, updated_at)
VALUES (?, ?, 'company', ?, 'company_hired', 'created', ?, 'active', NULL, ?, ?)
`).run(id, id, ownerCompanyId, legacyPath, now, now);
}
beforeEach(() => buildFixture());
afterEach(() => {
delete process.env.GRAPH_SEED_ROOT;
delete process.env.GRAPH_DB_PATH;
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
});
it('seeds exact row counts from the fixture and passes verifyCounts', () => {
seedService.seedAgents();
seedService.seedCompanyAgents();
expect(rows().map(r => [r.id, r.origin])).toEqual([
['alpha', 'builtin'],
['private-role', 'created'], // live-co 独有角色
['tmpl-a', 'builtin'],
['tmpl-b', 'builtin'],
]);
// `_base`(保留 id,有 agent.md)与 no-agent-md(数据碎片)都不是可调用 agent
expect(rowOf('_base')).toBeNull();
expect(rowOf('no-agent-md')).toBeNull();
// 路径恒由 id 推导,不再有第二个本体目录可选
expect(rowOf('alpha').legacy_path).toBe('system/agents/alpha');
expect(orgNodes()).toEqual([
{ company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' },
{ company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' },
]);
const v = seedService.verifyCounts();
expect(v.mismatches).toEqual([]);
expect(v.ok).toBe(true);
expect(v.expected).toMatchObject({ builtin: 3, created: 1 });
});
it('picks up an agent directory added after the first seed (W1: 新本体目录自动进表)', () => {
seedService.runStartupSeed();
expect(rowOf('tmpl-new')).toBeNull();
write(path.join(root, 'system/agents/tmpl-new/agent.md'), '# brand new');
write(path.join(root, 'system/agents/tmpl-new/config.json'), JSON.stringify({ name: 'Brand New' }));
seedService.runStartupSeed();
expect(rowOf('tmpl-new')).toEqual({
id: 'tmpl-new',
origin: 'builtin',
scope: 'public',
owner_company_id: null,
legacy_path: 'system/agents/tmpl-new',
});
});
it('reclaims a dangling created row that squats on a body id', () => {
// 历史遗留:公司 ghost-co 已被删,行还占着 tmpl-b 这个本体 id
insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
expect(rowOf('tmpl-b').origin).toBe('created');
const summary = seedService.runStartupSeed();
// reconcile 先删掉它(没有任何外键引着),seedAgents 再按目录重新种回来
expect(summary.reconciled.agentsRemoved).toContain('tmpl-b');
expect(rowOf('tmpl-b')).toEqual({
id: 'tmpl-b',
origin: 'builtin',
scope: 'public',
owner_company_id: null,
legacy_path: 'system/agents/tmpl-b',
});
});
it('takes over a squatting row in-place when a foreign key still points at it', () => {
insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
// 还活着的公司 live-co 有一个 org 节点引着这行 → reconcile 删不掉(外键)
graphDb.getDb().prepare(`
INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
VALUES ('live-co', 'tmpl-b', 'tmpl-b', ?)
`).run(new Date().toISOString());
const summary = seedService.runStartupSeed();
expect(summary.reconciled.agentsRemoved).not.toContain('tmpl-b');
expect(summary.reconciled.retained.map(r => r.id)).toContain('tmpl-b');
// 删不掉就地转正:同一行 id 保留(外键继续有效),origin / scope / legacy_path 全部改写
expect(summary.agents.reclaimed).toContain('tmpl-b');
expect(rowOf('tmpl-b')).toEqual({
id: 'tmpl-b',
origin: 'builtin',
scope: 'public',
owner_company_id: null,
legacy_path: 'system/agents/tmpl-b',
});
// 引着它的节点必须还在(转正而不是删了重建的全部意义所在)
expect(orgNodes().some(n => n.system_agent_id === 'tmpl-b')).toBe(true);
});
it('never reclaims rows a live company still backs (防误伤)', () => {
seedService.seedAgents();
seedService.seedCompanyAgents();
// ① 没有本体目录、但 live-co 的 company.json 仍在 agents[] 里点名它(兼容名册那一路)
expect(rowOf('private-role').origin).toBe('created');
// ② 存量行:legacy_path 还是批次 4 之前的公司侧路径(目录早没了),靠 agents[] 点名保住
insertHired('tmpl-a-clone', 'live-co', 'data/companies/live-co/agents/tmpl-a-clone');
write(
path.join(root, 'data/companies/live-co/company.json'),
JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role', 'tmpl-a-clone'] })
);
// ③ 只在 company_agents 里有行、company.json 一个字都没提的(28 批次 4-19 起的真相来源)。
// 少了这一路,凡是迁移后新雇的人都会在下一次启动 reconcile 时被当悬空行删掉。
insertHired('table-only-role', 'live-co', 'system/agents/table-only-role');
graphDb.getDb().prepare(`
INSERT INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
VALUES ('live-co', 'table-only-role', 'table-only-role', 'active', ?)
`).run(new Date().toISOString());
const r = seedService.reconcileDanglingRows();
expect(r.agentsRemoved).toEqual([]);
expect(rowOf('private-role')).not.toBeNull();
expect(rowOf('tmpl-a-clone')).not.toBeNull();
expect(rowOf('tmpl-a-clone').origin).toBe('created');
expect(rowOf('table-only-role')).not.toBeNull();
});
it('③ 那一路不是无脑放行:公司目录没了的 company_agents 行不算认领', () => {
// 与上一条同形态,唯一差别是公司目录不存在。company_agents 的行不会随公司目录消失,
// 拿它当「还活着」的信号会让回收永远不生效 —— 所以 claim 的存活判定仍走磁盘。
insertHired('ghost-table-role', 'ghost-co', 'system/agents/ghost-table-role');
graphDb.getDb().prepare(`
INSERT INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
VALUES ('ghost-co', 'ghost-table-role', 'ghost-table-role', 'active', ?)
`).run(new Date().toISOString());
const r = seedService.reconcileDanglingRows();
// 它被判成悬空行并真的删掉了。以前这里断言的是「落进 retained」——因为幽灵公司的
// company_agents 行还在,外键把 system_agents 那行拦住了。步骤 A 现在会先清掉
// 幽灵公司的名册行(否则 heartbeat-service.start() 会给已删公司挂上定时心跳),
// 拦路的外键随之消失,这一路才走完整。
expect(r.companyAgentsRemoved).toBeGreaterThanOrEqual(1);
expect(r.agentsRemoved).toContain('ghost-table-role');
expect(r.retained.map((x) => x.id)).not.toContain('ghost-table-role');
});
it('never clobbers a created row a live company is still using (合并后剩下的唯一冲突面)', () => {
// tmpl-a 既有本体目录、又被活着的 live-co 在 company.json.agents[] 里点名。
// 这种行是公司自己雇的那份私有拷贝在跑,转成 builtin 会让 lookupSharedAgentMd
// 突然返回本体路径 —— 等于在公司毫不知情的情况下换掉它的 agent.md。
insertHired('tmpl-a', 'live-co', 'data/companies/live-co/agents/tmpl-a');
const summary = seedService.runStartupSeed();
expect(summary.agents.skipped).toEqual([{ id: 'tmpl-a', existingOrigin: 'created' }]);
expect(summary.agents.reclaimed).toEqual([]);
expect(rowOf('tmpl-a')).toMatchObject({
origin: 'created',
legacy_path: 'data/companies/live-co/agents/tmpl-a',
});
});
it('drops org nodes of deleted companies but keeps live ones, and is idempotent', () => {
seedService.seedAgents();
seedService.seedCompanyAgents();
insertHired('ghost-role', 'ghost-co', 'data/companies/ghost-co/agents/ghost-role');
const now = new Date().toISOString();
graphDb.getDb().prepare(`
INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
VALUES ('ghost-co', 'ghost-role', 'ghost-role', ?)
`).run(now);
const first = seedService.reconcileDanglingRows();
expect(first.ghostCompanyIds).toEqual(['ghost-co']);
expect(first.orgNodesRemoved).toBe(1);
expect(first.agentsRemoved).toEqual(['ghost-role']);
expect(first.retained).toEqual([]);
// live-co 的两个节点一个没少
expect(orgNodes()).toEqual([
{ company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' },
{ company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' },
]);
const second = seedService.reconcileDanglingRows();
expect(second).toMatchObject({ orgNodesRemoved: 0, orgEdgesRemoved: 0, ghostCompanyIds: [], agentsRemoved: [], retained: [] });
});
it('skips org-node reconcile entirely when data/companies is absent (安全阀)', () => {
seedService.seedAgents();
seedService.seedCompanyAgents();
const before = orgNodes();
fs.rmSync(path.join(root, 'data/companies'), { recursive: true, force: true });
const r = seedService.reconcileDanglingRows();
expect(r.ghostCompanyIds).toEqual([]);
expect(r.orgNodesRemoved).toBe(0);
expect(orgNodes()).toEqual(before);
});
it('runStartupSeed leaves company-derived rows alone (it must not run seedCompanyAgents)', () => {
seedService.runStartupSeed();
// company.json 里的 private-role 没有被启动流程带进表,org 图也一行没写——
// 那是 hire()/fire() 与 scripts/graph-seed.js 的地盘
expect(rowOf('private-role')).toBeNull();
expect(orgNodes()).toEqual([]);
});
it('runStartupSeedSafely swallows failures and returns null', () => {
process.env.GRAPH_DB_PATH = path.join(root, 'no-such-dir/nested/graph.sqlite');
const { seedService: broken } = reloadServices();
expect(broken.runStartupSeedSafely()).toBeNull();
});
});
describe('graph-seed-service verifyCounts (real repo root)', () => {
let seedService;
let graphDb;
let snapshotRoot;
beforeAll(() => {
// mkdtemp 而不是固定 /tmp/test-graph-seed-service.sqlite:同一台机器上并行跑两遍
// 这个文件时不该共用一个库
snapshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-realroot-'));
freezeRepoRoot(snapshotRoot);
process.env.GRAPH_SEED_ROOT = snapshotRoot;
process.env.GRAPH_DB_PATH = path.join(snapshotRoot, 'graph.sqlite');
({ graphDb, seedService } = reloadServices());
});
afterAll(() => {
delete process.env.GRAPH_SEED_ROOT;
delete process.env.GRAPH_DB_PATH;
try { fs.rmSync(snapshotRoot, { recursive: true, force: true }); } catch {}
});
it('reports ok:true against a freshly-seeded DB (no drift)', () => {
seedService.seedAgents();
seedService.seedCompanyAgents();
const v = seedService.verifyCounts();
expect(v.mismatches).toEqual([]);
expect(v.ok).toBe(true);
});
it('every system/agents/* body dir lands as origin=builtin, and reserved ids never do', () => {
const db = graphDb.getDb();
const agentsDir = path.resolve(__dirname, '../../../system/agents');
// 先取目录清单、**再**补种一次,顺序不能反:`system/` 是软链进快照的(见 freezeRepoRoot),
// 同一轮 vitest 里别的用例会往真实 system/agents/ 下毕业出新本体
// (28 号裁决:测试建的 agent 是正式注册表条目,留在盘上)。
// 反过来写的话,「上一条 it 补种之后、本条 readdir 之前」新出现的那个本体
// 会被判成「没落库」——那是竞态,不是漂移。
const dirs = fs
.readdirSync(agentsDir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name);
const bodyDirs = dirs.filter(
id => !id.startsWith('_') && fs.existsSync(path.join(agentsDir, id, 'agent.md'))
);
seedService.seedAgents();
// 真实仓库里确实有保留 id(`_base`),否则下面那条负向断言是空跑
expect(dirs.some(id => id.startsWith('_'))).toBe(true);
const seeded = new Set(
db.prepare(`SELECT id FROM system_agents WHERE origin = 'builtin'`).all().map(r => r.id)
);
expect(bodyDirs.filter(id => !seeded.has(id))).toEqual([]);
expect([...seeded].filter(id => id.startsWith('_'))).toEqual([]);
});
it('catches a mismatch when the DB has a row the filesystem does not back', () => {
const db = graphDb.getDb();
const now = new Date().toISOString();
db.prepare(`
INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run('bogus-agent-no-fs-backing', 'Bogus', 'public', null, 'global_agent', 'system/agents/bogus-agent-no-fs-backing', 'active', null, now, now);
const v = seedService.verifyCounts();
expect(v.ok).toBe(false);
expect(v.mismatches.some(m => m.check.includes("origin='builtin'"))).toBe(true);
db.prepare('DELETE FROM system_agents WHERE id = ?').run('bogus-agent-no-fs-backing');
});
});