GraphConsole.vue 34.3 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718
<template>
  <div class="console">
    <!-- 页头:公司 + 常驻预算条(§四.B.4) -->
    <header class="top">
      <div class="who">
        <router-link :to="`/${companyId}/overview`" class="back">←</router-link>
        <b>{{ company?.name || companyId }}</b>
        <span class="sep">组织与执行</span>
      </div>
      <div class="budget" v-if="budget">
        <span class="chip">
          <i>calls</i> {{ spent.agent_calls || 0 }}/{{ budget.total_agent_calls || '∞' }}
          <em class="bar"><em :style="{ width: pct(spent.agent_calls, budget.total_agent_calls) }" /></em>
        </span>
        <span class="chip">
          <i>tokens</i> {{ fmt(spent.tokens) }}/{{ fmt(budget.total_tokens) }}
        </span>
        <!-- 停了就说停了:光冻住数字还不够,一个不动的计时器和一个动得很慢的计时器长得一样 -->
        <span class="chip" :class="{ frozen: clockStopped }">
          <i>墙钟</i> {{ elapsed }}
          <em v-if="clockStopped" class="halt" :title="`run 已 ${rootState.run.run_status},墙钟停在最后一次状态转换(${rootState.run.updated_at})`">已停</em>
        </span>
      </div>
      <div class="budget" v-else>
        <span class="chip muted">无 root run 预算</span>
      </div>
      <router-link :to="`/${companyId}/task-board`" class="to-board">📋 任务看板</router-link>
      <TaskFilesPanel
        v-if="selectedTaskId"
        :files="taskFiles"
        :company-root="taskFilesRoot"
        :company-id="companyId"
        :project-root="taskProjectRoot"
      />
    </header>

    <!-- 面包屑:公司 › org-coder › (子图)—— 与 §四.B.1 的图一致,点任一层回到那一层 -->
    <nav class="crumbs">
      <button class="crumb" :class="{ on: !stack.length }" @click="popTo(-1)">公司</button>
      <template v-for="(c, i) in stack" :key="c.runId">
        <span class="arrow">›</span>
        <button class="crumb" :class="{ on: i === stack.length - 1 }" @click="popTo(i)">{{ c.label }}</button>
      </template>
    </nav>

    <div class="cols" :class="{ rail: railMode, stacked }">
      <!-- 最左:目标·任务两级栏(§七.1)。窄屏收成 44px 图标条 + 抽屉展开 ——
           org graph 那栏最不能压,它是全局导航 -->
      <aside class="col goals" :class="{ rail: railMode && !drawer, 'drawer-open': railMode && drawer }">
        <button v-if="railMode" class="rail-toggle" :title="drawer ? '收起' : '展开目标·任务'" @click="drawer = !drawer">
          {{ drawer ? '×' : '☰' }}
        </button>
        <GoalTaskPane
          v-show="!railMode || drawer"
          :company-id="companyId"
          :goals="goals"
          :loading="goalsLoading"
          :selected-task-id="selectedTaskId"
          :selected-goal-id="selectedGoalId"
          :slots="slots"
          @select="selectTask"
          @refresh="reloadTree"
        />
      </aside>

      <!-- 左栏:org graph。与中栏是同一个画布组件的两个实例(§四.B.1 / R-15.4 的前端收益) -->
      <aside class="col left">
        <div v-if="hasOrgGraph || rootRun" class="left-actions">
          <ReplanOrgGraphButton :company-id="companyId" has-graph variant="inline" @planned="onReplanned" />
        </div>

        <GraphRunPane
          v-if="rootRun"
          :key="rootRun.id"
          :run-id="rootRun.id"
          title="Org Graph"
          compact
          @select="onOrgSelect"
          @state="onRootState"
        />
        <template v-else-if="hasOrgGraph">
          <header class="pane-head">
            <span class="ttl">Org Graph</span>
            <span class="tag">定义(未执行)</span>
          </header>
          <!-- 选中的是 draft / queued task(还没有在跑的 run)时也走这一支:图是公司级定义,节点全 pending。
               文案走 pendingRunHint:排队中要说清「前面还有几个」(那是「点了下达却什么都没发生」的
               唯一答案),其余状态用中文标签 —— 原先这里直接打 `{{ status }}` 的英文原文,
               跟左栏的中文标签是两套说法 -->
          <p class="hint">
            <template v-if="selectedTask">
              「{{ selectedTask.title }}」{{ pendingRunHint(selectedTask, slots) }}
            </template>
            <template v-else>
              从最左栏选一条任务;没有选中时这里画的是 org graph 定义本身,节点状态一律 pending。
            </template>
          </p>
          <WorkGraphCanvas :nodes="defNodes" :edges="defEdges" @select="onOrgSelect" />
        </template>

        <!-- 空态:整个控制台在这种公司上什么都做不了(没有 org graph 就没有 org node 可跑) -->
        <div v-else class="org-empty">
          <div class="es-icon">🕸️</div>
          <h3>这家公司还没有 org graph</h3>
          <p>
            org graph 由 <code>orggraph-planning</code> 规划:它读公司目标选骨架、把图落库,
            再按拓扑序把图里的角色招进公司。规划完成前,控制台没有可执行的 org node。
          </p>
          <ReplanOrgGraphButton :company-id="companyId" variant="primary" @planned="onReplanned" />
        </div>
      </aside>

      <!-- 中栏:选中 org node 的 work graph(下钻后是子 run) -->
      <main class="col mid">
        <div v-if="!currentRunId" class="empty-state">
          <template v-if="orgNode">
            <!-- §四.B.5 空态:从未跑过 work graph,不报错不留白 -->
            <div class="es-icon">◔</div>
            <h3>{{ orgNode.name || orgNode.defNodeId }} 还没有跑过 work graph</h3>
            <p>
              org node 的 work graph 由 <code>workgraph-planning</code> 在它接到任务时现规划(§四.A.1)。
              这个节点被 <code>advanceOne</code> 拾取执行后,这里会出现它的图。
            </p>
          </template>
          <template v-else>
            <div class="es-icon">◧</div>
            <h3>从左栏选一个 org node</h3>
            <p>中栏显示该 org node 这次任务里跑的 work graph;控制按钮作用于当前这一层。</p>
          </template>
        </div>

        <template v-else>
          <div v-if="historyRuns.length > 1" class="history">
            <label>历史 work run</label>
            <select :value="stack[0]?.runId" @change="switchHistory($event.target.value)">
              <option v-for="r in historyRuns" :key="r.id" :value="r.id">
                {{ r.created_at }} · {{ r.run_status }} · {{ r.id }}
              </option>
            </select>
          </div>
          <GraphRunPane
            :key="currentRunId"
            :run-id="currentRunId"
            :title="stack[stack.length - 1]?.label || 'Work Graph'"
            @select="onNodeSelect"
            @state="onMidState"
          >
            <template #empty>这张 work graph 还没有节点。</template>
          </GraphRunPane>
        </template>
      </main>

      <!-- 右栏:节点详情四 tab -->
      <aside class="col right">
        <!-- 账本与事件轨必须取**选中节点所属那一层**的:选中的是左栏 org node 时,
             它的回退记录与事件在 root run 上,中栏那份是另一张图的(W12-UI 修的第三处) -->
        <!-- nodes/edges/taskFiles/companyRoot 同样取**选中节点所属那一层**:R-19 的输入解析
             是「本层入边的契约 × 本 task 的产物实例」,拿错层的边就会解析出别的图的输入 -->
        <NodeDetailPanel
          :node="selectedNode"
          :loop-records="selectedState.loopRecords || []"
          :events="selectedState.events || []"
          :nodes="selectedState.nodes || []"
          :edges="selectedState.edges || []"
          :files="selectedState.taskFiles || null"
          :benchmark-verdicts="selectedState.benchmarkVerdicts || []"
          :company-root="selectedState.companyRoot || null"
          :company-id="companyId"
          :run-id="selectedRunId || ''"
          @dive="dive"
        />
      </aside>
    </div>

    <GraphChatBar :company-id="companyId" :context="chatContext" />
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import WorkGraphCanvas from '@/components/WorkGraphCanvas.vue';
import GraphRunPane from '@/components/graph-console/GraphRunPane.vue';
import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue';
import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue';
import GoalTaskPane from '@/components/graph-console/GoalTaskPane.vue';
import TaskFilesPanel from '@/components/graph-console/TaskFilesPanel.vue';
import GraphChatBar from '@/components/GraphChatBar.vue';
import {
  findTask, pickDefaultSelection, runStateToTaskPatch, hasStartedRun, pendingRunHint,
} from '@/components/graph-console/goal-task-model.js';

