graph-seed-service.test.js 17.5 KB
/**
 * Unit Tests — graph-seed-service.js
 *
 * 两块:
 *
 * A. **hermetic 夹具根**(P3B W1 起):`GRAPH_SEED_ROOT` 指向 /tmp 下现造的
 *    system/agents + system/templates + data/companies,配一个一次性 GRAPH_DB_PATH。
 *    行数因此是**写死的精确值**而不是「跟着仓库现状走」——原来的写法直接对着生产
 *    `data/graph.sqlite` 与真实 `data/companies/` 断言,任何人手建一家公司都会让它变红。
 *    悬空行回收 / 模板接管 / 防误伤三条规则都在这里锁。
 *
 * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实
 *    `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok,
 *    且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。
 *    读的仍然是真实仓库的内容,但 `data/companies/` 走一次**冻结快照**(见 freezeRepoRoot)——
 *    原因写在那个函数上。
 */
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';

/** 换掉 GRAPH_SEED_ROOT / GRAPH_DB_PATH 之后必须重新 require,两个模块都在顶层读了常量 */
function reloadServices() {
  delete require.cache[require.resolve('../../services/graph-db')];
  delete require.cache[require.resolve('../../services/graph-seed-service')];
  return {
    graphDb: require('../../services/graph-db'),
    seedService: require('../../services/graph-seed-service'),
  };
}

function write(file, content) {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, content);
}

const REPO_ROOT = path.resolve(__dirname, '../../..');

/**
 * 把「真实仓库根」冻成一个不会在用例中途变形的 GRAPH_SEED_ROOT。
 *
 * 为什么需要冻:verifyCounts() 的价值在于**独立重扫一遍目录**再跟库里的行数对,
 * 于是 seedCompanyAgents() 那次扫描和 verifyCounts() 那次扫描之间只要磁盘变了一次,
 * 期望值和实际值就必然不等 —— 而 `data/companies/` 是运行期状态,同一轮 `vitest run` 里
 * 至少有 company-hire-org-layer.test.js / graph-agent-p1-{hire,acceptance}.test.js
 * 三个文件在真实 `data/companies/` 下建公司再 hire()(company.json 的 agents[] 逐个变长),
 * afterAll 又整目录删掉;用户开着 dev server 时还会有人从 UI 建公司。
 * 实测:拿一个反复建/删的公司目录撞这个窗口,12 次单跑里 3 次红在
 * `org_graph_nodes COUNT(*) WHERE company_id=...`。这就是它在全套里偶发变红的全部原因。
 *
 * 冻的是「一致性」而不是「新鲜度」:快照本身可能抓到某家公司写一半的样子,
 * 但两次扫描读的是同一份,结论照旧成立。所以断言一条没放松 —— 真实仓库里多一个
 * 模板目录 / 多一家公司带着 seeder 会跳过的角色,照样红(下面有验证)。
 *
 * `system/` 不快照,直接软链:它是签入代码,跑测试期间没人往里加目录
 * (hire() 只 bump `_registry.json` 里的 usedCount,改内容不建目录,而 _registry.json
 * 只影响 benchmark_score,不影响任何行数)。软链还顺带让
 * 「every system/templates/* dir …」那条用例仍然对着真实目录清单比。
 */
function freezeRepoRoot(dest) {
  fs.mkdirSync(dest, { recursive: true });
  fs.symlinkSync(path.join(REPO_ROOT, 'system'), path.join(dest, 'system'));

  const src = path.join(REPO_ROOT, 'data', 'companies');
  const dst = path.join(dest, 'data', 'companies');
  if (!fs.existsSync(src)) return; // 连目录都没有 → 快照里也不该有,别把安全阀的判定条件改掉
  fs.mkdirSync(dst, { recursive: true });

  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
    if (!entry.isDirectory()) continue;
    try {
      // 没有 company.json → seedCompanyAgents 与 verifyCounts 都跳过它,不必进快照
      const companyJson = fs.readFileSync(path.join(src, entry.name, 'company.json'));
      write(path.join(dst, entry.name, 'company.json'), companyJson);

      let roles = [];
      try {
        const parsed = JSON.parse(companyJson);
        if (Array.isArray(parsed.agents)) roles = parsed.agents;
      } catch {
        // 坏掉的 company.json:两个实现都 continue,快照里留着原样即可
      }
      for (const role of roles) {
        const roleDir = path.join(src, entry.name, 'agents', role);
        if (!fs.existsSync(roleDir)) continue; // legacy_path 存在性也要一并复制
        fs.mkdirSync(path.join(dst, entry.name, 'agents', role), { recursive: true });
        // isMetaBootstrap() 只看这一个文件,而它决定 seedCompanyAgents 建不建 org 节点 —— 必须带上
        try {
          fs.copyFileSync(
            path.join(roleDir, 'config.json'),
            path.join(dst, entry.name, 'agents', role, 'config.json')
          );
        } catch {}
      }
    } catch {
      // 这家公司正被别的测试 / dev server 删掉 → 跳过。快照要的是自洽,不是全集。
    }
  }
}

