GraphRunPane.vue 19.5 KB
<template>
  <div class="pane" :class="{ compact }">
    <header class="pane-head">
      <span class="ttl">{{ title }}</span>
      <span v-if="run" class="run-status" :class="run.run_status">{{ run.run_status }}</span>
      <!-- 「点过了、还没落地」的软信号(pause_requested / stop_requested)。
           run_status 要到节点边界才变,不读这两列的话用户点完暂停看不到任何反馈 -->
      <span v-if="pendingCtl" class="req" :title="pendingCtl.hint">{{ pendingCtl.label }}</span>
      <!-- run 树级回退熔断计数(§四.D.2.1 的外层硬顶)。画布上的 N/3 是单边计数,
           「换个借口继续退」只有这一维拦得住,之前它落了库但没人读回 -->
      <span v-if="backwardCount" class="req back" :title="backwardHint">回退 {{ backwardCount }}{{ isRoot ? `/${LOOP_HARD_CAP_RUN}` : '' }}</span>
      <span v-if="run" class="rid" :title="run.id">{{ run.id }}</span>
      <span class="grow" />
      <span class="conn" :class="sseStatus" :title="`SSE: ${sseStatus}`">●</span>
      <button class="mini" :disabled="loading" @click="load()">刷新</button>
    </header>

    <!-- 控制栏(§四.C 五个操作;作用于本 pane 当前显示的这一层 run) -->
    <div v-if="run" class="ctrl">
      <label class="mode">
        <select v-model="mode" :disabled="busy">
          <option value="manual">手动</option>
          <option value="auto">自动</option>
        </select>
      </label>
      <button class="btn go"    :disabled="busy || !can('start')"  @click="onStart">▶ 启动</button>
      <button class="btn"       :disabled="busy || !can('next')"   @click="act('next')">⏭ 下一步</button>
      <button class="btn"       :disabled="busy || !can('pause')"  @click="act('pause')">⏸ 暂停</button>
      <button class="btn"       :disabled="busy || !can('stop')"   @click="act('stop')">⏹ 停止</button>
      <button class="btn retry" :disabled="busy || !canRetry"      @click="onRetry">🔁 重试</button>
      <span v-if="nextNodes.length === 1" class="next-hint">下一步将执行 <code>{{ nextNode.name || nextNode.defNodeId }}</code></span>
      <span v-else-if="nextNodes.length > 1" class="next-hint">自动模式下一批最多并发 <code>{{ nextNodes.length }}</code> 个:
        <code v-for="n in nextNodes" :key="n.defNodeId">{{ n.name || n.defNodeId }}</code></span>
    </div>

    <p v-if="err" class="err">{{ err }}</p>
    <!-- 200 但结果不是用户以为的那个:坑满 → 退回队列。不说的话界面上什么都看不出来 -->
    <p v-if="notice" class="notice">{{ notice }}</p>

    <WorkGraphCanvas
      :nodes="nodes"
      :edges="edges"
      :loop-records="loopRecords"
      :selected-id="selectedDefId"
      :next-node-ids="nextNodes.map((n) => String(n.defNodeId))"
      @select="onSelect"
    >
      <template #empty><slot name="empty">这张图还没有节点</slot></template>
    </WorkGraphCanvas>

    <!-- §四.B.4:自动模式启动前的预估成本确认,不是可选的礼貌 -->
    <div v-if="confirmAuto" class="modal-back" @click.self="confirmAuto = false">
      <div class="modal">
        <h3>自动模式启动前确认</h3>
        <p class="lead">自动模式一旦启动,引擎会连续调用 agent 直到收敛或撞预算,中途只能靠「暂停 / 停止」拦。</p>
        <ul class="est">
          <li>本次任务粗估:<b>60-100 次 agent 调用 · $10-20 · 1-2 小时</b>(27-P3 §四.A.3 量级)</li>
          <li v-if="budget">
            剩余预算:<b>{{ remain.calls }}</b> 次调用 ·
            <b>{{ fmtNum(remain.tokens) }}</b> tokens
          </li>
          <li v-else>该 run 没挂预算(budget_json 为空)——扣减与熔断只在 root run 上生效。</li>
        </ul>
        <div class="modal-btns">
          <button class="btn" @click="confirmAuto = false">取消</button>
          <button class="btn go" @click="confirmAuto = false; act('start', { mode: 'auto' })">确认并启动</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import WorkGraphCanvas from '../WorkGraphCanvas.vue';