/**
 * 执行控制台(T3 + T4,§四.B;P3C §七 加了最左侧的目标·任务栏)。四栏:
 * 目标·任务 / org graph / work graph 画布 / 节点详情。
 *
 * 层级模型直接对应 §四.B.1 的面包屑「公司 › org-coder › (子图)」:
 *   stack = []                       → 公司层(中栏提示选节点)
 *   stack = [work run]               → 某个 org node 的 work graph
 *   stack = [work run, 子 run, …]    → 逐层下钻,每层都是完整可交互的 pane(自带五个控制按钮)
 *
 * **右边三栏画的永远是「当前选中那条 task」的图**(P3C §七.3)。task 是 org graph / work graph
 * 的唯一上下文,所以 goal 只负责分组与展开收起,选中态一律落在 task 上。
 */
const route = useRoute();
const router = useRouter();
const props = defineProps({ id: { type: String, default: '' } });
const companyId = computed(() => props.id || route.params.id);

const company = ref(null);
const allRuns = ref([]);
const defNodes = ref([]);
const defEdges = ref([]);

const goals = ref([]);
const goalsLoading = ref(false);
/**
 * 执行位 `{ max, used, free }` —— 批 4 之后一家公司可以同时跑多条 root run,
 * 「在跑几条」不再等于「满了没有」。这个值**只从服务端读**,前端不数 goals 里的 running 去凑:
 * used 数的是活跃 root run(含 ready/paused),max 是 per-company 配置
 * (company.json.max_parallel_tasks),两个都不在这棵目标树里 —— 自己算就是 canDispatch 那次事故的第二版。
 *
 * ⚠️ 服务端今天还没把它挂到任何响应上(`routes/graph-run.js#slotState` 目前只喂 409 的 body),
 * 所以它恒为 null,界面文案自动退回批 3 原样。补的时候直接把 `slotState(companyId)` 的返回值
 * 挂到 `GET /:cid/goals`(或随 taskView 的那一份)上即可,别在那条路上写第二份取值域。
 */
