node-signals.js 5.66 KB
/**
 * 节点信号的共用读法 —— 事件轨过滤 / 事件摘要 / 时长格式化。
 *
 * 三个消费方(NodeRunLog 的分组、NodeDetailPanel 的失败原因回落与「还活着」条、
 * WorkGraphCanvas 的节点秒表)读的是同一份数据,过滤条件与格式化抽在这里一份,
 * 免得三处各写一遍再各错一遍。
 *
 * ⚠️ **SSE 帧里没有 summary**:`graph-run-sse.emitTransition` 把人类可读的 summary
 * 只写进 timeline.ndjson,广播给浏览器的 `data:` 只有 `ev.data`。所以前端要看懂一条
 * 事件,只能自己从 data 拼句子 —— 这就是 describeEvent 存在的理由。
 * (`d.summary` 那条分支留着:引擎哪天把 summary 并进 data,这里自动优先用它。)
 */

/** GraphRunPane 要订阅的事件名全集 —— 引擎 emitEvent / timelineNote 实际发出的那些。
 *  EventSource 对具名事件必须逐个 addEventListener,漏一个就是**整类事件收不到**:
 *  在此之前 node.error(执行器异常的原因)/ node.triage(归因判词)/ node.boundary
 *  三类从来没到过浏览器,失败节点因此在界面上「无原因」。 */
export const RUN_EVENT_KINDS = [
  'node.status', 'run.status',
  'agent.call', 'node.output', 'node.error',
  'node.triage', 'node.contract', 'node.boundary', 'node.subgraph',
  'loop.registered', 'loop.terminated', 'loop.cross_layer',
  'budget.spent',
  'plan.call', 'plan.done', 'plan.rejected',
];

/** 本节点的事件,保持到达顺序(= stream.log 顺序 = 事件 id 顺序) */
export function nodeEvents(events, node) {
  if (!node) return [];
  return (events || []).filter((e) => {
    const d = e.data || {};
    return (d.node_run_id || d.ref) === node.id;
  });
}

/** 事件属于哪一次 attempt:事件自带的优先,没带就算在节点当前 attempt 上 */
export function attemptOf(e, node) {
  const d = e.data || {};
  return d.attempt != null ? d.attempt : ((node && node.attempt) || 0);
}

/** 倒着找第一个满足条件的(「最近一条」的通用写法) */
export function lastWhere(list, fn) {
  for (let i = list.length - 1; i >= 0; i -= 1) if (fn(list[i])) return list[i];
  return null;
}

function clip(s, n = 160) {
  const str = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
  return str.length <= n ? str : `${str.slice(0, n - 1)}…`;
}

/** 一条事件的人类可读摘要(前端自拼,理由见文件头) */
export function describeEvent(e) {
  const d = (e && e.data) || {};
  if (d.summary) return clip(d.summary);
  switch (e && e.kind) {
    case 'node.status':
      return `状态 ${d.from || '*'} → ${d.to}`;
    case 'run.status':
      return `run 状态 ${d.from || '*'} → ${d.to}`;
    case 'agent.call':
      return `调用 ${d.system_agent_id || d.node_name || 'agent'}(attempt #${(d.attempt || 0) + 1})`;
    case 'node.output':
      return `产出 ${d.status || '?'}${d.fail_reason ? `(${d.fail_reason})` : ''}`
        + (d.output_excerpt ? ` · ${clip(d.output_excerpt, 90)}` : '');
    case 'node.error':
      return `执行器抛异常 ${d.error_type || 'Error'}:${clip(d.error, 110)}`;
    case 'node.triage':
      return d.degraded
        ? `归因降级(${d.degraded})`
        : `归因 verdict=${d.verdict || '(缺省)'} fail_reason=${d.fail_reason || 'null'}`;
    case 'node.contract':
      return `契约 ${d.dirty ? '变了' : '未变'}(evidence=${d.evidence || '?'},级联重置 ${(d.reset || []).length})`;
    case 'node.boundary':
      if (d.violation === 'boundary_escape') return `写入越界:宿主仓库留下 ${d.count || 0} 处改动`;
      if (d.degraded) return `边界守卫降级(${d.degraded})`;
      return `项目根 ${clip(d.root_path, 90)}`;
    case 'node.subgraph':
      return `子 run ${d.child_run_id || '?'}(${d.scope === 'node' ? '只重跑被点名的节点' : '整张图'})`;
    case 'loop.registered':
      return `回退备案 loop_count=${d.loop_count ?? '?'}`;
    case 'loop.terminated':
      return `熔断(${d.cap || 'run'} 级硬顶已撞)`;
    case 'loop.cross_layer':
      return '跨层定点回退';
    case 'budget.spent':
      return `预算扣减 +${(d.delta && d.delta.agent_calls) || 0} 次调用 / +${(d.delta && d.delta.tokens) || 0} tokens`;
    case 'plan.call':
      return `调 ${d.agent || 'workgraph-planning'} 规划子图(第 ${d.round ?? '?'} 轮)`;
    case 'plan.done':
      return `子图规划完成:${d.work_graph_id || '?'}`
        + (d.nodes != null ? `(${d.nodes} 节点 / ${d.edges ?? '?'} 边)` : '');
    case 'plan.rejected':
      return `子图未过写入前校验(第 ${d.round ?? '?'} 轮):`
        + clip((d.errors || []).map((x) => x.rule || x.detail).join('、') || '未给出规则', 110);
    default:
      return clip(JSON.stringify(stripNoise(d)), 140);
  }
}

/** 每条事件都带的通道字段,摘要里没有信息量 */
export function stripNoise(d) {
  const { run_id, root_run_id, node_run_id, ts, ...rest } = d || {};
  return rest;
}

/** 毫秒 → `12s` / `3m 07s` / `1h 04m`;不可用时返回 null(由调用方决定显示成什么) */
export function fmtDuration(ms) {
  if (!Number.isFinite(ms) || ms < 0) return null;
  const s = Math.floor(ms / 1000);
  if (s < 60) return `${s}s`;
  const m = Math.floor(s / 60);
  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`;
  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`;
}

/** ISO 时间戳 → 距 nowTs 过去了多久(同上,不可用返回 null) */
export function since(iso, nowTs) {
  if (!iso) return null;
  const t = typeof iso === 'number' ? iso : Date.parse(iso);
  return Number.isNaN(t) ? null : fmtDuration(nowTs - t);
}