diff --git a/apps/fabro-web/app/components/stage-popover.test.tsx b/apps/fabro-web/app/components/stage-popover.test.tsx index 4a8bbb12c..5d514e734 100644 --- a/apps/fabro-web/app/components/stage-popover.test.tsx +++ b/apps/fabro-web/app/components/stage-popover.test.tsx @@ -9,6 +9,7 @@ import { StagePopover } from "./stage-popover"; import { deriveStageSummary } from "./stage-popover-summary"; import type { Stage } from "../lib/stage-sidebar"; import { generatedAxios } from "../lib/api-client"; +import { makeStage as baseMakeStage } from "../lib/test-utils"; function makeEvent(overrides: Partial): EventEnvelope { return { @@ -22,20 +23,13 @@ function makeEvent(overrides: Partial): EventEnvelope { } function makeStage(overrides: Partial = {}): Stage { - return { - id: "implement@1", - name: "implement", - handler: "agent", - nodeId: "implement", - visit: 1, - graphVisit: null, - resumedFromStageId: null, - status: "succeeded", - duration: "1m 30s", - startedAt: "2026-05-24T11:58:30Z", - providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" }, + return baseMakeStage({ + status: "succeeded", + duration: "1m 30s", + startedAt: "2026-05-24T11:58:30Z", + providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" }, ...overrides, - }; + }); } describe("deriveStageSummary", () => { 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 beee51a41..1d28e97d5 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 @@ -4,7 +4,12 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; -import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; +import { + makeEventEnvelope, + makeStage as baseMakeStage, + setupReactTestEnv, + textContent, +} from "../../lib/test-utils"; import type { Stage } from "../stage-sidebar"; import { ParallelChildren } from "./parallel-children"; @@ -15,22 +20,14 @@ beforeEach(() => { afterEach(() => teardown()); function makeStage(overrides: Partial = {}): Stage { - return { + return baseMakeStage({ 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({ @@ -113,12 +110,6 @@ function renderParallel( return renderer; } -function textContent(node: TestRenderer.ReactTestInstance): string { - return node.children - .map((child) => typeof child === "string" ? child : textContent(child)) - .join(""); -} - function branchRowText(renderer: TestRenderer.ReactTestRenderer): string[] { return renderer.root.findAllByType("li").map(textContent); } @@ -128,11 +119,7 @@ function hrefs(renderer: TestRenderer.ReactTestRenderer): string[] { } 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]); + return textContent(renderer.root.findByProps({ "data-stat": label })); } describe("ParallelChildren", () => { @@ -171,6 +158,17 @@ describe("ParallelChildren", () => { expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@1"]); }); + test("labels a re-entered branch with its visit, matching the sidebar", () => { + const renderer = renderParallel( + [startedEvent(1)], + [branchStage("review_glm", 0, StageState.RUNNING, "fork@2", 2)], + makeStage({ id: "fork@2", name: "fork", handler: "parallel", visit: 2 }), + ); + + expect(branchRowText(renderer)).toEqual(["Runningreview_glm@2"]); + expect(hrefs(renderer)).toEqual(["/runs/run-1/stages/review_glm@2"]); + }); + test("keeps duplicate branch targets in index order and only links recorded stages", () => { const renderer = renderParallel( [ 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 d9b142046..d35fba140 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx @@ -5,7 +5,7 @@ import { StageState } from "@qltysh/fabro-api-client"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import type { Stage } from "../stage-sidebar"; -import { stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar"; +import { formatStageLabel, stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar"; import { formatDurationMs } from "../../lib/format"; import { StageMetaBar } from "./meta-bar"; import { parseParallelOverview } from "./helpers"; @@ -34,7 +34,9 @@ function StatItem({ {label} - {value} + + {value} + ); } @@ -108,20 +110,19 @@ export function ParallelChildren({ }, [allStages, stage.id]); const branchCount = overview.branchCount ?? stagesByBranchIndex.size; - const slots = Array.from({ length: branchCount }, (_, index) => ({ - index, - stage: stagesByBranchIndex.get(index) ?? null, - result: overview.results[index] ?? null, - })); - const rows = slots.map(({ index, stage: branchStage, result }) => { + const rows = Array.from({ length: branchCount }, (_, index): BranchRow => { + // A live branch stage is the freshest source; fall back to the completed + // event's result for runs whose branches predate parallel identity. + const branchStage = stagesByBranchIndex.get(index); if (branchStage) { return { branchIndex: index, - id: branchStage.name, + id: formatStageLabel(branchStage), status: branchStage.status, stageHref: `/runs/${runId}/stages/${branchStage.id}`, }; } + const result = overview.results[index]; if (result) { return { branchIndex: index, @@ -138,13 +139,12 @@ export function ParallelChildren({ }; }); - const liveBranchStages = Array.from(stagesByBranchIndex.values()); - const liveSuccessCount = liveBranchStages - .filter((branchStage) => branchStage.status === StageState.SUCCEEDED) - .length; - const liveFailureCount = liveBranchStages - .filter((branchStage) => branchStage.status === StageState.FAILED) - .length; + let liveSuccessCount = 0; + let liveFailureCount = 0; + for (const branchStage of stagesByBranchIndex.values()) { + if (branchStage.status === StageState.SUCCEEDED) liveSuccessCount += 1; + else if (branchStage.status === StageState.FAILED) liveFailureCount += 1; + } const successCount = overview.isComplete ? overview.successCount ?? 0 : liveSuccessCount; diff --git a/apps/fabro-web/app/components/stage-sidebar.test.tsx b/apps/fabro-web/app/components/stage-sidebar.test.tsx index 69cf0c097..ec17de9c1 100644 --- a/apps/fabro-web/app/components/stage-sidebar.test.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.test.tsx @@ -2,25 +2,9 @@ import { describe, expect, test } from "bun:test"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; +import { makeStage } from "../lib/test-utils"; import { StageSidebar, type Stage } from "./stage-sidebar"; -function makeStage(overrides: Partial = {}): Stage { - return { - id: "implement@1", - name: "implement", - handler: "agent", - nodeId: "implement", - visit: 1, - graphVisit: null, - resumedFromStageId: null, - status: "running", - duration: "--", - startedAt: null, - providerUsed: null, - ...overrides, - }; -} - function renderSidebar(stages: Stage[]): string { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let renderer!: TestRenderer.ReactTestRenderer; diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index e984be74d..e016671a2 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -92,6 +92,36 @@ describe("queryKeysForRunEvent", () => { ]); }); + test("parallel branch lifecycle invalidates the stages list backing live branch rows", () => { + // Branches bypass stage.started/stage.completed, so these events are the + // only signal that a branch row's status changed. + expect(queryKeysForRunEvent("run-1", "parallel.branch.started", "review_glm@1")).toEqual([ + queryKeys.runs.stages("run-1"), + queryKeys.runs.events("run-1", 1000), + queryKeys.runs.graph("run-1", "LR"), + queryKeys.runs.graph("run-1", "TB"), + queryKeys.runs.stageEvents("run-1", "review_glm@1"), + ]); + expect(queryKeysForRunEvent("run-1", "parallel.branch.completed", "review_glm@1")).toEqual([ + queryKeys.runs.stages("run-1"), + queryKeys.runs.events("run-1", 1000), + queryKeys.runs.graph("run-1", "LR"), + queryKeys.runs.graph("run-1", "TB"), + queryKeys.runs.stageEvents("run-1", "review_glm@1"), + ]); + }); + + test("fork lifecycle invalidates run-scoped resources without a stage id", () => { + for (const event of ["parallel.started", "parallel.completed"]) { + expect(queryKeysForRunEvent("run-1", event)).toEqual([ + queryKeys.runs.stages("run-1"), + queryKeys.runs.events("run-1", 1000), + queryKeys.runs.graph("run-1", "LR"), + queryKeys.runs.graph("run-1", "TB"), + ]); + } + }); + test("cancel requests invalidate the durable run summary", () => { expect(queryKeysForRunEvent("run-1", "run.cancel.requested")).toEqual([ queryKeys.runs.detail("run-1"), diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index d0d4f4b4d..35946e0c6 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -88,6 +88,17 @@ export const STAGE_ACTIVITY_EVENT_TYPES = [ ] as const; export type StageActivityEventType = (typeof STAGE_ACTIVITY_EVENT_TYPES)[number]; const STAGE_ACTIVITY_EVENTS = new Set(STAGE_ACTIVITY_EVENT_TYPES); +// Parallel branches bypass the engine's `stage.started` / `stage.completed` +// lifecycle (the parallel handler dispatches each branch directly), so +// `STAGE_EVENTS` never fires for them. Without this set the stages list never +// refetches while a fork runs and branch rows stay frozen at their first +// observed state. +const PARALLEL_EVENTS = new Set([ + "parallel.started", + "parallel.branch.started", + "parallel.branch.completed", + "parallel.completed", +]); const INTERVIEW_EVENTS = new Set([ "interview.started", "interview.completed", @@ -179,6 +190,19 @@ export function queryKeysForRunEvent( return keys; } + if (PARALLEL_EVENTS.has(event)) { + const keys: Key[] = [ + queryKeys.runs.stages(runId), + queryKeys.runs.events(runId, 1000), + queryKeys.runs.graph(runId, "LR"), + queryKeys.runs.graph(runId, "TB"), + ]; + if (stageId) { + keys.push(queryKeys.runs.stageEvents(runId, stageId)); + } + return keys; + } + if (STEERING_EVENTS.has(event)) { const keys: Key[] = [queryKeys.runs.events(runId, 1000)]; if (AGENT_CONTROL_STATE_EVENTS.has(event)) { diff --git a/apps/fabro-web/app/lib/stage-sidebar.test.ts b/apps/fabro-web/app/lib/stage-sidebar.test.ts index 0aabd5212..e3f883500 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.test.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.test.ts @@ -3,23 +3,10 @@ import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fa import type { Stage } from "../components/stage-sidebar"; import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar"; +import { makeStage as baseMakeStage } from "./test-utils"; function makeStage(nodeId: string, visit: number, status: StageState): Stage { - return { - id: `${nodeId}@${visit}`, - name: nodeId, - handler: "agent", - nodeId, - visit, - graphVisit: null, - resumedFromStageId: null, - parallelGroupId: null, - parallelBranchIndex: null, - status, - duration: "--", - startedAt: null, - providerUsed: null, - }; + return baseMakeStage({ id: `${nodeId}@${visit}`, name: nodeId, nodeId, visit, status }); } describe("mapRunStagesToSidebarStages", () => { diff --git a/apps/fabro-web/app/lib/test-utils.tsx b/apps/fabro-web/app/lib/test-utils.tsx index 995245aa5..1a9bac3c5 100644 --- a/apps/fabro-web/app/lib/test-utils.tsx +++ b/apps/fabro-web/app/lib/test-utils.tsx @@ -2,6 +2,8 @@ import { createElement, type ReactNode } from "react"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; +import type { Stage } from "./stage-sidebar"; + const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const; /** @@ -55,6 +57,37 @@ export function makeEventEnvelope( } as EventEnvelope; } +/** Flatten a rendered subtree to its visible text. */ +export function textContent(node: TestRenderer.ReactTestInstance): string { + return node.children + .map((child) => (typeof child === "string" ? child : textContent(child))) + .join(""); +} + +/** + * Build a sidebar `Stage` fixture; override any field via `overrides`. Kept + * here so widening `Stage` updates every fixture at once — test files are + * excluded from typecheck, so a per-file copy silently goes stale instead. + */ +export function makeStage(overrides: Partial = {}): Stage { + return { + id: "implement@1", + name: "implement", + handler: "agent", + nodeId: "implement", + visit: 1, + graphVisit: null, + resumedFromStageId: null, + parallelGroupId: null, + parallelBranchIndex: null, + status: "running", + duration: "--", + startedAt: null, + providerUsed: null, + ...overrides, + }; +} + export function renderHook( hook: () => T, options: { wrapper: React.ComponentType<{ children: ReactNode }> }, diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 5d605b7f6..8884fba55 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -9951,10 +9951,9 @@ components: Durable identity of one execution of a parallel node, formatted as "{node_id}@{visit}". parallel_branch_id: - type: ["string", "null"] - description: > - Durable identity of one branch within a parallel execution, - formatted as "{parallel_group_id}:{index}". + oneOf: + - $ref: "#/components/schemas/ParallelBranchId" + - type: "null" session_id: type: ["string", "null"] parent_session_id: @@ -10653,10 +10652,9 @@ components: $ref: "#/components/schemas/ParallelBranchResult" description: Ordered per-branch results produced by a parallel stage. parallel_branch_id: - type: ["string", "null"] - description: > - Durable identity of this branch within a parallel execution, - formatted as "{parallel_group_id}:{index}". + oneOf: + - $ref: "#/components/schemas/ParallelBranchId" + - type: "null" output: type: ["string", "null"] output_bytes: @@ -12575,6 +12573,13 @@ components: type: string example: verify@2 + ParallelBranchId: + description: >- + Durable identity of one branch within a parallel execution, in + `{parallel_group_id}:{index}` form. + type: string + example: review_fork@3:1 + StageState: description: Lifecycle projection state of a workflow stage. type: string diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 9b8de6236..aac17e4b8 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -4905,7 +4905,16 @@ async fn append_scoped_stage_event( parallel_group_id: None, parallel_branch_id: None, }; - let stored = fabro_workflow::event::to_run_event_at(&run_id, event, Utc::now(), Some(&scope)); + append_event_with_scope(state, run_id, event, &scope).await; +} + +async fn append_event_with_scope( + state: &Arc, + run_id: RunId, + event: &workflow_event::Event, + scope: &fabro_workflow::event::StageScope, +) { + let stored = fabro_workflow::event::to_run_event_at(&run_id, event, Utc::now(), Some(scope)); let payload = fabro_workflow::event::build_redacted_event_payload(&stored, &run_id).unwrap(); let run_store = state.stores.runs.open_run(&run_id).await.unwrap(); run_store.append_event(&payload).await.unwrap(); @@ -5551,11 +5560,7 @@ async fn list_run_stages_exposes_parallel_branch_identity() { parallel_group_id, parallel_branch_id, ); - let stored = - workflow_event::to_run_event_at(&run_id, &branch_event, Utc::now(), Some(&branch_scope)); - let payload = workflow_event::build_redacted_event_payload(&stored, &run_id).unwrap(); - let run_store = state.stores.runs.open_run(&run_id).await.unwrap(); - run_store.append_event(&payload).await.unwrap(); + append_event_with_scope(&state, run_id, &branch_event, &branch_scope).await; let response = app .oneshot( diff --git a/lib/components/fabro-store/src/run_state.rs b/lib/components/fabro-store/src/run_state.rs index 769f914d1..4be77d574 100644 --- a/lib/components/fabro-store/src/run_state.rs +++ b/lib/components/fabro-store/src/run_state.rs @@ -1589,6 +1589,18 @@ mod tests { event } + fn test_branch_event( + seq: u32, + body: EventBody, + stage_id: StageId, + branch_id: ParallelBranchId, + ) -> EventEnvelope { + let mut event = test_stage_event(seq, body, stage_id); + event.event.parallel_group_id = Some(branch_id.group().clone()); + event.event.parallel_branch_id = Some(branch_id); + event + } + fn test_stage_event_at( seq: u32, ts: &str, @@ -2286,7 +2298,7 @@ mod tests { let group_id = StageId::new("review_fork", 1); let branch_stage_id = StageId::new("review_glm", 1); let branch_id = ParallelBranchId::new(group_id.clone(), 0); - let mut started = test_stage_event( + let started = test_branch_event( 3, EventBody::ParallelBranchStarted(ParallelBranchStartedProps { index: 0, @@ -2294,9 +2306,8 @@ mod tests { resumed_from_stage_id: None, }), branch_stage_id.clone(), + branch_id.clone(), ); - started.event.parallel_group_id = Some(group_id.clone()); - started.event.parallel_branch_id = Some(branch_id.clone()); state.apply_event(&started).unwrap(); @@ -2309,8 +2320,7 @@ mod tests { Some(&branch_id) ); - let replacement_branch_id = ParallelBranchId::new(group_id.clone(), 1); - let mut reobserved = test_stage_event( + let reobserved = test_branch_event( 4, EventBody::ParallelBranchStarted(ParallelBranchStartedProps { index: 1, @@ -2318,9 +2328,8 @@ mod tests { resumed_from_stage_id: Some(StageId::new("review_glm", 2)), }), branch_stage_id.clone(), + ParallelBranchId::new(group_id, 1), ); - reobserved.event.parallel_group_id = Some(group_id); - reobserved.event.parallel_branch_id = Some(replacement_branch_id); state.apply_event(&reobserved).unwrap(); diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index fc943e99d..e48512ac8 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -345,6 +345,7 @@ fn main() { ("Conclusion", "fabro_types::Conclusion", &[]), ("StageOutcome", "fabro_types::StageOutcome", &[]), ("StageId", "fabro_types::StageId", &[]), + ("ParallelBranchId", "fabro_types::ParallelBranchId", &[]), ("StageHandler", "fabro_types::StageHandler", &[]), ("StageState", "fabro_types::StageState", &[]), ("AgentControlState", "fabro_types::AgentControlState", &[]), diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 4312acb91..4bd01b913 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -52,19 +52,19 @@ pub mod types { McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer, McpTransportView, Message, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry, - PairTranscriptResponse, ParallelBranchResult, PendingInterviewRecord, PermissionLevel, - PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus, - PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, - QuestionType, ReasoningOutput, RepositoryRef, Role, Run, RunApproval, RunApprovalState, - RunClientProvenance, RunEvent, RunEventDetailContentKind, RunEventDetailResponse, - RunFailure, RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, - RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, - RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxInfo, - SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy, - SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError, - SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState, - SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, - SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, + PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord, + PermissionLevel, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, + PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink, + PullRequestMeta, PullRequestResponse, QuestionType, ReasoningOutput, RepositoryRef, Role, + Run, RunApproval, RunApprovalState, RunClientProvenance, RunEvent, + RunEventDetailContentKind, RunEventDetailResponse, RunFailure, RunPairStatusResponse, + RunProjection, RunProvenance, RunRunnableSource, RunSandbox, RunSandboxFailure, + RunSandboxInstance, RunSandboxKind, RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, + RunSize, SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, + SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind, + SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse, + SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, + SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn, SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason, diff --git a/lib/foundation/fabro-api/tests/parallel_branch_id_round_trip.rs b/lib/foundation/fabro-api/tests/parallel_branch_id_round_trip.rs new file mode 100644 index 000000000..c11145b88 --- /dev/null +++ b/lib/foundation/fabro-api/tests/parallel_branch_id_round_trip.rs @@ -0,0 +1,34 @@ +use std::any::{TypeId, type_name}; + +use fabro_api::types::ParallelBranchId as ApiParallelBranchId; +use fabro_types::{ParallelBranchId, StageId}; +use serde_json::json; + +#[test] +fn parallel_branch_id_reuses_canonical_type() { + assert_same_type::(); +} + +#[test] +fn parallel_branch_id_round_trips_openapi_representation() { + let branch_id = ParallelBranchId::new(StageId::new("review_fork", 3), 1); + + assert_eq!( + serde_json::to_value(&branch_id).unwrap(), + json!("review_fork@3:1") + ); + assert_eq!( + serde_json::from_value::(json!("review_fork@3:1")).unwrap(), + branch_id + ); +} + +fn assert_same_type() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/packages/fabro-api-client/src/models/run-event.ts b/lib/packages/fabro-api-client/src/models/run-event.ts index f41b0bf9f..073afd03b 100644 --- a/lib/packages/fabro-api-client/src/models/run-event.ts +++ b/lib/packages/fabro-api-client/src/models/run-event.ts @@ -37,7 +37,7 @@ export interface RunEvent { */ 'parallel_group_id'?: string | null; /** - * Durable identity of one branch within a parallel execution, formatted as \"{parallel_group_id}:{index}\". + * Durable identity of one branch within a parallel execution, in `{parallel_group_id}:{index}` form. */ 'parallel_branch_id'?: string | null; 'session_id'?: string | null; diff --git a/lib/packages/fabro-api-client/src/models/stage-projection.ts b/lib/packages/fabro-api-client/src/models/stage-projection.ts index 2d114cdc0..26b654484 100644 --- a/lib/packages/fabro-api-client/src/models/stage-projection.ts +++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts @@ -88,7 +88,7 @@ export interface StageProjection { */ 'parallel_results'?: Array | null; /** - * Durable identity of this branch within a parallel execution, formatted as \"{parallel_group_id}:{index}\". + * Durable identity of one branch within a parallel execution, in `{parallel_group_id}:{index}` form. */ 'parallel_branch_id'?: string | null; 'output'?: string | null;