const slots = ref(null);
const selectedGoalId = ref('');
const selectedTaskId = ref('');

const orgNode = ref(null);                 // 左栏选中的 org node(run 状态合并后的对象)
const stack = ref([]);                     // 中栏层级栈
const selectedNode = ref(null);            // 右栏详情对象
/**
 * 右栏这个节点是从哪一栏选的('root' = 左栏 org run,'mid' = 中栏 work run)。
 *
 * 这一个字段是「NodeDetailPanel 不跟 SSE 走」那个缺陷的修法(27-P3B 记录的已知缺陷):
 * 在此之前只有 onMidState 会把 selectedNode 指到最新快照,onRootState 完全不管,
 * 于是**左栏选中的 org node 在右栏是一张永久冻结的快照**。
 */
const selectedFrom = ref('mid');
const rootState = ref({});
const midState = ref({ loopRecords: [], events: [] });
const selectedState = computed(() => (selectedFrom.value === 'root' ? rootState.value : midState.value));
/**
 * 右栏这个节点属于哪个 run —— 「LLM 原文」tab 拉 transcript 要它。
 * 必须跟着 selectedFrom 走:左栏选的节点属于 org run,中栏的属于当前 work run。
 */
const selectedRunId = computed(() =>
  (selectedFrom.value === 'root' ? (rootRun.value && rootRun.value.id) : currentRunId.value) || ''
);

const hasOrgGraph = computed(() => defNodes.value.length > 0);
const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null));
const budget = computed(() => rootState.value.budget || null);
const spent = computed(() => budget.value?.spent || {});
/**
 * 任务级产物汇总按钮要的两份数据(TaskFilesPanel)。taskFiles 是**任务级**检索
 * (`findFiles({companyId, taskId})`),不管从 root run 还是 mid run 拉都是同一份——
 * 优先用 root(选中 task 就有),它还没加载时(比如只下钻过中栏还没点过左栏)退到 mid,
 * 两者都没有就是 null(整块降级,不假装是空数组)。
 */
const taskFiles = computed(() => rootState.value.taskFiles ?? midState.value.taskFiles ?? null);
const taskFilesRoot = computed(() => rootState.value.companyRoot ?? midState.value.companyRoot ?? null);
const taskProjectRoot = computed(() => rootState.value.projectRoot ?? midState.value.projectRoot ?? null);

const selectedTask = computed(() => (findTask(goals.value, selectedTaskId.value) || {}).task || null);

// 底部聊天条要带给 agent 的当前上下文(GraphChatBar 消费)
const chatContext = computed(() => {
  const hit = findTask(goals.value, selectedTaskId.value);
  if (!hit) return null;
  return {
    taskId: hit.task.taskId,
    taskTitle: hit.task.title,
    taskStatus: hit.task.status,
    goalId: hit.goal.goalId,
    goalTitle: hit.goal.title,
  };
});

