/** * 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"); }); });