company-hire-org-layer.test.js
11.9 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
/**
* Unit Tests — 招聘的「层」:work 层本体不得进 org graph
* (27-P3B §四 W7 / 27-REQ R-18.2 末段)
*
* 锁的是 company-service.decideOrgNodeRecording 的三条判据 + 默认行为:
* ① config.type ∈ {meta-bootstrap, meta-planning} 的图基础设施本体永不入图;
* ② system/work-graph-templates/*.json 里点名当节点的本体永不入图;
* ③ 公司已有**成型**的 org graph(有 active 边)时,不在图上的角色不入图;
* ④ 都不命中 → 入图,即 P1/P2 既有语义(「雇一个角色 → org graph 出现它」)。
*
* 每条「不入图」的用例都配一条同角色(或同公司)只换一个条件的正例,
* 免得测试退化成「这个角色被整体拉黑了」而看不出判据本身有没有生效。
* 三条判据都做过变异验证(删判据①/②/③ 各会红掉对应用例)。
*
* 走的是**真的** hire()(不是 mock),落库后从 org-graph-service.getGraph() 读回
* ——即 GET /api/company/:id/org-graph 的同一条读路径。
*
* 隔离手法与 graph-agent-p1-acceptance.test.js 同源:
* - GRAPH_DB_PATH 指向一次性 sqlite,真实 data/graph.sqlite 全程不被碰;
* - 公司建在 data/companies/tmp-w7-* 下,afterAll 删掉;
* - hire() 会 bump system/templates/_registry.json 的 usedCount,快照后恢复。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
describe('hire() 的 org / work 分层(27-P3B W7)', () => {
const REPO_ROOT = path.resolve(__dirname, '../../..');
const TEST_DB_PATH = '/tmp/test-company-hire-org-layer.sqlite';
const DATA_DIR = path.join(REPO_ROOT, 'data/companies');
const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates');
const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json');
// 用到的模板(都是仓库里真实存在的),beforeAll 里逐个核实
const TEMPLATES = ['qa-judge', 'hr', 'orggraph-planning', 'prd-planning', 'validator-triager', 'coder-validator'];
const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const CO_PLANNED = `tmp-w7-planned-${RUN_ID}`; // orggraph-planning 已提交过成型的图
const CO_ZEROEDGE = `tmp-w7-zeroedge-${RUN_ID}`; // 真跑里 orggraph-planning 裁出过的**零条边单节点图**
const CO_BLANK = `tmp-w7-blank-${RUN_ID}`; // 从没有过图(P1/P2 的老场景)
const CO_BOOTSTRAP = `tmp-w7-bootstrap-${RUN_ID}`; // 刚建的公司,也没有图
// org graph(照抄 system/org-graph-templates/dev.json,能过 §3.3 四项校验)
const ORG_NODES = ['org-planner', 'org-coder', 'org-validator'];
const ORG_EDGES = [
{ from: 'org-planner', to: 'org-coder', condition: 'success' },
{ from: 'org-coder', to: 'org-validator', condition: 'success' },
{ from: 'org-validator', to: 'org-coder', condition: 'fail', edgeKind: 'backward' },
];
let graphDb;
let orgGraphService;
let companyService;
let registrySnapshot;
/** 从 GET org-graph 的同一条读路径拿 active 角色名 */
const roleNames = (companyId) =>
orgGraphService.getGraph(companyId).nodes.filter((n) => n.status === 'active').map((n) => n.roleName);
const systemAgentRow = (id) =>
graphDb.getDb().prepare('SELECT * FROM system_agents WHERE id = ?').get(id);
beforeAll(() => {
// 模板必须真的在仓库里,否则 hire() 会走 base 继承分支,测试变成空跑
for (const tpl of TEMPLATES) {
expect(fs.existsSync(path.join(TEMPLATES_DIR, tpl, 'config.json'))).toBe(true);
}
// 判据②的数据源也要真的点名了 validator-triager,否则那条用例是空跑
const workTpl = JSON.parse(
fs.readFileSync(path.join(REPO_ROOT, 'system/work-graph-templates/validator.json'), 'utf-8')
);
expect(workTpl.nodes.map((n) => n.systemAgentId)).toContain('validator-triager');
registrySnapshot = fs.readFileSync(REGISTRY_PATH, 'utf-8');
if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
for (const m of ['../../services/graph-db', '../../services/graph-service',
'../../services/org-graph-service', '../../services/company-service']) {
delete require.cache[require.resolve(m)];
}
graphDb = require('../../services/graph-db');
orgGraphService = require('../../services/org-graph-service');
const { CompanyService } = require('../../services/company-service');
companyService = new CompanyService();
// 镜像生产种子:这些模板在 system_agents 里是 source_kind='template'(引用模式)
const db = graphDb.getDb();
const now = new Date().toISOString();
for (const id of TEMPLATES) {
db.prepare(`
INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
VALUES (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?)
`).run(id, id, `system/templates/${id}`, now, now);
}
for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司'], [CO_ZEROEDGE, 'W7 零边图公司']]) {
companyService.create({ id, name, description: 'W7 分层测试' });
}
// CO_PLANNED 走 P2 §4.1 的正常顺序:orggraph-planning 先提交图,再招聘
const written = orgGraphService.writeGraph(CO_PLANNED, { nodes: ORG_NODES, edges: ORG_EDGES });
expect(written.ok).toBe(true);
expect(written.nodeCount).toBe(3);
expect(written.edgeCount).toBe(3);
// 零边单节点图 —— 这个形状是真跑里 orggraph-planning 自己裁出来的,
// 而上面那 9 例造的全是**有边**的成型图,正好绕开它(P3B 第三次真跑发现)。
const zero = orgGraphService.writeGraph(CO_ZEROEDGE, { nodes: ['org-writer'], edges: [] });
expect(zero.ok).toBe(true);
expect(zero.nodeCount).toBe(1);
expect(zero.edgeCount).toBe(0);
});
afterAll(() => {
for (const id of [CO_PLANNED, CO_BLANK, CO_BOOTSTRAP, CO_ZEROEDGE]) {
try { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); } catch {}
}
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;
if (registrySnapshot !== undefined) fs.writeFileSync(REGISTRY_PATH, registrySnapshot, 'utf-8');
});
// ── ④ 默认行为:P1/P2 语义原样保住 ──────────────────────────────────
it('公司还没有 org graph 时雇一个角色 → org graph 里出现这个节点(P2 §4.2 语义)', () => {
expect(roleNames(CO_BLANK)).toEqual([]);
expect(companyService.hire(CO_BLANK, 'qa-judge', {}).status).toBe('hired_from_template');
expect(roleNames(CO_BLANK)).toEqual(['qa-judge']);
});
it('同一家没有图的公司连雇两个人,第二个照样入图(判据③不能拿节点当信号的原因)', () => {
expect(companyService.hire(CO_BLANK, 'hr', {}).status).toBe('hired_from_template');
expect(roleNames(CO_BLANK).sort()).toEqual(['hr', 'qa-judge']);
});
// ── ② work graph 骨架点名的本体 ─────────────────────────────────────
it('validator-triager(type=validator,骨架里点过名)不入图,哪怕公司连一条边都没有', () => {
const before = roleNames(CO_BLANK).sort();
expect(companyService.hire(CO_BLANK, 'validator-triager', {}).status).toBe('hired_from_template');
expect(roleNames(CO_BLANK).sort()).toEqual(before);
// 但 system_agents 那行必须在:work_graph_nodes.system_agent_id 是指向它的外键,
// 缺行的话 workgraph-planning 雇完人再提交 work graph 会直接外键挂(W1 那条失效链)
expect(systemAgentRow('validator-triager')).toBeTruthy();
});
// ── ① 图基础设施本体 ────────────────────────────────────────────────
it('meta-bootstrap(orggraph-planning)不入图:建公司到规划跑完之间不会返回一张假组织图', () => {
expect(companyService.hire(CO_BOOTSTRAP, 'orggraph-planning', {}).status).toBe('hired_from_template');
expect(roleNames(CO_BOOTSTRAP)).toEqual([]);
expect(systemAgentRow('orggraph-planning')).toBeTruthy();
});
it('meta-planning(prd-planning)不入图,哪怕公司还没有图(判据①单独生效)', () => {
expect(roleNames(CO_BOOTSTRAP)).toEqual([]);
expect(companyService.hire(CO_BOOTSTRAP, 'prd-planning', {}).status).toBe('hired_from_template');
expect(roleNames(CO_BOOTSTRAP)).toEqual([]);
});
it('coder-validator 在没有成型图的公司里照样入图 —— 判据①②都不认识它', () => {
expect(companyService.hire(CO_BOOTSTRAP, 'coder-validator', {}).status).toBe('hired_from_template');
expect(roleNames(CO_BOOTSTRAP)).toEqual(['coder-validator']);
});
// ── ③ 成型的图 + 不在图上 = work 层本体 ─────────────────────────────
it('同一个 coder-validator,在已有成型 org graph 的公司里被雇 → GET org-graph 里没有它', () => {
expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
expect(companyService.hire(CO_PLANNED, 'coder-validator', {}).status).toBe('hired_from_template');
// 雇到位了:目录在、公司名册里有
expect(fs.existsSync(path.join(DATA_DIR, CO_PLANNED, 'agents/coder-validator'))).toBe(true);
expect(companyService.get(CO_PLANNED).agents).toContain('coder-validator');
// 但组织图一个节点都没多
expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
});
it('R-18.2 验收 7:workgraph-planning 雇一轮 work 层本体后,org graph 仍然只有 org 节点', () => {
for (const body of ['prd-planning', 'validator-triager']) {
expect(['hired_from_template', 'already_exists']).toContain(
companyService.hire(CO_PLANNED, body, {}).status
);
}
expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
});
it('W7-fix:零条边的单节点图也算「成型」—— work 层本体不得入图', () => {
// 判据原先看的是**边数**,零边图上三条判据全落空 → work 层本体被写进 org_graph_nodes,
// 下一次下达任务时它会被 advanceOne 当成 org 节点执行。真跑里 readme-writer 就是这么混进去的。
expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']);
expect(['hired_from_template', 'already_exists']).toContain(
companyService.hire(CO_ZEROEDGE, 'coder-validator', {}).status
);
// 雇到位了,但组织图一个节点都没多
expect(fs.existsSync(path.join(DATA_DIR, CO_ZEROEDGE, 'agents/coder-validator'))).toBe(true);
expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']);
});
it('W7-fix:零边图上,图里点名的那个 org 节点重复雇仍然入图(P2 语义不被误伤)', () => {
expect(['hired_from_template', 'hired_from_base', 'already_exists']).toContain(
companyService.hire(CO_ZEROEDGE, 'org-writer', {}).status
);
expect(roleNames(CO_ZEROEDGE)).toEqual(['org-writer']);
});
it('org 节点本身重复雇不产生新节点(P2 幂等语义不变)', () => {
const res = companyService.hire(CO_PLANNED, 'org-coder', {});
// 模板库里没有 org-coder → 走 base 元 agent 继承分支,同样要过 _recordHireInGraph
expect(['hired_from_template', 'hired_from_base']).toContain(res.status);
expect(roleNames(CO_PLANNED)).toEqual(ORG_NODES);
const count = graphDb.getDb()
.prepare(`SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = 'org-coder' AND status = 'active'`)
.get(CO_PLANNED).c;
expect(count).toBe(1);
});
});