/**
 * **左栏画哪张图,由选中的 task 决定**(P3C §七.3 / §一.A)。
 *
 * 改这一条之前是 `roots.find(active) || roots[0]` —— 拿到了公司全部 root run,
 * 却只画其中一条:任意一条历史任务都可能被当成「要画的图」,其余全丢,
 * 这正是「历史任务看不见、切不了」那个问题的成因。现在没有选中 task(或它还没起过 run)
 * 就退到 org graph 定义视图,而不是随手挑一条别的任务的图画上去。
 *
 * allRuns 里找不到时用 task 上的 runId 兜一个最小对象:run 列表是挂载时拉的,
 * 刚下达的那条可能还没进去,而 GraphRunPane 只需要一个 id 就能自己拉全量 + 订 SSE。
 *
 * **排队中的 task 有 runId 但不画 run 面板**(判据在 hasStartedRun,理由写在那里):
 * 那条 run 是 `run_status='queued'`,画出来是一块全灰、不刷新、也不解释自己为什么不动的面板。
 * 退到下面的定义视图,由 pendingRunHint 说清「排在第几」。
 */
const rootRun = computed(() => {
  const t = selectedTask.value;
  if (!hasStartedRun(t)) return null;
  return allRuns.value.find((r) => r.id === t.runId) || { id: t.runId, run_status: t.status };
});

/** 同一个 org node 生命周期里可能跑过多张 work graph,默认最新、下拉切历史(§四.B.1) */
const historyRuns = computed(() =>
  orgNode.value ? allRuns.value.filter((r) => r.parent_node_run_id === orgNode.value.id) : []);

const now = ref(Date.now());
let tick = null;
let treePoll = null;
/**
 * 墙钟:**run 不在跑的时候必须停**。
 *
 * 以前恒 `now - started_at`,没有终点:一条昨晚就 blocked 的 run,第二天顶栏还在涨到
 * 「16h 58min」,而那个数字是页面上唯一的时间信号 —— 读起来就是「它还在干活」。
 * 于是「已经停了、等人处置」被显示成了「还在跑、再等等」,这两件事需要的动作完全相反。
 *
 * 终点取 `run.updated_at`(最后一次状态转换的时刻)。不新加列:run 的每次状态转换都会
 * 写 updated_at,收敛那一下正是最后一次,够准了。
 * ponytail: 精度上限 = 「最后一次状态转换」而非「最后一个 agent 收尾」,两者差一次落库;
 * 要更准就得给 graph_runs 加一列 finished_at,为一个顶栏数字不值当。
 */
const CLOCK_LIVE = ['running', 'planning'];
const clockStopped = computed(() => {
  const r = rootState.value.run;
  return !!r && !CLOCK_LIVE.includes(r.run_status);
});
const elapsed = computed(() => {
  const s0 = spent.value.started_at;
  if (!s0) return '—';
  const r = rootState.value.run;
  const end = clockStopped.value && r && r.updated_at ? Date.parse(r.updated_at) : now.value;
  const ms = end - Date.parse(s0);
  if (!Number.isFinite(ms) || ms < 0) return '—';
  const m = Math.floor(ms / 60000);
  return m < 60 ? `${m}min` : `${Math.floor(m / 60)}h ${m % 60}min`;
});

// ── 响应式布局:≤1400px 最左栏收成图标条 + 抽屉;≤1100px 沿用既有堆叠 ──────────
const stacked = ref(false);
const railMode = ref(false);
const drawer = ref(false);
let mqNarrow = null;
let mqStack = null;
function applyMq() {
  stacked.value = !!(mqStack && mqStack.matches);
  railMode.value = !!(mqNarrow && mqNarrow.matches) && !stacked.value;
  if (!railMode.value) drawer.value = false; // 从窄屏拉宽时抽屉必须收掉,否则它会浮在四栏之上
}

onMounted(async () => {
  tick = setInterval(() => { now.value = Date.now(); }, 30000);
  if (typeof window !== 'undefined' && window.matchMedia) {
    mqNarrow = window.matchMedia('(max-width: 1400px)');
    mqStack = window.matchMedia('(max-width: 1100px)');
    applyMq();
    mqNarrow.addEventListener('change', applyMq);
    mqStack.addEventListener('change', applyMq);
  }
  // URL 里的选中态先认下来(刷新 / 分享可复原,§七.3),拉完数据再校验它还在不在
  selectedGoalId.value = String(route.query.goal || '');
  selectedTaskId.value = String(route.query.task || '');
  await Promise.all([loadCompany(), loadRuns(), loadOrgDef(), loadGoals()]);
  // ponytail: 没有公司级 SSE 通道,未选中的行只能靠轮询兜底(§七.3)。
  // 上限就是这 20s 的延迟;升级路径是 graph-run-sse 加一个 company topic,前端订一条就够。
  treePoll = setInterval(() => { loadGoals(); }, 20000);
});
onBeforeUnmount(() => {
  clearInterval(tick);
  clearInterval(treePoll);
  if (mqNarrow) mqNarrow.removeEventListener('change', applyMq);
  if (mqStack) mqStack.removeEventListener('change', applyMq);
});

