budget-wallclock-migration.test.js 4.13 KB
/**
 * 墙钟改成「只算活跃时长」之后的**存量库迁移路径**。
 *
 * 单独钉这一条的理由和 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);
  });
});