graph-seed-service.test.js 20.5 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 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442
/**
 * Unit Tests — graph-seed-service.js
 *
 * 两块:
 *
 * A. **hermetic 夹具根**(P3B W1 起):`GRAPH_SEED_ROOT` 指向 /tmp 下现造的
 *    system/agents + data/companies,配一个一次性 GRAPH_DB_PATH。
 *    行数因此是**写死的精确值**而不是「跟着仓库现状走」——原来的写法直接对着生产
 *    `data/graph.sqlite` 与真实 `data/companies/` 断言,任何人手建一家公司都会让它变红。
 *    悬空行回收 / 占位接管 / 防误伤 / `_` 保留 id 过滤四条规则都在这里锁。
 *
 * 28 批次 1 起数据源只剩 `system/agents/`(`system/templates/` 已并入),
 * `source_kind` 的三分收成 `origin` 二分,断言随之改口径 —— 不是放松。
 *
 * 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() 的使用计数写在 data/template-usage.json,不碰 `_registry.json`,
 * 而 _registry.json 只影响 benchmark_score,不影响任何行数)。软链还顺带让
 * 「every system/agents/* body 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;

  /** 夹具:3 个本体目录 + 1 个保留 id + 1 个无 agent.md 的杂目录、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/_registry.json'), JSON.stringify([{ id: 'tmpl-a', benchmarkScore: 7.5 }]));
    write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha');
    write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha' }));
    write(path.join(root, 'system/agents/tmpl-a/agent.md'), '# tmpl-a');
    write(path.join(root, 'system/agents/tmpl-a/config.json'), JSON.stringify({ name: 'Template A' }));
    write(path.join(root, 'system/agents/tmpl-b/agent.md'), '# tmpl-b'); // 无 config.json
    write(path.join(root, 'system/agents/_base/agent.md'), '# 元 agent 模板'); // 保留 id:有 agent.md 也不该入库
    fs.mkdirSync(path.join(root, 'system/agents/no-agent-md'), { recursive: true }); // 数据碎片,不算本体

    write(
      path.join(root, 'data/companies/live-co/company.json'),
      JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role'] })
    );
    // 28 批次 4 起公司侧没有 agent 目录了 —— private-role 这行靠「活着的公司还点名它」
    // 保住,不再靠 legacy_path 指向的目录真实存在(那条闸门只对存量行还有意义)。

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

  function rows() {
    return graphDb.getDb().prepare('SELECT id, origin, 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();
  }

  /** 直接造一行 origin='created'(模拟历史遗留 / hire() 写下的公司私有 agent 行) */
  function insertHired(id, ownerCompanyId, legacyPath) {
    const now = new Date().toISOString();
    graphDb.getDb().prepare(`
      INSERT INTO system_agents (id, name, scope, owner_company_id, origin, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, 'company', ?, 'created', ?, '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.seedAgents();
    seedService.seedCompanyAgents();

    expect(rows().map(r => [r.id, r.origin])).toEqual([
      ['alpha', 'builtin'],
      ['private-role', 'created'],   // live-co 独有角色
      ['tmpl-a', 'builtin'],
      ['tmpl-b', 'builtin'],
    ]);
    // `_base`(保留 id,有 agent.md)与 no-agent-md(数据碎片)都不是可调用 agent
    expect(rowOf('_base')).toBeNull();
    expect(rowOf('no-agent-md')).toBeNull();
    // 路径恒由 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({ builtin: 3, created: 1 });
  });

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

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

    seedService.runStartupSeed();

    expect(rowOf('tmpl-new')).toEqual({
      id: 'tmpl-new',
      origin: 'builtin',
      scope: 'public',
      owner_company_id: null,
      legacy_path: 'system/agents/tmpl-new',
    });
  });

  it('reclaims a dangling created row that squats on a body id', () => {
    // 历史遗留:公司 ghost-co 已被删,行还占着 tmpl-b 这个本体 id
    insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
    expect(rowOf('tmpl-b').origin).toBe('created');

    const summary = seedService.runStartupSeed();

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

  it('takes over a squatting row in-place when a foreign key still points at it', () => {
    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 保留(外键继续有效),origin / scope / legacy_path 全部改写
    expect(summary.agents.reclaimed).toContain('tmpl-b');
    expect(rowOf('tmpl-b')).toEqual({
      id: 'tmpl-b',
      origin: 'builtin',
      scope: 'public',
      owner_company_id: null,
      legacy_path: 'system/agents/tmpl-b',
    });
    // 引着它的节点必须还在(转正而不是删了重建的全部意义所在)
    expect(orgNodes().some(n => n.system_agent_id === 'tmpl-b')).toBe(true);
  });

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

    // ① 没有本体目录、但 live-co 的 company.json 仍在 agents[] 里点名它(兼容名册那一路)
    expect(rowOf('private-role').origin).toBe('created');
    // ② 存量行:legacy_path 还是批次 4 之前的公司侧路径(目录早没了),靠 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'] })
    );
    // ③ 只在 company_agents 里有行、company.json 一个字都没提的(28 批次 4-19 起的真相来源)。
    //    少了这一路,凡是迁移后新雇的人都会在下一次启动 reconcile 时被当悬空行删掉。
    insertHired('table-only-role', 'live-co', 'system/agents/table-only-role');
    graphDb.getDb().prepare(`
      INSERT INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
      VALUES ('live-co', 'table-only-role', 'table-only-role', 'active', ?)
    `).run(new Date().toISOString());

    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').origin).toBe('created');
    expect(rowOf('table-only-role')).not.toBeNull();
  });

  it('③ 那一路不是无脑放行:公司目录没了的 company_agents 行不算认领', () => {
    // 与上一条同形态,唯一差别是公司目录不存在。company_agents 的行不会随公司目录消失,
    // 拿它当「还活着」的信号会让回收永远不生效 —— 所以 claim 的存活判定仍走磁盘。
    insertHired('ghost-table-role', 'ghost-co', 'system/agents/ghost-table-role');
    graphDb.getDb().prepare(`
      INSERT INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
      VALUES ('ghost-co', 'ghost-table-role', 'ghost-table-role', 'active', ?)
    `).run(new Date().toISOString());

    const r = seedService.reconcileDanglingRows();
    // 它被判成悬空行并真的删掉了。以前这里断言的是「落进 retained」——因为幽灵公司的
    // company_agents 行还在,外键把 system_agents 那行拦住了。步骤 A 现在会先清掉
    // 幽灵公司的名册行(否则 heartbeat-service.start() 会给已删公司挂上定时心跳),
    // 拦路的外键随之消失,这一路才走完整。
    expect(r.companyAgentsRemoved).toBeGreaterThanOrEqual(1);
    expect(r.agentsRemoved).toContain('ghost-table-role');
    expect(r.retained.map((x) => x.id)).not.toContain('ghost-table-role');
  });

  it('never clobbers a created row a live company is still using (合并后剩下的唯一冲突面)', () => {
    // tmpl-a 既有本体目录、又被活着的 live-co 在 company.json.agents[] 里点名。
    // 这种行是公司自己雇的那份私有拷贝在跑,转成 builtin 会让 lookupSharedAgentMd
    // 突然返回本体路径 —— 等于在公司毫不知情的情况下换掉它的 agent.md。
    insertHired('tmpl-a', 'live-co', 'data/companies/live-co/agents/tmpl-a');

    const summary = seedService.runStartupSeed();

    expect(summary.agents.skipped).toEqual([{ id: 'tmpl-a', existingOrigin: 'created' }]);
    expect(summary.agents.reclaimed).toEqual([]);
    expect(rowOf('tmpl-a')).toMatchObject({
      origin: 'created',
      legacy_path: 'data/companies/live-co/agents/tmpl-a',
    });
  });

  it('drops org nodes of deleted companies but keeps live ones, and is idempotent', () => {
    seedService.seedAgents();
    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.seedAgents();
    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.seedAgents();
    seedService.seedCompanyAgents();

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

  it('every system/agents/* body dir lands as origin=builtin, and reserved ids never do', () => {
    const db = graphDb.getDb();
    const agentsDir = path.resolve(__dirname, '../../../system/agents');
    // 先取目录清单、**再**补种一次,顺序不能反:`system/` 是软链进快照的(见 freezeRepoRoot),
    // 同一轮 vitest 里别的用例会往真实 system/agents/ 下毕业出新本体
    // (28 号裁决:测试建的 agent 是正式注册表条目,留在盘上)。
    // 反过来写的话,「上一条 it 补种之后、本条 readdir 之前」新出现的那个本体
    // 会被判成「没落库」——那是竞态,不是漂移。
    const dirs = fs
      .readdirSync(agentsDir, { withFileTypes: true })
      .filter(d => d.isDirectory())
      .map(d => d.name);
    const bodyDirs = dirs.filter(
      id => !id.startsWith('_') && fs.existsSync(path.join(agentsDir, id, 'agent.md'))
    );
    seedService.seedAgents();
    // 真实仓库里确实有保留 id(`_base`),否则下面那条负向断言是空跑
    expect(dirs.some(id => id.startsWith('_'))).toBe(true);

    const seeded = new Set(
      db.prepare(`SELECT id FROM system_agents WHERE origin = 'builtin'`).all().map(r => r.id)
    );
    expect(bodyDirs.filter(id => !seeded.has(id))).toEqual([]);
    expect([...seeded].filter(id => id.startsWith('_'))).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, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run('bogus-agent-no-fs-backing', 'Bogus', 'public', null, '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("origin='builtin'"))).toBe(true);

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