async function loadCompany() {
  try {
    const r = await fetch(`/api/company/${companyId.value}`);
    if (r.ok) company.value = await r.json();
  } catch { /* 页头名字掉了不影响功能 */ }
}

/**
 * 整棵目标·任务树。withTasks=1 让服务端一次带出每个 goal 的 task 摘要(含进度),
 * 不按 goal 逐个查 —— 那就是端点注释里点名要避开的 N+1。
 */
async function loadGoals() {
  goalsLoading.value = true;
  try {
    const r = await fetch(`/api/company/${companyId.value}/goals?withTasks=1`);
    if (!r.ok) return;
    const j = await r.json();
    goals.value = j.goals || [];
    // 服务端没给就明确置回 null(不是保留上一份):坑位数过期比没有更坏 ——
    // 「还空着 1 个」在真的满了之后还挂在屏幕上,用户就会去点一个必然 409 的按钮
    slots.value = j.slots || null;
    ensureSelection();
  } catch { /* 轮询失败保留上一份树,别把已经画出来的东西清空 */ } finally {
    goalsLoading.value = false;
  }
}

/**
 * 选中态校验:URL 里带的 task 可能已经被归档 / 属于别的公司,这时退到默认
 * (最近一条 running,没有就最近创建的,§七.3)。
 */
function ensureSelection() {
  if (selectedTaskId.value && findTask(goals.value, selectedTaskId.value)) {
    // goal 侧顺手对齐一次:URL 里只带了 task 也能把它所在的 goal 高亮出来
    selectedGoalId.value = findTask(goals.value, selectedTaskId.value).goal.goalId;
    return;
  }
  const pick = pickDefaultSelection(goals.value);
  if (pick) selectTask(pick);
  else if (selectedTaskId.value) selectTask({ goalId: '', taskId: '' });
}

async function loadRuns() {
  try {
    const r = await fetch(`/api/company/${companyId.value}/graph-runs`);
    if (!r.ok) return;
    const j = await r.json();
    allRuns.value = j.runs || [];
    // 这里**不再挑 rootRun** —— 画哪张图由选中的 task 决定,见 rootRun 那个 computed。
    //
    // 响应里的 canDispatch 也不再读:建 goal 从不 409(POST /goals 只落草稿),
    // 所以「新目标」入口永远可用;真正会被并发约束挡住的是**下达**,那一步由服务端 409
    // 带着 hint 回来、界面原样透出。前端复述一份取值域正是 canDispatch 当初出事的原因
    // (比后端多写了一个 blocked,公司失败一次就再也下不了任务)。
  } catch { /* 无 run 时左栏退到定义视图 */ }
}

/** 没有 run 时左栏用 org graph 定义画一张静态图(org 节点全是 subgraph executor,§四.D.0) */
async function loadOrgDef() {
  try {
    const r = await fetch(`/api/company/${companyId.value}/org-graph`);
    if (!r.ok) return;
    const j = await r.json();
    defNodes.value = (j.nodes || []).map((n) => ({
      defNodeId: String(n.id), name: n.roleName, systemAgentId: n.systemAgentId,
      nodeKind: 'subgraph', status: 'pending', attempt: 0,
    }));
    defEdges.value = (j.edges || []).map((e) => ({
      // failReason 只在有值时出现在响应里(org-graph-service.getGraph),漏读它画布上就分不出
      // 「精确 fail 边」和「兜底边」——这两条在同一对节点之间可以并存
      id: e.id, from: String(e.from), to: String(e.to),
      condition: e.condition, failReason: e.failReason, edgeKind: e.edgeKind,
    }));
  } catch { /* ignore */ }
}

/**
 * **选中态**:三栏的「我正在看哪个节点 / 哪一层」。
 *
 * 它挂在**图的定义**上(orgNode / selectedNode 都带 defNodeId,stack 里是它们的子 run),
 * 所以任何让「图的节点集合」失效的事件都要清它 —— 换 task 会(不同 run 的节点行是各自一份),
 * replan 也会(新 def 里旧 defNodeId 可能压根不存在,留着就会在中栏画一个已经不在图上的
 * 节点的 work run、在右栏显示一个查不到的详情)。
 */
function clearSelection() {
  orgNode.value = null;
  selectedNode.value = null;
  stack.value = [];
}

