diff --git a/frontend/src/canvas/RunTimeline.tsx b/frontend/src/canvas/RunTimeline.tsx index 776b8249..adfd77d8 100644 --- a/frontend/src/canvas/RunTimeline.tsx +++ b/frontend/src/canvas/RunTimeline.tsx @@ -7,7 +7,7 @@ import { formatClock, formatDuration, formatLapse, formatOffset, formatSpan, for import { statusMeta } from '../lib/status' import { topology, withSubSteps, type GraphLike } from '../run/derive' import { - isActivePhase, isTerminal, lastEventAt, liveAt, project, type NodeState, type NodeTrace, type Projection, + isActivePhase, isTerminal, lastEventAt, liveAt, project, stepLabel, type NodeState, type NodeTrace, type Projection, type Segment, type Trace, } from '../run/trace' import { projectCached } from '../run/useNodeView' @@ -341,7 +341,9 @@ function buildRows(trace: Trace, graph: TimelineGraph, at: number, labels: Map [n.id, n])) // 子工作流里的步骤排在子工作流那一行下面(withSubSteps),老运行里改过图的照旧排在最后 const ids = withSubSteps(topo.order, Object.keys(trace.nodes).filter((id) => !byId.has(id))) - const failedLabel = trace.failedNodeId ? labels.get(trace.failedNodeId) ?? trace.failedNodeId : '' + const own = (id: string) => labels.get(id) + // 子工作流里的那一步图上没有:接上子工作流的名字(stepLabel) + const failedLabel = trace.failedNodeId ? stepLabel(trace, trace.failedNodeId, own) : '' const rows: Row[] = [] let top = 0 for (const id of ids) { @@ -350,7 +352,7 @@ function buildRows(trace: Trace, graph: TimelineGraph, at: number, labels: Map onFocusNode?.(trace.failedNodeId!)} title="在画布上定位到失败的节点"> - 失败于「{labels.get(trace.failedNodeId) ?? trace.failedNodeId}」 + 失败于「{stepLabel(trace, trace.failedNodeId, (x) => labels.get(x))}」 )} {trace.phase === 'waiting' && trace.waitingNodeId && ( diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index 1b363bd1..18ee841e 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -13,7 +13,7 @@ import { catalogDrift, decodePhase, decodeRun, exitLabels, schemaDrift, summariz import { sourceHandles } from '../../canvas/nodeDefs' import { EffectsPane, RunTaskCards, useRunTasks } from './RunTasks' import { OriginChip } from './RunList' -import { runStatusOf, type RunPhase } from '../../run/trace' +import { runStatusOf, stepLabel, type RunPhase } from '../../run/trace' import { useCatalog } from '../../store/catalog' import type { Approval, Run, RunEvent } from '../../types' import { CatalogDriftBanner, FailedBanner, FeedbackStrip, HeldBanner, WaitingBanner, type Feedback } from './Banners' @@ -161,9 +161,9 @@ export function RunDetailView({ runId, onChange, onDeleted }: { const labelOf = useCallback((id?: string | null) => { if (!id) return undefined - const n = d.graph?.nodes.find((x) => x.id === id) - return n?.data?.label || id - }, [d.graph]) + // 子工作流里的那一步(出错、被驳回在它上面时运行记下的节点是「sub/x」)图上没有:接上子工作流的名字(stepLabel) + return stepLabel(d.trace, id, (x) => d.graph?.nodes.find((n) => n.id === x)?.data?.label || undefined) + }, [d.graph, d.trace]) if (!run) { if (d.state === 'error') { diff --git a/frontend/src/pages/runs/Telemetry.tsx b/frontend/src/pages/runs/Telemetry.tsx index bbbd93f6..d39beade 100644 --- a/frontend/src/pages/runs/Telemetry.tsx +++ b/frontend/src/pages/runs/Telemetry.tsx @@ -7,7 +7,7 @@ import { NONE, formatClock, formatCost, formatDateTime, formatNumber, formatSpan, formatTime, formatTokens, shortId, } from '../../lib/format' import { type StatusCode } from '../../lib/status' -import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace' +import { isExecuting, isSubStep, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace' import { useRunClock } from '../../run/useRunClock' import type { Approval, Run } from '../../types' import { BUNDLE_TEXT, COST_TEXT, REJECTED_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms' @@ -96,7 +96,8 @@ export function Telemetry({ run, trace, phase, code, streaming, pending, labelOf const unpriced = (settled || !streaming ? (isNum(usage.unpriced_calls) ? usage.unpriced_calls : undefined) : undefined) ?? trace.unpricedCalls - const running = Object.values(trace.nodes).filter(isExecuting) + // 子工作流里的那一步算在子工作流那一步上(isSubStep),不和它一起算成两个同时在跑 + const running = Object.values(trace.nodes).filter((n) => isExecuting(n) && !isSubStep(n.id)) const waitingId = trace.waitingNodeId ?? pending?.[0]?.node_id const waitedMs = trace.waitingSince != null ? liveAt(trace, now) - trace.waitingSince : ageMs(pending?.[0]?.created_at, now) diff --git a/frontend/src/run/RunHud.tsx b/frontend/src/run/RunHud.tsx index b90bbc43..e0c7e454 100644 --- a/frontend/src/run/RunHud.tsx +++ b/frontend/src/run/RunHud.tsx @@ -14,7 +14,7 @@ import { structureSig, useStudio } from '../store/studio' import type { RunEvent } from '../types' import { topology } from './derive' import { - isActivePhase, isSettled, isTerminal, liveAt, project, type NodeState, type RunPhase, type Trace, + isActivePhase, isSettled, isTerminal, liveAt, project, stepLabel, type NodeState, type RunPhase, type Trace, } from './trace' import { useRunClock } from './useRunClock' @@ -204,7 +204,8 @@ export function useRunGlance(active: boolean): RunGlance { const order = useMemo(() => topology({ nodes, edges }).order, [nodes, edges]) const labels = useMemo(() => new Map(nodes.map((n) => [n.id, n.data.label || n.id])), [nodes]) - const label = (id: string) => labels.get(id) ?? id + // 子工作流里的那一步(失败、被驳回在它上面)图上没有:接上子工作流的名字(stepLabel) + const label = (id: string) => stepLabel(trace, id, (x) => labels.get(x)) const live = isActivePhase(phaseLive) const at = replayAt ?? (live ? liveAt(trace, now) : lastStampOf(trace)) diff --git a/frontend/src/run/trace.ts b/frontend/src/run/trace.ts index 08315bb8..72ef85d0 100644 --- a/frontend/src/run/trace.ts +++ b/frontend/src/run/trace.ts @@ -91,6 +91,8 @@ export interface NodeTrace { */ exited?: boolean exitTo?: string[] + /** node.started 上记的名字。子工作流里的步骤不在画布的图上,名字从这里取(stepLabel) */ + label?: string /** 用量随时间的累计,回放时按游标取那一刻的 tokens 和工具数。没调过模型和工具的节点没有 */ marks?: UsageMark[] } @@ -277,6 +279,25 @@ function openIndex(segs: Segment[], kind: Segment['kind'], agent?: string): numb return -1 } +/** + * 子工作流里的一步(节点 id「子工作流节点 id/…」,docs/design/orchestration-round/00-总方案.md §2):它在子工作流节点那一步 + * 里面跑,节点计数、并行数、「当前在跑」都算在子工作流那一步上,不另算 + */ +export const isSubStep = (id: string): boolean => id.includes('/') + +/** + * 一个节点给人看的名字:own 是画布的图上的名字(找不到为 undefined)。子工作流里的步骤(isSubStep)图上没有,接上子工作流 + * 那一步的名字:「取数子流程 › 查订单」——和服务端终态事件的 label 同一个拼法(runner._label_of),失败横幅、泳道、 + * 运行胶囊不再写裸的「sub/fetch」。别的找不到的照旧写 id + */ +export function stepLabel(t: Trace, id: string, own: (id: string) => string | undefined): string { + const mine = own(id) + if (mine) return mine + if (!isSubStep(id)) return id + const cut = id.lastIndexOf('/') + return `${stepLabel(t, id.slice(0, cut), own)} › ${t.nodes[id]?.label || id.slice(cut + 1)}` +} + /** * 此刻同时在跑的数量。 * @@ -286,8 +307,7 @@ function openIndex(segs: Segment[], kind: Segment['kind'], agent?: string): numb function parallelOf(nodes: Record): number { let total = 0 for (const n of Object.values(nodes)) { - // 子工作流里的那几步(「子工作流节点 id/…」)在子工作流节点那一格里跑,不另算一路并行 - if (n.id.includes('/')) continue + if (isSubStep(n.id)) continue let inner = 0 let run = 0 for (const s of n.segments) { @@ -521,6 +541,7 @@ export function foldEvent(t: Trace, ev: RunEvent): Trace { case 'node.started': { if (!nodeId) break const n = node(nodeId) + if (typeof d.label === 'string' && d.label && n.label !== d.label) n.label = d.label const open = openIndex(n.segments, 'run') const lastSeg = n.segments[n.segments.length - 1] if (open >= 0) { @@ -1012,7 +1033,8 @@ export function project(t: Trace, at: number): Projection { toolsRunning: state === 'running' ? m?.[5] ?? 0 : 0, } } - if (view.state === 'done' || view.state === 'skipped') nodesDone += 1 + // 分母是工作流里的节点数:子工作流里的步骤算在子工作流那一步里(isSubStep),不另算完成数 + if ((view.state === 'done' || view.state === 'skipped') && !isSubStep(n.id)) nodesDone += 1 nodes[n.id] = view } @@ -1039,7 +1061,7 @@ export function project(t: Trace, at: number): Projection { return { phase, elapsedMs, activeMs, waitMs, nodesDone, - nodesTotal: t.nodesTotal ?? Object.keys(t.nodes).length, + nodesTotal: t.nodesTotal ?? Object.keys(t.nodes).filter((id) => !isSubStep(id)).length, parallelNow, ...usage, nodes, } } diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 43d202a9..fd390a94 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -3030,6 +3030,36 @@ await section('子工作流里的每一步进了同一个运行(节点 id「 check('子工作流里的一步:「取数子流程 › 查订单」', title('sub/fetch') === '取数子流程 › 查订单', title('sub/fetch')) check('没起名字的(名字就是子图里的 id)退到节点类型', title('sub/o') === '取数子流程 › 成果', title('sub/o')) check('子工作流节点自己照旧', title('sub') === '取数子流程', title('sub')) + + // 航迹的计数:子工作流里的步骤算在子工作流那一步里,完成数的分母是工作流里的节点数(run.started 的 nodes) + const trace = await import(await load('/src/run/trace.ts')) + const evs = [E(1, 'run.started', null, { nodes: 2 }), E(2, 'node.started', 'start', {}), E(3, 'node.finished', 'start', {}), + E(4, 'node.started', 'sub', { label: '取数子流程' }), + E(5, 'node.started', 'sub/fetch', { label: '查订单' }), E(6, 'node.finished', 'sub/fetch', {}), + E(7, 'node.started', 'sub/o', {})] + const t = evs.reduce(trace.foldEvent, trace.emptyTrace()) + const mid = trace.project(t, 7500) + check('跑到子工作流里:完成数只数工作流里的节点(1/2,不是 2/2)', mid.nodesDone === 1 && mid.nodesTotal === 2, + `${mid.nodesDone}/${mid.nodesTotal}`) + check('……子工作流和它里面那一步不算成两路并行', mid.parallelNow === 1, String(mid.parallelNow)) + check('……「当前在跑」只认子工作流那一步', Object.values(t.nodes).filter((n) => trace.isExecuting(n) && !trace.isSubStep(n.id)) + .map((n) => n.id).join() === 'sub') + const done = [...evs, E(8, 'node.finished', 'sub/o', {}), E(9, 'node.finished', 'sub', {}), + E(10, 'run.finished', null, { output: {} })].reduce(trace.foldEvent, trace.emptyTrace()) + const end = trace.project(done, 10000) + check('跑完:2/2,不是 4/2', end.nodesDone === 2 && end.nodesTotal === 2, `${end.nodesDone}/${end.nodesTotal}`) + + // 子工作流里的一步出错:运行记下的出错节点是「sub/fetch」,图上没有它。失败横幅、泳道、运行胶囊照「子工作流 › 那一步」写 + const failedRun = [...evs.slice(0, 5), E(6, 'node.failed', 'sub/fetch', { error: '查询失败' }), + E(7, 'node.failed', 'sub', { error: '查询失败' }), + E(8, 'run.failed', null, { error: '查询失败', node_id: 'sub/fetch', label: '取数子流程 › 查订单' })] + .reduce(trace.foldEvent, trace.emptyTrace()) + const own = (id) => ({ start: '入口', sub: '取数子流程' })[id] + check('出错节点记的是子图里那一步', failedRun.failedNodeId === 'sub/fetch', failedRun.failedNodeId) + check('名字接上子工作流:「取数子流程 › 查订单」,不写裸 id', trace.stepLabel(failedRun, 'sub/fetch', own) === '取数子流程 › 查订单', + trace.stepLabel(failedRun, 'sub/fetch', own)) + check('图上的节点照旧写图上的名字', trace.stepLabel(failedRun, 'sub', own) === '取数子流程') + check('没起过名字的子图步骤写子图里的 id', trace.stepLabel(t, 'sub/o', own) === '取数子流程 › o', trace.stepLabel(t, 'sub/o', own)) }) console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')