describe('graph-seed-service (hermetic fixture root)', () => {
  let root;
  let dbPath;
  let seedService;
  let graphDb;

  /** 夹具:1 个 global agent、3 个模板目录(其中 alpha 与 global agent 同名)、1 家活着的公司 */
  function buildFixture() {
    root = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-fixture-'));
    dbPath = path.join(root, 'graph.sqlite');

    write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha global');
    write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha Global' }));
    fs.mkdirSync(path.join(root, 'system/agents/no-agent-md'), { recursive: true }); // 无 agent.md → 不算 global

    write(path.join(root, 'system/templates/_registry.json'), JSON.stringify([{ id: 'tmpl-a', benchmarkScore: 7.5 }]));
    write(path.join(root, 'system/templates/tmpl-a/agent.md'), '# tmpl-a');
    write(path.join(root, 'system/templates/tmpl-a/config.json'), JSON.stringify({ name: 'Template A' }));
    write(path.join(root, 'system/templates/tmpl-b/agent.md'), '# tmpl-b'); // 无 config.json
    write(path.join(root, 'system/templates/alpha/agent.md'), '# alpha template'); // 与 global agent 撞 id

    write(
      path.join(root, 'data/companies/live-co/company.json'),
      JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role'] })
    );
    write(path.join(root, 'data/companies/live-co/agents/private-role/agent.md'), '# private');

    process.env.GRAPH_SEED_ROOT = root;
    process.env.GRAPH_DB_PATH = dbPath;
    ({ graphDb, seedService } = reloadServices());
  }

  function rows() {
    return graphDb.getDb().prepare('SELECT id, source_kind, scope, owner_company_id, legacy_path FROM system_agents ORDER BY id').all();
  }
  function rowOf(id) {
    return rows().find(r => r.id === id) || null;
  }
  function orgNodes() {
    return graphDb.getDb().prepare('SELECT company_id, system_agent_id, role_name FROM org_graph_nodes ORDER BY company_id, role_name').all();
  }

  /** 直接造一行 company_hired(模拟历史遗留 / hire() 写下的行) */
  function insertHired(id, ownerCompanyId, legacyPath) {
    const now = new Date().toISOString();
    graphDb.getDb().prepare(`
      INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, 'company', ?, 'company_hired', ?, 'active', NULL, ?, ?)
    `).run(id, id, ownerCompanyId, legacyPath, now, now);
  }

  beforeEach(() => buildFixture());

  afterEach(() => {
    delete process.env.GRAPH_SEED_ROOT;
    delete process.env.GRAPH_DB_PATH;
    try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
  });

  it('seeds exact row counts from the fixture and passes verifyCounts', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();

    expect(rows().map(r => [r.id, r.source_kind])).toEqual([
      ['alpha', 'global_agent'],       // system/agents/alpha 有 agent.md
      ['private-role', 'company_hired'], // live-co 独有角色
      ['tmpl-a', 'template'],
      ['tmpl-b', 'template'],
    ]);
    // system/templates/alpha 与 global agent 撞 id:仍然被跳过,绝不被模板覆盖
    expect(rowOf('alpha').legacy_path).toBe('system/agents/alpha');

    expect(orgNodes()).toEqual([
      { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' },
      { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' },
    ]);

    const v = seedService.verifyCounts();
    expect(v.mismatches).toEqual([]);
    expect(v.ok).toBe(true);
    expect(v.expected).toMatchObject({ global_agent: 1, template: 2, company_hired: 1 });
  });

  it('picks up a template directory added after the first seed (W1: 新模板目录自动进表)', () => {
    seedService.runStartupSeed();
    expect(rowOf('tmpl-new')).toBeNull();

    write(path.join(root, 'system/templates/tmpl-new/agent.md'), '# brand new');
    write(path.join(root, 'system/templates/tmpl-new/config.json'), JSON.stringify({ name: 'Brand New' }));

    seedService.runStartupSeed();

    expect(rowOf('tmpl-new')).toEqual({
      id: 'tmpl-new',
      source_kind: 'template',
      scope: 'public',
      owner_company_id: null,
      legacy_path: 'system/templates/tmpl-new',
    });
  });

  it('reclaims a dangling company_hired row that squats on a template id', () => {
    seedService.seedGlobalAgents();
    // 历史遗留:公司 ghost-co 已被删,行还占着 tmpl-b 这个模板 id
    insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
    expect(rowOf('tmpl-b').source_kind).toBe('company_hired');

    const summary = seedService.runStartupSeed();

    // reconcile 先删掉它(没有任何外键引着),seedTemplates 再按目录重新种回来
    expect(summary.reconciled.agentsRemoved).toContain('tmpl-b');
    expect(rowOf('tmpl-b')).toEqual({
      id: 'tmpl-b',
      source_kind: 'template',
      scope: 'public',
      owner_company_id: null,
      legacy_path: 'system/templates/tmpl-b',
    });
  });

  it('takes over a squatting row in-place when a foreign key still points at it', () => {
    seedService.seedGlobalAgents();
    insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
    // 还活着的公司 live-co 有一个 org 节点引着这行 → reconcile 删不掉(外键)
    graphDb.getDb().prepare(`
      INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
      VALUES ('live-co', 'tmpl-b', 'tmpl-b', ?)
    `).run(new Date().toISOString());

    const summary = seedService.runStartupSeed();

    expect(summary.reconciled.agentsRemoved).not.toContain('tmpl-b');
    expect(summary.reconciled.retained.map(r => r.id)).toContain('tmpl-b');
    // 删不掉就地转正:同一行 id 保留(外键继续有效),source_kind / scope / legacy_path 全部改写
    expect(summary.templates.reclaimed).toContain('tmpl-b');
    expect(rowOf('tmpl-b')).toEqual({
      id: 'tmpl-b',
      source_kind: 'template',
      scope: 'public',
      owner_company_id: null,
      legacy_path: 'system/templates/tmpl-b',
    });
    // 引着它的节点必须还在(转正而不是删了重建的全部意义所在)
    expect(orgNodes().some(n => n.system_agent_id === 'tmpl-b')).toBe(true);
  });

  it('never reclaims rows a live company still backs (防误伤)', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();

    // ① 目录真实存在的公司私有 agent
    expect(rowOf('private-role').source_kind).toBe('company_hired');
    // ② 目录没了、但 live-co 的 company.json 仍在 agents[] 里点名它
    insertHired('tmpl-a-clone', 'live-co', 'data/companies/live-co/agents/tmpl-a-clone');
    write(
      path.join(root, 'data/companies/live-co/company.json'),
      JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role', 'tmpl-a-clone'] })
    );

    const r = seedService.reconcileDanglingRows();

    expect(r.agentsRemoved).toEqual([]);
    expect(rowOf('private-role')).not.toBeNull();
    expect(rowOf('tmpl-a-clone')).not.toBeNull();
    expect(rowOf('tmpl-a-clone').source_kind).toBe('company_hired');
  });

  it('never reclaims a global_agent row even when a same-named template dir exists', () => {
    const summary = seedService.runStartupSeed();

    expect(summary.templates.skipped).toEqual([{ id: 'alpha', existingSourceKind: 'global_agent' }]);
    expect(summary.templates.reclaimed).toEqual([]);
    expect(rowOf('alpha')).toMatchObject({ source_kind: 'global_agent', legacy_path: 'system/agents/alpha' });
  });

  it('drops org nodes of deleted companies but keeps live ones, and is idempotent', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();

    insertHired('ghost-role', 'ghost-co', 'data/companies/ghost-co/agents/ghost-role');
    const now = new Date().toISOString();
    graphDb.getDb().prepare(`
      INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
      VALUES ('ghost-co', 'ghost-role', 'ghost-role', ?)
    `).run(now);

    const first = seedService.reconcileDanglingRows();
    expect(first.ghostCompanyIds).toEqual(['ghost-co']);
    expect(first.orgNodesRemoved).toBe(1);
    expect(first.agentsRemoved).toEqual(['ghost-role']);
    expect(first.retained).toEqual([]);

    // live-co 的两个节点一个没少
    expect(orgNodes()).toEqual([
      { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' },
      { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' },
    ]);

    const second = seedService.reconcileDanglingRows();
    expect(second).toMatchObject({ orgNodesRemoved: 0, orgEdgesRemoved: 0, ghostCompanyIds: [], agentsRemoved: [], retained: [] });
  });

  it('skips org-node reconcile entirely when data/companies is absent (安全阀)', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();
    const before = orgNodes();

    fs.rmSync(path.join(root, 'data/companies'), { recursive: true, force: true });

    const r = seedService.reconcileDanglingRows();
    expect(r.ghostCompanyIds).toEqual([]);
    expect(r.orgNodesRemoved).toBe(0);
    expect(orgNodes()).toEqual(before);
  });

  it('runStartupSeed leaves company-derived rows alone (it must not run seedCompanyAgents)', () => {
    seedService.runStartupSeed();

    // company.json 里的 private-role 没有被启动流程带进表,org 图也一行没写——
    // 那是 hire()/fire() 与 scripts/graph-seed.js 的地盘
    expect(rowOf('private-role')).toBeNull();
    expect(orgNodes()).toEqual([]);
  });

  it('runStartupSeedSafely swallows failures and returns null', () => {
    process.env.GRAPH_DB_PATH = path.join(root, 'no-such-dir/nested/graph.sqlite');
    const { seedService: broken } = reloadServices();
    expect(broken.runStartupSeedSafely()).toBeNull();
  });
});

