graph-seed-reconcile.test.js 7.6 KB
/**
 * 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 条');
  });
});