feat(web): wire up non-demo run detail pages to real data

- Fix run detail status: display actual API status (submitted, running,
  succeeded, failed, etc.) instead of always showing "Working"
- Implement /runs/{id}/stages endpoint in non-demo mode, reading from
  checkpoint + events to build stage list with statuses and durations
- Fix /runs/{id}/graph to fall through to durable store when run is not
  in the live map
- Render real workflow graph SVG on overview and graph pages instead of
  hardcoded demo graph; remove unused DotDiagram component from overview
- Add dark mode CSS overrides for server-rendered SVG graphs
- Wire stage detail page to real event data: fetch from /events, filter
  by node_id, and render as system/assistant/tool blocks
- Fix stage page 500: use apiJsonOrNull for unimplemented /turns endpoint
- Filter start/exit graph control nodes from stage lists in the UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-15 09:42:49 -04:00
parent 80cb51300b
commit 75ab9965dd
No known key found for this signature in database
7 changed files with 364 additions and 324 deletions

View file

@ -151,6 +151,21 @@
--diffs-fg-number-deletion-override: rgba(232, 107, 107, 0.5);
}
/* ── Server-rendered SVG graph overrides ── */
/* The server injects @media (prefers-color-scheme: dark) but the app uses
.dark/.light classes, so we apply dark-mode colors via the class. */
.dark .graph-svg text { fill: #c6d4e0; }
.dark .graph-svg [stroke="#357f9e"] { stroke: #67B2D7; }
.dark .graph-svg [stroke="#666666"] { stroke: #5a7a94; }
.dark .graph-svg polygon[fill="#357f9e"] { fill: #67B2D7; }
.dark .graph-svg polygon[fill="#666666"] { fill: #5a7a94; }
.dark .graph-svg ellipse[fill="#ffffff"],
.dark .graph-svg polygon[fill="#ffffff"] { fill: #1a2b3c; }
.dark .graph-svg ellipse[stroke="#357f9e"],
.dark .graph-svg polygon[stroke="#357f9e"] { stroke: #67B2D7; }
.dark .graph-svg [fill="#1a1a1a"] { fill: #c6d4e0; }
.light {
--diffs-bg-buffer-override: #ffffff;
--diffs-bg-context-override: #f8fafc;

View file

@ -144,6 +144,13 @@ export function isRunStatus(s: string): s is RunStatus {
return knownRunStatuses.has(s);
}
/** Graph control nodes hidden from stage lists in the UI. */
const hiddenStageIds = new Set(["start", "exit"]);
export function isVisibleStage(id: string): boolean {
return !hiddenStageIds.has(id);
}
export const ciConfig: Record<CiStatus, { label: string; dot: string; text: string }> = {
passing: { label: "Passing", dot: "bg-mint", text: "text-mint" },
failing: { label: "Changes needed", dot: "bg-coral", text: "text-coral" },

View file

@ -6,6 +6,7 @@ import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { useTheme } from "../lib/theme";
import { getGraphTheme } from "../lib/graph-theme";
import { apiFetch, apiJsonOrNull } from "../api";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
@ -26,7 +27,7 @@ export async function loader({ request, params }: any) {
apiJsonOrNull<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiFetch(`/runs/${params.id}/graph`, { request }),
]);
const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({
const stages: Stage[] = (stagesResult?.data ?? []).filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,
name: s.name,
dotId: s.dot_id ?? s.id,
@ -208,12 +209,27 @@ export default function RunGraph({ loaderData }: any) {
let cancelled = false;
async function render() {
const { instance } = await import("@viz-js/viz");
const viz = await instance();
if (cancelled) return;
try {
const svg = viz.renderSVGElement(buildDot(direction, graphTheme));
let svg: SVGSVGElement;
if (graphSvg) {
// Use server-rendered SVG from the real graph endpoint.
const parser = new DOMParser();
const doc = parser.parseFromString(graphSvg, "image/svg+xml");
const parsed = doc.documentElement;
if (!(parsed instanceof SVGSVGElement)) {
setError("Invalid SVG from server");
return;
}
svg = parsed;
} else {
// Fall back to hardcoded demo graph rendered client-side.
const { instance } = await import("@viz-js/viz");
const viz = await instance();
if (cancelled) return;
svg = viz.renderSVGElement(buildDot(direction, graphTheme));
}
stripGraphTitle(svg);
annotateRunningNodes(svg, graphTheme, stages);
@ -229,7 +245,7 @@ export default function RunGraph({ loaderData }: any) {
setPan({ x: 0, y: 0 });
render();
return () => { cancelled = true; };
}, [direction, graphTheme]);
}, [direction, graphTheme, graphSvg]);
const onPointerDown = useCallback((e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
@ -325,7 +341,7 @@ export default function RunGraph({ loaderData }: any) {
</nav>
<div className="min-w-0 flex-1">
<div className="relative rounded-md border border-line bg-panel-alt/40">
<div className="graph-svg relative rounded-md border border-line bg-panel-alt/40">
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
<button

View file

@ -1,11 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { ArrowDownIcon, ArrowRightIcon, MinusIcon, PlusIcon } from "@heroicons/react/20/solid";
import { useState, useEffect, useRef } from "react";
import { Link, useParams, useRevalidator } from "react-router";
import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { useTheme } from "../lib/theme";
import { getGraphTheme } from "../lib/graph-theme";
import { apiJsonOrNull } from "../api";
import { apiFetch, apiJsonOrNull } from "../api";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
@ -25,13 +23,15 @@ export async function loader({ request, params }: any) {
`/runs/${params.id}/stages`,
{ request },
);
const stages: Stage[] = (stagesResult?.data ?? []).map((s) => ({
const stages: Stage[] = (stagesResult?.data ?? []).filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,
name: s.name,
status: s.status as StageStatus,
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
}));
return { stages, graphDot: null };
const graphRes = await apiFetch(`/runs/${params.id}/graph`, { request });
const graphSvg = graphRes.ok ? await graphRes.text() : null;
return { stages, graphSvg };
}
const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: string }> = {
@ -42,267 +42,102 @@ const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: s
cancelled: { icon: XCircleIcon, color: "text-fg-muted" },
};
function buildThemeAttrs(gt: ReturnType<typeof getGraphTheme>) {
return `
rankdir=LR
bgcolor="transparent"
pad=0.5
fontname="ui-monospace, monospace"
fontsize=10
fontcolor="${gt.fontcolor}"
color="${gt.edgeColor}"
graph [
fontname="ui-monospace, monospace"
fontsize=10
fontcolor="${gt.fontcolor}"
color="${gt.edgeColor}"
style="dashed"
penwidth=1
]
node [
fontname="ui-monospace, monospace"
fontsize=11
fontcolor="${gt.nodeText}"
color="${gt.edgeColor}"
fillcolor="${gt.nodeFill}"
style=filled
penwidth=1.2
]
edge [
fontname="ui-monospace, monospace"
fontsize=9
fontcolor="${gt.fontcolor}"
color="${gt.edgeColor}"
arrowsize=0.7
penwidth=1.2
]
`;
}
function colorSpecialNodes(dot: string, gt: ReturnType<typeof getGraphTheme>): string {
// Mdiamond / Msquare → teal start/exit nodes
dot = dot.replace(
/(\bshape\s*=\s*M(?:diamond|square)\b[^\]]*)\]/g,
`$1, fillcolor="${gt.startFill}", color="${gt.startBorder}", fontcolor="${gt.startText}"]`,
);
// hexagon / diamond (but not Mdiamond) → amber gate nodes
dot = dot.replace(
/(\bshape\s*=\s*(?:hexagon|(?<!M)diamond)\b[^\]]*)\]/g,
`$1, fillcolor="${gt.gateFill}", color="${gt.gateBorder}", fontcolor="${gt.gateText}"]`,
);
return dot;
}
function applyTheme(dot: string, gt: ReturnType<typeof getGraphTheme>): string {
return colorSpecialNodes(dot.replace(/\{/, `{\n${buildThemeAttrs(gt)}\n`), gt);
}
function stripGraphTitle(svg: SVGSVGElement) {
const title = svg.querySelector(".graph > title");
if (!title) return;
let sibling = title.nextElementSibling;
while (sibling && sibling.tagName === "text") {
const next = sibling.nextElementSibling;
sibling.remove();
sibling = next;
}
title.remove();
}
type Direction = "LR" | "TB";
const ZOOM_STEPS = [25, 50, 75, 100, 150, 200];
const DEFAULT_ZOOM_INDEX = 2; // 75%
function DotDiagram({ dot }: { dot: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [zoomIndex, setZoomIndex] = useState(DEFAULT_ZOOM_INDEX);
const [direction, setDirection] = useState<Direction>("LR");
const [pan, setPan] = useState({ x: 0, y: 0 });
const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null);
const zoom = ZOOM_STEPS[zoomIndex];
const { theme } = useTheme();
const graphTheme = getGraphTheme(theme);
useEffect(() => {
let cancelled = false;
async function render() {
const { instance } = await import("@viz-js/viz");
const viz = await instance();
if (cancelled) return;
try {
const themed = applyTheme(dot, graphTheme).replace(/rankdir\s*=\s*\w+/, `rankdir=${direction}`);
const svg = viz.renderSVGElement(themed);
stripGraphTitle(svg);
svgRef.current = svg;
if (innerRef.current) {
innerRef.current.replaceChildren(svg);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to render diagram");
}
}
setPan({ x: 0, y: 0 });
render();
return () => { cancelled = true; };
}, [dot, direction, graphTheme]);
const onPointerDown = useCallback((e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragState.current = { startX: e.clientX, startY: e.clientY, startPanX: pan.x, startPanY: pan.y };
}, [pan]);
const onPointerMove = useCallback((e: React.PointerEvent) => {
const drag = dragState.current;
if (!drag) return;
setPan({
x: drag.startPanX + e.clientX - drag.startX,
y: drag.startPanY + e.clientY - drag.startY,
});
}, []);
const onPointerUp = useCallback(() => {
dragState.current = null;
}, []);
const fitToWindow = useCallback(() => {
const svg = svgRef.current;
const container = containerRef.current;
if (!svg || !container) return;
const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width;
const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height;
const padPx = 48;
const containerW = container.clientWidth - padPx;
const containerH = container.clientHeight - padPx;
const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100;
let best = 0;
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
if (ZOOM_STEPS[i] <= fitPct) { best = i; break; }
}
setZoomIndex(best);
setPan({ x: 0, y: 0 });
}, []);
if (error) {
return <p className="text-sm text-coral">{error}</p>;
}
return (
<div className="relative">
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
<button
type="button"
title="Left to right"
onClick={() => setDirection("LR")}
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "LR" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
>
<ArrowRightIcon className="size-3.5" />
</button>
<button
type="button"
title="Top to bottom"
onClick={() => setDirection("TB")}
className={`flex size-7 items-center justify-center rounded transition-colors ${direction === "TB" ? "bg-overlay-strong text-fg-3" : "text-fg-muted hover:bg-overlay hover:text-fg-3"}`}
>
<ArrowDownIcon className="size-3.5" />
</button>
</div>
<div className="flex items-center rounded-md border border-line bg-panel/90 p-0.5">
<button
type="button"
title="Fit to window"
onClick={fitToWindow}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3"
>
<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" className="size-3.5" aria-hidden="true">
<rect x="1" y="1" width="12" height="12" rx="1.5" strokeWidth="1.5" strokeDasharray="3 2" />
</svg>
</button>
</div>
<div className="flex items-center gap-0.5 rounded-md border border-line bg-panel/90 p-0.5">
<button
type="button"
title="Zoom out"
onClick={() => setZoomIndex((i) => Math.max(0, i - 1))}
disabled={zoomIndex === 0}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
>
<MinusIcon className="size-4" />
</button>
<button
type="button"
title="Zoom in"
onClick={() => setZoomIndex((i) => Math.min(ZOOM_STEPS.length - 1, i + 1))}
disabled={zoomIndex === ZOOM_STEPS.length - 1}
className="flex size-7 items-center justify-center rounded text-fg-muted transition-colors hover:bg-overlay hover:text-fg-3 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-fg-muted"
>
<PlusIcon className="size-4" />
</button>
</div>
</div>
<div
ref={containerRef}
className="overflow-hidden p-6"
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<div
ref={innerRef}
className="flex items-center justify-center"
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
>
<p className="text-sm text-fg-muted">Loading diagram...</p>
</div>
</div>
</div>
);
}
const STAGE_EVENTS = new Set([
"stage.started", "stage.completed", "stage.failed",
"run.completed", "run.failed",
]);
export default function RunOverview({ loaderData }: any) {
const { id } = useParams();
const { stages, graphDot } = loaderData;
const { stages, graphSvg } = loaderData;
const revalidator = useRevalidator();
// Track when we first observed each running stage (for ticking timer)
const runningStartRef = useRef<Map<string, number>>(new Map());
const [, setTick] = useState(0);
// Subscribe to SSE for live stage updates
useEffect(() => {
const source = new EventSource("/api/v1/attach");
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
source.onmessage = (msg) => {
try {
const payload = JSON.parse(msg.data);
if (payload.run_id === id && STAGE_EVENTS.has(payload.event)) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => revalidator.revalidate(), 300);
}
} catch {
// ignore malformed events
}
};
return () => {
clearTimeout(debounceTimer);
source.close();
};
}, [id]);
// Track start times for running stages
useEffect(() => {
const running = new Set<string>(
stages.filter((s: Stage) => s.status === "running").map((s: Stage) => s.id),
);
for (const stageId of running) {
if (!runningStartRef.current.has(stageId)) {
runningStartRef.current.set(stageId, Date.now());
}
}
for (const stageId of runningStartRef.current.keys()) {
if (!running.has(stageId)) {
runningStartRef.current.delete(stageId);
}
}
}, [stages]);
// Tick every second while any stage is running
useEffect(() => {
if (!stages.some((s: Stage) => s.status === "running")) return;
const interval = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(interval);
}, [stages]);
function stageDuration(stage: Stage): string {
if (stage.status === "running") {
const start = runningStartRef.current.get(stage.id);
if (start) return formatDurationSecs(Math.floor((Date.now() - start) / 1000));
return "0s";
}
return stage.duration;
}
return (
<div className="flex gap-6">
<nav className="w-56 shrink-0 space-y-6">
<div>
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-fg-muted">Stages</h3>
<ul className="mt-2 space-y-0.5">
{stages.map((stage) => {
const config = statusConfig[stage.status];
const Icon = config.icon;
return (
<li key={stage.id}>
<Link
to={`/runs/${id}/stages/${stage.id}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-fg-3 transition-colors hover:bg-overlay hover:text-fg"
>
<Icon className={`size-4 shrink-0 ${config.color} ${stage.status === "running" ? "animate-spin" : ""}`} />
<span className="flex-1 truncate">{stage.name}</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">{stage.duration}</span>
</Link>
</li>
);
})}
</ul>
</div>
{stages.length > 0 && (
<div>
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-fg-muted">Stages</h3>
<ul className="mt-2 space-y-0.5">
{stages.map((stage: Stage) => {
const config = statusConfig[stage.status];
const Icon = config.icon;
return (
<li key={stage.id}>
<Link
to={`/runs/${id}/stages/${stage.id}`}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-fg-3 transition-colors hover:bg-overlay hover:text-fg"
>
<Icon className={`size-4 shrink-0 ${config.color} ${stage.status === "running" ? "animate-spin" : ""}`} />
<span className="flex-1 truncate">{stage.name}</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">{stageDuration(stage)}</span>
</Link>
</li>
);
})}
</ul>
</div>
)}
<div>
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-fg-muted">Workflow</h3>
@ -330,10 +165,11 @@ export default function RunOverview({ loaderData }: any) {
</nav>
<div className="min-w-0 flex-1">
{graphDot ? (
<div className="rounded-md border border-line bg-panel-alt/40 overflow-hidden">
<DotDiagram dot={graphDot} />
</div>
{graphSvg ? (
<div
className="graph-svg rounded-md border border-line bg-panel-alt/40 overflow-auto p-6 [&_svg]:mx-auto [&_svg]:block"
dangerouslySetInnerHTML={{ __html: graphSvg }}
/>
) : (
<p className="text-sm text-fg-muted">No workflow graph available.</p>
)}

View file

@ -3,6 +3,7 @@ import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@h
import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline";
import { CollapsibleFile } from "../components/collapsible-file";
import { apiJson } from "../api";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import type { RunSettings } from "../lib/workflow-api";
@ -31,7 +32,7 @@ export async function loader({ request, params }: any) {
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
apiJson<RunSettings>(`/runs/${params.id}/settings`, { request }),
]);
const stages: Stage[] = apiStages.map((s) => ({
const stages: Stage[] = apiStages.filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,
name: s.name,
status: s.status as StageStatus,

View file

@ -1,13 +1,12 @@
import { useState } from "react";
import { Link, useParams } from "react-router";
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid";
import { DocumentTextIcon, MapIcon, CommandLineIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline";
import { ToolRow, ToolBlock } from "../components/tool-use";
import { ToolBlock } from "../components/tool-use";
import type { ToolUse } from "../components/tool-use";
import { apiJson } from "../api";
import { apiJson, apiJsonOrNull } from "../api";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList } from "@qltysh/fabro-api-client";
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client";
export const handle = { wide: true };
@ -20,21 +19,113 @@ interface Stage {
duration: string;
}
type TurnType =
| { kind: "system"; content: string }
| { kind: "assistant"; content: string }
| { kind: "tool"; tools: ToolUse[] };
interface RawEvent {
node_id?: string;
event: string;
properties?: Record<string, unknown>;
text?: string;
tool_name?: string;
tool_call_id?: string;
arguments?: unknown;
output?: unknown;
is_error?: boolean;
}
function turnsFromEvents(events: RawEvent[], stageId: string): TurnType[] {
const stageEvents = events.filter((e) => e.node_id === stageId);
const turns: TurnType[] = [];
// Collect tool pairs: started → completed
const pendingTools = new Map<string, { toolName: string; input: string }>();
for (const e of stageEvents) {
switch (e.event) {
case "stage.prompt":
turns.push({ kind: "system", content: e.properties?.text as string ?? e.text ?? "" });
break;
case "agent.message":
turns.push({ kind: "assistant", content: e.properties?.text as string ?? e.text ?? "" });
break;
case "agent.tool.started": {
const callId = e.properties?.tool_call_id as string ?? e.tool_call_id ?? "";
pendingTools.set(callId, {
toolName: e.properties?.tool_name as string ?? e.tool_name ?? "",
input: typeof (e.properties?.arguments ?? e.arguments) === "string"
? (e.properties?.arguments ?? e.arguments) as string
: JSON.stringify(e.properties?.arguments ?? e.arguments ?? ""),
});
break;
}
case "agent.tool.completed": {
const callId = e.properties?.tool_call_id as string ?? e.tool_call_id ?? "";
const started = pendingTools.get(callId);
const output = e.properties?.output ?? e.output ?? "";
const result = typeof output === "string" ? output : JSON.stringify(output);
const tool: ToolUse = {
id: callId,
toolName: started?.toolName ?? e.properties?.tool_name as string ?? e.tool_name ?? "",
input: started?.input ?? "",
result,
isError: (e.properties?.is_error ?? e.is_error) === true,
};
pendingTools.delete(callId);
turns.push({ kind: "tool", tools: [tool] });
break;
}
}
}
return turns;
}
export async function loader({ request, params }: any) {
const { data: apiStages } = await apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request });
const stages: Stage[] = apiStages.map((s) => ({
const stages: Stage[] = apiStages.filter((s) => isVisibleStage(s.id)).map((s) => ({
id: s.id,
name: s.name,
status: s.status as StageStatus,
duration: s.duration_secs != null ? formatDurationSecs(s.duration_secs) : "--",
}));
// Fetch turns for the selected stage (first stage if none specified)
const selectedStageId = params.stageId ?? stages[0]?.id;
let turns: ApiStageTurn[] = [];
// Try demo turns endpoint first, fall back to events.
let turns: TurnType[] = [];
if (selectedStageId) {
const { data } = await apiJson<PaginatedStageTurnList>(`/runs/${params.id}/stages/${selectedStageId}/turns`, { request });
turns = data;
const turnsResult = await apiJsonOrNull<PaginatedStageTurnList>(
`/runs/${params.id}/stages/${selectedStageId}/turns`,
{ request },
);
if (turnsResult?.data?.length) {
turns = turnsResult.data.map((t: ApiStageTurn): TurnType => {
if (t.kind === "tool" && t.tools) {
return {
kind: "tool",
tools: t.tools.map((tu) => ({
id: tu.id,
toolName: tu.tool_name,
input: tu.input,
result: tu.result,
isError: tu.is_error,
durationMs: tu.duration_ms,
})),
};
}
return { kind: t.kind as "system" | "assistant", content: t.content ?? "" };
});
} else {
// Fetch events and build turns from them.
const eventsResult = await apiJsonOrNull<PaginatedEventList>(
`/runs/${params.id}/events?limit=1000`,
{ request },
);
if (eventsResult?.data) {
turns = turnsFromEvents(eventsResult.data as unknown as RawEvent[], selectedStageId);
}
}
}
return { stages, turns };
@ -48,13 +139,6 @@ const statusConfig: Record<StageStatus, { icon: typeof CheckCircleIcon; color: s
cancelled: { icon: XCircleIcon, color: "text-fg-muted" },
};
type TurnType =
| { kind: "system"; content: string }
| { kind: "assistant"; content: string }
| { kind: "tool"; tools: ToolUse[] };
// selectedStage is resolved from the URL param in RunStages below
function SystemBlock({ content }: { content: string }) {
return (
<div className="rounded-md border border-amber/10 bg-amber/5 overflow-hidden">
@ -85,27 +169,10 @@ function AssistantBlock({ content }: { content: string }) {
export default function RunStages({ loaderData }: any) {
const { id, stageId } = useParams();
const { stages, turns: apiTurns } = loaderData;
const { stages, turns } = loaderData;
const mappedTurns: TurnType[] = apiTurns.map((t) => {
if (t.kind === "tool" && t.tools) {
return {
kind: "tool" as const,
tools: t.tools.map((tu) => ({
id: tu.id,
toolName: tu.tool_name,
input: tu.input,
result: tu.result,
isError: tu.is_error,
durationMs: tu.duration_ms,
})),
};
}
return { kind: t.kind as "system" | "assistant", content: t.content ?? "" };
});
const selectedStage = stages.find((s) => s.id === stageId) ?? stages[0];
const selectedConfig = statusConfig[selectedStage.status];
const selectedStage = stages.find((s: Stage) => s.id === stageId) ?? stages[0];
const selectedConfig = selectedStage ? statusConfig[selectedStage.status] : statusConfig.pending;
const SelectedIcon = selectedConfig.icon;
return (
@ -170,7 +237,7 @@ export default function RunStages({ loaderData }: any) {
<span className="font-mono text-xs text-fg-muted">{selectedStage.duration}</span>
</div>
{mappedTurns.map((turn, i) => {
{turns.map((turn: TurnType, i: number) => {
switch (turn.kind) {
case "system":
return <SystemBlock key={`turn-${i}`} content={turn.content} />;

View file

@ -32,10 +32,11 @@ pub use fabro_api::types::{
PruneRunEntry, PruneRunsRequest, PruneRunsResponse, QuestionType as ApiQuestionType,
RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RunArtifactEntry,
RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals,
RunControlAction as ApiRunControlAction, RunError, RunManifest, RunStatus, RunStatusResponse,
SandboxFileEntry, SandboxFileListResponse, SecretType as ApiSecretType, ServerSettings,
SshAccessRequest, SshAccessResponse, StartRunRequest, StatusReason as ApiStatusReason,
SubmitAnswerRequest, SystemFeatures, SystemInfoResponse, SystemRunCounts, WriteBlobResponse,
RunControlAction as ApiRunControlAction, RunError, RunManifest, RunStage, RunStatus,
RunStatusResponse, SandboxFileEntry, SandboxFileListResponse, SecretType as ApiSecretType,
ServerSettings, SshAccessRequest, SshAccessResponse, StageStatus as ApiStageStatus,
StartRunRequest, StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemFeatures,
SystemInfoResponse, SystemRunCounts, WriteBlobResponse,
};
use fabro_auth::parse_credential_secret;
use fabro_config::{Storage, resolve_server_from_file};
@ -1080,7 +1081,7 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/pause", post(pause_run))
.route("/runs/{id}/unpause", post(unpause_run))
.route("/runs/{id}/graph", get(get_graph))
.route("/runs/{id}/stages", get(not_implemented))
.route("/runs/{id}/stages", get(list_run_stages))
.route("/runs/{id}/artifacts", get(list_run_artifacts))
.route("/runs/{id}/stages/{stageId}/turns", get(not_implemented))
.route(
@ -2059,6 +2060,104 @@ async fn get_aggregate_billing(
(StatusCode::OK, Json(response)).into_response()
}
async fn list_run_stages(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(_pagination): Query<PaginationParams>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
// Try live run first.
let (checkpoint, run_is_active) = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => {
let active = !matches!(
managed_run.status,
RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
);
(managed_run.checkpoint.clone(), active)
}
None => (None, false),
}
};
// Fall back to stored run.
let (checkpoint, run_is_active) = if checkpoint.is_some() {
(checkpoint, run_is_active)
} else {
match state.store.open_run_reader(&id).await {
Ok(run_store) => match run_store.state().await {
Ok(run_state) => {
let active = run_state
.status
.as_ref()
.map_or(false, |s| !s.status.is_terminal());
(run_state.checkpoint, active)
}
Err(_) => (None, false),
},
Err(_) => return ApiError::not_found("Run not found.").into_response(),
}
};
let Some(checkpoint) = checkpoint else {
return (
StatusCode::OK,
Json(ListResponse::new(Vec::<RunStage>::new())),
)
.into_response();
};
// Get durations from events.
let stage_durations = match state.store.open_run_reader(&id).await {
Ok(run_store) => match run_store.list_events().await {
Ok(events) => fabro_workflow::extract_stage_durations_from_events(&events),
Err(_) => HashMap::new(),
},
Err(_) => HashMap::new(),
};
let mut stages = Vec::new();
for node_id in &checkpoint.completed_nodes {
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
let status = match checkpoint.node_outcomes.get(node_id) {
Some(outcome) => match outcome.status {
fabro_types::outcome::StageStatus::Success
| fabro_types::outcome::StageStatus::PartialSuccess => ApiStageStatus::Completed,
fabro_types::outcome::StageStatus::Fail => ApiStageStatus::Failed,
fabro_types::outcome::StageStatus::Skipped => ApiStageStatus::Cancelled,
fabro_types::outcome::StageStatus::Retry => ApiStageStatus::Pending,
},
None => ApiStageStatus::Completed,
};
stages.push(RunStage {
id: node_id.clone(),
name: node_id.clone(),
status,
duration_secs: Some(duration_ms as f64 / 1000.0),
dot_id: Some(node_id.clone()),
});
}
// Add current node as running if the run is still active.
if run_is_active && !checkpoint.completed_nodes.contains(&checkpoint.current_node) {
stages.push(RunStage {
id: checkpoint.current_node.clone(),
name: checkpoint.current_node.clone(),
status: ApiStageStatus::Running,
duration_secs: None,
dot_id: Some(checkpoint.current_node.clone()),
});
}
(StatusCode::OK, Json(ListResponse::new(stages))).into_response()
}
async fn get_run_billing(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
@ -6411,13 +6510,12 @@ async fn get_graph(
};
let live_dot_source = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => managed_run.dot_source.clone(),
None => return ApiError::not_found("Run not found.").into_response(),
}
runs.get(&id).map(|managed_run| managed_run.dot_source.clone())
};
if !live_dot_source.is_empty() {
return render_graph_bytes(&live_dot_source).await;
if let Some(dot) = &live_dot_source {
if !dot.is_empty() {
return render_graph_bytes(dot).await;
}
}
match state.store.open_run_reader(&id).await {