diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts index 8f036ac3e..4840c03fb 100644 --- a/apps/fabro-web/app/lib/query-keys.test.ts +++ b/apps/fabro-web/app/lib/query-keys.test.ts @@ -19,6 +19,7 @@ describe("queryKeys", () => { ]); expect(queryKeysForRunEvent("run-1", "stage.completed", "stage-1")).toEqual([ queryKeys.runs.stages("run-1"), + queryKeys.runs.billing("run-1"), queryKeys.runs.events("run-1", 1000), queryKeys.runs.graph("run-1", "LR"), queryKeys.runs.graph("run-1", "TB"), @@ -26,4 +27,4 @@ describe("queryKeys", () => { queryKeys.runs.stageTurns("run-1", "stage-1"), ]); }); -}); +}); \ No newline at end of file diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index e9faa93fc..c16eb20f9 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -49,6 +49,18 @@ describe("queryKeysForRunEvent", () => { queryKeys.runs.graph("run-1", "TB"), ]); }); + + test("stage.retrying invalidates stages, billing, events, and stage turns", () => { + expect(queryKeysForRunEvent("run-1", "stage.retrying", "stage-7")).toEqual([ + queryKeys.runs.stages("run-1"), + queryKeys.runs.billing("run-1"), + queryKeys.runs.events("run-1", 1000), + queryKeys.runs.graph("run-1", "LR"), + queryKeys.runs.graph("run-1", "TB"), + queryKeys.runs.detail("run-1"), + queryKeys.runs.stageTurns("run-1", "stage-7"), + ]); + }); }); describe("subscribeToRunEvents", () => { @@ -239,4 +251,4 @@ async function waitFor(condition: () => boolean, timeoutMs = 200) { await new Promise((resolve) => setTimeout(resolve, 2)); } throw new Error("condition did not become true before timeout"); -} +} \ No newline at end of file diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 1413d38d4..caea81225 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -42,7 +42,12 @@ const RUN_SUMMARY_EVENTS = new Set([ "run.archived", "run.unarchived", ]); -const STAGE_EVENTS = new Set(["stage.started", "stage.completed", "stage.failed"]); +const STAGE_EVENTS = new Set([ + "stage.started", + "stage.completed", + "stage.failed", + "stage.retrying", +]); const COMMAND_EVENTS = new Set(["command.started", "command.completed"]); const INTERVIEW_EVENTS = new Set([ "interview.started", @@ -85,6 +90,7 @@ export function queryKeysForRunEvent( if (STAGE_EVENTS.has(event)) { const keys = [ queryKeys.runs.stages(runId), + queryKeys.runs.billing(runId), queryKeys.runs.events(runId, 1000), queryKeys.runs.graph(runId, "LR"), queryKeys.runs.graph(runId, "TB"), @@ -178,4 +184,4 @@ export function useRunEvents(runId: string | undefined) { if (!runId) return; return subscribeToRunEvents(runId, mutate as MutateFn); }, [mutate, runId]); -} +} \ No newline at end of file diff --git a/apps/fabro-web/app/routes/run-billing.test.tsx b/apps/fabro-web/app/routes/run-billing.test.tsx index ed9e3040c..4953b7499 100644 --- a/apps/fabro-web/app/routes/run-billing.test.tsx +++ b/apps/fabro-web/app/routes/run-billing.test.tsx @@ -75,12 +75,14 @@ describe("RunBilling", () => { model: null, billing: zeroBilling(), runtime_secs: 0, + state: "succeeded", }, { stage: { id: "command", name: "command" }, model: null, billing: zeroBilling(), runtime_secs: 61, + state: "succeeded", }, ], totals: { @@ -108,6 +110,7 @@ describe("RunBilling", () => { model: null, billing: zeroBilling(), runtime_secs: 0, + state: "succeeded", }, { stage: { id: "agent", name: "agent" }, @@ -119,6 +122,7 @@ describe("RunBilling", () => { total_usd_micros: 240000, }), runtime_secs: 42, + state: "succeeded", }, ], totals: { @@ -162,4 +166,54 @@ describe("RunBilling", () => { expect(text).toContain("No completed stages yet"); expect(text).toContain("Stages will appear once the run produces completed nodes."); }); -}); + + test("renders an in-flight row with live runtime and includes its elapsed time in the footer", () => { + const originalNow = Date.now; + // Pin "now" to 30s after the in-flight row started. + const startedAt = "2026-04-29T12:00:00.000Z"; + const fakeNow = new Date("2026-04-29T12:00:30.000Z").getTime(); + Date.now = () => fakeNow; + + try { + const renderer = renderBilling( + billing({ + stages: [ + { + stage: { id: "in-flight", name: "in-flight" }, + model: null, + // Server reports 0 runtime / no billing; the row is still being executed. + billing: zeroBilling(), + runtime_secs: 0, + started_at: startedAt, + state: "running", + }, + ], + // Server total is 0 because the in-flight row hasn't been finalized. + totals: { + runtime_secs: 0, + ...zeroBilling(), + }, + }), + ); + + const text = textFromNode(renderer.toJSON()); + // Empty-state must NOT show — the table should appear as soon as the + // first stage starts. + expect(text).not.toContain("No completed stages yet"); + expect(text).toContain("in-flight"); + + // Both the row's runtime cell and the footer total should reflect + // ~30s elapsed since started_at. + expect(text).toContain("30s"); + + const footers = renderer.root.findAll((node) => node.type === "tfoot"); + const footerCells = footers[0].findAll((node) => node.type === "td"); + // The Run time column in the footer is index 3 (Total / [empty Model] / + // Tokens / Run time / Billing). + const footerRuntime = textFromInstance(footerCells[3]); + expect(footerRuntime).toContain("30s"); + } finally { + Date.now = originalNow; + } + }); +}); \ No newline at end of file diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 6bb6ab763..74c712fd5 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; + import { EmptyState } from "../components/state"; import { formatDurationSecs } from "../lib/format"; import { useRunBilling } from "../lib/queries"; @@ -14,33 +16,87 @@ function formatUsdMicros(usdMicros?: number | null) { return usdMicros == null ? EMPTY_VALUE : `$${(usdMicros / 1_000_000).toFixed(2)}`; } -function mapBilling(billing: RunBilling | undefined) { +function isInFlightState(state: string | null | undefined): boolean { + return state === "running" || state === "retrying" || state === "pending"; +} + +interface MappedStageRow { + stage: string; + model: string | null; + inputTokens: number | null; + outputTokens: number | null; + runtimeSecs: number; + totalUsdMicros: number | null | undefined; + inFlight: boolean; + startedAt: string | null | undefined; +} + +interface MappedBilling { + rows: MappedStageRow[]; + totalRuntimeSecs: number; + totalUsdMicros: number | null | undefined; + totalInput: number | null; + totalOutput: number | null; + modelBreakdown: { + model: string; + stages: number; + inputTokens: number; + outputTokens: number; + totalUsdMicros: number | null | undefined; + }[]; + modelStageCount: number; + hasInFlight: boolean; +} + +function mapBilling(billing: RunBilling | undefined, now: number): MappedBilling { if (!billing) { return { - stages: [], - totalRuntime: formatDurationSecs(0), - totalUsdMicros: undefined, - totalInput: null, - totalOutput: null, - modelBreakdown: [], - modelStageCount: 0, + rows: [], + totalRuntimeSecs: 0, + totalUsdMicros: undefined, + totalInput: null, + totalOutput: null, + modelBreakdown: [], + modelStageCount: 0, + hasInFlight: false, }; } - const stages = billing.stages.map((stage) => { + let hasInFlight = false; + const rows: MappedStageRow[] = billing.stages.map((stage) => { const hasModel = stage.model != null; + const inFlight = isInFlightState(stage.state); + if (inFlight) hasInFlight = true; + + let runtimeSecs = stage.runtime_secs; + if (inFlight && stage.started_at) { + const startedMs = new Date(stage.started_at).getTime(); + if (Number.isFinite(startedMs)) { + runtimeSecs = Math.max(0, (now - startedMs) / 1000); + } + } + return { - stage: stage.stage.name, - model: stage.model?.id ?? null, - inputTokens: hasModel ? stage.billing.input_tokens : null, - outputTokens: hasModel + stage: stage.stage.name, + model: stage.model?.id ?? null, + inputTokens: hasModel ? stage.billing.input_tokens : null, + outputTokens: hasModel ? stage.billing.output_tokens + stage.billing.reasoning_tokens : null, - runtime: formatDurationSecs(stage.runtime_secs), + runtimeSecs, totalUsdMicros: stage.billing.total_usd_micros, + inFlight, + startedAt: stage.started_at, }; }); - const totalRuntime = formatDurationSecs(billing.totals.runtime_secs); + + // While ticking, derive total runtime from the displayed row runtimes so the + // footer updates in lock-step with the in-flight row(s). Otherwise trust the + // server's authoritative total. + const totalRuntimeSecs = hasInFlight + ? rows.reduce((sum, row) => sum + row.runtimeSecs, 0) + : billing.totals.runtime_secs; + const hasLlmStages = billing.by_model.length > 0; const totalInput = hasLlmStages ? billing.totals.input_tokens : null; const totalOutput = hasLlmStages @@ -49,38 +105,53 @@ function mapBilling(billing: RunBilling | undefined) { const totalUsdMicros = billing.totals.total_usd_micros; const modelBreakdown = billing.by_model .map((entry) => ({ - model: entry.model.id, - stages: entry.stages, - inputTokens: entry.billing.input_tokens, - outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens, + model: entry.model.id, + stages: entry.stages, + inputTokens: entry.billing.input_tokens, + outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens, totalUsdMicros: entry.billing.total_usd_micros, })) .sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1)); const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0); + return { - stages, - totalRuntime, + rows, + totalRuntimeSecs, totalUsdMicros, totalInput, totalOutput, modelBreakdown, modelStageCount, + hasInFlight, }; } export default function RunBilling({ params }: { params: { id: string } }) { const billingQuery = useRunBilling(params.id); + + // Tick state for live runtime computation. Re-rendered every second only + // while at least one stage is in-flight. + const [now, setNow] = useState(() => Date.now()); + const billing = billingQuery.data; + const hasInFlight = billing?.stages.some((stage) => isInFlightState(stage.state)) ?? false; + + useEffect(() => { + if (!hasInFlight) return; + const interval = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(interval); + }, [hasInFlight]); + const { - stages, - totalRuntime, + rows, + totalRuntimeSecs, totalUsdMicros, totalInput, totalOutput, modelBreakdown, modelStageCount, - } = mapBilling(billingQuery.data); + } = mapBilling(billing, now); - if (!stages.length) { + if (!rows.length) { return (