import { RUN_EVENT_KINDS } from './node-signals';

/**
 * 一层 run 的完整视图(T3/T4):拉状态 + 控制栏 + 画布 + SSE。
 * 一个页面上会挂多个实例(左栏 org run、中栏 work run、下钻后的子 run),彼此独立。
 *
 * 事件轨:SSE 带 ?lastEventId=0 连接 —— graph-run-sse 的 replayTo 会把 stream.log 从头重放,
 * 所以**一条连接同时拿到历史与实时**,「运行日志」tab 的 history 轨不需要另开端点。
 */
const props = defineProps({
  runId: { type: String, default: null },
  title: { type: String, default: 'Graph Run' },
  compact: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'state']);

const run = ref(null);
const nodes = ref([]);
const edges = ref([]);
const loopRecords = ref([]);
const childRuns = ref([]);       // 路由一直在回这个字段,之前 load() 直接丢掉(见 push 的注释)
const budget = ref(null);
// R-19 产物索引的两个字段:taskFiles = 本 task 的产物实例(graph_files 当前有效行),
// companyRoot = 公司根绝对路径(库里只存相对路径,根由后端按 companyRootDir 推导)。
// 后端还没吐这两个字段时 taskFiles 保持 null —— 详情面板据此**整块降级**而不是显示空列表,
// 「没有产物」和「后端没告诉我有没有产物」必须分得开。
const taskFiles = ref(null);
const companyRoot = ref(null);
// 这个任务真实交付物的落点(W2「每个 root run 一个项目根」)——与 taskFiles 是两类数据,
// 不要合并:graph_files 只索引节点私有目录,projectRoot 才是代码真正写的地方。
// 没有记录(节点还没跑到 ensureProjectRoot 那一步)时保持 null。
const projectRoot = ref(null);
// 35 号文档提案 B:这个节点最近一次 benchmark 判决按维度标了有没有证据支撑,纯展示。
const benchmarkVerdicts = ref([]);
const events = ref([]);          // SSE 事件缓冲,供详情面板的「运行日志」tab 消费
const selectedDefId = ref(null);
const loading = ref(false);
const busy = ref(false);
const err = ref('');
/** 非错误、但必须让用户看见的一句话(例:启动被退回队列)。与 err 互斥。 */
const notice = ref('');
const mode = ref('manual');
const confirmAuto = ref(false);
const sseStatus = ref('idle');

let es = null;
let refreshTimer = null;
let pollTimer = null;
const MAX_EVENTS = 400;

// 27-P4 T4:手动模式的「下一步」(nextStep→advanceOne)仍然只推一个,但自动模式的
// advanceBatch 一次最多认领 run.max_concurrency 个 ready 节点——「下一步会跑哪个」
// 不再是单数。引擎按 (created_at, id) 取候选,getRunState 用同样的排序返回 nodes,
// 取前 N 个 ready 即「下一批大概率会认领谁」。
// ponytail: 这是提示,不是承诺——真实拾取时若跟别的 advanceBatch 撞了车(§4.1 决策),
// 实际认领的集合可能有出入;纯展示用途,不影响任何控制逻辑,不值得为了精确复刻
// 后端排序再传一次 max_concurrency 的默认值同步逻辑。
const DEFAULT_MAX_CONCURRENCY_HINT = 5; // 与 work-graph-db.DEFAULT_MAX_CONCURRENCY 对齐,纯展示兜底
const nextNodes = computed(() => {
  const cap = (run.value && run.value.max_concurrency) || DEFAULT_MAX_CONCURRENCY_HINT;
  return nodes.value.filter((n) => n.status === 'ready').slice(0, cap);
});
const nextNode = computed(() => nextNodes.value[0] || null); // 手动模式「下一步」提示仍用单数
const selectedNode = computed(() => nodes.value.find((n) => String(n.defNodeId) === selectedDefId.value) || null);
const canRetry = computed(() => selectedNode.value && ['fail', 'blocked'].includes(selectedNode.value.status));