/**
 * 换了 task 之后所有**属于上一条 task** 的下游状态 = 选中态 + 两份 run 快照。
 *
 * 只写一份、两个调用方(selectTask 与 route.query.task 的 watcher)共用:
 * 这两条路径清的东西必须逐字一致 —— 之前 watcher 少清 rootState / midState,
 * 用浏览器后退切回上一条 task 时,顶栏的 budget / spent / elapsed(全部从 rootState 算)
 * 显示的还是**上一条任务**的数字;新选中的若是 draft task(没有 run,走定义视图分支,
 * 不会再有 onRootState 覆盖),那份旧预算就一直挂着不刷新。
 *
 * ⚠️ 与 onReplanned 的差别是**故意的**,别把它们合成一个:
 * rootState / midState 是 **run 快照**(预算、账本、节点运行行),归属于 run 而不是图定义。
 * 换 task 就是换 run,快照必须清;replan 不换 run —— 当前 run 的预算照旧在扣,
 * 而且 GraphRunPane 用 `:key="rootRun.id"` 挂载,replan 后 runId 没变 → 组件不重挂、
 * 不重连 SSE、不会再 emit 一次 state。此时清了 rootState,顶栏就会退成「无 root run 预算」,
 * run 已结束的话再也没有下一个 SSE 事件把它填回来。
 */
function clearTaskScopedState() {
  clearSelection();
  rootState.value = {};
  midState.value = { loopRecords: [], events: [] };
}

/**
 * 切 task:**下游选中态全清**。
 * 不同任务的 def node 不可复用(org node 的 run 行是每条 run 各一份),
 * 串了会画出一张不存在的图 —— 与 onReplanned 的处理同理。
 */
function selectTask({ goalId, taskId }) {
  const changed = taskId !== selectedTaskId.value;
  selectedGoalId.value = goalId || '';
  selectedTaskId.value = taskId || '';
  if (changed) clearTaskScopedState();
  if (railMode.value) drawer.value = false; // 窄屏抽屉里选完就让路给图
  syncQuery();
}

/** 选中态进 URL(replace 不进历史栈:切任务不该把浏览器的后退按钮变成「上一个任务」) */
function syncQuery() {
  const q = { ...route.query };
  if (selectedGoalId.value) q.goal = selectedGoalId.value; else delete q.goal;
  if (selectedTaskId.value) q.task = selectedTaskId.value; else delete q.task;
  if (q.goal === route.query.goal && q.task === route.query.task) return;
  router.replace({ query: q }).catch(() => { /* 重复导航不是错误 */ });
}

/** 树变了(建 goal / 下达 / 归档 / 改 task)→ 重拉树 + run 列表 */
async function reloadTree() {
  await Promise.all([loadGoals(), loadRuns()]);
}

// 浏览器前进后退 / 外部链接改了 query → 跟着走
watch(() => route.query.task, (v) => {
  const next = String(v || '');
  if (next !== selectedTaskId.value) {
    selectedGoalId.value = String(route.query.goal || '');
    selectedTaskId.value = next;
    clearTaskScopedState();
  }
});

/**
 * 规划完成(org graph 指纹变了)后重载左栏。run 列表一起重拉:新图的节点与旧 run 的
 * def node 已经对不上了,不重拉的话左栏会拿旧 run 画一张不存在的图。
 *
 * 只清选中态、**不清 run 快照**(rootState / midState)—— 理由见 clearTaskScopedState 的注释:
 * replan 换的是图定义,不是 run,当前 run 的预算与账本仍然有效且不会被重新推送一次。
 */
async function onReplanned() {
  clearSelection();
  await Promise.all([loadOrgDef(), loadRuns()]);
}

function onRootState(s) {
  rootState.value = s;
  // root run 的 childRuns 就是各 org node 这次跑的 work run。allRuns 只在挂载时拉过一次,
  // 运行中新开的子 run 不会进去,「历史 work run」下拉于是永远少最新那条 —— 用这份实时数据补上
  mergeRuns(s.childRuns);
  // §七.3:选中 task 的 run 有 SSE → 只更新那一行,不为一次状态变化重拉整棵树
  patchSelectedTask(runStateToTaskPatch(s));

  // 左栏节点也要跟着 SSE 走。orgNode 是中栏 runId 的来源,selectedNode 是右栏详情的来源,
  // 两者可能指向同一行也可能不是(选了 org node 之后又去中栏点了 work node)。
  const fresh = (id) => (s.nodes || []).find((n) => n.id === id) || null;
  if (orgNode.value) {
    const next = fresh(orgNode.value.id);
    if (next) {
      orgNode.value = next;
      // org node 是在**被拾取执行时**才现规划出 work graph 的:选中它的那一刻多半还没有
      // childRunId。子 run 一出现就把中栏指过去,不然用户得手动再点一次这个节点。
      // 只在栈空时做 —— 用户已经下钻到某一层时不能把他的位置抢走。
      if (next.childRunId && !stack.value.length) {
        stack.value = [{ runId: next.childRunId, label: next.name || next.defNodeId }];
      }
    }
  }
  if (selectedFrom.value === 'root' && selectedNode.value) {
    selectedNode.value = fresh(selectedNode.value.id) || selectedNode.value;
  }
}

