/** * 墙钟改成「只算活跃时长」之后的**存量库迁移路径**。 * * 单独钉这一条的理由和 tasks-executor-migration 一样:新建库那条路由单测天然覆盖, * 「存量数据 + 重启」这条必须另外安排人走一遍。这里要守的是两点: * * ① **必须在开库时重锚**,不能只靠转换钩子。钩子只在「进 / 出 running」时触发, * 而升级那一刻正停在 running 的 run 不会再进一次 running —— 旧锚点一直挂着, * 下一次 checkBudget 直接判耗尽(= 那条 run 永远醒不过来)。 * ② **迁移代码必须放在建 graph_runs 的那个模块里**。第一版写进了 graph-db.js 的 * migrate(),而 graph_runs 是 work-graph-db 建的表、迁移块跑在它之前 —— * 新库上直接 `no such table: graph_runs`,getDb() 起不来。 */ 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/work-graph-db', '../../services/graph-db']) { delete require_.cache[require_.resolve(m)]; } const wdb = require_('../../services/work-graph-db'); wdb.getDb(); return wdb; } const OLD_ANCHOR = '2026-08-18T15:48:17.630Z'; const legacyBudget = () => JSON.stringify({ total_agent_calls: 100, total_tokens: 5000000, wallclock_ms: 7200000, spent: { agent_calls: 7, tokens: 69961, started_at: OLD_ANCHOR }, // 没有 active_ms = 老形态 }); function seed(db, id, status, budgetJson) { db.prepare(`INSERT INTO graph_runs (id, graph_kind, graph_id, company_id, root_run_id, execution_mode, run_status, budget_json, created_at, updated_at) VALUES (?, 'org', 'g1', 'co1', ?, 'manual', ?, ?, ?, ?)`) .run(id, id, status, budgetJson, OLD_ANCHOR, OLD_ANCHOR); } describe('墙钟锚点的存量迁移', () => { it('存量库重启:老锚点归零重锚,running 的续上、其余断开,且不炸', () => { const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wc-mig-')), 'graph.sqlite'); const wdb = freshDb(dbPath); const db = wdb.getDb(); seed(db, 'r_running', 'running', legacyBudget()); seed(db, 'r_blocked', 'blocked', legacyBudget()); seed(db, 'r_nowc', 'running', JSON.stringify({ total_agent_calls: 10, spent: { agent_calls: 1 } })); seed(db, 'r_broken', 'running', '{ 这不是 json'); db.close(); // 重开进程 = 服务重启 const wdb2 = freshDb(dbPath); const get = (id) => wdb2.getRun(id).budget_json; const running = JSON.parse(get('r_running')).spent; expect(running.active_ms).toBe(0); // 旧锚点一笔勾销 expect(Date.parse(running.started_at)).toBeGreaterThan(Date.parse('2026-08-19T00:00:00Z')); expect(running.agent_calls).toBe(7); // 精确的那两维一个字不动 expect(running.tokens).toBe(69961); const blocked = JSON.parse(get('r_blocked')).spent; expect(blocked.active_ms).toBe(0); expect(blocked.started_at).toBeNull(); // 没在跑就不该有锚点 // 没配 wallclock_ms 的行不许被塞字段 —— 塞了会让人以为它限了墙钟 expect(JSON.parse(get('r_nowc')).spent).toEqual({ agent_calls: 1 }); // 坏字段跳过即可,不能把开库卡死(上面 getDb() 没抛就是证据) expect(get('r_broken')).toBe('{ 这不是 json'); }); it('幂等:再重启一次,已经重锚过的行一个字不动', () => { const dbPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'wc-mig2-')), 'graph.sqlite'); const wdb = freshDb(dbPath); seed(wdb.getDb(), 'r_x', 'blocked', legacyBudget()); wdb.getDb().close(); const first = freshDb(dbPath).getRun('r_x').budget_json; const second = freshDb(dbPath).getRun('r_x').budget_json; expect(second).toBe(first); expect(JSON.parse(first).spent.active_ms).toBe(0); }); });