const remain = computed(() => {
  const b = budget.value || {};
  const spent = b.spent || {};
  return {
    calls: Math.max(0, (b.total_agent_calls || 0) - (spent.agent_calls || 0)),
    tokens: Math.max(0, (b.total_tokens || 0) - (spent.tokens || 0)),
  };
});

// 与 routes/graph-run.js 的 ensureStatus 同一张表(§四.C「可用条件」那一列)
const ALLOW = {
  start: ['ready', 'paused', 'blocked'],   // 外加 running + pause_requested,见 can()
  next: ['running', 'paused'],
  pause: ['running'],
  stop: ['running', 'paused', 'blocked'],
};
function can(op) {
  const r = run.value;
  if (!r) return false;
  // 已经在途的同一个操作不再可点:软信号已置位、只是还没到落库的节点边界,
  // 再点一次既不会更快也没有反馈(此前只看 run_status,按钮一直是亮的)
  if (op === 'pause' && r.pause_requested) return false;
  if (op === 'stop' && r.stop_requested) return false;
  // 「暂停中」(running + pause_requested,暂停还没落到节点边界)同样可以点启动 = 撤回暂停意图。
  // org 层每个节点都是 subgraph,子 run 在飞时父节点恒为 running,landPause 因 busy 永远落不下来,
  // 只认 run_status 的话这一层的启动按钮永久置灰,用户只剩「下一步」一条路(R6-F3 问题 1)。
  if (op === 'start' && r.run_status === 'running') return Boolean(r.pause_requested) && !r.stop_requested;
  return ALLOW[op].includes(r.run_status);
}

const TERMINAL = ['completed', 'failed', 'stopped'];

/**
 * run 行上的 pause_requested / stop_requested —— 后端一直在写、前端一直没读的那两列。
 *
 * 「暂停」是软信号:引擎在**节点边界**才把 running 落成 paused(landPause),
 * 期间 run_status 仍是 running。预算撞顶时引擎也会把整棵树置 pause_requested=1
 * 而只把 root 置 blocked —— 子 run 面板于是显示 running 却再也不动,无从解释。
 * 落地之后(status 已经是 paused/stopped)就不再重复提示,交给 run_status 徽标。
 */
const pendingCtl = computed(() => {
  const r = run.value;
  if (!r || TERMINAL.includes(r.run_status)) return null;
  if (r.stop_requested) return { label: '停止中', hint: '已请求停止:不再拾取新节点,手头节点跑完即落 stopped' };
  if (r.pause_requested && r.run_status === 'running') {
    return { label: '暂停中', hint: '已请求暂停:不打断当前节点,到节点边界才落 paused(预算撞顶时也会级联置位)' };
  }
  return null;
});

// ponytail: 与 graph-engine.LOOP_HARD_CAP_RUN 重复的常量(API 没吐这个上限)。
// 上限一旦改,这里要一起改;升级路径是 GET /api/graph-runs/:id 顺带回 caps。
const LOOP_HARD_CAP_RUN = 5;
const isRoot = computed(() => !!run.value && run.value.root_run_id === run.value.id);
const backwardCount = computed(() => (run.value && run.value.backward_count) || 0);
const backwardHint = computed(() => (isRoot.value
  ? `整棵 run 树累计回退次数 / run 级硬顶 ${LOOP_HARD_CAP_RUN}(§四.D.2.1);越顶后不再备案,直接收敛等人工`
  : '本层累计回退次数;熔断判据是 root run 那一行的全树计数'));

async function load() {
  if (!props.runId) { run.value = null; nodes.value = []; edges.value = []; return; }
  loading.value = true;
  try {
    const r = await fetch(`/api/graph-runs/${props.runId}`);
    if (!r.ok) { err.value = `加载失败 HTTP ${r.status}`; return; }
    const j = await r.json();
    err.value = '';
    run.value = j.run;
    nodes.value = j.nodes || [];
    edges.value = j.edges || [];
    loopRecords.value = j.loopRecords || [];
    childRuns.value = j.childRuns || [];
    budget.value = j.budget || null;
    taskFiles.value = Array.isArray(j.taskFiles) ? j.taskFiles : null;
    companyRoot.value = j.companyRoot || null;
    projectRoot.value = j.projectRoot || null;
    benchmarkVerdicts.value = Array.isArray(j.benchmarkVerdicts) ? j.benchmarkVerdicts : [];
    mode.value = j.run?.execution_mode || 'manual';
    push();
  } catch (e) {
    err.value = e.message;
  } finally {
    loading.value = false;
  }
}

