heartbeat-company-scoped.test.js 13.4 KB
/**
 * 28 号改造遗留项 —— 心跳设置是**公司级**状态,不许落到全系统共享的本体上。
 *
 * 改之前 `PUT/POST /api/company/:cid/agents/:aid/heartbeat*` 三个端点读改写的是
 * `system/agents/{aid}/config.json`:本体只有一份,所以 A 公司把某角色调成 30 分钟,
 * 雇了同一个本体的 B 公司会跟着变。而且危害不止「用户点了开关」—— 端点是
 * **读的时候补默认值再整份回写**,28 号 E2E 全程没人碰过心跳设置,
 * `system/agents/orggraph-planning/config.json` 照样多出一行 `"intervalMinutes": 15`
 * (这条 diff 就是本测试的出处)。同类事故第一轮已经修过一次:公司对话内容写进共享 SKILL.md。
 *
 * **端点有两个入口**(28-followups §3.3):除了 `/api/company/...` 这三个,
 * 还有前端 Agent 详情页走的 `PUT /api/agent/:agentId/heartbeat`。后者当时没改,
 * 症状比串写更隐蔽:公司态文件一旦存在,读侧优先取公司态 → **这个端点的写入静默无效**,
 * 界面收到 `{success:true}`、显示的却不是实际在跑的值。下面 §二 那组专门盯它。
 *
 * 三条断言各锁一个方向:
 *   ① 写 co-A 之后**本体 config.json 的 mtime 与内容一个字节没变**(危害全在磁盘上,只看响应码看不出来);
 *   ② co-B 读到的是它自己的值,不是 co-A 的(跨公司串味);
 *   ③ 没设置过的公司回落到本体的缺省值(本体仍可携带「这个角色建议多久心跳一次」,只是只读)。
 *
 * 隔离手法:
 *   - GRAPH_DB_PATH 指向一次性 sqlite,真实 data/graph.sqlite 全程不碰;
 *   - 端点的本体根硬编码在 `<repo>/system/agents`(无 env 覆盖口),只能在真目录下建具名本体。
 *     **本体建完不删**:测试建出来的 agent 也是正式注册表条目,删掉与「尽量复用 agent」相悖;
 *     afterAll 只清公司态目录(`system/agents/{aid}/{cid}/`,本来就 gitignore)。
 *   - 固定 id、不带随机后缀:随机后缀等于每跑一次单测就往注册表里丢一个再也没人认领的本体。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import express from 'express';
import { createRequire } from 'node:module';

const require_ = createRequire(import.meta.url);
const REPO_ROOT = path.resolve(__dirname, '../../..');
const AGENTS_ROOT = path.join(REPO_ROOT, 'system', 'agents');

const BODY_ID = 'zz-heartbeat-scoped';               // 本体 id(正式注册表条目,留在仓库里)
const ROLE = 'zz-hb-role';                            // 角色名故意 ≠ 本体 id:顺带锁住 role_name → system_agent_id 的映射
const CO_A = 'zz-hb-co-a';
const CO_B = 'zz-hb-co-b';
const BODY_DIR = path.join(AGENTS_ROOT, BODY_ID);
const BODY_CONFIG = path.join(BODY_DIR, 'config.json');
const TEST_DB_PATH = '/tmp/test-heartbeat-company-scoped.sqlite';

// 本体自带的缺省:42 是个谁也不会碰巧写出来的数,回落断言才有区分度(默认兜底是 15)
const BODY_CONFIG_TEXT = JSON.stringify(
  { id: BODY_ID, name: '心跳公司级化测试本体', type: 'skill', status: 'enabled',
    heartbeat: { enabled: false, intervalMinutes: 42 } },
  null, 2
) + '\n';

describe('心跳设置按公司隔离(28 遗留项)', () => {
  let base;
  let srv;
  let heartbeatService;

  const companyStateDir = (cid) => path.join(BODY_DIR, cid);
  const putHeartbeat = (cid, body) =>
    fetch(`${base}/api/company/${cid}/agents/${ROLE}/heartbeat`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });

  beforeAll(async () => {
    // 1. 本体(缺 agent.md / soul.md 的目录不算合格本体,一并写上)
    fs.mkdirSync(BODY_DIR, { recursive: true });
    fs.writeFileSync(path.join(BODY_DIR, 'agent.md'), '# 心跳公司级化测试本体\n\n只用来验证心跳设置不写本体。\n');
    fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# 人格\n\n沉默寡言,只负责被测试。\n');
    fs.writeFileSync(BODY_CONFIG, BODY_CONFIG_TEXT);
    // 上一轮跑剩的公司态不能留,否则③的回落断言会读到旧值而空过
    for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true });

    // 38 号文档 Phase 2:company.js 现在挂了 router.param('companyId', ...),
    // 靠 company.json 判断"这家公司存不存在"——CO_A/CO_B 在这份测试里从来不是真正建过的
    // 公司(只有雇佣关系和公司态目录),补一份最小 company.json 让请求能过归属校验,
    // 不影响本文件真正要测的心跳隔离逻辑。
    for (const cid of [CO_A, CO_B]) {
      const dir = path.join(REPO_ROOT, 'data', 'companies', cid);
      fs.mkdirSync(dir, { recursive: true });
      fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: cid, name: cid, ownerId: -1 }));
    }

    // 2. 一次性 graph.sqlite + 两家公司的雇佣关系
    if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    for (const m of ['../../services/graph-db', '../../services/graph-service',
      '../../services/org-graph-service', '../../services/company-service',
      '../../services/heartbeat-service', '../../routes/company', '../../routes/agent-chat']) {
      delete require_.cache[require_.resolve(m)];
    }
    const graphDb = require_('../../services/graph-db');
    heartbeatService = require_('../../services/heartbeat-service');
    const db = graphDb.getDb();
    const now = new Date().toISOString();
    db.prepare(`
      INSERT OR REPLACE INTO system_agents (id, name, scope, owner_company_id, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, ?, 'active', NULL, ?, ?)
    `).run(BODY_ID, BODY_ID, `system/agents/${BODY_ID}`, now, now);
    for (const cid of [CO_A, CO_B]) {
      db.prepare(`INSERT OR REPLACE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
                  VALUES (?, ?, ?, 'active', ?)`).run(cid, BODY_ID, ROLE, now);
    }

    // 3. 只挂 company 路由,不起真服务器进程(3010 上另有一个在跑)
    const app = express();
    app.use(express.json());
    app.use('/api/company', require_('../../routes/company'));
    app.use('/api/agent', require_('../../routes/agent-chat')); // §二 那组用的第二个入口
    await new Promise((resolve) => {
      srv = app.listen(0, '127.0.0.1', () => {
        base = `http://127.0.0.1:${srv.address().port}`;
        resolve();
      });
    });
  });

  afterAll(async () => {
    if (srv) await new Promise((r) => srv.close(r));
    heartbeatService?.stop(); // 端点挂上的 setInterval 会吊住 vitest 进程
    // 只清公司态(运行态、gitignore);本体留在 system/agents/ 里当正式注册表条目
    // `_global` 是裸 agent id 的公司态落点(GET /info 那条用它),同样要清
    for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true });
    for (const cid of [CO_A, CO_B]) fs.rmSync(path.join(REPO_ROOT, 'data', 'companies', cid), { recursive: true, force: true });
    if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
    delete process.env.GRAPH_DB_PATH;
  });

  it('没设置过的公司回落到本体的缺省心跳(本体可以带缺省,但只读)', () => {
    expect(fs.existsSync(path.join(companyStateDir(CO_A), 'heartbeat.json'))).toBe(false);
    expect(heartbeatService.readHeartbeatConfig(`${CO_A}:${ROLE}`)).toMatchObject({
      enabled: false,
      intervalMinutes: 42,
    });
  });

  it('co-A 改心跳 → 本体 config.json 的 mtime 与内容都没变,值落在公司态目录里', async () => {
    const before = fs.statSync(BODY_CONFIG);

    const res = await putHeartbeat(CO_A, { intervalMinutes: 30, enabled: false });
    expect(res.status).toBe(200);
    expect((await res.json()).heartbeat).toMatchObject({ intervalMinutes: 30 });

    // 危害全在磁盘上:响应码曾经也是 200,脏的是本体那份共享文件
    expect(fs.readFileSync(BODY_CONFIG, 'utf-8')).toBe(BODY_CONFIG_TEXT);
    expect(fs.statSync(BODY_CONFIG).mtimeMs).toBe(before.mtimeMs);

    // 值确实落了盘(否则上一条可以靠「什么都没写」空过)
    const own = JSON.parse(fs.readFileSync(path.join(companyStateDir(CO_A), 'heartbeat.json'), 'utf-8'));
    expect(own.intervalMinutes).toBe(30);
  });

  it('co-B 读到的是自己的值,不跟着 co-A 变', async () => {
    // 此刻 co-A 已是 30,co-B 还没设置过 → 必须仍是本体缺省 42
    expect(heartbeatService.readHeartbeatConfig(`${CO_B}:${ROLE}`).intervalMinutes).toBe(42);

    const res = await putHeartbeat(CO_B, { intervalMinutes: 7, enabled: false });
    expect(res.status).toBe(200);

    expect(heartbeatService.readHeartbeatConfig(`${CO_B}:${ROLE}`).intervalMinutes).toBe(7);
    expect(heartbeatService.readHeartbeatConfig(`${CO_A}:${ROLE}`).intervalMinutes).toBe(30);
    expect(fs.readFileSync(BODY_CONFIG, 'utf-8')).toBe(BODY_CONFIG_TEXT);
  });

  it('start / stop 端点同样只写公司态', async () => {
    const before = fs.statSync(BODY_CONFIG);
    for (const action of ['start', 'stop']) {
      const res = await fetch(`${base}/api/company/${CO_A}/agents/${ROLE}/heartbeat/${action}`, { method: 'POST' });
      expect(res.status).toBe(200);
    }
    expect(fs.readFileSync(BODY_CONFIG, 'utf-8')).toBe(BODY_CONFIG_TEXT);
    expect(fs.statSync(BODY_CONFIG).mtimeMs).toBe(before.mtimeMs);
    // 开关落在公司态,且不影响 co-A 自己的间隔
    expect(heartbeatService.readHeartbeatConfig(`${CO_A}:${ROLE}`)).toMatchObject({
      enabled: false,
      intervalMinutes: 30,
    });
  });

  it('没雇过这个角色的公司 → 404;非法 intervalMinutes → 400(都不落盘)', async () => {
    const res404 = await putHeartbeat('zz-hb-co-never', { enabled: true });
    expect(res404.status).toBe(404);

    const res400 = await putHeartbeat(CO_A, { intervalMinutes: 'abc' });
    expect(res400.status).toBe(400);
    expect(heartbeatService.readHeartbeatConfig(`${CO_A}:${ROLE}`).intervalMinutes).toBe(30);
  });

  // ── 第二个入口:PUT / GET /api/agent/:agentId/heartbeat(28-followups §3.3)──
  // 放在同一个 describe 里而不是另起一个:夹具(express 实例、本体、公司态)是共用的,
  // 另起 describe 的话上面那个 afterAll 会先把 server 关掉 —— 实测 ECONNREFUSED。
    const addrA = `${CO_A}:${ROLE}`;

    it('写公司态、不碰本体,且写完立刻能从读侧读回来(原来是静默无效)', async () => {
      const before = fs.readFileSync(BODY_CONFIG, 'utf-8');
      const beforeMtime = fs.statSync(BODY_CONFIG).mtimeMs;

      const res = await fetch(`${base}/api/agent/${addrA}/heartbeat`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ enabled: false, intervalMinutes: 7 }),
      });
      expect(res.status).toBe(200);
      expect((await res.json()).heartbeat).toMatchObject({ intervalMinutes: 7 });

      // 本体一个字节没动(修之前这里整份被重写,公司段在寻址第一步就丢了)
      expect(fs.readFileSync(BODY_CONFIG, 'utf-8')).toBe(before);
      expect(fs.statSync(BODY_CONFIG).mtimeMs).toBe(beforeMtime);

      // **这条才是「静默无效」的精确形态**:写完之后,心跳挂载读到的必须是新值。
      // 修之前写的是本体、读的是公司态,这里会读回 30(上面 §一 留下的公司态值)。
      expect(heartbeatService.readHeartbeatConfig(addrA)).toMatchObject({ intervalMinutes: 7 });
    });

    it('GET /info 报的是实际在跑的那份,不是本体缺省', async () => {
      // 用**裸 agent id**(落 `_global` 公司态)而不是 `cid:role`:/info 的 404 闸门
      // 仍是 `getBodyDir(id)` = 拿角色名当本体 id,对 `cid:role` 会直接 404。
      // 那是 /info 整体寻址的既有缺口(它还要按同一个目录读 identity / agent.md / cron),
      // 不在本次心跳这一条的范围内 —— 记在这里,别当成已经修过。
      const hb = require('../../services/heartbeat-service');
      hb.writeHeartbeatConfig(BODY_ID, { enabled: false, intervalMinutes: 9 });

      const res = await fetch(`${base}/api/agent/${BODY_ID}/info`);
      expect(res.status).toBe(200);
      // 修之前读的是本体 config.json → 会报 42(本体缺省),而调度器按 9 在跑。
      // 界面写一个数、系统按另一个数跑,两边都不报错。
      expect((await res.json()).heartbeat).toMatchObject({ intervalMinutes: 9 });
    });

    it('非法 intervalMinutes 拦在写盘之前(400,不是 500,也不是写进去再说)', async () => {
      // 这个值直接进 setInterval:0 / 负数 / NaN 会让定时器退化成「每 tick 触发一次」,
      // 而每次 tick 都真起一轮 LLM。
      for (const bad of [0, -5, 'abc']) {
        const res = await fetch(`${base}/api/agent/${addrA}/heartbeat`, {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ enabled: true, intervalMinutes: bad }),
        });
        expect(res.status).toBe(400);
      }
      // 拦下来之后旧值必须还在(不能写一半)
      expect(heartbeatService.readHeartbeatConfig(addrA)).toMatchObject({ intervalMinutes: 7 });
    });
});