/** 把 SSE 算出来的补丁打到选中那一行上(整棵树的其余部分等 20s 轮询) */
function patchSelectedTask(patch) {
  if (!patch || !selectedTaskId.value) return;
  const hit = findTask(goals.value, selectedTaskId.value);
  if (!hit) return;
  goals.value = goals.value.map((g) => (g.goalId !== hit.goal.goalId ? g : {
    ...g,
    tasks: (g.tasks || []).map((t) => (t.taskId === selectedTaskId.value ? { ...t, ...patch } : t)),
  }));
}

function mergeRuns(list) {
  if (!list || !list.length) return;
  const seen = new Set(allRuns.value.map((r) => r.id));
  const add = list.filter((r) => !seen.has(r.id));
  if (add.length) allRuns.value = [...add, ...allRuns.value];
}
function onMidState(s) {
  midState.value = s;
  // 中栏刷新后把右栏详情指向同一条最新记录,否则详情面板会停在旧快照上
  if (selectedFrom.value === 'mid' && selectedNode.value) {
    const fresh = (s.nodes || []).find((n) => n.id === selectedNode.value.id);
    if (fresh) selectedNode.value = fresh;
  }
}

function onOrgSelect(node) {
  orgNode.value = node;
  selectedNode.value = node;
  selectedFrom.value = 'root';
  const runId = node.childRunId || historyRuns.value[0]?.id || null;
  stack.value = runId ? [{ runId, label: node.name || node.defNodeId }] : [];
}

function onNodeSelect(node) {
  selectedNode.value = node;
  selectedFrom.value = 'mid';
}

/** 「子图」tab 的下钻:把子 run 推到栈顶,那一层的控制按钮就作用在子 run 上(§四.B.3) */
function dive({ runId, label }) {
  if (!runId) return;
  stack.value = [...stack.value, { runId, label: `${label || '子图'} ›子图` }];
  selectedNode.value = null;
  selectedFrom.value = 'mid';
}

function popTo(i) {
  stack.value = i < 0 ? [] : stack.value.slice(0, i + 1);
  selectedNode.value = null;
  if (i < 0) orgNode.value = null;
}

function switchHistory(runId) {
  const label = stack.value[0]?.label || 'work graph';
  stack.value = [{ runId, label }];
  selectedNode.value = null;
}

function pct(a, b) { return b ? `${Math.min(100, Math.round(((a || 0) / b) * 100))}%` : '0%'; }
function fmt(n) { return n == null ? '∞' : Number(n).toLocaleString(); }
</script>

