seed-org-node-guard.test.js 6.91 KB
/**
 * Unit Tests — 补种不许把「图基础设施本体」塞进 org_graph_nodes(28 三轮复验,问题 2)
 *
 * 锁的是一次**守卫漂移**:`company-service.decideOrgNodeRecording` 的判据①在 P3B/W7 从
 * `{'meta-bootstrap'}` 放宽到 `{'meta-bootstrap','meta-planning'}`(要盖住按需雇的
 * workgraph-planning,以及它自己雇的 prd/sdd/brd-planning、goal-benchmark),
 * 而 `graph-seed-service` 里那份**手抄**的 `isMetaBootstrap()` 停在只认 meta-bootstrap。
 * 于是 `seedCompanyAgents()` 把 hire() 特意排除掉的规划本体重新塞回 org_graph_nodes ——
 * `authored_by=null` 的孤儿节点,正是 graph-engine.resolvePlanningAgentName 注释里点名要防的
 * 「谁也点不亮的 subgraph 节点」;而 work-graph-db 起 org run 时读的就是这张表的 active 行,
 * 多出来的是一个**真的会被执行**的 org 节点。
 *
 * 最坏的一半在对账:`verifyCounts()` 用**同一个**手抄函数算期望值 —— 期望和实际
 * 一起错,`npm run graph:seed` exit 0、全程零告警。所以本文件的三条用例缺一不可:
 *   ① 节点没被写进去(真实症状);
 *   ② 对账的期望值跟着一致(否则「修好了 seed、对账反而变红」,或者反过来一起错还是绿);
 *   ③ 存量孤儿节点被 reconcile 清掉且幂等(库里已经有 5 行,光修 seed 不会自己消失)。
 * 外加一条漂移守卫:判据的取值域只能有一份。
 *
 * 隔离手法同 graph-seed-meta-bootstrap.test.js:GRAPH_SEED_ROOT 指向临时目录自建最小夹具,
 * GRAPH_DB_PATH 指向一次性 sqlite,完全不碰真实仓库与生产库。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const MODULES = [
  '../../services/graph-db',
  '../../services/graph-service',
  '../../services/graph-seed-service',
];

describe('seedCompanyAgents × 图基础设施本体守卫', () => {
  let seed;
  let root;

  const COMPANY = 'co-guard';
  const ORG_ROLE = 'org-planner';            // type='executor' —— 正经 org 节点
  const BOOTSTRAP_ROLE = 'orggraph-planning'; // type='meta-bootstrap' —— 老守卫就认识
  const PLANNING_ROLE = 'workgraph-planning'; // type='meta-planning' —— W7 放宽后才该被拦

  const nodeRoles = () =>
    require('../../services/graph-db').getDb()
      .prepare(`SELECT role_name FROM org_graph_nodes WHERE company_id = ? ORDER BY role_name`)
      .all(COMPANY)
      .map((r) => r.role_name);

  beforeAll(() => {
    root = fs.mkdtempSync(path.join(os.tmpdir(), 'seed-org-guard-'));

    for (const [id, type] of [
      [ORG_ROLE, 'executor'],
      [BOOTSTRAP_ROLE, 'meta-bootstrap'],
      [PLANNING_ROLE, 'meta-planning'],
    ]) {
      const d = path.join(root, 'system', 'agents', id);
      fs.mkdirSync(d, { recursive: true });
      fs.writeFileSync(path.join(d, 'agent.md'), `# ${id}\n`);
      fs.writeFileSync(path.join(d, 'config.json'), JSON.stringify({ type }));
    }
    fs.writeFileSync(path.join(root, 'system', 'agents', '_registry.json'), JSON.stringify({}));

    const companyDir = path.join(root, 'data', 'companies', COMPANY);
    fs.mkdirSync(companyDir, { recursive: true });
    fs.writeFileSync(
      path.join(companyDir, 'company.json'),
      JSON.stringify({ id: COMPANY, name: COMPANY, agents: [ORG_ROLE, BOOTSTRAP_ROLE, PLANNING_ROLE] })
    );

    process.env.GRAPH_SEED_ROOT = root;
    process.env.GRAPH_DB_PATH = path.join(root, 'graph.sqlite');
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    seed = require('../../services/graph-seed-service');

    seed.seedAgents();
    seed.seedCompanyAgents();
  });

  afterAll(() => {
    delete process.env.GRAPH_SEED_ROOT;
    delete process.env.GRAPH_DB_PATH;
    try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  it('① meta-planning 本体不进 org_graph_nodes(meta-bootstrap 早就不进)', () => {
    // 守卫只认 meta-bootstrap 时这里是 ['org-planner', 'workgraph-planning']。
    // 两侧都断言:只断言「没有 PLANNING_ROLE」的话,夹具一行都没种进去也会“通过”。
    expect(nodeRoles()).toEqual([ORG_ROLE]);
  });

  it('② verifyCounts() 的期望值跟着一致 —— 两边一致地错才是最难发现的那种', () => {
    const v = seed.verifyCounts();
    expect(v.mismatches).toEqual([]);
    expect(v.ok).toBe(true);
    // 显式钉住期望值本身:光看 mismatches 为空,分不清「两边都对」和「两边一致地错」。
    expect(v.expected.nodesPerCompany[COMPANY]).toBe(1);
  });

  it('③ 库里已有的孤儿节点被 reconcile 清掉,且幂等', () => {
    const db = require('../../services/graph-db').getDb();
    // 复刻漂移期间留下的存量行:authored_by IS NULL(hire 写 'hire'、writeGraph 写 'planner',
    // 只有补种脚本两个都不写)。生产库里 mycompany 就有 5 行这样的节点。
    db.prepare(`
      INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, status, created_at)
      VALUES (?, ?, ?, 'active', ?)
    `).run(COMPANY, PLANNING_ROLE, PLANNING_ROLE, new Date().toISOString());
    expect(nodeRoles()).toEqual([ORG_ROLE, PLANNING_ROLE]);

    const r = seed.reconcileDanglingRows();
    expect(r.orphanNonOrgNodesRemoved).toEqual([`${COMPANY}/${PLANNING_ROLE}`]);
    expect(nodeRoles()).toEqual([ORG_ROLE]);
    // 清完之后对账仍然干净(清理与期望值是同一条判据算出来的)
    expect(seed.verifyCounts().mismatches).toEqual([]);

    expect(seed.reconcileDanglingRows().orphanNonOrgNodesRemoved).toEqual([]);
  });

  it('④ 漂移守卫:取值域只有一份 —— company-service 的字面量必须与 graph-service 一致', () => {
    // 判据的持有者是 graph-service.NON_ORG_AGENT_TYPES;company-service 眼下还留着一份
    // 同名字面量(那是本轮之外的文件),所以这里直接读它的源码比对。
    // 这条断言变红有两种正当读法:要么两边真的漂了,要么 company-service 已经改成
    // `require('./graph-service').NON_ORG_AGENT_TYPES` —— 后者正是我们想要的终点,
    // 那时把这条用例删掉即可。两种都必须有人看见,所以不能只靠注释提醒。
    const src = fs.readFileSync(path.resolve(__dirname, '../../services/company-service.js'), 'utf-8');
    const m = src.match(/const NON_ORG_AGENT_TYPES = new Set\(\[([^\]]*)\]\)/);
    expect(m, 'company-service 里找不到 NON_ORG_AGENT_TYPES 的字面量了,请核对它是否已改为引用 graph-service').not.toBeNull();

    const companyServiceTypes = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]).sort();
    const graphServiceTypes = [...require('../../services/graph-service').NON_ORG_AGENT_TYPES].sort();
    expect(companyServiceTypes).toEqual(graphServiceTypes);
  });
});