graph-seed-service.test.js
13.8 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
/**
* Unit Tests — graph-seed-service.js
*
* 两块:
*
* A. **hermetic 夹具根**(P3B W1 起):`GRAPH_SEED_ROOT` 指向 /tmp 下现造的
* system/agents + system/templates + data/companies,配一个一次性 GRAPH_DB_PATH。
* 行数因此是**写死的精确值**而不是「跟着仓库现状走」——原来的写法直接对着生产
* `data/graph.sqlite` 与真实 `data/companies/` 断言,任何人手建一家公司都会让它变红。
* 悬空行回收 / 模板接管 / 防误伤三条规则都在这里锁。
*
* B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实
* `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok,
* 且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。
*/
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);
}
describe('graph-seed-service (hermetic fixture root)', () => {
let root;
let dbPath;
let seedService;
let graphDb;
/** 夹具:1 个 global agent、3 个模板目录(其中 alpha 与 global agent 同名)、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/alpha/agent.md'), '# alpha global');
write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha Global' }));
fs.mkdirSync(path.join(root, 'system/agents/no-agent-md'), { recursive: true }); // 无 agent.md → 不算 global
write(path.join(root, 'system/templates/_registry.json'), JSON.stringify([{ id: 'tmpl-a', benchmarkScore: 7.5 }]));
write(path.join(root, 'system/templates/tmpl-a/agent.md'), '# tmpl-a');
write(path.join(root, 'system/templates/tmpl-a/config.json'), JSON.stringify({ name: 'Template A' }));
write(path.join(root, 'system/templates/tmpl-b/agent.md'), '# tmpl-b'); // 无 config.json
write(path.join(root, 'system/templates/alpha/agent.md'), '# alpha template'); // 与 global agent 撞 id
write(
path.join(root, 'data/companies/live-co/company.json'),
JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role'] })
);
write(path.join(root, 'data/companies/live-co/agents/private-role/agent.md'), '# private');
process.env.GRAPH_SEED_ROOT = root;
process.env.GRAPH_DB_PATH = dbPath;
({ graphDb, seedService } = reloadServices());
}
function rows() {
return graphDb.getDb().prepare('SELECT id, source_kind, 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();
}
/** 直接造一行 company_hired(模拟历史遗留 / hire() 写下的行) */
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, legacy_path, status, benchmark_score, created_at, updated_at)
VALUES (?, ?, 'company', ?, 'company_hired', ?, '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.seedGlobalAgents();
seedService.seedTemplates();
seedService.seedCompanyAgents();
expect(rows().map(r => [r.id, r.source_kind])).toEqual([
['alpha', 'global_agent'], // system/agents/alpha 有 agent.md
['private-role', 'company_hired'], // live-co 独有角色
['tmpl-a', 'template'],
['tmpl-b', 'template'],
]);
// system/templates/alpha 与 global agent 撞 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({ global_agent: 1, template: 2, company_hired: 1 });
});
it('picks up a template directory added after the first seed (W1: 新模板目录自动进表)', () => {
seedService.runStartupSeed();
expect(rowOf('tmpl-new')).toBeNull();
write(path.join(root, 'system/templates/tmpl-new/agent.md'), '# brand new');
write(path.join(root, 'system/templates/tmpl-new/config.json'), JSON.stringify({ name: 'Brand New' }));
seedService.runStartupSeed();
expect(rowOf('tmpl-new')).toEqual({
id: 'tmpl-new',
source_kind: 'template',
scope: 'public',
owner_company_id: null,
legacy_path: 'system/templates/tmpl-new',
});
});
it('reclaims a dangling company_hired row that squats on a template id', () => {
seedService.seedGlobalAgents();
// 历史遗留:公司 ghost-co 已被删,行还占着 tmpl-b 这个模板 id
insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
expect(rowOf('tmpl-b').source_kind).toBe('company_hired');
const summary = seedService.runStartupSeed();
// reconcile 先删掉它(没有任何外键引着),seedTemplates 再按目录重新种回来
expect(summary.reconciled.agentsRemoved).toContain('tmpl-b');
expect(rowOf('tmpl-b')).toEqual({
id: 'tmpl-b',
source_kind: 'template',
scope: 'public',
owner_company_id: null,
legacy_path: 'system/templates/tmpl-b',
});
});
it('takes over a squatting row in-place when a foreign key still points at it', () => {
seedService.seedGlobalAgents();
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 保留(外键继续有效),source_kind / scope / legacy_path 全部改写
expect(summary.templates.reclaimed).toContain('tmpl-b');
expect(rowOf('tmpl-b')).toEqual({
id: 'tmpl-b',
source_kind: 'template',
scope: 'public',
owner_company_id: null,
legacy_path: 'system/templates/tmpl-b',
});
// 引着它的节点必须还在(转正而不是删了重建的全部意义所在)
expect(orgNodes().some(n => n.system_agent_id === 'tmpl-b')).toBe(true);
});
it('never reclaims rows a live company still backs (防误伤)', () => {
seedService.seedGlobalAgents();
seedService.seedTemplates();
seedService.seedCompanyAgents();
// ① 目录真实存在的公司私有 agent
expect(rowOf('private-role').source_kind).toBe('company_hired');
// ② 目录没了、但 live-co 的 company.json 仍在 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'] })
);
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').source_kind).toBe('company_hired');
});
it('never reclaims a global_agent row even when a same-named template dir exists', () => {
const summary = seedService.runStartupSeed();
expect(summary.templates.skipped).toEqual([{ id: 'alpha', existingSourceKind: 'global_agent' }]);
expect(summary.templates.reclaimed).toEqual([]);
expect(rowOf('alpha')).toMatchObject({ source_kind: 'global_agent', legacy_path: 'system/agents/alpha' });
});
it('drops org nodes of deleted companies but keeps live ones, and is idempotent', () => {
seedService.seedGlobalAgents();
seedService.seedTemplates();
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.seedGlobalAgents();
seedService.seedTemplates();
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;
const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite';
beforeAll(() => {
if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
delete process.env.GRAPH_SEED_ROOT; // 真实仓库根
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
({ graphDb, seedService } = reloadServices());
});
afterAll(() => {
try { fs.unlinkSync(TEST_DB_PATH); } catch {}
try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {}
try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {}
delete process.env.GRAPH_DB_PATH;
});
it('reports ok:true against a freshly-seeded DB (no drift)', () => {
seedService.seedGlobalAgents();
seedService.seedTemplates();
seedService.seedCompanyAgents();
const v = seedService.verifyCounts();
expect(v.mismatches).toEqual([]);
expect(v.ok).toBe(true);
});
it('every system/templates/* dir that is not a global_agent id lands as source_kind=template', () => {
const db = graphDb.getDb();
const globalIds = new Set(
db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'global_agent'`).all().map(r => r.id)
);
const templateDirs = fs
.readdirSync(path.resolve(__dirname, '../../../system/templates'), { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => d.name)
.filter(id => !globalIds.has(id));
const seededTemplates = new Set(
db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'template'`).all().map(r => r.id)
);
expect(templateDirs.filter(id => !seededTemplates.has(id))).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("source_kind='global_agent'"))).toBe(true);
db.prepare('DELETE FROM system_agents WHERE id = ?').run('bogus-agent-no-fs-backing');
});
});