<style scoped>
.console { display:flex; flex-direction:column; height:100vh; background:#fff; color:#1a1a1a; }

.top {
  display:flex; align-items:center; gap:16px; padding:10px 18px;
  border-bottom:1px solid #eee; flex:0 0 auto;
}
.who { display:flex; align-items:center; gap:9px; font-size:14px; }
.back { text-decoration:none; color:#888; font-size:16px; }
.sep { color:#999; font-size:12px; }
.budget { margin-left:auto; display:flex; gap:10px; align-items:center; }
.chip { font-size:11px; color:#444; background:#f7f7f9; border-radius:12px; padding:3px 10px; display:flex; align-items:center; gap:6px; }
.chip i { color:#999; font-style:normal; }
.chip.muted { color:#aaa; }
/* 墙钟停摆:数字保留(那是真实耗时),但要一眼看出它不再走 */
.chip.frozen { background:#fff4e5; color:#8a4b00; }
.chip.frozen i { color:#c08a4a; }
.halt { font-style:normal; font-size:10px; background:#f0d0a8; color:#7a3d00; border-radius:7px; padding:0 5px; cursor:help; }
.bar { display:block; width:56px; height:4px; background:#e4e4e8; border-radius:2px; overflow:hidden; }
.bar em { display:block; height:100%; background:#2b6cb0; }
.to-board { text-decoration:none; color:#2b6cb0; font-size:12px; font-weight:600; white-space:nowrap; }
.to-board:hover { text-decoration:underline; }

.crumbs { display:flex; align-items:center; gap:6px; padding:7px 18px; border-bottom:1px solid #f2f2f4; flex:0 0 auto; }
.crumb { border:none; background:none; cursor:pointer; font-size:12px; color:#2b6cb0; padding:2px 4px; }
.crumb.on { color:#1a1a1a; font-weight:600; cursor:default; }
.arrow { color:#ccc; font-size:12px; }

/* 四栏(§七.1)。每一栏都显式指定 grid-column:抽屉态下最左栏是 absolute(脱离流),
   靠自动放置的话剩下三栏会整体左移一格、挤进那条 44px 的轨道里 */
/* org graph 那栏原来是死的 320px,减去 padding 只剩 292px 可用 —— 而两个节点加一条边就要
   380px+,实际效果是节点名被从中间切断(`org-cod…`)、必须横向滚才看得全,而它是**全局导航**。
   同一时刻中间那栏(work graph)拿着 600px 却经常是空的(选中的 task 还没跑过 work graph)。
   改成「有下限、按比例分剩余空间」:org graph 保底 380px,两张画布按 0.9 : 1.1 分空间,
   屏幕越宽两边一起长。work graph 仍拿多数,因为它才是下钻后的主画布。 */
.cols { position:relative; flex:1; display:grid;
  grid-template-columns:280px minmax(380px, 0.9fr) minmax(0, 1.1fr) 360px; min-height:0; }
.cols > .goals { grid-column:1; }
.cols > .left  { grid-column:2; }
.cols > .mid   { grid-column:3; }
.cols > .right { grid-column:4; }
.col { padding:12px 14px; overflow:auto; min-width:0; }
.goals { border-right:1px solid #eee; background:#fcfcfd; }
.left { border-right:1px solid #eee; background:#fcfcfd; }
.right { border-left:1px solid #eee; }

/* ≤1400px:最左栏收成 44px 图标条(org graph 那栏最不能压,它是全局导航) */
.cols.rail { grid-template-columns:44px minmax(380px, 0.9fr) minmax(0, 1.1fr) 360px; }
.goals.rail { padding:8px 4px; overflow:hidden; }
.goals.drawer-open {
  position:absolute; left:0; top:0; bottom:0; width:280px; z-index:30;
  box-shadow:2px 0 14px rgba(0,0,0,.14); padding:12px 14px;
}
.rail-toggle {
  border:1px solid #e4e4e8; background:#fff; border-radius:6px; cursor:pointer;
  width:32px; height:32px; font-size:14px; color:#666; line-height:1;
}
.goals.drawer-open .rail-toggle { position:absolute; right:6px; top:6px; width:24px; height:24px; }

.pane-head { display:flex; align-items:center; gap:8px; font-size:13px; margin-bottom:6px; }
.ttl { font-weight:700; }
.tag { font-size:10px; color:#999; background:#f0f0f2; padding:1px 7px; border-radius:9px; }
.hint { font-size:11px; color:#999; margin:0 0 8px; line-height:1.6; }

.history { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; color:#666; }
.history select { font-size:11px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; max-width:100%; }

.left-actions {
  display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start;
  justify-content:flex-end; margin-bottom:8px; padding:8px 12px 0;
}

/* 左栏空态:控制台在无图公司上唯一能做的事,所以按钮是主操作而不是脚注 */
.org-empty { text-align:center; color:#888; padding:34px 10px; }
.org-empty h3 { font-size:13px; color:#555; margin:10px 0 6px; }
.org-empty p { font-size:11px; line-height:1.9; margin:0 0 14px; }
.org-empty code { background:#f2f2f4; padding:1px 5px; border-radius:3px; }

.empty-state { text-align:center; color:#888; padding:60px 24px; }
.es-icon { font-size:34px; color:#d8d8dc; }
.empty-state h3 { font-size:14px; color:#555; margin:10px 0 6px; }
.empty-state p { font-size:12px; line-height:1.8; max-width:460px; margin:0 auto; }
.empty-state code { background:#f2f2f4; padding:1px 5px; border-radius:3px; }

/* ≤1100px:沿用既有堆叠,目标栏排最上(DOM 顺序就是它) */
@media (max-width: 1100px) {
  .cols, .cols.rail { grid-template-columns:1fr; }
  .cols > .goals, .cols > .left, .cols > .mid, .cols > .right { grid-column:auto; }
  .goals.drawer-open { position:static; width:auto; box-shadow:none; }
  .goals, .left, .right { border:none; border-bottom:1px solid #eee; }
}
</style>