graph-seed-reconcile.test.js
7.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
/**
* Unit Test — `npm run graph:seed` 的对账必须能在**存量形态**上跑绿(28 二轮修复 问题 1)
*
* 背景:`scripts/graph-seed.js` 的对账在生产库上确定性 exit 1,而且反复跑都红 ——
* 一个用来发现真实漂移的告警永久性地喊狼来了。两个成因都是 28 号改造留下的,
* 本用例把它们各造一遍:
*
* ① **目录改名残渣**:批次 1 把 `system/templates/base` 改名成 `system/agents/_base`,
* 库里那行 `id='base'`(legacy_path 指着已不存在的旧目录)没人删也没人改名 ——
* reconcile 只回收 `origin='created'` 行,够不着它;seedAgents 只按目录扫,也碰不到它。
* 于是它永远多算一行 builtin。
* ② **迁移契约 vs 对账契约打架**:§七 M2 要求毕业出来的 org-coder / org-planner
* 保持 `origin='created'`(未来 create-agent 造的本体同样是 created),
* 而 verifyCounts() 原来的期望是「created 必须为 0」。裁决是**改对账口径**:
* 验「created 行在磁盘上都有对应本体目录」,而不是验它为零。
*
* 为什么跑真的子进程而不是直接调 verifyCounts():要证的就是**退出码**。
* 脚本里 `process.exitCode = 1` 那段和对账结果之间还隔着一层拼装,直接调函数证不到它。
* 夹具走 GRAPH_SEED_ROOT / GRAPH_DB_PATH(与 graph-seed-service.test.js 同一套落地缝),
* 真实仓库的 `data/graph.sqlite` 一个字节都不碰。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { execFileSync } from 'child_process';
import Database from 'better-sqlite3';
const REPO_ROOT = path.resolve(__dirname, '../../..');
const SCRIPT = path.join(REPO_ROOT, 'scripts/graph-seed.js');
function write(file, content) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
describe('graph-seed 对账在存量形态上退出码为 0', () => {
let root;
let dbPath;
let stdout;
beforeAll(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-reconcile-'));
dbPath = path.join(root, 'graph.sqlite');
// ── 磁盘:一个保留 id + 一个普通本体 + 一个「已毕业」的本体 ──
write(path.join(root, 'system/agents/_registry.json'), JSON.stringify([{ id: 'alpha', benchmarkScore: 9 }]));
write(path.join(root, 'system/agents/_base/agent.md'), '# 元 agent 模板');
write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha');
write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha' }));
// M2 的「毕业」产物:本体已经在 system/agents/ 下,库里那行按契约保持 created
write(path.join(root, 'system/agents/graduated/agent.md'), '# graduated');
write(
path.join(root, 'data/companies/live-co/company.json'),
JSON.stringify({ id: 'live-co', agents: ['alpha', 'graduated'] })
);
// ── 库:先建表,再塞进两行「存量」──
process.env.GRAPH_SEED_ROOT = root;
process.env.GRAPH_DB_PATH = dbPath;
delete require.cache[require.resolve('../../services/graph-db')];
const graphDb = require('../../services/graph-db');
const db = graphDb.getDb();
const now = new Date().toISOString();
const insert = db.prepare(`
INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, origin, legacy_path, status, benchmark_score, created_at, updated_at)
VALUES (@id, @name, @scope, @owner, @source_kind, @origin, @legacy_path, 'active', NULL, @now, @now)
`);
insert.run({ id: 'base', name: 'base', scope: 'public', owner: null, source_kind: 'global_agent', origin: 'builtin', legacy_path: 'system/templates/base', now });
insert.run({ id: 'graduated', name: 'graduated', scope: 'company', owner: 'live-co', source_kind: 'company_hired', origin: 'created', legacy_path: 'system/agents/graduated', now });
// ── 幽灵公司的名册行:目录(连同 company.json)已经不在,company_agents 那行还在 ──
// 这张表批次 4-19 之后是**唯一被读的名册**,heartbeat-service.start() 拿它挂心跳时
// 不查公司还在不在。漏清的话,一家删掉的公司每次服务启动都会把员工重新挂上定时心跳,
// 到点真起一次 Claude CLI —— 全程零报错。实测生产库里已经积了 10 行。
// 注意 ghost-co **没有** org_graph_nodes 行:基础设施角色不入图,只扫 org 表会整家漏掉。
const hire = db.prepare(`
INSERT INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
VALUES (?, ?, ?, 'active', ?)
`);
hire.run('live-co', 'graduated', 'graduated', now);
hire.run('ghost-co', 'graduated', 'graduated', now);
db.close(); // 子进程要自己开一次连接,别拿着写锁不放
stdout = execFileSync(process.execPath, [SCRIPT], {
cwd: REPO_ROOT,
encoding: 'utf-8',
env: { ...process.env, GRAPH_SEED_ROOT: root, GRAPH_DB_PATH: dbPath },
}); // execFileSync 在非零退出码时抛异常 —— 这就是「exit 0」那条断言本身
});
afterAll(() => {
delete process.env.GRAPH_SEED_ROOT;
delete process.env.GRAPH_DB_PATH;
delete require.cache[require.resolve('../../services/graph-db')];
try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
});
function rows() {
const db = new Database(dbPath, { readonly: true });
try {
return db.prepare('SELECT id, origin, legacy_path FROM system_agents ORDER BY id').all();
} finally {
db.close();
}
}
it('脚本跑完对账通过(退出码 0,报告里没有失败段)', () => {
expect(stdout).toContain('行数对账通过');
expect(stdout).not.toContain('行数对账失败');
});
it('目录改名残渣 id=base 被清掉(reconcile 够得着 builtin 行了)', () => {
expect(rows().find((r) => r.id === 'base')).toBeUndefined();
expect(stdout).toContain('磁盘上已无本体的 builtin 行: 1 条 (base)');
// 保留 id 本身也从来不该入库
expect(rows().find((r) => r.id === '_base')).toBeUndefined();
});
it("origin='created' 的行都有对应磁盘本体目录(M2 契约不再和对账打架)", () => {
const created = rows().filter((r) => r.origin === 'created');
expect(created.map((r) => r.id)).toEqual(['graduated']); // 毕业行还在,没被当悬空行回收
for (const row of created) {
expect(
fs.existsSync(path.join(root, 'system/agents', row.id, 'agent.md')),
`created 行 ${row.id} 在 system/agents/ 下没有本体目录`
).toBe(true);
}
});
it('幽灵公司的名册行被清掉,活着公司的一行不动(否则心跳会给已删公司挂定时器)', () => {
const db = new Database(dbPath, { readonly: true });
try {
const all = db.prepare('SELECT company_id, role_name FROM company_agents ORDER BY company_id').all();
expect(all).toEqual([{ company_id: 'live-co', role_name: 'graduated' }]);
} finally {
db.close();
}
expect(stdout).toContain('已删公司的名册行: 1 行');
});
it('再跑一遍仍然 exit 0 且不再有可清理项(幂等:告警不会隔天又喊狼来了)', () => {
const second = execFileSync(process.execPath, [SCRIPT], {
cwd: REPO_ROOT,
encoding: 'utf-8',
env: { ...process.env, GRAPH_SEED_ROOT: root, GRAPH_DB_PATH: dbPath },
});
expect(second).toContain('行数对账通过');
expect(second).toContain('磁盘上已无本体的 builtin 行: 0 条');
});
});