source-kind-drop-migration.test.js 5.44 KB
/**
 * 28-followups §3.1 —— `system_agents.source_kind` 删列的**存量库路径**。
 *
 * 单独钉这一条的理由与 tasks-executor-migration 一样:新建库那条路由单测天然覆盖
 * (建表块里已经没有这一列),「存量库 + 重启」这条必须另外安排人走一遍。
 *
 * ── 要守的三件事 ──────────────────────────────────────────────────────
 * ① **顺序**:先把 source_kind 的信息回填进 origin,再删列。反过来做,
 *    「origin 列出现之前写下的行」永远回不到正确的 origin —— 那正是那条回填存在的理由。
 *    这一条最容易在重构里被弄反,而且弄反之后**不报错**:库能开、测试能过,
 *    只有那几行 origin 悄悄是错的。
 * ② **幂等**:删过之后第二次开库不能抛(`no such column: source_kind`)。
 * ③ **位置**:删列必须跑在任何写入之前。migrate() 由 getDb() 在建表之后立刻调用,
 *    正是那个位置;挪到别处的话,带着 source_kind 的 INSERT 会先撞 NOT NULL。
 *
 * ── 为什么 legacy_path 不一起删 ────────────────────────────────────────
 * 它**还没死**:新行里它恒等于 system/agents/{id}(冗余),但存量行里存着批次 4 之前的
 * 公司侧路径,而 graph-seed-service 判「悬空行 / 孤儿行」靠的就是它指向的目录还在不在。
 * 实查生产库(2026-08-20)76 行里有 2 行仍是老路径。退出条件写在 graph-db.js 的建表注释里。
 */
import { describe, it, expect } from 'vitest';
import { createRequire } from 'module';
import fs from 'fs';
import os from 'os';
import path from 'path';
const require_ = createRequire(import.meta.url);

/** 用独立 DB 文件跑一遍 getDb()(写法照抄 tasks-executor-migration.test.js) */
function freshDb(dbPath) {
  process.env.GRAPH_DB_PATH = dbPath;
  for (const m of ['../../services/graph-db', '../../services/work-graph-db']) {
    delete require_.cache[require_.resolve(m)];
  }
  const graphDb = require_('../../services/graph-db');
  graphDb.getDb();
  return graphDb;
}

const cols = (db) => db.pragma('table_info(system_agents)').map((c) => c.name);

describe('system_agents.source_kind 删列', () => {
  it('存量库重启:先回填 origin 再删列,且不炸', () => {
    const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sk-drop-')), 'graph.sqlite');
    const graphDb = freshDb(dbPath);
    expect(cols(graphDb.getDb())).not.toContain('source_kind'); // 新建库本来就没有
    graphDb.getDb().close();

    // 退回删列之前的形态:把列加回来,并造两行「origin 列出现之前」的记录 ——
    // 它们的 origin 是加列时的默认值 'builtin',真相只存在 source_kind 里。
    const Database = require_('better-sqlite3');
    const raw = new Database(dbPath);
    raw.exec(`ALTER TABLE system_agents ADD COLUMN source_kind TEXT NOT NULL DEFAULT 'global_agent'`);
    const now = new Date().toISOString();
    const ins = raw.prepare(`INSERT INTO system_agents
      (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at, origin, source_kind)
      VALUES (?, ?, ?, ?, ?, 'active', ?, ?, 'builtin', ?)`);
    ins.run('legacy-hired', 'legacy-hired', 'company', 'co-x', 'system/agents/legacy-hired', now, now, 'company_hired');
    ins.run('legacy-global', 'legacy-global', 'public', null, 'system/agents/legacy-global', now, now, 'global_agent');
    expect(raw.pragma('table_info(system_agents)').map((c) => c.name)).toContain('source_kind');
    raw.close();

    // 重开进程 = 服务重启
    const graphDb2 = freshDb(dbPath);
    const db2 = graphDb2.getDb();

    expect(cols(db2)).not.toContain('source_kind'); // ② 列没了
    // ① 顺序:company_hired 那行的 origin 必须已经被回填成 created。
    //    先删后填的话这里会停在 'builtin' —— 而且不会有任何报错。
    const get = (id) => db2.prepare('SELECT origin FROM system_agents WHERE id = ?').get(id);
    expect(get('legacy-hired').origin).toBe('created');
    expect(get('legacy-global').origin).toBe('builtin'); // 对照:不该被一起改写
    db2.close();

    // ③ 幂等:再重启一次,不抛,结果不变
    const graphDb3 = freshDb(dbPath);
    expect(cols(graphDb3.getDb())).not.toContain('source_kind');
    expect(graphDb3.getDb().prepare('SELECT origin FROM system_agents WHERE id = ?').get('legacy-hired').origin).toBe('created');

    fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
    delete process.env.GRAPH_DB_PATH;
  });

  it('legacy_path 仍在,且删它的退出条件写在了建表注释里(别顺手一起删)', () => {
    const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sk-drop2-')), 'graph.sqlite');
    const graphDb = freshDb(dbPath);
    expect(cols(graphDb.getDb())).toContain('legacy_path');
    graphDb.getDb().close();
    fs.rmSync(path.dirname(dbPath), { recursive: true, force: true });
    delete process.env.GRAPH_DB_PATH;

    // 退出条件必须留在源码里可被读到 —— 否则下一个人只会看到一列「没人读的冗余字段」
    const src = fs.readFileSync(path.join(__dirname, '../../services/graph-db.js'), 'utf-8');
    expect(src).toContain("legacy_path <> 'system/agents/' || id");
  });
});