/** * 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/agents/_registry.json 的 usedCount,快照后恢复; * - 28 批次 4 起雇佣会在**真实**的 `system/agents/{aid}/{cid}/` 下建公司态目录, * afterAll 逐个删掉;没有本体的角色(org-writer)会从 `_base` 毕业出一份新本体, * 那份**留着**——28 号裁决:测试建出来的 agent 是正式注册表条目,下次跑正好复用它, * 顺带把「本体已存在 → 走复用分支」这条路也测到(本文件的断言两条分支都收)。 */ 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/agents'); const REGISTRY_PATH = path.join(TEMPLATES_DIR, '_registry.json'); // 用到的模板(都是仓库里真实存在的),beforeAll 里逐个核实 const TEMPLATES = ['qa-judge', 'hr', 'orggraph-planning', 'prd-planning', 'validator-triager', 'coder-validator']; // 公司 id 固定:随机后缀会让 afterAll 漏掉的那一次永远躺在 data/companies/ 下 // (E2E 基线里就有 `tmp-w7-blank-1787029777338-8idwe` 这类垃圾)。 const CO_PLANNED = 'tmp-w7-planned'; // orggraph-planning 已提交过成型的图 const CO_ZEROEDGE = 'tmp-w7-zeroedge'; // 真跑里 orggraph-planning 裁出过的**零条边单节点图** const CO_BLANK = 'tmp-w7-blank'; // 从没有过图(P1/P2 的老场景) const CO_BOOTSTRAP = 'tmp-w7-bootstrap'; // 刚建的公司,也没有图 // 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); /** 雇佣关系的新真相源(28 §4.2)—— 公司私有目录没有了,「雇到位没有」只能问它 */ const companyAgentRow = (companyId, roleName) => graphDb.getDb().prepare( `SELECT * FROM company_agents WHERE company_id = ? AND role_name = ? AND status = 'active'` ).get(companyId, roleName); 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, legacy_path, status, benchmark_score, created_at, updated_at) VALUES (?, ?, 'public', NULL, ?, 'active', NULL, ?, ?) `).run(id, id, `system/agents/${id}`, now, now); } // 固定 id 的代价:上一次跑要是崩在中途,公司目录会留在盘上,而 create() 撞见已存在的 // 目录会直接抛 —— 整个文件连一条用例都跑不了。所以建之前先清一次(清的是公司,不是本体)。 for (const [id, name] of [[CO_PLANNED, 'W7 已有组织图'], [CO_BLANK, 'W7 空图公司'], [CO_BOOTSTRAP, 'W7 新建公司'], [CO_ZEROEDGE, 'W7 零边图公司']]) { fs.rmSync(path.join(DATA_DIR, id), { recursive: true, force: true }); 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 {} // 公司态目录散在每个被雇本体下面,只能按公司 id 逐个本体扫回来 for (const body of fs.readdirSync(TEMPLATES_DIR, { withFileTypes: true })) { if (!body.isDirectory()) continue; try { fs.rmSync(path.join(TEMPLATES_DIR, body.name, 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'); // 雇到位了:company_agents 有行、公司名册里有(28 批次 4 起没有公司私有目录可看了) expect(companyAgentRow(CO_PLANNED, 'coder-validator')).toBeTruthy(); 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(companyAgentRow(CO_ZEROEDGE, 'coder-validator')).toBeTruthy(); 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); }); });