run-registry.js 4.33 KB
/**
 * RunRegistry - 进程运行注册表
 *
 * 参考 openclaw/src/process/supervisor/registry.ts
 *
 * 核心功能:
 *   - 状态机追踪: starting → running → exiting → exited
 *   - 按条件查询: byBackend, byState, active
 *   - 自动淘汰: exited 记录超过 maxExited 时 FIFO 清除
 *   - 统计输出: 给 /api/runs 端点提供数据
 */

/** @typedef {'starting'|'running'|'exiting'|'exited'} RunState */
/** @typedef {'manual-cancel'|'overall-timeout'|'no-output-timeout'|'spawn-error'|'signal'|'exit'|'unknown'} TerminationReason */

/**
 * @typedef {object} RunRecord
 * @property {string} runId
 * @property {string} agentName
 * @property {string} backendId           - launcher / cliBackend 标识
 * @property {string} [scopeKey]          - 串行化 key
 * @property {number} [pid]
 * @property {RunState} state
 * @property {TerminationReason} [terminationReason]
 * @property {number} [exitCode]
 * @property {number} createdAtMs
 * @property {number} startedAtMs
 * @property {number} lastOutputAtMs
 * @property {number} [updatedAtMs]
 */

const DEFAULT_MAX_EXITED = 200;

class RunRegistry {
  /**
   * @param {object} [opts]
   * @param {number} [opts.maxExitedRecords=200]
   */
  constructor(opts = {}) {
    /** @type {Map<string, RunRecord>} */
    this._records = new Map();
    this._maxExited = opts.maxExitedRecords || DEFAULT_MAX_EXITED;
  }

  /**
   * 注册新的 run
   * @param {object} params
   * @param {string} params.runId
   * @param {string} params.agentName
   * @param {string} params.backendId
   * @param {string} [params.scopeKey]
   * @param {number} [params.pid]
   * @returns {string} runId
   */
  add(params) {
    const now = Date.now();
    /** @type {RunRecord} */
    const record = {
      runId: params.runId,
      agentName: params.agentName,
      backendId: params.backendId,
      scopeKey: params.scopeKey,
      pid: params.pid,
      state: 'starting',
      createdAtMs: now,
      startedAtMs: 0,
      lastOutputAtMs: 0,
    };
    this._records.set(record.runId, record);
    this._pruneExited();
    return record.runId;
  }

  /**
   * 获取记录
   * @param {string} runId
   * @returns {RunRecord|undefined}
   */
  get(runId) {
    return this._records.get(runId);
  }

  /**
   * 变更状态
   * @param {string} runId
   * @param {RunState} state
   * @param {object} [extra] - 额外字段 (exitCode, terminationReason, pid 等)
   */
  markState(runId, state, extra = {}) {
    const record = this._records.get(runId);
    if (!record) return;

    record.state = state;
    record.updatedAtMs = Date.now();

    if (state === 'running' && !record.startedAtMs) {
      record.startedAtMs = Date.now();
    }

    Object.assign(record, extra);
  }

  /** 记录输出活动 (给 ProcessGuard 联动) */
  touchOutput(runId) {
    const record = this._records.get(runId);
    if (record) {
      record.lastOutputAtMs = Date.now();
    }
  }

  /** 获取所有活跃 runs (starting + running) */
  listActive() {
    return [...this._records.values()].filter(
      r => r.state === 'starting' || r.state === 'running'
    );
  }

  /** 按 backendId 查询 */
  listByBackend(backendId) {
    return [...this._records.values()].filter(r => r.backendId === backendId);
  }

  /** 按 agentName 查询 */
  listByAgent(agentName) {
    return [...this._records.values()].filter(r => r.agentName === agentName);
  }

  /** 获取全部记录 */
  list() {
    return [...this._records.values()];
  }

  /** 删除指定 run */
  remove(runId) {
    this._records.delete(runId);
  }

  /** 统计信息 */
  stats() {
    const all = [...this._records.values()];
    return {
      total: all.length,
      starting: all.filter(r => r.state === 'starting').length,
      running: all.filter(r => r.state === 'running').length,
      exiting: all.filter(r => r.state === 'exiting').length,
      exited: all.filter(r => r.state === 'exited').length,
    };
  }

  /** 清空全部 */
  clear() {
    this._records.clear();
  }

  /** 淘汰过期 exited 记录 (FIFO) */
  _pruneExited() {
    const exited = [...this._records.entries()]
      .filter(([, r]) => r.state === 'exited')
      .sort((a, b) => (a[1].createdAtMs || 0) - (b[1].createdAtMs || 0));

    while (exited.length > this._maxExited) {
      const [id] = exited.shift();
      this._records.delete(id);
    }
  }
}

module.exports = { RunRegistry, DEFAULT_MAX_EXITED };