From c812274db8f88313421e99118e0cf476ea114f17 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Mon, 27 Jul 2026 17:08:08 -0400 Subject: [PATCH] Show live status for parallel branches --- .../parallel-children.test.tsx | 247 ++++++++++++++---- .../stage-renderers/parallel-children.tsx | 115 +++++--- apps/fabro-web/app/lib/stage-sidebar.test.ts | 24 ++ apps/fabro-web/app/lib/stage-sidebar.ts | 6 + docs/public/api-reference/fabro-api.yaml | 22 ++ lib/apps/fabro-server/src/demo/mod.rs | 2 + .../src/server/handler/billing.rs | 7 + lib/apps/fabro-server/src/server/tests.rs | 79 +++++- lib/components/fabro-store/src/run_state.rs | 59 ++++- .../tests/stage_projection_round_trip.rs | 11 +- .../fabro-types/src/run_projection.rs | 10 +- .../fabro-api-client/src/models/run-stage.ts | 8 + .../src/models/stage-projection.ts | 4 + 13 files changed, 497 insertions(+), 97 deletions(-) diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx index f1313b780..beee51a41 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { StageState } from "@qltysh/fabro-api-client"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; @@ -13,35 +14,98 @@ beforeEach(() => { }); afterEach(() => teardown()); -const parallelStage: Stage = { +function makeStage(overrides: Partial = {}): Stage { + return { + id: "stage@1", + name: "stage", + handler: "agent", + status: StageState.RUNNING, + duration: "--", + nodeId: "stage", + visit: 1, + graphVisit: 1, + resumedFromStageId: null, + parallelGroupId: null, + parallelBranchIndex: null, + startedAt: "2026-04-09T12:00:00Z", + providerUsed: null, + ...overrides, + }; +} + +const parallelStage = makeStage({ id: "fork@1", name: "fork", handler: "parallel", - status: "succeeded", + status: StageState.RUNNING, duration: "12s", nodeId: "fork", - visit: 1, - startedAt: "2026-04-09T12:00:00Z", - providerUsed: null, -}; +}); -function event(partial: Partial): EventEnvelope { - return makeEventEnvelope(partial.seq ?? 1, { event: "parallel.completed", ...partial }); +function branchStage( + name: string, + index: number, + status: StageState, + groupId = "fork@1", + visit = 1, +): Stage { + return makeStage({ + id: `${name}@${visit}`, + name, + nodeId: name, + visit, + status, + parallelGroupId: groupId, + parallelBranchIndex: index, + }); } -function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer { +function event(partial: Partial): EventEnvelope { + return makeEventEnvelope(partial.seq ?? 1, { + event: "parallel.completed", + stage_id: "fork@1", + ...partial, + }); +} + +function startedEvent(branchCount: number): EventEnvelope { + return event({ + event: "parallel.started", + properties: { branch_count: branchCount }, + }); +} + +function completedEvent( + results: Array<{ id: string; status: string }>, + successCount: number, + failureCount: number, +): EventEnvelope { + return event({ + seq: 2, + event: "parallel.completed", + properties: { + duration_ms: 12000, + success_count: successCount, + failure_count: failureCount, + results: results.map((result) => ({ ...result, context_updates: {} })), + }, + }); +} + +function renderParallel( + events: EventEnvelope[], + allStages: Stage[], + stage = parallelStage, +): TestRenderer.ReactTestRenderer { let renderer!: TestRenderer.ReactTestRenderer; act(() => { renderer = TestRenderer.create( , ); @@ -49,37 +113,132 @@ function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer return renderer; } -describe("ParallelChildren", () => { - test("renders branch status and stage links without checkout metadata", () => { - const renderer = renderParallel([ - event({ - event: "parallel.started", - properties: { branch_count: 2 }, - }), - event({ - seq: 2, - event: "parallel.completed", - properties: { - duration_ms: 12000, - success_count: 1, - failure_count: 1, - results: [ - { id: "branch-a", status: "succeeded", context_updates: {} }, - { id: "branch-b", status: "failed", context_updates: {} }, - ], - }, - }), - ]); +function textContent(node: TestRenderer.ReactTestInstance): string { + return node.children + .map((child) => typeof child === "string" ? child : textContent(child)) + .join(""); +} - const rendered = JSON.stringify(renderer.toJSON()); - expect(rendered).toContain("branch-a"); - expect(rendered).toContain("Succeeded"); - expect(rendered).toContain("branch-b"); - expect(rendered).toContain("Failed"); - const hrefs = renderer.root.findAllByType("a").map((link) => link.props.href); - expect(hrefs).toEqual([ - "/runs/run-1/stages/branch-a@1", - "/runs/run-1/stages/branch-b@1", +function branchRowText(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root.findAllByType("li").map(textContent); +} + +function hrefs(renderer: TestRenderer.ReactTestRenderer): string[] { + return renderer.root.findAllByType("a").map((link) => link.props.href); +} + +function statValue(renderer: TestRenderer.ReactTestRenderer, label: string): string { + const stat = renderer.root + .findAllByProps({ className: "flex flex-col gap-0.5" }) + .find((item) => textContent(item).startsWith(label)); + if (!stat) throw new Error(`stat ${label} not found`); + return textContent(stat.findAllByType("span")[1]); +} + +describe("ParallelChildren", () => { + test("renders live branch names, statuses, counts, and stage links", () => { + const renderer = renderParallel( + [startedEvent(2)], + [ + branchStage("review_glm", 0, StageState.SUCCEEDED), + branchStage("review_opus", 1, StageState.RUNNING), + ], + ); + + const rows = branchRowText(renderer); + expect(rows).toHaveLength(2); + expect(rows[0]).toContain("Succeeded"); + expect(rows[0]).toContain("review_glm"); + expect(rows[1]).toContain("Running"); + expect(rows[1]).toContain("review_opus"); + expect(hrefs(renderer)).toEqual([ + "/runs/run-1/stages/review_glm@1", + "/runs/run-1/stages/review_opus@1", ]); + expect(statValue(renderer, "Succeeded")).toBe("1"); + expect(statValue(renderer, "Failed")).toBe("0"); + }); + + test("keeps looped fork links scoped to the selected fork visit", () => { + const renderer = renderParallel( + [startedEvent(1)], + [ + branchStage("review_glm", 0, StageState.SUCCEEDED, "fork@1", 1), + branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2), + ], + ); + + expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@1"]); + }); + + test("keeps duplicate branch targets in index order and only links recorded stages", () => { + const renderer = renderParallel( + [ + startedEvent(2), + completedEvent( + [ + { id: "review", status: "failed" }, + { id: "review", status: "failed" }, + ], + 1, + 1, + ), + ], + [branchStage("review", 0, StageState.SUCCEEDED)], + ); + + const rows = branchRowText(renderer); + expect(rows).toHaveLength(2); + expect(rows[0]).toContain("Succeeded"); + expect(rows[1]).toContain("Failed"); + expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review@1"]); + }); + + test("renders a completed result without a matching stage as an unlinked row", () => { + const renderer = renderParallel( + [ + startedEvent(1), + completedEvent( + [{ id: "legacy_branch", status: "succeeded" }], + 1, + 0, + ), + ], + [], + ); + + expect(branchRowText(renderer)).toEqual(["Succeededlegacy_branch"]); + expect(hrefs(renderer)).toEqual([]); + }); + + test("counts partial and skipped branches as neither succeeded nor failed", () => { + const allStages = [ + branchStage("partial", 0, StageState.PARTIALLY_SUCCEEDED), + branchStage("skipped", 1, StageState.SKIPPED), + ]; + const running = renderParallel([startedEvent(2)], allStages); + const completed = renderParallel( + [ + startedEvent(2), + completedEvent( + [ + { id: "partial", status: "partially_succeeded" }, + { id: "skipped", status: "skipped" }, + ], + 0, + 0, + ), + ], + allStages, + ); + + expect([ + statValue(running, "Succeeded"), + statValue(running, "Failed"), + ]).toEqual(["0", "0"]); + expect([ + statValue(completed, "Succeeded"), + statValue(completed, "Failed"), + ]).toEqual(["0", "0"]); }); }); diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx index 30cb6169c..d9b142046 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx @@ -10,10 +10,12 @@ import { formatDurationMs } from "../../lib/format"; import { StageMetaBar } from "./meta-bar"; import { parseParallelOverview } from "./helpers"; -/** Branch row view state: completed outcomes plus a synthesized in-flight row. */ +/** Branch row view state sourced from a live branch stage or completed result. */ interface BranchRow { + branchIndex: number; id: string; status: StageState; + stageHref: string | null; } function StatItem({ @@ -38,25 +40,23 @@ function StatItem({ } function ChildRow({ - result, - stageHref, + row, }: { - result: BranchRow; - stageHref: string | null; + row: BranchRow; }) { - const tone = stageStatusTone(result.status); + const tone = stageStatusTone(row.status); const inner = ( <> - {stageStatusLabel(result.status)} + {stageStatusLabel(row.status)} - {result.id} + {row.id} - {stageHref && ( + {row.stageHref && (