function push() {
  emit('state', {
    run: run.value, nodes: nodes.value, edges: edges.value,
    loopRecords: loopRecords.value, budget: budget.value, events: events.value,
    // childRuns 随每次 load 刷新,是「运行中新开的子 run」唯一的实时来源;
    // 页面挂载时那一次公司级 run 列表不会自己更新(R5-F2 字段对账)
    childRuns: childRuns.value,
    // R-19:右栏「输入输出」tab 的实例侧数据,与 nodes/edges 走同一条 load
    taskFiles: taskFiles.value, companyRoot: companyRoot.value,
    projectRoot: projectRoot.value,
    benchmarkVerdicts: benchmarkVerdicts.value,
  });
}

/** 控制操作统一入口:五个端点形态一致,失败时把后端给的 error 直接显示(它已经写清了为什么不可用) */
async function act(op, body) {
  busy.value = true;
  try {
    const url = op === 'retry'
      ? `/api/graph-runs/${props.runId}/nodes/${selectedNode.value.id}/retry`
      : `/api/graph-runs/${props.runId}/${op}`;
    const r = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body || {}),
    });
    const j = await r.json().catch(() => ({}));
    // ⚠️ 200 不等于「你要的事发生了」。坑满时「启动」会走 admitOrRequeue 把这条 run
    // **退回队列**,返回的是 200 + { ok:true, requeued:true, runStatus:'queued' }。
    // 只判 r.ok 的话界面上一个字都不会说,而随后按钮全灰、run 面板换成定义视图 ——
    // 用户点了「启动」,得到的是「什么都没说、图不见了」(批 3 复核 N2)。
    if (r.ok && j.requeued) {
      err.value = '';
      notice.value = '执行位已满,这条任务已退回队列排队 —— 坑一空会自动接着跑,不用再点。';
    } else {
      notice.value = '';
      err.value = r.ok ? '' : (j.error || `HTTP ${r.status}`);
    }
  } catch (e) {
    err.value = e.message;
  } finally {
    busy.value = false;
    await load();
  }
}

function onStart() {
  if (mode.value === 'auto') { confirmAuto.value = true; return; }
  act('start', { mode: 'manual' });
}
function onRetry() { if (canRetry.value) act('retry'); }

function onSelect(node) {
  selectedDefId.value = String(node.defNodeId);
  emit('select', node);
}

// ── SSE ────────────────────────────────────────────────────────────
function connect() {
  disconnect();
  if (!props.runId) return;
  sseStatus.value = 'connecting';
  try {
    // lastEventId=0 → 服务端 replayTo 从头重放 stream.log,历史 + 实时一条连接搞定
    es = new EventSource(`/api/graph-runs/${props.runId}/stream?lastEventId=0`);
  } catch { sseStatus.value = 'error'; return; }

  es.onopen = () => { sseStatus.value = 'connected'; };
  es.onerror = () => { sseStatus.value = 'reconnecting'; };   // EventSource 自带重连,不手工重建
  // 具名事件必须逐个 addEventListener(onmessage 只接无 event: 字段的帧),漏一个 = 整类收不到。
  // 之前这里只列了 6 个,node.error / node.triage / node.boundary / node.contract 等
  // **从来没到过浏览器** —— 失败节点「无原因」的根因之一(W12-UI)。名单见 node-signals.js。
  for (const k of RUN_EVENT_KINDS) {
    es.addEventListener(k, (ev) => onEvent(k, ev));
  }
  es.onmessage = (ev) => onEvent(ev.type || 'message', ev);
}

function onEvent(kind, ev) {
  let data = {};
  try { data = JSON.parse(ev.data); } catch { data = { raw: ev.data }; }
  events.value.push({ id: ev.lastEventId, kind, data, ts: data.ts || Date.now() });
  if (events.value.length > MAX_EVENTS) events.value.splice(0, events.value.length - MAX_EVENTS);
  // 状态类事件去抖后重拉一次全量——SSE 帧只带增量,画布要的是一致快照
  if (kind === 'node.status' || kind === 'run.status' || kind === 'loop.registered') {
    clearTimeout(refreshTimer);
    refreshTimer = setTimeout(load, 300);
  } else {
    push();
  }
}