describe('graph-seed-service verifyCounts (real repo root)', () => {
  let seedService;
  let graphDb;
  let snapshotRoot;

  beforeAll(() => {
    // mkdtemp 而不是固定 /tmp/test-graph-seed-service.sqlite:同一台机器上并行跑两遍
    // 这个文件时不该共用一个库
    snapshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-realroot-'));
    freezeRepoRoot(snapshotRoot);
    process.env.GRAPH_SEED_ROOT = snapshotRoot;
    process.env.GRAPH_DB_PATH = path.join(snapshotRoot, 'graph.sqlite');
    ({ graphDb, seedService } = reloadServices());
  });

  afterAll(() => {
    delete process.env.GRAPH_SEED_ROOT;
    delete process.env.GRAPH_DB_PATH;
    try { fs.rmSync(snapshotRoot, { recursive: true, force: true }); } catch {}
  });

  it('reports ok:true against a freshly-seeded DB (no drift)', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();

    const v = seedService.verifyCounts();
    expect(v.mismatches).toEqual([]);
    expect(v.ok).toBe(true);
  });

  it('every system/templates/* dir that is not a global_agent id lands as source_kind=template', () => {
    const db = graphDb.getDb();
    const globalIds = new Set(
      db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'global_agent'`).all().map(r => r.id)
    );
    const templateDirs = fs
      .readdirSync(path.resolve(__dirname, '../../../system/templates'), { withFileTypes: true })
      .filter(d => d.isDirectory())
      .map(d => d.name)
      .filter(id => !globalIds.has(id));

    const seededTemplates = new Set(
      db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'template'`).all().map(r => r.id)
    );
    expect(templateDirs.filter(id => !seededTemplates.has(id))).toEqual([]);
  });

  it('catches a mismatch when the DB has a row the filesystem does not back', () => {
    const db = graphDb.getDb();
    const now = new Date().toISOString();
    db.prepare(`
      INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run('bogus-agent-no-fs-backing', 'Bogus', 'public', null, 'global_agent', 'system/agents/bogus-agent-no-fs-backing', 'active', null, now, now);

    const v = seedService.verifyCounts();
    expect(v.ok).toBe(false);
    expect(v.mismatches.some(m => m.check.includes("source_kind='global_agent'"))).toBe(true);

    db.prepare('DELETE FROM system_agents WHERE id = ?').run('bogus-agent-no-fs-backing');
  });
});