function disconnect() {
  if (es) { try { es.close(); } catch { /* ignore */ } es = null; }
  clearTimeout(refreshTimer);
}

// ponytail: 引擎侧的 SSE 接线(T5→T2)尚未完成时事件轨是空的,靠这个 6s 轮询兜底,
// 只在 run 真的在跑的时候转。等引擎接上 emitTransition 后可以摘掉。
function startPoll() {
  clearInterval(pollTimer);
  pollTimer = setInterval(() => {
    if (run.value && ['running', 'planning'].includes(run.value.run_status)) load();
  }, 6000);
}

watch(() => props.runId, () => {
  selectedDefId.value = null;
  events.value = [];
  load();
  connect();
}, { immediate: true });

startPoll();
onBeforeUnmount(() => { disconnect(); clearInterval(pollTimer); });

function fmtNum(n) { return (n || 0).toLocaleString(); }
</script>

<style scoped>
.pane { display:flex; flex-direction:column; gap:8px; min-width:0; }
.pane-head { display:flex; align-items:center; gap:8px; font-size:13px; }
.ttl { font-weight:700; }
.grow { flex:1; }
.rid { font-family: ui-monospace, monospace; font-size:10px; color:#aaa; max-width:130px; overflow:hidden; text-overflow:ellipsis; }
.run-status { font-size:10px; padding:1px 7px; border-radius:9px; background:#eee; color:#555; text-transform:uppercase; }
.run-status.running   { background:#cce5ff; color:#004085; }
.run-status.completed { background:#d4edda; color:#155724; }
.run-status.blocked   { background:#ffe8cc; color:#a35b00; }
.run-status.failed    { background:#f8d7da; color:#721c24; }
.run-status.paused    { background:#fff3cd; color:#856404; }
.req { font-size:10px; padding:1px 7px; border-radius:9px; background:#fff3cd; color:#856404; border:1px solid #f0dca8; cursor:help; }
.req.back { background:#fff4e5; color:#8a4b00; border-color:#f0d0a8; }
.conn { font-size:11px; color:#ccc; }
.conn.connected { color:#0a6; }
.conn.reconnecting, .conn.connecting { color:#c70; }
.conn.error { color:#c00; }

.ctrl { display:flex; flex-wrap:wrap; align-items:center; gap:6px; }
.ctrl select { font-size:12px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; }
.btn, .mini {
  border:1px solid #ddd; background:#fff; border-radius:5px; cursor:pointer;
  font-size:12px; padding:4px 10px; color:#333;
}
.mini { font-size:11px; padding:2px 8px; color:#777; }
.btn:hover:not(:disabled) { border-color:#999; }
.btn:disabled { opacity:.4; cursor:not-allowed; }
.btn.go { background:#1a7f37; border-color:#1a7f37; color:#fff; }
.btn.retry { border-color:#c96a00; color:#c96a00; }
.next-hint { font-size:11px; color:#666; }
.next-hint code { background:#eef4ff; padding:1px 5px; border-radius:3px; }
.notice { color: var(--text-secondary); background: var(--bg-overlay); border: 1px dashed var(--border-primary);
  border-radius: 6px; padding: 6px 8px; font-size: 12px; line-height: 1.5; margin: 6px 0; }
.err { color:#c00; font-size:12px; margin:0; background:#fff5f5; border-left:3px solid #c00; padding:5px 8px; }

.compact .ctrl .btn { padding:3px 7px; font-size:11px; }

.modal-back { position:fixed; inset:0; background:rgba(0,0,0,.35); display:flex; align-items:center; justify-content:center; z-index:950; }
.modal { background:#fff; border-radius:10px; padding:20px 24px; width:460px; max-width:92vw; box-shadow:0 12px 40px rgba(0,0,0,.2); }
.modal h3 { margin:0 0 8px; font-size:15px; }
.lead { font-size:13px; color:#555; margin:0 0 10px; }
.est { font-size:12.5px; color:#444; padding-left:18px; margin:0 0 16px; }
.est li { margin-bottom:4px; }
.modal-btns { display:flex; justify-content:flex-end; gap:8px; }
</style>