From 6c3211e3ce40a63425f9e9807b7396460f7bebaa Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Wed, 22 Apr 2026 17:15:13 -0400 Subject: [PATCH 1/6] refactor(run-status): unify tagged lifecycle states --- apps/fabro-web/app/data/runs.test.ts | 12 +- apps/fabro-web/app/data/runs.ts | 29 +- apps/fabro-web/app/lib/run-actions.test.ts | 25 +- apps/fabro-web/app/lib/run-actions.ts | 2 +- apps/fabro-web/app/routes/run-detail.test.ts | 33 +- apps/fabro-web/app/routes/run-detail.tsx | 9 +- .../app/routes/run-files/states.test.tsx | 23 +- docs/api-reference/fabro-api.yaml | 263 +++++-- lib/crates/fabro-api/build.rs | 14 +- lib/crates/fabro-api/src/lib.rs | 2 +- .../fabro-api/tests/status_round_trip.rs | 203 +++-- .../fabro-cli/src/commands/run/attach.rs | 20 +- lib/crates/fabro-cli/src/commands/run/logs.rs | 2 +- .../fabro-cli/src/commands/run/rewind.rs | 5 +- .../fabro-cli/src/commands/run/runner.rs | 16 +- lib/crates/fabro-cli/src/commands/run/wait.rs | 63 +- .../fabro-cli/src/commands/runs/inspect.rs | 5 +- .../fabro-cli/src/commands/runs/list.rs | 17 +- .../fabro-cli/src/commands/store/dump.rs | 30 +- lib/crates/fabro-cli/src/server_runs.rs | 6 +- lib/crates/fabro-cli/src/shared/utilities.rs | 17 + lib/crates/fabro-cli/tests/it/cmd/archive.rs | 21 +- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 4 + lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 36 +- lib/crates/fabro-cli/tests/it/cmd/ps.rs | 21 +- lib/crates/fabro-cli/tests/it/cmd/rewind.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/rm.rs | 18 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 8 +- lib/crates/fabro-cli/tests/it/cmd/runner.rs | 10 +- lib/crates/fabro-cli/tests/it/cmd/start.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/support.rs | 19 +- .../fabro-cli/tests/it/cmd/unarchive.rs | 20 +- lib/crates/fabro-cli/tests/it/cmd/wait.rs | 12 +- .../fabro-cli/tests/it/scenario/archive.rs | 10 +- .../fabro-cli/tests/it/scenario/lifecycle.rs | 14 +- .../fabro-cli/tests/it/scenario/smoke.rs | 20 +- lib/crates/fabro-server/src/demo/mod.rs | 77 +- lib/crates/fabro-server/src/run_files.rs | 2 +- lib/crates/fabro-server/src/server.rs | 707 ++++++++++-------- lib/crates/fabro-server/tests/it/helpers.rs | 8 +- .../fabro-server/tests/it/scenario/archive.rs | 4 +- .../tests/it/scenario/lifecycle.rs | 8 +- .../{entry-q11nrnd3.js => entry-8ah4aryw.js} | 146 ++-- lib/crates/fabro-spa/assets/index.html | 2 +- lib/crates/fabro-store/src/error.rs | 2 + lib/crates/fabro-store/src/run_state.rs | 702 ++++++++--------- lib/crates/fabro-store/src/slate/mod.rs | 27 +- .../tests/serializable_projection.rs | 17 +- lib/crates/fabro-types/src/event_envelope.rs | 6 +- lib/crates/fabro-types/src/lib.rs | 4 +- lib/crates/fabro-types/src/run_event/mod.rs | 14 +- lib/crates/fabro-types/src/run_event/run.rs | 25 +- lib/crates/fabro-types/src/run_projection.rs | 30 +- lib/crates/fabro-types/src/run_summary.rs | 12 +- lib/crates/fabro-types/src/status.rs | 441 +++++++---- lib/crates/fabro-workflow/src/event.rs | 95 +-- .../fabro-workflow/src/lifecycle/event.rs | 16 +- .../fabro-workflow/src/operations/archive.rs | 121 +-- .../fabro-workflow/src/operations/create.rs | 5 +- .../fabro-workflow/src/operations/resume.rs | 7 +- .../fabro-workflow/src/operations/rewind.rs | 12 +- .../fabro-workflow/src/operations/start.rs | 52 +- .../src/pipeline/execute/tests.rs | 10 +- .../fabro-workflow/src/pipeline/finalize.rs | 39 +- lib/crates/fabro-workflow/src/run_dump.rs | 8 +- lib/crates/fabro-workflow/src/run_lookup.rs | 12 +- lib/crates/fabro-workflow/src/run_status.rs | 2 +- .../src/.openapi-generator/FILES | 16 +- .../src/models/failure-reason.ts | 35 + .../fabro-api-client/src/models/index.ts | 16 +- .../src/models/run-list-item.ts | 5 +- .../src/models/run-projection.ts | 5 +- .../src/models/run-status-archived.ts | 31 + .../src/models/run-status-blocked.ts | 31 + .../src/models/run-status-dead.ts | 27 + .../src/models/run-status-failed.ts | 31 + .../src/models/run-status-paused.ts | 31 + .../src/models/run-status-queued.ts | 27 + .../src/models/run-status-removing.ts | 27 + .../src/models/run-status-response.ts | 10 +- .../src/models/run-status-running.ts | 27 + .../src/models/run-status-starting.ts | 27 + .../src/models/run-status-submitted.ts | 27 + .../src/models/run-status-succeeded.ts | 31 + .../fabro-api-client/src/models/run-status.ts | 61 +- .../src/models/store-run-summary.ts | 8 - .../src/models/success-reason.ts | 29 + .../src/models/terminal-status.ts | 35 + lib/packages/fabro-api-client/tsconfig.json | 2 +- 90 files changed, 2580 insertions(+), 1591 deletions(-) rename lib/crates/fabro-spa/assets/assets/{entry-q11nrnd3.js => entry-8ah4aryw.js} (84%) create mode 100644 lib/packages/fabro-api-client/src/models/failure-reason.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-archived.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-blocked.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-dead.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-failed.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-paused.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-queued.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-removing.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-running.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-starting.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-submitted.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-succeeded.ts create mode 100644 lib/packages/fabro-api-client/src/models/success-reason.ts create mode 100644 lib/packages/fabro-api-client/src/models/terminal-status.ts diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index a5ad3955d..3b6c6cb92 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -18,7 +18,7 @@ describe("mapRunListItem", () => { workflow_name: "Fix Build", host_repo_path: "/home/user/myrepo", repository: { name: "myrepo" }, - status: "paused", + status: { kind: "paused", prior_block: null }, labels: {}, column: "running", elapsed_secs: 65, @@ -26,7 +26,6 @@ describe("mapRunListItem", () => { total_usd_micros: 500000, created_at: "2026-04-08T12:00:00Z", start_time: "2026-04-08T12:00:00Z", - status_reason: null, pending_control: null, } as const; const item = mapRunListItem(summary); @@ -48,7 +47,7 @@ describe("mapRunListItem", () => { workflow_name: "Fix Build", host_repo_path: "/home/user/myrepo", repository: { name: "myrepo" }, - status: "running", + status: { kind: "running" }, labels: {}, column: "running", elapsed_secs: null, @@ -56,7 +55,6 @@ describe("mapRunListItem", () => { total_usd_micros: null, created_at: "2026-04-08T12:00:00Z", start_time: null, - status_reason: null, pending_control: null, } as const; @@ -74,14 +72,13 @@ describe("mapRunSummaryToRunItem", () => { workflow_name: "Fix Build", host_repo_path: "/home/user/myrepo", repository: { name: "myrepo" }, - status: "running", + status: { kind: "running" }, duration_ms: 65000, elapsed_secs: 65, total_usd_micros: 500000, labels: {}, created_at: "2026-04-08T12:00:00Z", start_time: "2026-04-08T12:00:00Z", - status_reason: null, pending_control: null, }; const item = mapRunSummaryToRunItem(summary); @@ -102,14 +99,13 @@ describe("mapRunSummaryToRunItem", () => { workflow_name: null, host_repo_path: null, repository: { name: "unknown" }, - status: "submitted", + status: { kind: "submitted" }, duration_ms: null, elapsed_secs: null, total_usd_micros: null, labels: {}, created_at: "2026-04-08T12:00:00Z", start_time: null, - status_reason: null, pending_control: null, }; const item = mapRunSummaryToRunItem(summary); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index 5dfb3f4e0..16561cc0c 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -1,5 +1,9 @@ import { formatElapsedSecs, formatDurationSecs } from "../lib/format"; -import type { RunListItem, StoreRunSummary } from "@qltysh/fabro-api-client"; +import type { + RunListItem, + RunStatus as ApiRunStatus, + StoreRunSummary, +} from "@qltysh/fabro-api-client"; export type CiStatus = "passing" | "failing" | "pending"; @@ -17,7 +21,7 @@ export interface RunItem { title: string; workflow: string; column?: ColumnStatus; - lifecycleStatus?: string | null; + lifecycleStatus?: RunStatus | null; lifecycleStatusLabel?: string; number?: number; additions?: number; @@ -50,14 +54,19 @@ function displayRunTitle(title: string | null | undefined): string { return title?.trim() ? title : "Untitled run"; } +function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | null { + return status?.kind ?? null; +} + export function mapRunListItem(item: RunListItem): RunItem { + const lifecycleStatus = runStatusKind(item.status); return { id: item.run_id, repo: item.repository.name, title: displayRunTitle(item.title), workflow: item.workflow_slug ?? item.workflow_name ?? "unknown", column: item.column, - lifecycleStatus: item.status, + lifecycleStatus, lifecycleStatusLabel: lifecycleStatusLabel(item.status), number: item.pull_request?.number, additions: item.pull_request?.additions, @@ -78,12 +87,13 @@ export function mapRunListItem(item: RunListItem): RunItem { export type RunSummaryResponse = StoreRunSummary; export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { + const lifecycleStatus = runStatusKind(summary.status); return { id: summary.run_id, repo: summary.repository.name, title: displayRunTitle(summary.title), workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown", - lifecycleStatus: summary.status, + lifecycleStatus, lifecycleStatusLabel: lifecycleStatusLabel(summary.status), elapsed: summary.elapsed_secs != null @@ -94,8 +104,8 @@ export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { }; } -export function columnForStatus(status: string | null | undefined): ColumnStatus | null { - switch (status) { +export function columnForStatus(status: ApiRunStatus | null | undefined): ColumnStatus | null { + switch (status?.kind) { case "submitted": case "queued": case "starting": @@ -155,9 +165,10 @@ export function isRunStatus(s: string): s is RunStatus { return knownRunStatuses.has(s); } -function lifecycleStatusLabel(status: string | null | undefined): string | undefined { - if (!status) return undefined; - return isRunStatus(status) ? runStatusDisplay[status].label : status; +function lifecycleStatusLabel(status: ApiRunStatus | null | undefined): string | undefined { + const kind = runStatusKind(status); + if (!kind) return undefined; + return runStatusDisplay[kind].label; } /** Graph control nodes hidden from stage lists in the UI. */ diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index c50116614..5ec61fa5c 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -58,15 +58,16 @@ describe("run lifecycle actions", () => { status: 200, body: JSON.stringify({ id: "run-1", - status: "failed", - status_reason: "cancelled", + status: { kind: "failed", reason: "cancelled" }, created_at: "2026-04-20T12:00:00Z", }), }); const result = await cancelRun("run-1"); - expect(result.status).toBe("failed"); - expect(result.status_reason).toBe("cancelled"); + expect(result.status.kind).toBe("failed"); + if (result.status.kind === "failed") { + expect(result.status.reason).toBe("cancelled"); + } }); test("archiveRun parses a 200 response", async () => { @@ -74,13 +75,16 @@ describe("run lifecycle actions", () => { status: 200, body: JSON.stringify({ id: "run-1", - status: "archived", + status: { + kind: "archived", + prior: { kind: "succeeded", reason: "completed" }, + }, created_at: "2026-04-20T12:00:00Z", }), }); const result = await archiveRun("run-1"); - expect(result.status).toBe("archived"); + expect(result.status.kind).toBe("archived"); }); test("unarchiveRun parses a 200 response", async () => { @@ -88,13 +92,13 @@ describe("run lifecycle actions", () => { status: 200, body: JSON.stringify({ id: "run-1", - status: "succeeded", + status: { kind: "succeeded", reason: "completed" }, created_at: "2026-04-20T12:00:00Z", }), }); const result = await unarchiveRun("run-1"); - expect(result.status).toBe("succeeded"); + expect(result.status.kind).toBe("succeeded"); }); test("404 and 409 preserve the parsed error envelope", async () => { @@ -162,15 +166,14 @@ describe("run lifecycle actions", () => { expect( isTerminalCancelledRun({ id: "run-1", - status: "failed", - status_reason: "cancelled", + status: { kind: "failed", reason: "cancelled" }, created_at: "2026-04-20T12:00:00Z", }), ).toBe(true); expect( isTerminalCancelledRun({ id: "run-1", - status: "running", + status: { kind: "running" }, pending_control: "cancel", created_at: "2026-04-20T12:00:00Z", }), diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index a7276dbae..d679574a3 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -49,7 +49,7 @@ export function canUnarchive(status: string | null | undefined): boolean { } export function isTerminalCancelledRun(run: RunStatusResponse): boolean { - return (run.status === "failed" || run.status === "dead") && run.status_reason === "cancelled"; + return run.status.kind === "failed" && run.status.reason === "cancelled"; } export function mapError(error: unknown, action: LifecycleAction): string { diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index e5e03f62e..e0b9b9868 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -64,7 +64,7 @@ describe("run-detail loader", () => { run_id: "run-1", title: "Blocked run", repository: { name: "repo" }, - status: "blocked", + status: { kind: "blocked", blocked_reason: "human_input_required" }, workflow_name: "review", }, }, @@ -94,7 +94,7 @@ describe("run-detail loader", () => { run_id: "run-1", title: "Blocked run", repository: { name: "repo" }, - status: "blocked", + status: { kind: "blocked", blocked_reason: "human_input_required" }, workflow_name: "review", }, }, @@ -154,8 +154,7 @@ describe("run-detail action", () => { status: 200, body: { id: "run-1", - status: "failed", - status_reason: "cancelled", + status: { kind: "failed", reason: "cancelled" }, created_at: "2026-04-20T12:00:00Z", }, }, @@ -171,8 +170,7 @@ describe("run-detail action", () => { ok: true, run: { id: "run-1", - status: "failed", - status_reason: "cancelled", + status: { kind: "failed", reason: "cancelled" }, created_at: "2026-04-20T12:00:00Z", }, }); @@ -235,7 +233,11 @@ describe("handleLifecycleToastResult", () => { const result: RunDetailActionResult = { intent: "cancel", ok: true, - run: { id: "run-1", status: "failed", status_reason: "cancelled", created_at: "2026-04-20T12:00:00Z" }, + run: { + id: "run-1", + status: { kind: "failed", reason: "cancelled" }, + created_at: "2026-04-20T12:00:00Z", + }, }; const firstState = handleLifecycleToastResult("cancel", result, initialState, api); @@ -255,7 +257,7 @@ describe("handleLifecycleToastResult", () => { const result: RunDetailActionResult = { intent: "cancel", ok: true, - run: { id: "run-1", status: "running", created_at: "2026-04-20T12:00:00Z" }, + run: { id: "run-1", status: { kind: "running" }, created_at: "2026-04-20T12:00:00Z" }, }; handleLifecycleToastResult("cancel", result, initialState, api); @@ -269,7 +271,14 @@ describe("handleLifecycleToastResult", () => { const result: RunDetailActionResult = { intent: "archive", ok: true, - run: { id: "run-1", status: "archived", created_at: "2026-04-20T12:00:00Z" }, + run: { + id: "run-1", + status: { + kind: "archived", + prior: { kind: "succeeded", reason: "completed" }, + }, + created_at: "2026-04-20T12:00:00Z", + }, }; const firstState = handleLifecycleToastResult("archive", result, initialState, api, () => { @@ -297,7 +306,11 @@ describe("handleLifecycleToastResult", () => { const result: RunDetailActionResult = { intent: "unarchive", ok: true, - run: { id: "run-1", status: "succeeded", created_at: "2026-04-20T12:00:00Z" }, + run: { + id: "run-1", + status: { kind: "succeeded", reason: "completed" }, + created_at: "2026-04-20T12:00:00Z", + }, }; const stateWithActiveToast: LifecycleToastState = { activeArchiveToastId: "toast-9", diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 4bde02602..d8512d768 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -128,9 +128,10 @@ export async function loader({ request, params }: any): Promise { }); test("every documented RunStatus gets a non-unknown empty kind", () => { - // Regression guard sourced from the generated API client enum so any - // new RunStatus added to the OpenAPI spec fails this test until the - // decision table grows a branch. Without this guard, unhandled - // statuses silently render as "unknown" ("not available right now") — - // misleading copy for e.g. a paused or archived run. - for (const status of Object.values(RunStatus)) { + // Regression guard sourced from the documented wire discriminators. If + // a new RunStatus kind lands in the API, this list should be updated + // alongside the decision table below. + for (const status of [ + "submitted", + "queued", + "starting", + "running", + "blocked", + "paused", + "removing", + "succeeded", + "failed", + "dead", + "archived", + ]) { const result = deriveEmptyKind({ runStatus: status, totalChanged: 0, diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 9eefde011..1c59a5fec 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -2814,19 +2814,192 @@ components: an explicit user action on a previously terminal (`succeeded`, `failed`, or `dead`) run; archived runs are hidden from default listings and are read-only until unarchived. + oneOf: + - $ref: "#/components/schemas/RunStatusSubmitted" + - $ref: "#/components/schemas/RunStatusQueued" + - $ref: "#/components/schemas/RunStatusStarting" + - $ref: "#/components/schemas/RunStatusRunning" + - $ref: "#/components/schemas/RunStatusBlocked" + - $ref: "#/components/schemas/RunStatusPaused" + - $ref: "#/components/schemas/RunStatusRemoving" + - $ref: "#/components/schemas/RunStatusSucceeded" + - $ref: "#/components/schemas/RunStatusFailed" + - $ref: "#/components/schemas/RunStatusDead" + - $ref: "#/components/schemas/RunStatusArchived" + discriminator: + propertyName: kind + mapping: + submitted: "#/components/schemas/RunStatusSubmitted" + queued: "#/components/schemas/RunStatusQueued" + starting: "#/components/schemas/RunStatusStarting" + running: "#/components/schemas/RunStatusRunning" + blocked: "#/components/schemas/RunStatusBlocked" + paused: "#/components/schemas/RunStatusPaused" + removing: "#/components/schemas/RunStatusRemoving" + succeeded: "#/components/schemas/RunStatusSucceeded" + failed: "#/components/schemas/RunStatusFailed" + dead: "#/components/schemas/RunStatusDead" + archived: "#/components/schemas/RunStatusArchived" + + RunStatusSubmitted: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - submitted + + RunStatusQueued: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - queued + + RunStatusStarting: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - starting + + RunStatusRunning: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - running + + RunStatusBlocked: + type: object + required: + - kind + - blocked_reason + properties: + kind: + type: string + enum: + - blocked + blocked_reason: + $ref: "#/components/schemas/BlockedReason" + + RunStatusPaused: + type: object + required: + - kind + - prior_block + properties: + kind: + type: string + enum: + - paused + prior_block: + oneOf: + - $ref: "#/components/schemas/BlockedReason" + - type: "null" + + RunStatusRemoving: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - removing + + RunStatusSucceeded: + type: object + required: + - kind + - reason + properties: + kind: + type: string + enum: + - succeeded + reason: + $ref: "#/components/schemas/SuccessReason" + + RunStatusFailed: + type: object + required: + - kind + - reason + properties: + kind: + type: string + enum: + - failed + reason: + $ref: "#/components/schemas/FailureReason" + + RunStatusDead: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - dead + + RunStatusArchived: + type: object + required: + - kind + - prior + properties: + kind: + type: string + enum: + - archived + prior: + $ref: "#/components/schemas/TerminalStatus" + + TerminalStatus: + description: Terminal run status captured inside an archived run. + oneOf: + - $ref: "#/components/schemas/RunStatusSucceeded" + - $ref: "#/components/schemas/RunStatusFailed" + - $ref: "#/components/schemas/RunStatusDead" + discriminator: + propertyName: kind + mapping: + succeeded: "#/components/schemas/RunStatusSucceeded" + failed: "#/components/schemas/RunStatusFailed" + dead: "#/components/schemas/RunStatusDead" + + SuccessReason: + description: Reason attached to a successful terminal run status. type: string enum: - - submitted - - queued - - starting - - running - - blocked - - paused - - removing - - succeeded - - failed - - dead - - archived + - completed + - partial_success + + FailureReason: + description: Reason attached to a failed terminal run status. + type: string + enum: + - workflow_error + - cancelled + - terminated + - transient_infra + - budget_exhausted + - launch_failed + - bootstrap_failed + - sandbox_init_failed RunManifest: description: Self-contained workflow run manifest. @@ -3199,16 +3372,8 @@ components: $ref: "#/components/schemas/RunError" queue_position: type: integer - description: Position in the queue (1-based). Only present when status is `queued`. + description: Position in the queue (1-based). Only present when the status kind is `queued`. example: 3 - status_reason: - oneOf: - - $ref: "#/components/schemas/StatusReason" - - type: "null" - blocked_reason: - oneOf: - - $ref: "#/components/schemas/BlockedReason" - - type: "null" pending_control: oneOf: - $ref: "#/components/schemas/RunControlAction" @@ -3590,22 +3755,6 @@ components: items: $ref: "#/components/schemas/RunArtifactEntry" - StatusReason: - description: Optional reason attached to a run status transition. - type: string - enum: - - completed - - partial_success - - workflow_error - - cancelled - - terminated - - transient_infra - - budget_exhausted - - launch_failed - - bootstrap_failed - - sandbox_init_failed - - sandbox_initializing - BlockedReason: description: Specific reason a run is blocked on external intervention. type: string @@ -3620,27 +3769,6 @@ components: - pause - unpause - RunStatusRecord: - description: Internal run status record from the event projection. - type: object - required: - - status - - updated_at - properties: - status: - $ref: "#/components/schemas/RunStatus" - status_reason: - oneOf: - - $ref: "#/components/schemas/StatusReason" - - type: "null" - blocked_reason: - oneOf: - - $ref: "#/components/schemas/BlockedReason" - - type: "null" - updated_at: - type: string - format: date-time - InternalStageStatus: description: Internal stage status from outcomes and node status records. type: string @@ -3717,7 +3845,12 @@ components: additionalProperties: true status: oneOf: - - $ref: "#/components/schemas/RunStatusRecord" + - $ref: "#/components/schemas/RunStatus" + - type: "null" + status_updated_at: + oneOf: + - type: string + format: date-time - type: "null" checkpoint: oneOf: @@ -3800,14 +3933,6 @@ components: format: date-time status: $ref: "#/components/schemas/RunStatus" - status_reason: - oneOf: - - $ref: "#/components/schemas/StatusReason" - - type: "null" - blocked_reason: - oneOf: - - $ref: "#/components/schemas/BlockedReason" - - type: "null" pending_control: oneOf: - $ref: "#/components/schemas/RunControlAction" @@ -4167,7 +4292,7 @@ components: description: Human-readable title describing the run's goal. example: Add rate limiting to auth endpoints status: - type: string + $ref: "#/components/schemas/RunStatus" labels: type: object additionalProperties: @@ -4177,10 +4302,6 @@ components: start_time: type: ["string", "null"] format: date-time - status_reason: - oneOf: - - $ref: "#/components/schemas/StatusReason" - - type: "null" pending_control: oneOf: - $ref: "#/components/schemas/RunControlAction" diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 168fe8bb9..605461af5 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -161,22 +161,16 @@ fn main() { let mut settings = GenerationSettings::default(); settings.with_interface(InterfaceStyle::Builder); let replacements: &[(&str, &str, &[TypeImpl])] = &[ - ("RunStatus", "fabro_types::status::RunStatus", &[ - TypeImpl::FromStr, - TypeImpl::Display, - ]), - ("StatusReason", "fabro_types::status::StatusReason", &[]), + ("RunStatus", "fabro_types::status::RunStatus", &[]), + ("SuccessReason", "fabro_types::status::SuccessReason", &[]), + ("FailureReason", "fabro_types::status::FailureReason", &[]), + ("TerminalStatus", "fabro_types::status::TerminalStatus", &[]), ("BlockedReason", "fabro_types::status::BlockedReason", &[]), ( "RunControlAction", "fabro_types::status::RunControlAction", &[], ), - ( - "RunStatusRecord", - "fabro_types::status::RunStatusRecord", - &[], - ), ]; for (name, path, impls) in replacements { settings.with_replacement(*name, *path, impls.iter().copied()); diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index b039d6c12..69fd6c9ce 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -15,7 +15,7 @@ mod generated { } pub mod types { pub use fabro_types::status::{ - BlockedReason, RunControlAction, RunStatus, RunStatusRecord, StatusReason, + BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus, }; pub use crate::generated::types::*; diff --git a/lib/crates/fabro-api/tests/status_round_trip.rs b/lib/crates/fabro-api/tests/status_round_trip.rs index 894df6ccb..d355560f7 100644 --- a/lib/crates/fabro-api/tests/status_round_trip.rs +++ b/lib/crates/fabro-api/tests/status_round_trip.rs @@ -1,13 +1,12 @@ use std::any::{TypeId, type_name}; -use chrono::{TimeZone, Utc}; use fabro_api::types::{ - BlockedReason as ApiBlockedReason, RunControlAction as ApiRunControlAction, - RunStatus as ApiRunStatus, RunStatusRecord as ApiRunStatusRecord, - StatusReason as ApiStatusReason, + BlockedReason as ApiBlockedReason, FailureReason as ApiFailureReason, + RunControlAction as ApiRunControlAction, RunStatus as ApiRunStatus, + SuccessReason as ApiSuccessReason, TerminalStatus as ApiTerminalStatus, }; use fabro_types::status::{ - BlockedReason, RunControlAction, RunStatus, RunStatusRecord, StatusReason, + BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus, }; use serde::Serialize; use serde_json::{Value, json}; @@ -15,44 +14,117 @@ use serde_json::{Value, json}; #[test] fn status_family_reuses_domain_types() { assert_same_type::(); - assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); assert_same_type::(); assert_same_type::(); - assert_same_type::(); -} - -// The `status_family_reuses_domain_types` assertions above prove each API type -// is the same type as its domain counterpart, so each variant below only needs -// to be asserted once to lock in the OpenAPI string token. - -#[test] -fn run_status_json_tokens_match_openapi() { - assert_string_json(RunStatus::Submitted, "submitted"); - assert_string_json(RunStatus::Queued, "queued"); - assert_string_json(RunStatus::Starting, "starting"); - assert_string_json(RunStatus::Running, "running"); - assert_string_json(RunStatus::Blocked, "blocked"); - assert_string_json(RunStatus::Paused, "paused"); - assert_string_json(RunStatus::Removing, "removing"); - assert_string_json(RunStatus::Succeeded, "succeeded"); - assert_string_json(RunStatus::Failed, "failed"); - assert_string_json(RunStatus::Dead, "dead"); - assert_string_json(RunStatus::Archived, "archived"); } #[test] -fn status_reason_json_tokens_match_openapi() { - assert_string_json(StatusReason::Completed, "completed"); - assert_string_json(StatusReason::PartialSuccess, "partial_success"); - assert_string_json(StatusReason::WorkflowError, "workflow_error"); - assert_string_json(StatusReason::Cancelled, "cancelled"); - assert_string_json(StatusReason::Terminated, "terminated"); - assert_string_json(StatusReason::TransientInfra, "transient_infra"); - assert_string_json(StatusReason::BudgetExhausted, "budget_exhausted"); - assert_string_json(StatusReason::LaunchFailed, "launch_failed"); - assert_string_json(StatusReason::BootstrapFailed, "bootstrap_failed"); - assert_string_json(StatusReason::SandboxInitFailed, "sandbox_init_failed"); - assert_string_json(StatusReason::SandboxInitializing, "sandbox_initializing"); +fn run_status_json_matches_openapi_shape() { + assert_json( + RunStatus::Submitted, + json!({ + "kind": "submitted" + }), + ); + assert_json( + RunStatus::Queued, + json!({ + "kind": "queued" + }), + ); + assert_json( + RunStatus::Starting, + json!({ + "kind": "starting" + }), + ); + assert_json( + RunStatus::Running, + json!({ + "kind": "running" + }), + ); + assert_json( + RunStatus::Blocked { + blocked_reason: BlockedReason::HumanInputRequired, + }, + json!({ + "kind": "blocked", + "blocked_reason": "human_input_required" + }), + ); + assert_json( + RunStatus::Paused { prior_block: None }, + json!({ + "kind": "paused", + "prior_block": null + }), + ); + assert_json( + RunStatus::Removing, + json!({ + "kind": "removing" + }), + ); + assert_json( + RunStatus::Succeeded { + reason: SuccessReason::Completed, + }, + json!({ + "kind": "succeeded", + "reason": "completed" + }), + ); + assert_json( + RunStatus::Failed { + reason: FailureReason::Cancelled, + }, + json!({ + "kind": "failed", + "reason": "cancelled" + }), + ); + assert_json( + RunStatus::Dead, + json!({ + "kind": "dead" + }), + ); + assert_json( + RunStatus::Archived { + prior: TerminalStatus::Succeeded { + reason: SuccessReason::PartialSuccess, + }, + }, + json!({ + "kind": "archived", + "prior": { + "kind": "succeeded", + "reason": "partial_success" + } + }), + ); +} + +#[test] +fn success_reason_json_tokens_match_openapi() { + assert_string_json(SuccessReason::Completed, "completed"); + assert_string_json(SuccessReason::PartialSuccess, "partial_success"); +} + +#[test] +fn failure_reason_json_tokens_match_openapi() { + assert_string_json(FailureReason::WorkflowError, "workflow_error"); + assert_string_json(FailureReason::Cancelled, "cancelled"); + assert_string_json(FailureReason::Terminated, "terminated"); + assert_string_json(FailureReason::TransientInfra, "transient_infra"); + assert_string_json(FailureReason::BudgetExhausted, "budget_exhausted"); + assert_string_json(FailureReason::LaunchFailed, "launch_failed"); + assert_string_json(FailureReason::BootstrapFailed, "bootstrap_failed"); + assert_string_json(FailureReason::SandboxInitFailed, "sandbox_init_failed"); } #[test] @@ -68,34 +140,31 @@ fn run_control_action_json_tokens_match_openapi() { } #[test] -fn run_status_record_json_matches_openapi_shape() { - let updated_at = Utc - .with_ymd_and_hms(2026, 1, 2, 3, 4, 5) - .single() - .expect("fixed timestamp should be valid"); - let expected = json!({ - "status": "failed", - "status_reason": "cancelled", - "blocked_reason": "human_input_required", - "updated_at": "2026-01-02T03:04:05Z" - }); - - let record = RunStatusRecord { - status: RunStatus::Failed, - status_reason: Some(StatusReason::Cancelled), - blocked_reason: Some(BlockedReason::HumanInputRequired), - updated_at, - }; - assert_eq!(serde_json::to_value(&record).unwrap(), expected); - - let round_trip: RunStatusRecord = serde_json::from_value(expected).unwrap(); - assert_eq!(round_trip.status, RunStatus::Failed); - assert_eq!(round_trip.status_reason, Some(StatusReason::Cancelled)); - assert_eq!( - round_trip.blocked_reason, - Some(BlockedReason::HumanInputRequired) +fn terminal_status_json_matches_openapi_shape() { + assert_json( + TerminalStatus::Succeeded { + reason: SuccessReason::Completed, + }, + json!({ + "kind": "succeeded", + "reason": "completed" + }), + ); + assert_json( + TerminalStatus::Failed { + reason: FailureReason::Cancelled, + }, + json!({ + "kind": "failed", + "reason": "cancelled" + }), + ); + assert_json( + TerminalStatus::Dead, + json!({ + "kind": "dead" + }), ); - assert_eq!(round_trip.updated_at, updated_at); } fn assert_same_type() { @@ -114,3 +183,7 @@ fn assert_string_json(value: T, expected: &str) { Value::String(expected.into()) ); } + +fn assert_json(value: T, expected: Value) { + assert_eq!(serde_json::to_value(value).unwrap(), expected); +} diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 0c634fbd9..7aee9333d 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -355,7 +355,7 @@ fn state_is_terminal(state: &server_client::RunProjection) -> bool { || state .status .as_ref() - .is_some_and(|record| record.status.is_terminal()) + .is_some_and(|status| status.is_terminal()) } fn emit_progress_line( @@ -454,9 +454,9 @@ fn state_exit_code(state: &server_client::RunProjection) -> Option { }); } - match state.status.as_ref() { - Some(record) if record.status == RunStatus::Succeeded => Some(ExitCode::from(0)), - Some(record) if record.status.is_terminal() => Some(ExitCode::from(1)), + match state.status { + Some(status) if matches!(status, RunStatus::Succeeded { .. }) => Some(ExitCode::from(0)), + Some(status) if status.is_terminal() => Some(ExitCode::from(1)), Some(_) | None => None, } } @@ -502,10 +502,10 @@ mod tests { "graph_source": null, "start": null, "status": { - "status": "failed", - "reason": "cancelled", - "updated_at": "2026-04-05T12:00:02Z" + "kind": "failed", + "reason": "cancelled" }, + "status_updated_at": "2026-04-05T12:00:02Z", "checkpoint": null, "checkpoints": [], "conclusion": null, @@ -522,10 +522,12 @@ mod tests { fn cancel_run_response(run_id: RunId) -> serde_json::Value { serde_json::json!({ "id": run_id, - "status": "failed", + "status": { + "kind": "failed", + "reason": "cancelled" + }, "error": null, "queue_position": null, - "status_reason": "cancelled", "pending_control": null, "created_at": "2026-04-05T12:00:00Z" }) diff --git a/lib/crates/fabro-cli/src/commands/run/logs.rs b/lib/crates/fabro-cli/src/commands/run/logs.rs index a732bc023..9fcbec1da 100644 --- a/lib/crates/fabro-cli/src/commands/run/logs.rs +++ b/lib/crates/fabro-cli/src/commands/run/logs.rs @@ -235,7 +235,7 @@ async fn run_concluded( Ok(state.conclusion.is_some() || state .status - .is_some_and(|record| record.status.is_terminal())) + .is_some_and(|status| status.is_terminal())) } async fn flush_remaining_store_events( diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index 1645dacfa..c2aeb909f 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -44,8 +44,6 @@ pub(crate) async fn run( let state = client.get_run_state(&run_id).await?; let current_status = state .status - .as_ref() - .map(|record| record.status) .context("run has no recorded status — cannot rewind")?; let run_spec = state.spec.context("Failed to load run spec from store")?; ensure_matching_repo_origin(run_spec.repo_origin_url.as_deref(), "rewind")?; @@ -127,7 +125,7 @@ async fn reset_rewound_run_state( let checkpoint = MetadataStore::read_run_projection(git_store.repo_dir(), &run_id.to_string())? .and_then(|projection| projection.checkpoint) .context("rewound metadata branch is missing run.json checkpoint state")?; - let previous_status = state.status.map(|status| status.status.to_string()); + let previous_status = state.status.map(|status| status.to_string()); client .append_run_event( @@ -157,7 +155,6 @@ async fn reset_rewound_run_state( *run_id, None, EventBody::RunSubmitted(RunSubmittedProps { - reason: None, definition_blob, }), ), diff --git a/lib/crates/fabro-cli/src/commands/run/runner.rs b/lib/crates/fabro-cli/src/commands/run/runner.rs index 78c1198d0..898ce27ae 100644 --- a/lib/crates/fabro-cli/src/commands/run/runner.rs +++ b/lib/crates/fabro-cli/src/commands/run/runner.rs @@ -17,7 +17,7 @@ use fabro_interview::{ControlInterviewer, WorkerControlEnvelope, WorkerControlMe use fabro_store::{EventEnvelope, RunProjection, RunProjectionReducer}; use fabro_types::settings::run::RunMode; use fabro_types::settings::{InterpString, SettingsLayer}; -use fabro_types::{ArtifactUpload, EventBody, RunBlobId, RunEvent, RunId, StatusReason}; +use fabro_types::{ArtifactUpload, EventBody, FailureReason, RunBlobId, RunEvent, RunId}; use fabro_vault::Vault; use fabro_workflow::artifact_upload::{ArtifactSink, StageArtifactUploader}; use fabro_workflow::event::{Emitter, RunEventSink}; @@ -485,7 +485,7 @@ fn worker_title_phase_for_event(body: &EventBody) -> Option { } EventBody::RunPaused(_) => Some(WorkerTitlePhase::Paused), EventBody::RunCompleted(_) => Some(WorkerTitlePhase::Succeeded), - EventBody::RunFailed(props) => Some(if props.reason == Some(StatusReason::Cancelled) { + EventBody::RunFailed(props) => Some(if props.reason == FailureReason::Cancelled { WorkerTitlePhase::Cancelled } else { WorkerTitlePhase::Failed @@ -593,7 +593,7 @@ mod tests { InterviewCompletedProps, InterviewStartedProps, RunCompletedProps, RunControlEffectProps, RunFailedProps, RunStatusTransitionProps, }; - use fabro_types::{EventBody, StatusReason, fixtures}; + use fabro_types::{EventBody, FailureReason, SuccessReason, fixtures}; use fabro_vault::{SecretType, Vault}; use fabro_workflow::artifact_upload::StageArtifactUploader; @@ -633,9 +633,7 @@ mod tests { #[test] fn worker_title_phase_tracks_lifecycle_events() { assert_eq!( - worker_title_phase_for_event(&EventBody::RunStarting(RunStatusTransitionProps { - reason: None, - })), + worker_title_phase_for_event(&EventBody::RunStarting(RunStatusTransitionProps {})), Some(WorkerTitlePhase::Init) ); assert_eq!( @@ -669,7 +667,7 @@ mod tests { duration_ms: 10, artifact_count: 0, status: "success".to_string(), - reason: None, + reason: SuccessReason::Completed, total_usd_micros: None, final_git_commit_sha: None, final_patch: None, @@ -681,7 +679,7 @@ mod tests { worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps { error: "cancelled".to_string(), duration_ms: 10, - reason: Some(StatusReason::Cancelled), + reason: FailureReason::Cancelled, git_commit_sha: None, final_patch: None, })), @@ -691,7 +689,7 @@ mod tests { worker_title_phase_for_event(&EventBody::RunFailed(RunFailedProps { error: "boom".to_string(), duration_ms: 10, - reason: Some(StatusReason::Terminated), + reason: FailureReason::Terminated, git_commit_sha: None, final_patch: None, })), diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 49e8557a7..0454a0155 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -22,7 +22,7 @@ use tracing::info; use crate::args::WaitArgs; use crate::command_context::CommandContext; -use crate::shared::{format_duration_ms, format_usd_micros}; +use crate::shared::{format_duration_ms, format_usd_micros, run_status_kind}; pub(crate) async fn run( args: &WaitArgs, @@ -72,7 +72,7 @@ pub(crate) async fn run( print_human_output(final_status, &run_id, conclusion.as_ref(), styles, printer); } - if final_status == RunStatus::Succeeded { + if matches!(final_status, RunStatus::Succeeded { .. }) { Ok(()) } else { std::process::exit(1); @@ -86,7 +86,7 @@ fn build_json_output( ) -> serde_json::Value { let mut value = serde_json::json!({ "run_id": run_id, - "status": status.to_string(), + "status": run_status_kind(status), }); if let Some(c) = conclusion { value["duration_ms"] = c.duration_ms.into(); @@ -109,10 +109,10 @@ fn print_human_output( printer: Printer, ) { let (style, label) = match status { - RunStatus::Succeeded => (&styles.bold_green, "Succeeded"), - RunStatus::Failed => (&styles.bold_red, "Failed"), + RunStatus::Succeeded { .. } => (&styles.bold_green, "Succeeded"), + RunStatus::Failed { .. } => (&styles.bold_red, "Failed"), RunStatus::Dead => (&styles.bold_red, "Dead"), - RunStatus::Archived => (&styles.dim, "Archived"), + RunStatus::Archived { .. } => (&styles.dim, "Archived"), // Poll loop only breaks on is_terminal() which is the four arms above. _ => unreachable!(), }; @@ -142,10 +142,9 @@ fn print_human_output( #[cfg(test)] mod tests { - use fabro_types::{BilledTokenCounts, fixtures}; + use fabro_types::{BilledTokenCounts, RunStatus, SuccessReason, fixtures}; use fabro_workflow::outcome::StageStatus; use fabro_workflow::records::Conclusion; - use fabro_workflow::run_status::RunStatusRecord; use super::*; @@ -174,7 +173,13 @@ mod tests { }), total_retries: 0, }; - let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion)); + let json = build_json_output( + RunStatus::Succeeded { + reason: SuccessReason::Completed, + }, + &run_id, + Some(&conclusion), + ); assert_eq!(json["run_id"], run_id.to_string()); assert_eq!(json["status"], "succeeded"); assert_eq!(json["duration_ms"], 12345); @@ -184,7 +189,13 @@ mod tests { #[test] fn json_output_failed_without_conclusion() { let run_id = fixtures::RUN_2; - let json = build_json_output(RunStatus::Failed, &run_id, None); + let json = build_json_output( + RunStatus::Failed { + reason: fabro_types::FailureReason::WorkflowError, + }, + &run_id, + None, + ); assert_eq!(json["run_id"], run_id.to_string()); assert_eq!(json["status"], "failed"); assert!(json.get("duration_ms").is_none()); @@ -210,7 +221,13 @@ mod tests { billing: None, total_retries: 0, }; - let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion)); + let json = build_json_output( + RunStatus::Failed { + reason: fabro_types::FailureReason::WorkflowError, + }, + &run_id, + Some(&conclusion), + ); assert!(json.get("total_usd_micros").is_none()); assert_eq!(json["duration_ms"], 500); } @@ -239,7 +256,9 @@ mod tests { }; // Just verify no panic; actual stderr output is hard to capture print_human_output( - RunStatus::Succeeded, + RunStatus::Succeeded { + reason: SuccessReason::Completed, + }, &run_id, Some(&conclusion), &styles, @@ -251,7 +270,9 @@ mod tests { fn human_output_failed_no_conclusion() { let styles = no_color_styles(); print_human_output( - RunStatus::Failed, + RunStatus::Failed { + reason: fabro_types::FailureReason::WorkflowError, + }, &fixtures::RUN_6, None, &styles, @@ -263,16 +284,22 @@ mod tests { fn poll_terminal_immediately() { let dir = tempfile::tempdir().unwrap(); let status_path = dir.path().join("status.json"); - let record = RunStatusRecord::new(RunStatus::Succeeded, None); + let record = RunStatus::Succeeded { + reason: SuccessReason::Completed, + }; std::fs::write(&status_path, serde_json::to_string_pretty(&record).unwrap()).unwrap(); // Simulate what the poll loop does - let status = serde_json::from_str::( + let status = serde_json::from_str::( &std::fs::read_to_string(&status_path).unwrap(), ) - .unwrap() - .status; + .unwrap(); assert!(status.is_terminal()); - assert_eq!(status, RunStatus::Succeeded); + assert_eq!( + status, + RunStatus::Succeeded { + reason: SuccessReason::Completed, + } + ); } } diff --git a/lib/crates/fabro-cli/src/commands/runs/inspect.rs b/lib/crates/fabro-cli/src/commands/runs/inspect.rs index 2c3b7911d..347292a73 100644 --- a/lib/crates/fabro-cli/src/commands/runs/inspect.rs +++ b/lib/crates/fabro-cli/src/commands/runs/inspect.rs @@ -41,10 +41,7 @@ pub(crate) async fn run( fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> InspectOutput { InspectOutput { run_id: run.run_id().to_string(), - status: state - .status - .as_ref() - .map_or(run.status(), |record| record.status), + status: state.status.unwrap_or(run.status()), run_spec: state .spec .and_then(|record| serde_json::to_value(record).ok()), diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 5864f4da0..6b83fec36 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -15,7 +15,7 @@ use super::short_run_id; use crate::args::RunsListArgs; use crate::command_context::CommandContext; use crate::server_runs::{ServerSummaryLookup, filter_server_runs}; -use crate::shared::{color_if, format_duration_ms, tilde_path}; +use crate::shared::{color_if, format_duration_ms, run_status_kind, tilde_path}; pub(crate) async fn list_command( args: &RunsListArgs, @@ -44,7 +44,6 @@ pub(crate) async fn list_command( "workflow_name": run.workflow_name(), "workflow_slug": run.workflow_slug(), "status": run.status(), - "status_reason": run.status_reason(), "start_time": run.start_time(), "labels": run.labels(), "duration_ms": run.duration_ms(), @@ -142,14 +141,16 @@ pub(crate) async fn list_command( } fn status_cell(status: RunStatus, use_color: bool) -> CellStruct { - let text = status.to_string(); + let text = run_status_kind(status); let color = match status { - RunStatus::Succeeded => Some(Color::Green), - RunStatus::Failed => Some(Color::Red), + RunStatus::Succeeded { .. } => Some(Color::Green), + RunStatus::Failed { .. } => Some(Color::Red), RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan), - RunStatus::Queued | RunStatus::Dead | RunStatus::Archived => Some(Color::Ansi256(8)), - RunStatus::Blocked | RunStatus::Removing => Some(Color::Yellow), - RunStatus::Paused => Some(Color::Magenta), + RunStatus::Queued | RunStatus::Dead | RunStatus::Archived { .. } => { + Some(Color::Ansi256(8)) + } + RunStatus::Blocked { .. } | RunStatus::Removing => Some(Color::Yellow), + RunStatus::Paused { .. } => Some(Color::Magenta), }; text.cell() .bold(use_color && color != Some(Color::Ansi256(8))) diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 3e27dbcde..1c96d336d 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -317,8 +317,8 @@ mod tests { use fabro_types::settings::SettingsLayer; use fabro_types::{ AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph, - NodeStatusRecord, Retro, RunId, RunSpec, RunStatus, RunStatusRecord, SandboxRecord, - StageStatus, StartRecord, StatusReason, fixtures, + NodeStatusRecord, Retro, RunId, RunSpec, RunStatus, SandboxRecord, StageStatus, + StartRecord, SuccessReason, fixtures, }; use fabro_workflow::event::{Event, append_event}; use object_store::ObjectStore; @@ -379,15 +379,6 @@ mod tests { } } - fn sample_status() -> RunStatusRecord { - RunStatusRecord { - status: RunStatus::Running, - status_reason: Some(StatusReason::SandboxInitializing), - blocked_reason: None, - updated_at: dt("2026-03-27T12:05:00Z"), - } - } - fn sample_checkpoint(current_node: &str, visit: u32) -> Checkpoint { Checkpoint { timestamp: dt("2026-03-27T12:10:00Z"), @@ -480,7 +471,6 @@ mod tests { let run = store.create_run(&run_id).await.unwrap(); let run_spec = sample_run_spec(run_id, created_at); let start_record = sample_start_record(run_id, created_at); - let status_record = sample_status(); let mut first_checkpoint = sample_checkpoint("plan", 1); let mut second_checkpoint = sample_checkpoint("code", 2); let conclusion = sample_conclusion(); @@ -528,11 +518,9 @@ mod tests { }) .await .unwrap(); - append_event(&run, &run_id, &Event::RunRunning { - reason: status_record.status_reason, - }) - .await - .unwrap(); + append_event(&run, &run_id, &Event::RunRunning) + .await + .unwrap(); for checkpoint in [&first_checkpoint, &second_checkpoint] { append_event(&run, &run_id, &Event::CheckpointCompleted { node_id: checkpoint.current_node.clone(), @@ -654,7 +642,7 @@ mod tests { duration_ms: conclusion.duration_ms, artifact_count: 0, status: "success".to_string(), - reason: None, + reason: SuccessReason::Completed, total_usd_micros: conclusion .billing .as_ref() @@ -719,8 +707,10 @@ mod tests { Some(run_id) ); assert_eq!( - exported_run.status.as_ref().map(|status| status.status), - Some(RunStatus::Succeeded) + exported_run.status, + Some(RunStatus::Succeeded { + reason: SuccessReason::Completed, + }) ); assert_eq!( exported_run diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index a751a453b..06bc8162e 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::Result; use chrono::{DateTime, Utc}; -use fabro_types::{RunId, RunStatus, RunSummary, StatusReason}; +use fabro_types::{RunId, RunStatus, RunSummary}; use crate::server_client::Client; @@ -36,10 +36,6 @@ impl ServerRunSummaryInfo { self.summary.status } - pub(crate) fn status_reason(&self) -> Option { - self.summary.status_reason - } - pub(crate) fn start_time(&self) -> String { self.start_time_dt() .map(|time| time.to_rfc3339()) diff --git a/lib/crates/fabro-cli/src/shared/utilities.rs b/lib/crates/fabro-cli/src/shared/utilities.rs index a58bb3dff..2dd06587f 100644 --- a/lib/crates/fabro-cli/src/shared/utilities.rs +++ b/lib/crates/fabro-cli/src/shared/utilities.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use cli_table::Color; +use fabro_types::RunStatus; use fabro_util::printer::Printer; use fabro_util::terminal::Styles; use fabro_validate::{Diagnostic, Severity}; @@ -112,6 +113,22 @@ pub(crate) fn color_if(use_color: bool, color: Color) -> Option { if use_color { Some(color) } else { None } } +pub(crate) fn run_status_kind(status: RunStatus) -> &'static str { + match status { + RunStatus::Submitted => "submitted", + RunStatus::Queued => "queued", + RunStatus::Starting => "starting", + RunStatus::Running => "running", + RunStatus::Blocked { .. } => "blocked", + RunStatus::Paused { .. } => "paused", + RunStatus::Removing => "removing", + RunStatus::Succeeded { .. } => "succeeded", + RunStatus::Failed { .. } => "failed", + RunStatus::Dead => "dead", + RunStatus::Archived { .. } => "archived", + } +} + pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> { if s.starts_with('/') || s.starts_with("./") || s.starts_with("../") { return None; diff --git a/lib/crates/fabro-cli/tests/it/cmd/archive.rs b/lib/crates/fabro-cli/tests/it/cmd/archive.rs index 06f276557..e80f717dd 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/archive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/archive.rs @@ -96,7 +96,9 @@ fn archive_succeeded_run_hides_it_from_default_ps() { assert!(output.status.success()); let runs: Vec = serde_json::from_slice(&output.stdout).expect("ps JSON should parse"); assert_eq!(runs.len(), 1, "ps -a should show the archived run"); - assert_eq!(runs[0]["status"], "archived"); + assert_eq!(runs[0]["status"]["kind"], "archived"); + assert_eq!(runs[0]["status"]["prior"]["kind"], "succeeded"); + assert_eq!(runs[0]["status"]["prior"]["reason"], "completed"); assert_eq!(runs[0]["run_id"], run.run_id); } @@ -223,9 +225,10 @@ fn archive_resolves_selector_via_server_endpoint() { "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", "created_at": "2026-04-05T12:00:00Z", - "status": "succeeded", - "status_reason": null, - "blocked_reason": null, + "status": { + "kind": "succeeded", + "reason": "completed" + }, "pending_control": null, "duration_ms": 123, "elapsed_secs": 0, @@ -242,11 +245,15 @@ fn archive_resolves_selector_via_server_endpoint() { .body( serde_json::json!({ "id": run_id, - "status": "archived", - "blocked_reason": null, + "status": { + "kind": "archived", + "prior": { + "kind": "succeeded", + "reason": "completed" + } + }, "error": null, "queue_position": null, - "status_reason": null, "pending_control": null, "created_at": "2026-04-05T12:00:00Z" }) diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index e313b0d69..68af2b229 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -645,9 +645,7 @@ fn attach_json_errors_without_prompting_for_human_input() { { "event": "run.starting", "id": "[EVENT_ID]", - "properties": { - "reason": "sandbox_initializing" - }, + "properties": {}, "run_id": "[ULID]", "ts": "[TIMESTAMP]" }, diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index d09845eaa..fb768cea3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -13,6 +13,10 @@ fn resolved_run( } fn run_status_response(run_id: &str, status: &str) -> serde_json::Value { + let status = match status { + "submitted" => json!({ "kind": "submitted" }), + other => panic!("unsupported test status {other:?}"), + }; serde_json::json!({ "id": run_id, "status": status, diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 7b35d48af..3203e0662 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -9,7 +9,7 @@ use super::support::{ }; use crate::support::unique_run_id; -fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value { +fn remote_run_summary(run_id: &str, status: serde_json::Value) -> serde_json::Value { json!({ "run_id": run_id, "workflow_name": "Nightly Build", @@ -22,8 +22,6 @@ fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value { "start_time": "2026-04-19T12:00:00Z", "created_at": "2026-04-19T12:00:00Z", "status": status, - "status_reason": null, - "blocked_reason": null, "pending_control": null, "duration_ms": null, "elapsed_secs": null, @@ -64,7 +62,13 @@ fn inspect_resolves_selector_via_server_endpoint() { let context = test_context!(); let server = MockServer::start(); let run_id = unique_run_id(); - let summary = remote_run_summary(run_id.as_str(), "succeeded"); + let summary = remote_run_summary( + run_id.as_str(), + json!({ + "kind": "succeeded", + "reason": "completed" + }), + ); let resolve_run = server.mock(|when, then| { when.method("GET") @@ -97,7 +101,10 @@ fn inspect_resolves_selector_via_server_endpoint() { [ { "run_id": "[ULID]", - "status": "succeeded", + "status": { + "kind": "succeeded", + "reason": "completed" + }, "run_spec": null, "start_record": null, "conclusion": null, @@ -122,7 +129,9 @@ fn inspect_created_run_shows_run_spec_without_start_or_conclusion() { [ { "run_id": "[ULID]", - "status": "submitted", + "status": { + "kind": "submitted" + }, "run_spec": { "goal": "Run tests and report results", "workflow_name": "Simple", @@ -155,7 +164,10 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() { [ { "run_id": "[ULID]", - "status": "succeeded", + "status": { + "kind": "succeeded", + "reason": "completed" + }, "run_spec": { "goal": "Run tests and report results", "workflow_name": "Simple", @@ -221,7 +233,10 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() { [ { "run_id": "[ULID]", - "status": "succeeded", + "status": { + "kind": "succeeded", + "reason": "completed" + }, "run_spec": { "goal": "Run tests and report results", "workflow_name": "Simple", @@ -272,7 +287,10 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() { [ { "run_id": "[ULID]", - "status": "succeeded", + "status": { + "kind": "succeeded", + "reason": "completed" + }, "run_spec": { "goal": "Edit a tracked file", "workflow_name": "Flow", diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 268cdfa3f..d20d3998c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -204,11 +204,13 @@ fn ps_all_json_lists_created_and_completed_runs() { "all runs should be scoped to the current test session: {runs:#?}" ); assert!( - runs.iter().any(|run| run["status"] == "submitted"), + runs.iter().any(|run| run["status"]["kind"] == "submitted"), "ps should include the created run: {runs:#?}" ); assert!( - runs.iter().any(|run| run["status"] == "succeeded"), + runs.iter().any(|run| { + run["status"]["kind"] == "succeeded" && run["status"]["reason"] == "completed" + }), "ps should include the completed run: {runs:#?}" ); } @@ -319,7 +321,8 @@ fn ps_filters_by_workflow_and_label() { ); let run = &runs[0]; assert_eq!(run["workflow_name"], "Simple"); - assert_eq!(run["status"], "succeeded"); + assert_eq!(run["status"]["kind"], "succeeded"); + assert_eq!(run["status"]["reason"], "completed"); assert_eq!(run["labels"]["suite"], "alpha"); assert_eq!(run["labels"]["fabro_test_case"], context.test_case_id()); assert_eq!(run["labels"]["fabro_test_run"], context.test_run_id()); @@ -349,8 +352,10 @@ fn ps_uses_configured_server_target_without_server_flag() { "repository": { "name": "repo" }, "start_time": "2026-04-05T12:00:00Z", "created_at": "2026-04-05T12:00:00Z", - "status": "succeeded", - "status_reason": null, + "status": { + "kind": "succeeded", + "reason": "completed" + }, "duration_ms": 123, "total_usd_micros": null }], @@ -403,8 +408,10 @@ fn ps_explicit_remote_target_ignores_broken_local_storage_settings() { "repository": { "name": "repo" }, "start_time": "2026-04-20T12:00:00Z", "created_at": "2026-04-20T12:00:00Z", - "status": "succeeded", - "status_reason": null, + "status": { + "kind": "succeeded", + "reason": "completed" + }, "duration_ms": 123, "total_usd_micros": null }], diff --git a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs index 997b16a7f..982057343 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs @@ -184,8 +184,8 @@ fn rewind_preserves_event_history_and_clears_terminal_snapshot_state() { let state = run_state(&setup.run.run_dir); assert_eq!( - state.status.as_ref().map(|status| &status.status), - Some(&fabro_types::RunStatus::Submitted) + state.status, + Some(fabro_types::RunStatus::Submitted) ); assert!(state.conclusion.is_none(), "rewind should clear conclusion"); assert!( diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index e5830dd06..fe08fb3bf 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -170,9 +170,9 @@ fn rm_force_removes_active_run() { "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", "created_at": "2026-04-05T12:00:00Z", - "status": "running", - "status_reason": null, - "blocked_reason": null, + "status": { + "kind": "running" + }, "pending_control": null, "duration_ms": 123, "elapsed_secs": 0, @@ -236,9 +236,9 @@ fn rm_without_force_uses_resolve_then_surfaces_server_conflict() { "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", "created_at": "2026-04-05T12:00:00Z", - "status": "running", - "status_reason": null, - "blocked_reason": null, + "status": { + "kind": "running" + }, "pending_control": null, "duration_ms": 123, "elapsed_secs": 0, @@ -370,8 +370,10 @@ fn rm_uses_configured_server_target_without_local_run_dir() { "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", "created_at": "2026-04-05T12:00:00Z", - "status": "succeeded", - "status_reason": null, + "status": { + "kind": "succeeded", + "reason": "completed" + }, "duration_ms": 123, "total_usd_micros": null }) diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 4ceac1336..c29798113 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -15,6 +15,11 @@ use super::support::{output_stderr, wait_for_event_names}; use crate::support::{fabro_json_snapshot, run_output_filters, unique_run_id}; fn run_status_response(run_id: &str, status: &str) -> serde_json::Value { + let status = match status { + "submitted" => serde_json::json!({ "kind": "submitted" }), + "queued" => serde_json::json!({ "kind": "queued" }), + other => panic!("unsupported test status {other:?}"), + }; serde_json::json!({ "id": run_id, "status": status, @@ -90,7 +95,8 @@ fn run_completed_event(run_id: &str) -> serde_json::Value { "properties": { "duration_ms": 12, "artifact_count": 0, - "status": "success" + "status": "success", + "reason": "completed" } }) } diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index 3adbb0e89..4262e5b2e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use fabro_store::EventEnvelope; use fabro_test::{assert_reqwest_status, expect_reqwest_json, fabro_snapshot, test_context}; -use fabro_types::{EventBody, RunEvent, StatusReason}; +use fabro_types::{EventBody, FailureReason, RunEvent}; use httpmock::MockServer; use super::support::{ @@ -757,6 +757,10 @@ fn worker_exits_after_sigterm_cancel_even_when_stdin_stays_open() { let status_record = run_state(&run_dir) .status .expect("cancelled run should have a status record"); - assert_eq!(status_record.status.to_string(), "failed"); - assert_eq!(status_record.status_reason, Some(StatusReason::Cancelled)); + assert_eq!( + status_record, + fabro_types::RunStatus::Failed { + reason: FailureReason::Cancelled, + } + ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/start.rs b/lib/crates/fabro-cli/tests/it/cmd/start.rs index cf586e3bc..7eec89a3d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/start.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/start.rs @@ -185,7 +185,7 @@ fn start_rejects_already_active_or_completed_run() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: cannot start run: status is Succeeded, expected submitted + error: cannot start run: status is succeeded(completed), expected submitted "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index f706d67bb..20b2ff40f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -538,12 +538,21 @@ pub(crate) fn write_gated_workflow(path: &Path, name: &str, goal: &str) -> Workf pub(crate) fn wait_for_status(run_dir: &Path, expected: &[&str]) -> String { let deadline = Instant::now() + COMMAND_TIMEOUT; loop { - if let Some(status) = run_state(run_dir) - .status - .map(|record| record.status.to_string()) - { + if let Some(status) = run_state(run_dir).status.map(|status| match status { + fabro_types::RunStatus::Submitted => "submitted", + fabro_types::RunStatus::Queued => "queued", + fabro_types::RunStatus::Starting => "starting", + fabro_types::RunStatus::Running => "running", + fabro_types::RunStatus::Blocked { .. } => "blocked", + fabro_types::RunStatus::Paused { .. } => "paused", + fabro_types::RunStatus::Removing => "removing", + fabro_types::RunStatus::Succeeded { .. } => "succeeded", + fabro_types::RunStatus::Failed { .. } => "failed", + fabro_types::RunStatus::Dead => "dead", + fabro_types::RunStatus::Archived { .. } => "archived", + }) { if expected.iter().any(|candidate| *candidate == status) { - return status; + return status.to_string(); } } assert!( diff --git a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs index 0ee460afa..de5c500b3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs @@ -94,7 +94,8 @@ fn unarchive_archived_run_restores_prior_terminal_status() { assert!(output.status.success()); let runs: Vec = serde_json::from_slice(&output.stdout).expect("ps JSON should parse"); assert_eq!(runs.len(), 1, "ps -a should show the unarchived run"); - assert_eq!(runs[0]["status"], "succeeded"); + assert_eq!(runs[0]["status"]["kind"], "succeeded"); + assert_eq!(runs[0]["status"]["reason"], "completed"); } #[test] @@ -229,9 +230,13 @@ fn unarchive_resolves_selector_via_server_endpoint() { "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", "created_at": "2026-04-05T12:00:00Z", - "status": "archived", - "status_reason": null, - "blocked_reason": null, + "status": { + "kind": "archived", + "prior": { + "kind": "succeeded", + "reason": "completed" + } + }, "pending_control": null, "duration_ms": 123, "elapsed_secs": 0, @@ -248,11 +253,12 @@ fn unarchive_resolves_selector_via_server_endpoint() { .body( serde_json::json!({ "id": run_id, - "status": "succeeded", - "blocked_reason": null, + "status": { + "kind": "succeeded", + "reason": "completed" + }, "error": null, "queue_position": null, - "status_reason": null, "pending_control": null, "created_at": "2026-04-05T12:00:00Z" }) diff --git a/lib/crates/fabro-cli/tests/it/cmd/wait.rs b/lib/crates/fabro-cli/tests/it/cmd/wait.rs index 5cc03f234..65b88108b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/wait.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/wait.rs @@ -5,7 +5,7 @@ use serde_json::json; use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run}; use crate::support::unique_run_id; -fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value { +fn remote_run_summary(run_id: &str, status: serde_json::Value) -> serde_json::Value { json!({ "run_id": run_id, "workflow_name": "Blocked Remote Workflow", @@ -18,8 +18,6 @@ fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value { "start_time": "2026-04-19T12:00:00Z", "created_at": "2026-04-19T12:00:00Z", "status": status, - "status_reason": null, - "blocked_reason": null, "duration_ms": null, "total_usd_micros": null }) @@ -143,7 +141,13 @@ fn wait_blocked_run_times_out_without_treating_it_as_terminal() { let context = test_context!(); let run_id = unique_run_id(); let server = MockServer::start(); - let summary = remote_run_summary(run_id.as_str(), "blocked"); + let summary = remote_run_summary( + run_id.as_str(), + json!({ + "kind": "blocked", + "blocked_reason": "human_input_required" + }), + ); let resolve_run = server.mock(|when, then| { when.method("GET") diff --git a/lib/crates/fabro-cli/tests/it/scenario/archive.rs b/lib/crates/fabro-cli/tests/it/scenario/archive.rs index 34f48f8d7..8c7b4d4f3 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/archive.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/archive.rs @@ -28,7 +28,8 @@ fn archive_lifecycle_end_to_end() { let visible = ps_runs(&context, true); assert_eq!(visible.len(), 1); assert_eq!(visible[0]["run_id"], run.run_id); - assert_eq!(visible[0]["status"], "succeeded"); + assert_eq!(visible[0]["status"]["kind"], "succeeded"); + assert_eq!(visible[0]["status"]["reason"], "completed"); let archive = context .command() @@ -50,7 +51,9 @@ fn archive_lifecycle_end_to_end() { let with_archived = ps_runs(&context, true); assert_eq!(with_archived.len(), 1); assert_eq!(with_archived[0]["run_id"], run.run_id); - assert_eq!(with_archived[0]["status"], "archived"); + assert_eq!(with_archived[0]["status"]["kind"], "archived"); + assert_eq!(with_archived[0]["status"]["prior"]["kind"], "succeeded"); + assert_eq!(with_archived[0]["status"]["prior"]["reason"], "completed"); let unarchive = context .command() @@ -63,7 +66,8 @@ fn archive_lifecycle_end_to_end() { String::from_utf8_lossy(&unarchive.stderr) ); let restored = ps_runs(&context, true); - assert_eq!(restored[0]["status"], "succeeded"); + assert_eq!(restored[0]["status"]["kind"], "succeeded"); + assert_eq!(restored[0]["status"]["reason"], "completed"); // `rm` must remain available on archived runs — archive and delete are // orthogonal per the plan's Scope Boundaries. diff --git a/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs index b352bd8d0..496a3c029 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs @@ -147,7 +147,19 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() { fabro_json_snapshot!( context, serde_json::json!({ - "status": state.status.map(|status| status.status), + "status": state.status.map(|status| match status { + fabro_types::RunStatus::Submitted => "submitted", + fabro_types::RunStatus::Queued => "queued", + fabro_types::RunStatus::Starting => "starting", + fabro_types::RunStatus::Running => "running", + fabro_types::RunStatus::Blocked { .. } => "blocked", + fabro_types::RunStatus::Paused { .. } => "paused", + fabro_types::RunStatus::Removing => "removing", + fabro_types::RunStatus::Succeeded { .. } => "succeeded", + fabro_types::RunStatus::Failed { .. } => "failed", + fabro_types::RunStatus::Dead => "dead", + fabro_types::RunStatus::Archived { .. } => "archived", + }), "has_conclusion": state.conclusion.is_some(), }), @r#" diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs index 13160b955..965558ff7 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs @@ -9,10 +9,9 @@ fn live_run_state_response() -> serde_json::Value { "graph_source": null, "start": null, "status": { - "status": "running", - "reason": null, - "updated_at": "2026-04-05T12:00:01Z" + "kind": "running" }, + "status_updated_at": "2026-04-05T12:00:01Z", "checkpoint": null, "checkpoints": [], "conclusion": null, @@ -36,7 +35,8 @@ fn run_sse_body(run_id: &str) -> String { "properties": { "duration_ms": 12, "artifact_count": 0, - "status": "success" + "status": "success", + "reason": "completed" } }); @@ -251,9 +251,9 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", "created_at": "2026-04-05T12:00:00Z", - "status": "running", - "status_reason": null, - "blocked_reason": null, + "status": { + "kind": "running" + }, "pending_control": null, "duration_ms": 12, "elapsed_secs": 0, @@ -355,9 +355,9 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { "repository": { "name": "unknown" }, "start_time": "2026-04-05T12:00:00Z", "created_at": "2026-04-05T12:00:00Z", - "status": "running", - "status_reason": null, - "blocked_reason": null, + "status": { + "kind": "running" + }, "pending_control": null, "duration_ms": 12, "elapsed_secs": 0, diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 7ca48a246..ec72d3950 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -379,8 +379,7 @@ pub(crate) async fn cancel_stub( StatusCode::OK, Json(serde_json::json!({ "id": id, - "status": "failed", - "status_reason": "cancelled", + "status": { "kind": "failed", "reason": "cancelled" }, "created_at": "2026-03-06T14:30:00Z" })), ) @@ -767,7 +766,6 @@ fn ts(s: &str) -> DateTime { mod runs { use std::collections::HashMap; - use std::str::FromStr; use std::time::Duration; use fabro_api::types::*; @@ -796,8 +794,6 @@ mod runs { total_usd_micros: Option, entries: &[(&str, &str)], ) -> StoreRunSummary { - let status_reason = status_reason.and_then(parse_status_reason); - StoreRunSummary { created_at: ts(created_at), duration_ms: elapsed_secs.and_then(duration_ms_from_secs), @@ -811,10 +807,8 @@ mod runs { }, run_id: run_id.into(), start_time: Some(ts(created_at)), - status: RunStatus::from_str(status) - .unwrap_or_else(|_| panic!("invalid demo run status: {status}")), - status_reason, - blocked_reason: None, + status: parse_run_status(status, status_reason) + .unwrap_or_else(|| panic!("invalid demo run status: {status}")), title: truncate_goal(goal), total_usd_micros, workflow_name: Some(workflow_name.into()), @@ -822,8 +816,52 @@ mod runs { } } - fn parse_status_reason(reason: &str) -> Option { - StatusReason::from_str(reason).ok() + fn parse_run_status(status: &str, status_reason: Option<&str>) -> Option { + match status { + "submitted" => Some(RunStatus::Submitted), + "queued" => Some(RunStatus::Queued), + "starting" => Some(RunStatus::Starting), + "running" => Some(RunStatus::Running), + "blocked" => Some(RunStatus::Blocked { + blocked_reason: BlockedReason::HumanInputRequired, + }), + "paused" => Some(RunStatus::Paused { prior_block: None }), + "removing" => Some(RunStatus::Removing), + "succeeded" => Some(RunStatus::Succeeded { + reason: status_reason + .and_then(parse_success_reason) + .unwrap_or(SuccessReason::Completed), + }), + "failed" => Some(RunStatus::Failed { + reason: status_reason + .and_then(parse_failure_reason) + .unwrap_or(FailureReason::WorkflowError), + }), + "dead" => Some(RunStatus::Dead), + _ => None, + } + } + + fn parse_success_reason(reason: &str) -> Option { + match reason { + "completed" => Some(SuccessReason::Completed), + "partial_success" => Some(SuccessReason::PartialSuccess), + _ => None, + } + } + + fn parse_failure_reason(reason: &str) -> Option { + match reason { + "workflow_error" => Some(FailureReason::WorkflowError), + "cancelled" => Some(FailureReason::Cancelled), + "terminated" => Some(FailureReason::Terminated), + "transient_infra" => Some(FailureReason::TransientInfra), + "budget_exhausted" => Some(FailureReason::BudgetExhausted), + "launch_failed" => Some(FailureReason::LaunchFailed), + "bootstrap_failed" => Some(FailureReason::BootstrapFailed), + "sandbox_init_failed" => Some(FailureReason::SandboxInitFailed), + _ => None, + } } fn duration_ms_from_secs(secs: f64) -> Option { @@ -862,8 +900,7 @@ mod runs { run_id: summary.run_id, sandbox, start_time: summary.start_time, - status: summary.status.to_string(), - status_reason: summary.status_reason, + status: summary.status, title: summary.title, total_usd_micros: summary.total_usd_micros, workflow_name: summary.workflow_name, @@ -1372,7 +1409,12 @@ mod runs { &[], ); - assert_eq!(summary.status_reason, Some(StatusReason::Cancelled)); + assert_eq!( + summary.status, + RunStatus::Failed { + reason: FailureReason::Cancelled, + } + ); } #[test] @@ -1392,7 +1434,12 @@ mod runs { &[], ); - assert_eq!(summary.status_reason, None); + assert_eq!( + summary.status, + RunStatus::Failed { + reason: FailureReason::WorkflowError, + } + ); } #[test] diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs index 7e4486a17..9bd1e7655 100644 --- a/lib/crates/fabro-server/src/run_files.rs +++ b/lib/crates/fabro-server/src/run_files.rs @@ -385,7 +385,7 @@ fn reason_for_fallback(projection: &fabro_store::RunProjection) -> RunFilesMetaD let is_terminal = projection .status .as_ref() - .is_some_and(|s| s.status.is_terminal()); + .is_some_and(|status| status.is_terminal()); if is_terminal { RunFilesMetaDegradedReason::SandboxGone } else { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 9714a5d39..5e4821dc1 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -61,6 +61,8 @@ use fabro_slack::{blocks as slack_blocks, connection as slack_connection}; use fabro_store::{ ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId, }; +#[cfg(test)] +use fabro_types::BlockedReason; use fabro_types::settings::run::RunMode; use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, ServerAuthLayer, ServerAuthMethod, @@ -70,7 +72,7 @@ use fabro_types::settings::{ InterpString, ServerSettings as ResolvedServerSettings, SettingsLayer, }; use fabro_types::{ - ActorRef, BlockedReason, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, + ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance, }; @@ -88,7 +90,7 @@ use fabro_workflow::records::Checkpoint; use fabro_workflow::run_lookup::{ RunInfo, StatusFilter, filter_runs, scan_runs_with_summaries, scratch_base, }; -use fabro_workflow::run_status::{RunStatus, StatusReason}; +use fabro_workflow::run_status::{FailureReason, RunStatus, SuccessReason}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; use object_store::memory::InMemory as MemoryObjectStore; use rand::TryRngCore; @@ -1381,8 +1383,8 @@ async fn get_system_info( RunStatus::Queued | RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } ) }) .count(); @@ -1800,10 +1802,7 @@ fn attach_event_is_terminal(event: &EventEnvelope) -> bool { } fn run_projection_is_active(state: &fabro_store::RunProjection) -> bool { - state - .status - .as_ref() - .is_some_and(|record| record.status.is_active()) + state.status.is_some_and(RunStatus::is_active) } fn dir_size(path: &std::path::Path) -> u64 { @@ -2240,7 +2239,7 @@ async fn list_run_stages( Some(managed_run) => { let active = !matches!( managed_run.status, - RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead + RunStatus::Succeeded { .. } | RunStatus::Failed { .. } | RunStatus::Dead ); (managed_run.checkpoint.clone(), active) } @@ -2255,10 +2254,7 @@ async fn list_run_stages( 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() - .is_some_and(|s| !s.status.is_terminal()); + let active = run_state.status.is_some_and(|status| !status.is_terminal()); (run_state.checkpoint, active) } Err(_) => (None, false), @@ -2725,11 +2721,11 @@ fn test_secret_store_path() -> PathBuf { fn board_column(status: RunStatus) -> Option<&'static str> { match status { RunStatus::Submitted | RunStatus::Queued | RunStatus::Starting => Some("initializing"), - RunStatus::Running | RunStatus::Paused => Some("running"), - RunStatus::Blocked => Some("blocked"), - RunStatus::Succeeded => Some("succeeded"), - RunStatus::Failed | RunStatus::Dead => Some("failed"), - RunStatus::Removing | RunStatus::Archived => None, + RunStatus::Running | RunStatus::Paused { .. } => Some("running"), + RunStatus::Blocked { .. } => Some("blocked"), + RunStatus::Succeeded { .. } => Some("succeeded"), + RunStatus::Failed { .. } | RunStatus::Dead => Some("failed"), + RunStatus::Removing | RunStatus::Archived { .. } => None, } } @@ -2784,8 +2780,6 @@ fn summary_to_api_run_summary(summary: fabro_types::RunSummary) -> serde_json::V "repository": { "name": repository }, "start_time": summary.start_time.map(|time| time.to_rfc3339()), "status": summary.status, - "status_reason": summary.status_reason, - "blocked_reason": summary.blocked_reason, "pending_control": summary.pending_control, "duration_ms": summary.duration_ms, "elapsed_secs": elapsed_secs(summary.duration_ms), @@ -2918,7 +2912,9 @@ async fn list_runs( let include_archived = params.include_archived; let items = runs .into_iter() - .filter(|summary| include_archived || summary.status != RunStatus::Archived) + .filter(|summary| { + include_archived || !matches!(summary.status, RunStatus::Archived { .. }) + }) .map(summary_to_api_run_summary) .collect::>(); let (data, has_more) = paginate_items(items, ¶ms.pagination()); @@ -3037,8 +3033,8 @@ async fn delete_run_internal( | RunStatus::Queued | RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } ) { WORKER_CANCEL_GRACE } else { @@ -3092,8 +3088,8 @@ async fn reject_active_delete_without_force( | RunStatus::Queued | RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } ) { return Err(ApiError::new( StatusCode::CONFLICT, @@ -3380,13 +3376,13 @@ struct LiveWorkerProcess { fn failure_for_incomplete_run( pending_control: Option, terminated_message: String, -) -> (WorkflowError, Option) { +) -> (WorkflowError, FailureReason) { if pending_control == Some(RunControlAction::Cancel) { - (WorkflowError::Cancelled, Some(StatusReason::Cancelled)) + (WorkflowError::Cancelled, FailureReason::Cancelled) } else { ( WorkflowError::engine(terminated_message), - Some(StatusReason::Terminated), + FailureReason::Terminated, ) } } @@ -3397,8 +3393,8 @@ fn should_reconcile_run_on_startup(status: RunStatus) -> bool { RunStatus::Queued | RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } | RunStatus::Removing ) } @@ -3467,11 +3463,7 @@ async fn persist_shutdown_run_failures( for run_id in run_ids { let run_store = state.store.open_run(&run_id).await?; let run_state = run_store.state().await?; - if run_state - .status - .as_ref() - .is_some_and(|status| status.status.is_terminal()) - { + if run_state.status.is_some_and(RunStatus::is_terminal) { continue; } @@ -3559,7 +3551,7 @@ async fn persist_cancelled_run_status(state: &AppState, run_id: RunId) -> anyhow &workflow_event::Event::WorkflowRunFailed { error: WorkflowError::Cancelled, duration_ms: 0, - reason: Some(StatusReason::Cancelled), + reason: FailureReason::Cancelled, git_commit_sha: None, final_patch: None, }, @@ -3620,24 +3612,6 @@ fn worker_mode_arg(mode: RunExecutionMode) -> &'static str { } } -async fn load_run_status_metadata( - state: &AppState, - run_id: RunId, -) -> ( - Option, - Option, - Option, -) { - match state.store.runs().find(&run_id).await { - Ok(Some(summary)) => ( - summary.status_reason, - summary.blocked_reason, - summary.pending_control, - ), - _ => (None, None, None), - } -} - async fn load_pending_control( state: &AppState, run_id: RunId, @@ -3650,16 +3624,16 @@ async fn load_pending_control( .and_then(|summary| summary.pending_control)) } -fn fail_managed_run(state: &Arc, run_id: RunId, message: String) { +fn fail_managed_run(state: &Arc, run_id: RunId, reason: FailureReason, message: String) { let mut runs = state.runs.lock().expect("runs lock poisoned"); if let Some(managed_run) = runs.get_mut(&run_id) { - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { reason }; managed_run.error = Some(message); clear_live_run_state(managed_run); } } -fn update_live_run_from_event(state: &Arc, run_id: RunId, event: &RunEvent) { +fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent) { use fabro_types::EventBody; let mut runs = state.runs.lock().expect("runs lock poisoned"); @@ -3668,27 +3642,71 @@ fn update_live_run_from_event(state: &Arc, run_id: RunId, event: &RunE }; match &event.body { + EventBody::RunSubmitted(_) => managed_run.status = RunStatus::Submitted, EventBody::RunQueued(_) => managed_run.status = RunStatus::Queued, EventBody::RunStarting(_) => managed_run.status = RunStatus::Starting, - EventBody::RunRunning(_) | EventBody::RunUnpaused(_) => { - managed_run.status = RunStatus::Running; + EventBody::RunRunning(_) => managed_run.status = RunStatus::Running, + EventBody::RunBlocked(props) => { + managed_run.status = match managed_run.status { + RunStatus::Paused { .. } => RunStatus::Paused { + prior_block: Some(props.blocked_reason), + }, + _ => RunStatus::Blocked { + blocked_reason: props.blocked_reason, + }, + }; } - EventBody::RunBlocked(_) if managed_run.status != RunStatus::Paused => { - managed_run.status = RunStatus::Blocked; + EventBody::RunUnblocked(_) => { + managed_run.status = match managed_run.status { + RunStatus::Paused { + prior_block: Some(_), + } => RunStatus::Paused { prior_block: None }, + RunStatus::Paused { prior_block: None } => RunStatus::Paused { prior_block: None }, + _ => RunStatus::Running, + }; } - EventBody::RunUnblocked(_) if managed_run.status != RunStatus::Paused => { - managed_run.status = RunStatus::Running; + EventBody::RunPaused(_) => { + let prior_block = match managed_run.status { + RunStatus::Blocked { blocked_reason } => Some(blocked_reason), + RunStatus::Paused { prior_block } => prior_block, + _ => None, + }; + managed_run.status = RunStatus::Paused { prior_block }; + } + EventBody::RunUnpaused(_) => { + managed_run.status = match managed_run.status { + RunStatus::Paused { + prior_block: Some(blocked_reason), + } => RunStatus::Blocked { blocked_reason }, + _ => RunStatus::Running, + }; } - EventBody::RunPaused(_) => managed_run.status = RunStatus::Paused, EventBody::RunRemoving(_) => managed_run.status = RunStatus::Removing, EventBody::RunCompleted(_) => { - managed_run.status = RunStatus::Succeeded; + let EventBody::RunCompleted(props) = &event.body else { + unreachable!(); + }; + managed_run.status = RunStatus::Succeeded { + reason: props.reason, + }; managed_run.error = None; } EventBody::RunFailed(props) => { - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: props.reason, + }; managed_run.error = Some(props.error.clone()); } + EventBody::RunArchived(_) => { + if let Some(prior) = managed_run.status.terminal_status() { + managed_run.status = RunStatus::Archived { prior }; + } + } + EventBody::RunUnarchived(_) => { + if let RunStatus::Archived { prior } = managed_run.status { + managed_run.status = prior.into(); + } + } _ => {} } } @@ -3730,10 +3748,7 @@ async fn append_worker_exit_failure( } }; - let terminal = state - .status - .as_ref() - .is_some_and(|status| status.status.is_terminal()); + let terminal = state.status.is_some_and(RunStatus::is_terminal); if terminal { return; } @@ -4148,12 +4163,10 @@ async fn create_run( ( StatusCode::CREATED, Json(RunStatusResponse { - blocked_reason: None, id: run_id.to_string(), status: RunStatus::Submitted, error: None, queue_position: None, - status_reason: None, pending_control: None, created_at, }), @@ -4284,8 +4297,8 @@ async fn start_run( RunStatus::Queued | RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } ) { return ApiError::new( StatusCode::CONFLICT, @@ -4319,17 +4332,14 @@ async fn start_run( return ApiError::new(StatusCode::CONFLICT, "no checkpoint to resume from") .into_response(); } - } else if let Some(record) = run_state.status.as_ref() { + } else if let Some(status) = run_state.status { if !matches!( - record.status, + status, RunStatus::Submitted | RunStatus::Queued | RunStatus::Starting ) { return ApiError::new( StatusCode::CONFLICT, - format!( - "cannot start run: status is {:?}, expected submitted", - record.status - ), + format!("cannot start run: status is {status}, expected submitted"), ) .into_response(); } @@ -4384,12 +4394,10 @@ async fn start_run( ( StatusCode::OK, Json(RunStatusResponse { - blocked_reason: None, id: id.to_string(), status: RunStatus::Queued, error: None, queue_position: None, - status_reason: None, pending_control: None, created_at: id.created_at(), }), @@ -4492,7 +4500,9 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { tracing::error!(run_id = %run_id, error = %e, "Failed to open run store"); let mut runs = state.runs.lock().expect("runs lock poisoned"); if let Some(managed_run) = runs.get_mut(&run_id) { - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::WorkflowError, + }; managed_run.error = Some(format!("Failed to open run store: {e}")); clear_live_run_state(managed_run); } @@ -4511,7 +4521,9 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { tracing::error!(run_id = %run_id, error = %e, "Failed to load persisted run"); let mut runs = state.runs.lock().expect("runs lock poisoned"); if let Some(managed_run) = runs.get_mut(&run_id) { - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::WorkflowError, + }; managed_run.error = Some(format!("Failed to load persisted run: {e}")); clear_live_run_state(managed_run); } @@ -4525,7 +4537,9 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { tracing::error!(run_id = %run_id, error = %err, "Invalid GitHub integration config"); let mut runs = state.runs.lock().expect("runs lock poisoned"); if let Some(managed_run) = runs.get_mut(&run_id) { - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::WorkflowError, + }; managed_run.error = Some(format!("Invalid GitHub integration config: {err}")); clear_live_run_state(managed_run); } @@ -4567,7 +4581,9 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { tracing::error!(run_id = %run_id, error = %e, "Invalid GitHub credentials"); let mut runs = state.runs.lock().expect("runs lock poisoned"); if let Some(managed_run) = runs.get_mut(&run_id) { - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::WorkflowError, + }; managed_run.error = Some(format!("Invalid GitHub credentials: {e}")); clear_live_run_state(managed_run); } @@ -4656,31 +4672,43 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { Ok(started) => match &started.finalized.outcome { Ok(_) => { info!(run_id = %run_id, "Run completed"); - managed_run.status = RunStatus::Succeeded; + managed_run.status = RunStatus::Succeeded { + reason: SuccessReason::Completed, + }; } Err(WorkflowError::Cancelled) => { info!(run_id = %run_id, "Run cancelled"); - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::Cancelled, + }; } Err(e) => { error!(run_id = %run_id, error = %e, "Run failed"); - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::WorkflowError, + }; managed_run.error = Some(e.to_string()); } }, Err(WorkflowError::Cancelled) => { info!(run_id = %run_id, "Run cancelled"); - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::Cancelled, + }; } Err(e) => { error!(run_id = %run_id, error = %e, "Run failed"); - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::WorkflowError, + }; managed_run.error = Some(e.to_string()); } }, ExecutionResult::CancelledBySignal => { info!(run_id = %run_id, "Run cancelled"); - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::Cancelled, + }; } } managed_run.checkpoint = checkpoint; @@ -4712,7 +4740,12 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { Ok(run_store) => run_store, Err(err) => { tracing::error!(run_id = %run_id, error = %err, "Failed to open run store"); - fail_managed_run(&state, run_id, format!("Failed to open run store: {err}")); + fail_managed_run( + &state, + run_id, + FailureReason::WorkflowError, + format!("Failed to open run store: {err}"), + ); state.scheduler_notify.notify_one(); return; } @@ -4749,13 +4782,18 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { &workflow_event::Event::WorkflowRunFailed { error: WorkflowError::engine(err.to_string()), duration_ms: 0, - reason: Some(StatusReason::LaunchFailed), + reason: FailureReason::LaunchFailed, git_commit_sha: None, final_patch: None, }, ) .await; - fail_managed_run(&state, run_id, format!("Failed to spawn worker: {err}")); + fail_managed_run( + &state, + run_id, + FailureReason::LaunchFailed, + format!("Failed to spawn worker: {err}"), + ); state.scheduler_notify.notify_one(); return; } @@ -4771,13 +4809,13 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { &workflow_event::Event::WorkflowRunFailed { error: WorkflowError::engine(message.clone()), duration_ms: 0, - reason: Some(StatusReason::LaunchFailed), + reason: FailureReason::LaunchFailed, git_commit_sha: None, final_patch: None, }, ) .await; - fail_managed_run(&state, run_id, message); + fail_managed_run(&state, run_id, FailureReason::LaunchFailed, message); state.scheduler_notify.notify_one(); return; }; @@ -4801,13 +4839,13 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { &workflow_event::Event::WorkflowRunFailed { error: WorkflowError::engine(message.clone()), duration_ms: 0, - reason: Some(StatusReason::LaunchFailed), + reason: FailureReason::LaunchFailed, git_commit_sha: None, final_patch: None, }, ) .await; - fail_managed_run(&state, run_id, message); + fail_managed_run(&state, run_id, FailureReason::LaunchFailed, message); state.scheduler_notify.notify_one(); return; }; @@ -4822,13 +4860,13 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { &workflow_event::Event::WorkflowRunFailed { error: WorkflowError::engine(message.clone()), duration_ms: 0, - reason: Some(StatusReason::LaunchFailed), + reason: FailureReason::LaunchFailed, git_commit_sha: None, final_patch: None, }, ) .await; - fail_managed_run(&state, run_id, message); + fail_managed_run(&state, run_id, FailureReason::LaunchFailed, message); state.scheduler_notify.notify_one(); return; }; @@ -4855,13 +4893,18 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { &workflow_event::Event::WorkflowRunFailed { error: WorkflowError::engine(err.to_string()), duration_ms: 0, - reason: Some(StatusReason::Terminated), + reason: FailureReason::Terminated, git_commit_sha: None, final_patch: None, }, ) .await; - fail_managed_run(&state, run_id, format!("Worker wait failed: {err}")); + fail_managed_run( + &state, + run_id, + FailureReason::Terminated, + format!("Worker wait failed: {err}"), + ); state.scheduler_notify.notify_one(); return; } @@ -4903,6 +4946,7 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { fail_managed_run( &state, run_id, + FailureReason::WorkflowError, format!("Failed to load final run state: {err}"), ); state.scheduler_notify.notify_one(); @@ -4940,10 +4984,12 @@ async fn execute_run_subprocess(state: Arc, run_id: RunId) { let mut runs = state.runs.lock().expect("runs lock poisoned"); if let Some(managed_run) = runs.get_mut(&run_id) { - if let Some(status) = final_state.status.as_ref() { - managed_run.status = status.status; + if let Some(status) = final_state.status { + managed_run.status = status; } else if !wait_status.success() { - managed_run.status = RunStatus::Failed; + managed_run.status = RunStatus::Failed { + reason: FailureReason::Terminated, + }; } managed_run.error = final_state .conclusion @@ -4983,8 +5029,8 @@ pub fn spawn_scheduler(state: Arc) { r.status, RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } ) }) .count(); @@ -6259,8 +6305,8 @@ fn denied_lifecycle_event_name(body: &EventBody) -> Option<&'static str> { async fn reject_if_archived(state: &AppState, run_id: &RunId) -> Option { let run_store = state.store.open_run_reader(run_id).await.ok()?; let projection = run_store.state().await.ok()?; - let status = projection.status.as_ref()?.status; - (status == RunStatus::Archived).then(|| { + let status = projection.status?; + matches!(status, RunStatus::Archived { .. }).then(|| { ApiError::new( StatusCode::CONFLICT, operations::archived_rejection_message(run_id), @@ -6318,8 +6364,8 @@ async fn cancel_run( | RunStatus::Queued | RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused => { + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } => { let use_cancel_signal = !matches!( managed_run.answer_transport, Some(RunAnswerTransport::InProcess { .. }) @@ -6327,8 +6373,11 @@ async fn cancel_run( let persist_cancelled_status = matches!(managed_run.status, RunStatus::Submitted | RunStatus::Queued); let response_status = if persist_cancelled_status { - managed_run.status = RunStatus::Failed; - RunStatus::Failed + let cancelled = RunStatus::Failed { + reason: FailureReason::Cancelled, + }; + managed_run.status = cancelled; + cancelled } else { managed_run.status }; @@ -6394,18 +6443,21 @@ async fn cancel_run( .into_response(); } } - let (status_reason, blocked_reason, pending_control) = - load_run_status_metadata(state.as_ref(), id).await; + let pending_control = match load_pending_control(state.as_ref(), id).await { + Ok(pending_control) => pending_control, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; ( StatusCode::OK, Json(RunStatusResponse { - blocked_reason, id: id.to_string(), status: response_status, error: None, queue_position: None, - status_reason, pending_control, created_at, }), @@ -6428,9 +6480,9 @@ enum PauseMode { enum UnpauseMode { /// No outstanding block; ask the worker to resume via SIGUSR2. Signal { worker_pid: u32 }, - /// Was paused while blocked; append `RunUnpaused` then re-assert - /// `RunBlocked` so the projection reports `Blocked`. - AppendEvents { blocked_reason: BlockedReason }, + /// Was paused while blocked; append `RunUnpaused` and let the reducer + /// restore the underlying blocked state from `Paused { prior_block }`. + AppendEvent, } async fn pause_run( @@ -6462,7 +6514,7 @@ async fn pause_run( }; (managed_run.created_at, PauseMode::Signal { worker_pid }) } - Some(managed_run) if managed_run.status == RunStatus::Blocked => { + Some(managed_run) if matches!(managed_run.status, RunStatus::Blocked { .. }) => { (managed_run.created_at, PauseMode::AppendEvent) } Some(_) => { @@ -6498,29 +6550,37 @@ async fn pause_run( RunStatus::Running } PauseMode::AppendEvent => { - if let Some(response) = - synchronous_transition(state.as_ref(), id, RunStatus::Paused, |events| { - events.push(workflow_event::Event::RunPaused); - }) - .await + if let Some(response) = synchronous_transition(state.as_ref(), id, |events| { + events.push(workflow_event::Event::RunPaused); + }) + .await { return response; } - RunStatus::Paused + state + .runs + .lock() + .expect("runs lock poisoned") + .get(&id) + .map(|run| run.status) + .unwrap_or(RunStatus::Paused { prior_block: None }) + } + }; + let pending_control = match load_pending_control(state.as_ref(), id).await { + Ok(pending_control) => pending_control, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); } }; - let (status_reason, blocked_reason, pending_control) = - load_run_status_metadata(state.as_ref(), id).await; ( StatusCode::OK, Json(RunStatusResponse { id: id.to_string(), - blocked_reason, status: response_status, error: None, queue_position: None, - status_reason, pending_control, created_at, }), @@ -6547,32 +6607,25 @@ async fn unpause_run( .into_response(); } }; - let paused_blocked_reason = match state.store.runs().find(&id).await { - Ok(Some(summary)) => summary.blocked_reason, - Ok(None) => None, - Err(err) => { - return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) - .into_response(); - } - }; let (created_at, mode) = { let runs = state.runs.lock().expect("runs lock poisoned"); match runs.get(&id) { - Some(managed_run) if managed_run.status == RunStatus::Paused => { - let mode = if let Some(blocked_reason) = paused_blocked_reason { - UnpauseMode::AppendEvents { blocked_reason } - } else { + Some(managed_run) => match managed_run.status { + RunStatus::Paused { + prior_block: Some(_), + } => (managed_run.created_at, UnpauseMode::AppendEvent), + RunStatus::Paused { prior_block: None } => { let Some(worker_pid) = managed_run.worker_pid else { return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.") .into_response(); }; - UnpauseMode::Signal { worker_pid } - }; - (managed_run.created_at, mode) - } - Some(_) => { - return ApiError::new(StatusCode::CONFLICT, "Run is not paused.").into_response(); - } + (managed_run.created_at, UnpauseMode::Signal { worker_pid }) + } + _ => { + return ApiError::new(StatusCode::CONFLICT, "Run is not paused.") + .into_response(); + } + }, None => return ApiError::not_found("Run not found.").into_response(), } }; @@ -6600,33 +6653,40 @@ async fn unpause_run( fabro_proc::sigusr2(worker_pid); #[cfg(not(unix))] let _ = worker_pid; - RunStatus::Paused + RunStatus::Paused { prior_block: None } } - UnpauseMode::AppendEvents { blocked_reason } => { - if let Some(response) = - synchronous_transition(state.as_ref(), id, RunStatus::Blocked, |events| { - events.push(workflow_event::Event::RunUnpaused); - events.push(workflow_event::Event::RunBlocked { blocked_reason }); - }) - .await + UnpauseMode::AppendEvent => { + if let Some(response) = synchronous_transition(state.as_ref(), id, |events| { + events.push(workflow_event::Event::RunUnpaused); + }) + .await { return response; } - RunStatus::Blocked + state + .runs + .lock() + .expect("runs lock poisoned") + .get(&id) + .map(|run| run.status) + .unwrap_or(RunStatus::Running) + } + }; + let pending_control = match load_pending_control(state.as_ref(), id).await { + Ok(pending_control) => pending_control, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); } }; - let (status_reason, blocked_reason, pending_control) = - load_run_status_metadata(state.as_ref(), id).await; ( StatusCode::OK, Json(RunStatusResponse { id: id.to_string(), - blocked_reason, status: response_status, error: None, queue_position: None, - status_reason, pending_control, created_at, }), @@ -6701,25 +6761,20 @@ async fn archive_status_response(state: &AppState, id: RunId) -> Response { .into_response(); } }; - let Some(record) = projection.status.as_ref() else { + let Some(status) = projection.status else { return ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, - "run has no status record after archive/unarchive", + "run has no status after archive/unarchive", ) .into_response(); }; - let status = record.status; - let status_reason = record.status_reason; - let blocked_reason = record.blocked_reason; ( StatusCode::OK, Json(RunStatusResponse { id: id.to_string(), - blocked_reason, status, error: None, queue_position: None, - status_reason, pending_control: None, created_at: id.created_at(), }), @@ -6733,7 +6788,6 @@ async fn archive_status_response(state: &AppState, id: RunId) -> Response { async fn synchronous_transition( state: &AppState, id: RunId, - new_status: RunStatus, append_events: impl FnOnce(&mut Vec), ) -> Option { let run_store = match state.store.open_run(&id).await { @@ -6752,11 +6806,8 @@ async fn synchronous_transition( ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), ); } - } - if let Ok(mut runs) = state.runs.lock() { - if let Some(managed_run) = runs.get_mut(&id) { - managed_run.status = new_status; - } + let stored = workflow_event::to_run_event(&id, &event); + update_live_run_from_event(state, id, &stored); } None } @@ -8684,11 +8735,10 @@ slug = "fabro" create_durable_run_with_events(&state, run_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, ]) .await; append_raw_run_event( @@ -9255,7 +9305,7 @@ slug = "fabro" let response = app.oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::CREATED).await; - assert_eq!(body["status"], "submitted"); + assert_eq!(body["status"]["kind"], "submitted"); } #[tokio::test] @@ -9282,7 +9332,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["status"], "queued"); + assert_eq!(body["status"]["kind"], "queued"); let status = state .store @@ -9294,7 +9344,7 @@ slug = "fabro" .unwrap() .status .unwrap(); - assert_eq!(status.status.to_string(), "queued"); + assert_eq!(status, RunStatus::Queued); } #[tokio::test] @@ -9336,18 +9386,11 @@ slug = "fabro" let state = create_app_state(); let app = build_router(Arc::clone(&state), AuthMode::Disabled); - // Start a run - let req = Request::builder() - .method("POST") - .uri(api("/runs")) - .header("content-type", "application/json") - .body(manifest_body(MINIMAL_DOT)) + let run_id = create_and_start_run(&app, MINIMAL_DOT) + .await + .parse::() .unwrap(); - let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; - let run_id = body["id"].as_str().unwrap().parse::().unwrap(); - // Cancel it let req = Request::builder() .method("POST") @@ -9617,9 +9660,8 @@ slug = "fabro" assert!(items[0]["title"].is_string()); assert!(items[0]["repository"]["name"].is_string()); assert!(items[0]["created_at"].is_string()); - assert!(items[0]["status"].as_str().is_some()); + assert!(items[0]["status"].is_object()); assert!(items[0]["labels"].is_object()); - assert!(items[0]["status_reason"].is_null()); assert!(items[0]["pending_control"].is_null()); assert!(items[0]["total_usd_micros"].is_null()); } @@ -9632,16 +9674,15 @@ slug = "fabro" create_durable_run_with_events(&state, run_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, workflow_event::Event::WorkflowRunCompleted { duration_ms: 1000, artifact_count: 0, status: "success".to_string(), - reason: None, + reason: SuccessReason::Completed, total_usd_micros: None, final_git_commit_sha: None, final_patch: None, @@ -9662,7 +9703,9 @@ slug = "fabro" .await .unwrap(); let archive_body = response_json!(archive_response, StatusCode::OK).await; - assert_eq!(archive_body["status"].as_str(), Some("archived")); + assert_eq!(archive_body["status"]["kind"], "archived"); + assert_eq!(archive_body["status"]["prior"]["kind"], "succeeded"); + assert_eq!(archive_body["status"]["prior"]["reason"], "completed"); let hidden_response = app .clone() @@ -9703,7 +9746,9 @@ slug = "fabro" .iter() .find(|item| item["run_id"].as_str() == Some(&run_id.to_string())) .expect("archived run should appear when include_archived=true"); - assert_eq!(archived_item["status"].as_str(), Some("archived")); + assert_eq!(archived_item["status"]["kind"], "archived"); + assert_eq!(archived_item["status"]["prior"]["kind"], "succeeded"); + assert_eq!(archived_item["status"]["prior"]["reason"], "completed"); let unarchive_response = app .clone() @@ -9717,7 +9762,8 @@ slug = "fabro" .await .unwrap(); let unarchive_body = response_json!(unarchive_response, StatusCode::OK).await; - assert_eq!(unarchive_body["status"].as_str(), Some("succeeded")); + assert_eq!(unarchive_body["status"]["kind"], "succeeded"); + assert_eq!(unarchive_body["status"]["reason"], "completed"); let restored_response = app .oneshot( @@ -9736,7 +9782,8 @@ slug = "fabro" .iter() .find(|item| item["run_id"].as_str() == Some(&run_id.to_string())) .expect("unarchived run should reappear in default listing"); - assert_eq!(restored_item["status"].as_str(), Some("succeeded")); + assert_eq!(restored_item["status"]["kind"], "succeeded"); + assert_eq!(restored_item["status"]["reason"], "completed"); } #[tokio::test] @@ -9911,7 +9958,7 @@ slug = "fabro" let response = app.oneshot(req).await.unwrap(); let body = body_json(response.into_body()).await; - assert_eq!(body["status"].as_str().unwrap(), "submitted"); + assert_eq!(body["status"]["kind"], "submitted"); } #[tokio::test] @@ -10030,18 +10077,11 @@ level = "debug" let state = create_app_state(); let app = build_router(Arc::clone(&state), AuthMode::Disabled); - // Submit a run (no start, stays submitted) - let req = Request::builder() - .method("POST") - .uri(api("/runs")) - .header("content-type", "application/json") - .body(manifest_body(MINIMAL_DOT)) + let run_id = create_and_start_run(&app, MINIMAL_DOT) + .await + .parse::() .unwrap(); - let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; - let run_id = body["id"].as_str().unwrap().parse::().unwrap(); - // Cancel it let req = Request::builder() .method("POST") @@ -10061,8 +10101,8 @@ level = "debug" let response = app.clone().oneshot(req).await.unwrap(); let body = body_json(response.into_body()).await; - assert_eq!(body["status"].as_str().unwrap(), "failed"); - assert_eq!(body["status_reason"].as_str().unwrap(), "cancelled"); + assert_eq!(body["status"]["kind"], "failed"); + assert_eq!(body["status"]["reason"], "cancelled"); // Cancelled runs appear on the board in the "failed" column let req = Request::builder() @@ -10083,7 +10123,7 @@ level = "debug" "cancelled run should appear on the board" ); assert_eq!( - board_item.unwrap()["status"].as_str(), + board_item.unwrap()["status"]["kind"].as_str(), Some("failed"), "cancelled run should preserve the failed lifecycle status" ); @@ -10091,8 +10131,9 @@ level = "debug" let run_store = state.store.open_run_reader(&run_id).await.unwrap(); let status = run_store.state().await.unwrap().status.unwrap(); - assert_eq!(status.status, RunStatus::Failed); - assert_eq!(status.status_reason, Some(StatusReason::Cancelled)); + assert_eq!(status, RunStatus::Failed { + reason: FailureReason::Cancelled, + }); } #[tokio::test] @@ -10175,7 +10216,7 @@ level = "debug" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["status"].as_str(), Some("running")); + assert_eq!(body["status"]["kind"], "running"); assert_eq!(body["pending_control"].as_str(), Some("pause")); // Verify pending_control via /runs/{id} (board no longer includes this field) @@ -10203,7 +10244,7 @@ level = "debug" .iter() .find(|item| item["run_id"].as_str() == Some(run_id_str.as_str())) .expect("board item should exist"); - assert!(item["status"].as_str().is_some()); + assert!(item["status"].is_object()); assert_eq!(item["column"].as_str(), Some("initializing")); assert_eq!(item["pending_control"].as_str(), Some("pause")); } @@ -10215,6 +10256,26 @@ level = "debug" let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); + append_raw_run_event( + &state, + run_id, + "pause-starting", + "2026-04-19T11:59:58Z", + "run.starting", + json!({}), + None, + ) + .await; + append_raw_run_event( + &state, + run_id, + "pause-running", + "2026-04-19T11:59:59Z", + "run.running", + json!({}), + None, + ) + .await; append_raw_run_event( &state, run_id, @@ -10229,7 +10290,9 @@ level = "debug" { let mut runs = state.runs.lock().expect("runs lock poisoned"); let managed_run = runs.get_mut(&run_id).expect("run should exist"); - managed_run.status = RunStatus::Blocked; + managed_run.status = RunStatus::Blocked { + blocked_reason: BlockedReason::HumanInputRequired, + }; managed_run.worker_pid = Some(u32::MAX); } @@ -10240,19 +10303,14 @@ level = "debug" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["status"].as_str(), Some("paused")); - assert_eq!( - body["blocked_reason"].as_str(), - Some("human_input_required") - ); + assert_eq!(body["status"]["kind"], "paused"); + assert_eq!(body["status"]["prior_block"], "human_input_required"); assert_eq!(body["pending_control"], serde_json::Value::Null); let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); - assert_eq!(summary.status, RunStatus::Paused); - assert_eq!( - summary.blocked_reason, - Some(BlockedReason::HumanInputRequired) - ); + assert_eq!(summary.status, RunStatus::Paused { + prior_block: Some(BlockedReason::HumanInputRequired), + }); assert_eq!(summary.pending_control, None); } @@ -10266,7 +10324,7 @@ level = "debug" { let mut runs = state.runs.lock().expect("runs lock poisoned"); let managed_run = runs.get_mut(&run_id).expect("run should exist"); - managed_run.status = RunStatus::Paused; + managed_run.status = RunStatus::Paused { prior_block: None }; managed_run.worker_pid = Some(u32::MAX); } @@ -10277,7 +10335,8 @@ level = "debug" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["status"].as_str(), Some("paused")); + assert_eq!(body["status"]["kind"], "paused"); + assert!(body["status"]["prior_block"].is_null()); assert_eq!(body["pending_control"].as_str(), Some("unpause")); let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); @@ -10291,6 +10350,26 @@ level = "debug" let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await; let run_id = run_id_str.parse::().unwrap(); + append_raw_run_event( + &state, + run_id, + "paused-blocked-starting", + "2026-04-19T11:59:58Z", + "run.starting", + json!({}), + None, + ) + .await; + append_raw_run_event( + &state, + run_id, + "paused-blocked-running", + "2026-04-19T11:59:59Z", + "run.running", + json!({}), + None, + ) + .await; append_raw_run_event( &state, run_id, @@ -10315,7 +10394,9 @@ level = "debug" { let mut runs = state.runs.lock().expect("runs lock poisoned"); let managed_run = runs.get_mut(&run_id).expect("run should exist"); - managed_run.status = RunStatus::Paused; + managed_run.status = RunStatus::Paused { + prior_block: Some(BlockedReason::HumanInputRequired), + }; managed_run.worker_pid = Some(u32::MAX); } @@ -10326,19 +10407,14 @@ level = "debug" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["status"].as_str(), Some("blocked")); - assert_eq!( - body["blocked_reason"].as_str(), - Some("human_input_required") - ); + assert_eq!(body["status"]["kind"], "blocked"); + assert_eq!(body["status"]["blocked_reason"], "human_input_required"); assert_eq!(body["pending_control"], serde_json::Value::Null); let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); - assert_eq!(summary.status, RunStatus::Blocked); - assert_eq!( - summary.blocked_reason, - Some(BlockedReason::HumanInputRequired) - ); + assert_eq!(summary.status, RunStatus::Blocked { + blocked_reason: BlockedReason::HumanInputRequired, + }); assert_eq!(summary.pending_control, None); } @@ -10348,27 +10424,24 @@ level = "debug" create_durable_run_with_events(&state, fixtures::RUN_1, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, ]) .await; create_durable_run_with_events(&state, fixtures::RUN_2, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, ]) .await; create_durable_run_with_events(&state, fixtures::RUN_3, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, workflow_event::Event::RunPaused, workflow_event::Event::RunCancelRequested { actor: None }, ]) @@ -10385,7 +10458,7 @@ level = "debug" .state() .await .unwrap(); - assert_eq!(run_1.status.unwrap().status, RunStatus::Submitted); + assert_eq!(run_1.status.unwrap(), RunStatus::Submitted); let run_2 = state .store @@ -10396,8 +10469,9 @@ level = "debug" .await .unwrap(); let run_2_status = run_2.status.unwrap(); - assert_eq!(run_2_status.status, RunStatus::Failed); - assert_eq!(run_2_status.status_reason, Some(StatusReason::Terminated)); + assert_eq!(run_2_status, RunStatus::Failed { + reason: FailureReason::Terminated, + }); let run_3 = state .store @@ -10408,8 +10482,9 @@ level = "debug" .await .unwrap(); let run_3_status = run_3.status.unwrap(); - assert_eq!(run_3_status.status, RunStatus::Failed); - assert_eq!(run_3_status.status_reason, Some(StatusReason::Cancelled)); + assert_eq!(run_3_status, RunStatus::Failed { + reason: FailureReason::Cancelled, + }); assert_eq!(run_3.pending_control, None); } @@ -10421,11 +10496,10 @@ level = "debug" create_durable_run_with_events(&state, run_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, ]) .await; @@ -10480,8 +10554,9 @@ level = "debug" .await .unwrap(); let run_status = run_state.status.unwrap(); - assert_eq!(run_status.status, RunStatus::Failed); - assert_eq!(run_status.status_reason, Some(StatusReason::Terminated)); + assert_eq!(run_status, RunStatus::Failed { + reason: FailureReason::Terminated, + }); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -10519,8 +10594,8 @@ timeout = "30s" RunStatus::Queued | RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } ) ) { break; @@ -10534,8 +10609,8 @@ timeout = "30s" RunStatus::Queued | RunStatus::Starting | RunStatus::Running - | RunStatus::Blocked - | RunStatus::Paused + | RunStatus::Blocked { .. } + | RunStatus::Paused { .. } ) ), "run should become cancellable before finishing, saw {live_status_before_cancel:?}" @@ -10559,7 +10634,9 @@ timeout = "30s" let runs = state.runs.lock().expect("runs lock poisoned"); let managed_run = runs.get(&run_id).expect("run should exist"); - assert_eq!(managed_run.status, RunStatus::Failed); + assert_eq!(managed_run.status, RunStatus::Failed { + reason: FailureReason::Cancelled, + }); drop(runs); let run_store = state.store.open_run_reader(&run_id).await.unwrap(); @@ -10567,8 +10644,10 @@ timeout = "30s" let mut status_record = None; for _ in 0..50 { if let Some(record) = run_store.state().await.unwrap().status { - if record.status == RunStatus::Failed - && record.status_reason == Some(StatusReason::Cancelled) + if record + == (RunStatus::Failed { + reason: FailureReason::Cancelled, + }) { status_record = Some(record); break; @@ -10578,8 +10657,9 @@ timeout = "30s" } let status_record = status_record.expect("status record should be persisted"); - assert_eq!(status_record.status, RunStatus::Failed); - assert_eq!(status_record.status_reason, Some(StatusReason::Cancelled)); + assert_eq!(status_record, RunStatus::Failed { + reason: FailureReason::Cancelled, + }); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -10651,21 +10731,20 @@ timeout = "30s" // Give scheduler time to pick up the first run tokio::time::sleep(std::time::Duration::from_millis(50)).await; - // Board only shows runs with board-column statuses (Running -> "working", - // Paused -> "pending", Completed -> "merge"). Queued/Starting/Failed are - // excluded. With max_concurrent_runs=1, at most 1 should be active on - // the board. + // The board only shows runs with a visible board column. With + // max_concurrent_runs=1, at most one run should land in the live + // "running" column. let req = Request::builder() .method("GET") .uri(api("/boards/runs")) .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json!(response, StatusCode::OK).await; let items = body["data"].as_array().unwrap(); let active_count = items .iter() - .filter(|item| item["status"].as_str() == Some("working")) + .filter(|item| item["column"].as_str() == Some("running")) .count(); assert!( active_count <= 1, @@ -10737,7 +10816,7 @@ timeout = "30s" assert!(first["goal"].is_string()); assert!(first["repository"].is_object()); assert!(first["title"].is_string()); - assert!(first["status"].is_string()); + assert!(first["status"].is_object()); assert!(first["column"].is_string()); assert!(first["workflow_slug"].is_string() || first["workflow_slug"].is_null()); assert!(first["labels"].is_object()); @@ -10817,11 +10896,10 @@ timeout = "30s" assert!(item["workflow_slug"].is_string() || item["workflow_slug"].is_null()); assert!(item["workflow_name"].is_string() || item["workflow_name"].is_null()); assert!(item["labels"].is_object()); - assert!(item["status"].is_string()); + assert!(item["status"].is_object()); assert!(item["column"].is_string()); assert!(item["created_at"].is_string()); assert!(item["pending_control"].is_null()); - assert!(item["status_reason"].is_null()); assert!(item["total_usd_micros"].is_null()); } @@ -10834,12 +10912,11 @@ timeout = "30s" // A run in Removing status should not appear on the board create_durable_run_with_events(&state, run_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, - workflow_event::Event::RunRemoving { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, + workflow_event::Event::RunRemoving, ]) .await; @@ -10868,16 +10945,15 @@ timeout = "30s" create_durable_run_with_events(&state, succeeded_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, workflow_event::Event::WorkflowRunCompleted { duration_ms: 1000, artifact_count: 0, status: "success".to_string(), - reason: None, + reason: SuccessReason::Completed, total_usd_micros: None, final_git_commit_sha: None, final_patch: None, @@ -10888,21 +10964,19 @@ timeout = "30s" create_durable_run_with_events(&state, removing_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, - workflow_event::Event::RunRemoving { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, + workflow_event::Event::RunRemoving, ]) .await; create_durable_run_with_events(&state, blocked_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, ]) .await; append_raw_run_event( @@ -10928,7 +11002,7 @@ timeout = "30s" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); let body = response_json!(response, StatusCode::OK).await; - assert_eq!(body["status"].as_str(), Some(expected_status)); + assert_eq!(body["status"]["kind"].as_str(), Some(expected_status)); } } @@ -10943,26 +11017,24 @@ timeout = "30s" create_durable_run_with_events(&state, paused_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, workflow_event::Event::RunPaused, ]) .await; create_durable_run_with_events(&state, succeeded_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, workflow_event::Event::WorkflowRunCompleted { duration_ms: 1000, artifact_count: 0, status: "success".to_string(), - reason: None, + reason: SuccessReason::Completed, total_usd_micros: None, final_git_commit_sha: None, final_patch: None, @@ -10972,11 +11044,10 @@ timeout = "30s" .await; create_durable_run_with_events(&state, blocked_id, &[ workflow_event::Event::RunSubmitted { - reason: None, definition_blob: None, }, - workflow_event::Event::RunStarting { reason: None }, - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, ]) .await; append_raw_run_event( @@ -11041,21 +11112,33 @@ timeout = "30s" .iter() .find(|i| i["run_id"].as_str() == Some(&paused_id.to_string())) .expect("paused run should be on board"); - assert_eq!(paused_item["status"].as_str().unwrap(), "paused"); + assert_eq!(paused_item["status"]["kind"].as_str().unwrap(), "paused"); + assert!(paused_item["status"]["prior_block"].is_null()); assert_eq!(paused_item["column"].as_str().unwrap(), "running"); let succeeded_item = data .iter() .find(|i| i["run_id"].as_str() == Some(&succeeded_id.to_string())) .expect("succeeded run should be on board"); - assert_eq!(succeeded_item["status"].as_str().unwrap(), "succeeded"); + assert_eq!( + succeeded_item["status"]["kind"].as_str().unwrap(), + "succeeded" + ); + assert_eq!( + succeeded_item["status"]["reason"].as_str().unwrap(), + "completed" + ); assert_eq!(succeeded_item["column"].as_str().unwrap(), "succeeded"); let blocked_item = data .iter() .find(|i| i["run_id"].as_str() == Some(&blocked_id.to_string())) .expect("blocked run should be on board"); - assert_eq!(blocked_item["status"].as_str().unwrap(), "blocked"); + assert_eq!(blocked_item["status"]["kind"].as_str().unwrap(), "blocked"); + assert_eq!( + blocked_item["status"]["blocked_reason"].as_str().unwrap(), + "human_input_required" + ); assert_eq!(blocked_item["column"].as_str().unwrap(), "blocked"); assert_eq!( blocked_item["question"]["text"].as_str(), @@ -11084,7 +11167,8 @@ timeout = "30s" .unwrap(); let run_store = state.store.open_run(&run_id).await.unwrap(); for event in [ - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, workflow_event::Event::SandboxInitialized { provider: "local".to_string(), working_directory: "/sandbox/workdir".to_string(), @@ -11153,7 +11237,8 @@ timeout = "30s" for (run_id, sandbox_id) in [(first_run_id, "sb-first"), (second_run_id, "sb-second")] { let run_store = state.store.open_run(&run_id).await.unwrap(); for event in [ - workflow_event::Event::RunRunning { reason: None }, + workflow_event::Event::RunStarting, + workflow_event::Event::RunRunning, workflow_event::Event::SandboxInitialized { provider: "local".to_string(), working_directory: "/sandbox/workdir".to_string(), diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 03a3dbcaa..b082c778c 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -234,9 +234,9 @@ pub(crate) async fn wait_for_run_status( ) -> String { for _ in 0..POLL_ATTEMPTS { let body = run_json(app, run_id).await; - let status = body["status"] + let status = body["status"]["kind"] .as_str() - .expect("run response should include a string status") + .expect("run response should include a tagged status kind") .to_string(); if expected.iter().any(|candidate| *candidate == status) { return status; @@ -253,9 +253,9 @@ pub(crate) async fn wait_for_run_status_not_in( ) -> String { for _ in 0..POLL_ATTEMPTS { let body = run_json(app, run_id).await; - let status = body["status"] + let status = body["status"]["kind"] .as_str() - .expect("run response should include a string status") + .expect("run response should include a tagged status kind") .to_string(); if unexpected.iter().all(|candidate| *candidate != status) { return status; diff --git a/lib/crates/fabro-server/tests/it/scenario/archive.rs b/lib/crates/fabro-server/tests/it/scenario/archive.rs index 481621313..fd7a431d8 100644 --- a/lib/crates/fabro-server/tests/it/scenario/archive.rs +++ b/lib/crates/fabro-server/tests/it/scenario/archive.rs @@ -31,7 +31,7 @@ async fn archived_runs_reject_mutations_with_actionable_body() { format!("POST /api/v1/runs/{run_id}/archive"), ) .await; - assert_eq!(body["status"], "archived"); + assert_eq!(body["status"]["kind"], "archived"); for path in &["/cancel", "/pause", "/unpause", "/start"] { let req = Request::builder() @@ -143,7 +143,7 @@ async fn archived_runs_reject_mutations_with_actionable_body() { format!("POST /api/v1/runs/{run_id}/unarchive"), ) .await; - assert_eq!(body["status"], "succeeded"); + assert_eq!(body["status"]["kind"], "succeeded"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs index f04300075..e8729ee0b 100644 --- a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs @@ -91,8 +91,8 @@ async fn wait_for_run_state( ) -> serde_json::Value { for _ in 0..POLL_ATTEMPTS { let body = run_json(app, run_id).await; - if body["status"].as_str() == Some(expected_status) - && body["status_reason"].as_str() == Some(expected_reason) + if body["status"]["kind"].as_str() == Some(expected_status) + && body["status"]["reason"].as_str() == Some(expected_reason) { return body; } @@ -248,7 +248,7 @@ async fn full_http_lifecycle_cancel() { format!("POST /api/v1/runs/{run_id}/cancel"), ) .await; - assert_eq!(body["status"], "running"); + assert_eq!(body["status"]["kind"], "running"); // `pending_control` is computed from the store projection after the cancel // event is appended AND the worker is signaled. The worker is sitting at a // human gate; once notified it can emit a clearing event before this @@ -263,7 +263,7 @@ async fn full_http_lifecycle_cancel() { // Verify the durable store view converges to cancelled failure. let body = wait_for_run_state(&app, &run_id, "failed", "cancelled").await; - assert_eq!(body["status_reason"], "cancelled"); + assert_eq!(body["status"]["reason"], "cancelled"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/lib/crates/fabro-spa/assets/assets/entry-q11nrnd3.js b/lib/crates/fabro-spa/assets/assets/entry-8ah4aryw.js similarity index 84% rename from lib/crates/fabro-spa/assets/assets/entry-q11nrnd3.js rename to lib/crates/fabro-spa/assets/assets/entry-8ah4aryw.js index 9a5faddae..38c960bfe 100644 --- a/lib/crates/fabro-spa/assets/assets/entry-q11nrnd3.js +++ b/lib/crates/fabro-spa/assets/assets/entry-8ah4aryw.js @@ -1,4 +1,4 @@ -import{X as h,Y as D3,Z as p5,_ as x}from"./chunk-q07bg6gn.js";var J0=D3((Pa,YU)=>{(function(){function Z(y,Y0){Object.defineProperty(z.prototype,y,{get:function(){console.warn("%s(...) is deprecated in plain JavaScript React classes. %s",Y0[0],Y0[1])}})}function Y(y){if(y===null||typeof y!=="object")return null;return y=B1&&y[B1]||y["@@iterator"],typeof y==="function"?y:null}function Q(y,Y0){y=(y=y.constructor)&&(y.displayName||y.name)||"ReactClass";var F0=y+"."+Y0;R0[F0]||(console.error("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.",Y0,y),R0[F0]=!0)}function z(y,Y0,F0){this.props=y,this.context=Y0,this.refs=n5,this.updater=F0||C1}function q(){}function B(y,Y0,F0){this.props=y,this.context=Y0,this.refs=n5,this.updater=F0||C1}function W(){}function $(y){return""+y}function U(y){try{$(y);var Y0=!1}catch(k0){Y0=!0}if(Y0){Y0=console;var F0=Y0.error,L0=typeof Symbol==="function"&&Symbol.toStringTag&&y[Symbol.toStringTag]||y.constructor.name||"Object";return F0.call(Y0,"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",L0),$(y)}}function M(y){if(y==null)return null;if(typeof y==="function")return y.$$typeof===t6?null:y.displayName||y.name||null;if(typeof y==="string")return y;switch(y){case z0:return"Fragment";case q0:return"Profiler";case f:return"StrictMode";case i0:return"Suspense";case H0:return"SuspenseList";case L1:return"Activity"}if(typeof y==="object")switch(typeof y.tag==="number"&&console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),y.$$typeof){case n:return"Portal";case M0:return y.displayName||"Context";case N0:return(y._context.displayName||"Context")+".Consumer";case I0:var Y0=y.render;return y=y.displayName,y||(y=Y0.displayName||Y0.name||"",y=y!==""?"ForwardRef("+y+")":"ForwardRef"),y;case l0:return Y0=y.displayName||null,Y0!==null?Y0:M(y.type)||"Memo";case t0:Y0=y._payload,y=y._init;try{return M(y(Y0))}catch(F0){}}return null}function w(y){if(y===z0)return"<>";if(typeof y==="object"&&y!==null&&y.$$typeof===t0)return"<...>";try{var Y0=M(y);return Y0?"<"+Y0+">":"<...>"}catch(F0){return"<...>"}}function O(){var y=W1.A;return y===null?null:y.getOwner()}function _(){return Error("react-stack-top-frame")}function F(y){if(w4.call(y,"key")){var Y0=Object.getOwnPropertyDescriptor(y,"key").get;if(Y0&&Y0.isReactWarning)return!1}return y.key!==void 0}function P(y,Y0){function F0(){P6||(P6=!0,console.error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)",Y0))}F0.isReactWarning=!0,Object.defineProperty(y,"key",{get:F0,configurable:!0})}function R(){var y=M(this.type);return c4[y]||(c4[y]=!0,console.error("Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.")),y=this.props.ref,y!==void 0?y:null}function v(y,Y0,F0,L0,k0,Y1){var x0=F0.ref;return y={$$typeof:$0,type:y,key:Y0,props:F0,_owner:L0},(x0!==void 0?x0:null)!==null?Object.defineProperty(y,"ref",{enumerable:!1,get:R}):Object.defineProperty(y,"ref",{enumerable:!1,value:null}),y._store={},Object.defineProperty(y._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:0}),Object.defineProperty(y,"_debugInfo",{configurable:!1,enumerable:!1,writable:!0,value:null}),Object.defineProperty(y,"_debugStack",{configurable:!1,enumerable:!1,writable:!0,value:k0}),Object.defineProperty(y,"_debugTask",{configurable:!1,enumerable:!1,writable:!0,value:Y1}),Object.freeze&&(Object.freeze(y.props),Object.freeze(y)),y}function L(y,Y0){return Y0=v(y.type,Y0,y.props,y._owner,y._debugStack,y._debugTask),y._store&&(Y0._store.validated=y._store.validated),Y0}function T(y){C(y)?y._store&&(y._store.validated=1):typeof y==="object"&&y!==null&&y.$$typeof===t0&&(y._payload.status==="fulfilled"?C(y._payload.value)&&y._payload.value._store&&(y._payload.value._store.validated=1):y._store&&(y._store.validated=1))}function C(y){return typeof y==="object"&&y!==null&&y.$$typeof===$0}function E(y){var Y0={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(F0){return Y0[F0]})}function b(y,Y0){return typeof y==="object"&&y!==null&&y.key!=null?(U(y.key),E(""+y.key)):Y0.toString(36)}function S(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status==="string"?y.then(W,W):(y.status="pending",y.then(function(Y0){y.status==="pending"&&(y.status="fulfilled",y.value=Y0)},function(Y0){y.status==="pending"&&(y.status="rejected",y.reason=Y0)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function j(y,Y0,F0,L0,k0){var Y1=typeof y;if(Y1==="undefined"||Y1==="boolean")y=null;var x0=!1;if(y===null)x0=!0;else switch(Y1){case"bigint":case"string":case"number":x0=!0;break;case"object":switch(y.$$typeof){case $0:case n:x0=!0;break;case t0:return x0=y._init,j(x0(y._payload),Y0,F0,L0,k0)}}if(x0){x0=y,k0=k0(x0);var Q1=L0===""?"."+b(x0,0):L0;return w1(k0)?(F0="",Q1!=null&&(F0=Q1.replace(Z7,"$&/")+"/"),j(k0,Y0,F0,"",function(O5){return O5})):k0!=null&&(C(k0)&&(k0.key!=null&&(x0&&x0.key===k0.key||U(k0.key)),F0=L(k0,F0+(k0.key==null||x0&&x0.key===k0.key?"":(""+k0.key).replace(Z7,"$&/")+"/")+Q1),L0!==""&&x0!=null&&C(x0)&&x0.key==null&&x0._store&&!x0._store.validated&&(F0._store.validated=2),k0=F0),Y0.push(k0)),1}if(x0=0,Q1=L0===""?".":L0+":",w1(y))for(var j0=0;j0{(function(){function Z(y,Y0){Object.defineProperty(z.prototype,y,{get:function(){console.warn("%s(...) is deprecated in plain JavaScript React classes. %s",Y0[0],Y0[1])}})}function Y(y){if(y===null||typeof y!=="object")return null;return y=B1&&y[B1]||y["@@iterator"],typeof y==="function"?y:null}function Q(y,Y0){y=(y=y.constructor)&&(y.displayName||y.name)||"ReactClass";var F0=y+"."+Y0;R0[F0]||(console.error("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.",Y0,y),R0[F0]=!0)}function z(y,Y0,F0){this.props=y,this.context=Y0,this.refs=n5,this.updater=F0||C1}function q(){}function B(y,Y0,F0){this.props=y,this.context=Y0,this.refs=n5,this.updater=F0||C1}function W(){}function $(y){return""+y}function U(y){try{$(y);var Y0=!1}catch(k0){Y0=!0}if(Y0){Y0=console;var F0=Y0.error,L0=typeof Symbol==="function"&&Symbol.toStringTag&&y[Symbol.toStringTag]||y.constructor.name||"Object";return F0.call(Y0,"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",L0),$(y)}}function M(y){if(y==null)return null;if(typeof y==="function")return y.$$typeof===t6?null:y.displayName||y.name||null;if(typeof y==="string")return y;switch(y){case z0:return"Fragment";case q0:return"Profiler";case f:return"StrictMode";case i0:return"Suspense";case H0:return"SuspenseList";case L1:return"Activity"}if(typeof y==="object")switch(typeof y.tag==="number"&&console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),y.$$typeof){case n:return"Portal";case M0:return y.displayName||"Context";case N0:return(y._context.displayName||"Context")+".Consumer";case I0:var Y0=y.render;return y=y.displayName,y||(y=Y0.displayName||Y0.name||"",y=y!==""?"ForwardRef("+y+")":"ForwardRef"),y;case l0:return Y0=y.displayName||null,Y0!==null?Y0:M(y.type)||"Memo";case t0:Y0=y._payload,y=y._init;try{return M(y(Y0))}catch(F0){}}return null}function w(y){if(y===z0)return"<>";if(typeof y==="object"&&y!==null&&y.$$typeof===t0)return"<...>";try{var Y0=M(y);return Y0?"<"+Y0+">":"<...>"}catch(F0){return"<...>"}}function O(){var y=W1.A;return y===null?null:y.getOwner()}function _(){return Error("react-stack-top-frame")}function F(y){if(w4.call(y,"key")){var Y0=Object.getOwnPropertyDescriptor(y,"key").get;if(Y0&&Y0.isReactWarning)return!1}return y.key!==void 0}function P(y,Y0){function F0(){P6||(P6=!0,console.error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)",Y0))}F0.isReactWarning=!0,Object.defineProperty(y,"key",{get:F0,configurable:!0})}function R(){var y=M(this.type);return c4[y]||(c4[y]=!0,console.error("Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.")),y=this.props.ref,y!==void 0?y:null}function v(y,Y0,F0,L0,k0,Y1){var x0=F0.ref;return y={$$typeof:$0,type:y,key:Y0,props:F0,_owner:L0},(x0!==void 0?x0:null)!==null?Object.defineProperty(y,"ref",{enumerable:!1,get:R}):Object.defineProperty(y,"ref",{enumerable:!1,value:null}),y._store={},Object.defineProperty(y._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:0}),Object.defineProperty(y,"_debugInfo",{configurable:!1,enumerable:!1,writable:!0,value:null}),Object.defineProperty(y,"_debugStack",{configurable:!1,enumerable:!1,writable:!0,value:k0}),Object.defineProperty(y,"_debugTask",{configurable:!1,enumerable:!1,writable:!0,value:Y1}),Object.freeze&&(Object.freeze(y.props),Object.freeze(y)),y}function L(y,Y0){return Y0=v(y.type,Y0,y.props,y._owner,y._debugStack,y._debugTask),y._store&&(Y0._store.validated=y._store.validated),Y0}function T(y){C(y)?y._store&&(y._store.validated=1):typeof y==="object"&&y!==null&&y.$$typeof===t0&&(y._payload.status==="fulfilled"?C(y._payload.value)&&y._payload.value._store&&(y._payload.value._store.validated=1):y._store&&(y._store.validated=1))}function C(y){return typeof y==="object"&&y!==null&&y.$$typeof===$0}function E(y){var Y0={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(F0){return Y0[F0]})}function b(y,Y0){return typeof y==="object"&&y!==null&&y.key!=null?(U(y.key),E(""+y.key)):Y0.toString(36)}function S(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status==="string"?y.then(W,W):(y.status="pending",y.then(function(Y0){y.status==="pending"&&(y.status="fulfilled",y.value=Y0)},function(Y0){y.status==="pending"&&(y.status="rejected",y.reason=Y0)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function j(y,Y0,F0,L0,k0){var Y1=typeof y;if(Y1==="undefined"||Y1==="boolean")y=null;var x0=!1;if(y===null)x0=!0;else switch(Y1){case"bigint":case"string":case"number":x0=!0;break;case"object":switch(y.$$typeof){case $0:case n:x0=!0;break;case t0:return x0=y._init,j(x0(y._payload),Y0,F0,L0,k0)}}if(x0){x0=y,k0=k0(x0);var Q1=L0===""?"."+b(x0,0):L0;return w1(k0)?(F0="",Q1!=null&&(F0=Q1.replace(Z7,"$&/")+"/"),j(k0,Y0,F0,"",function(O5){return O5})):k0!=null&&(C(k0)&&(k0.key!=null&&(x0&&x0.key===k0.key||U(k0.key)),F0=L(k0,F0+(k0.key==null||x0&&x0.key===k0.key?"":(""+k0.key).replace(Z7,"$&/")+"/")+Q1),L0!==""&&x0!=null&&C(x0)&&x0.key==null&&x0._store&&!x0._store.validated&&(F0._store.validated=2),k0=F0),Y0.push(k0)),1}if(x0=0,Q1=L0===""?".":L0+":",w1(y))for(var j0=0;j0 import('./MyComponent')) @@ -10,11 +10,11 @@ Your code should look like: 1. You might have mismatching versions of React and the renderer (such as React DOM) 2. You might be breaking the Rules of Hooks 3. You might have more than one copy of React in the same app -See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),y}function o(){W1.asyncTransitions--}function X0(y){if(N4===null)try{var Y0=("require"+Math.random()).slice(0,7);N4=(YU&&YU[Y0]).call(YU,"timers").setImmediate}catch(F0){N4=function(L0){$2===!1&&($2=!0,typeof MessageChannel>"u"&&console.error("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var k0=new MessageChannel;k0.port1.onmessage=L0,k0.port2.postMessage(void 0)}}return N4(y)}function W0(y){return 1 ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),{then:function(j0,O5){k0=!0,x0.then(function(q5){if(e(Y0,F0),F0===0){try{Z0(L0),X0(function(){return c(q5,j0,O5)})}catch(e5){W1.thrownErrors.push(e5)}if(0 ...)"))}),W1.actQueue=null),0W1.recentlyCreatedOwnerStacks++;return v(y,k0,L0,O(),j0?Error("react-stack-top-frame"):x2,j0?S1(w(y)):V6)},Pa.createRef=function(){var y={current:null};return Object.seal(y),y},Pa.forwardRef=function(y){y!=null&&y.$$typeof===l0?console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof y!=="function"?console.error("forwardRef requires a render function but was given %s.",y===null?"null":typeof y):y.length!==0&&y.length!==2&&console.error("forwardRef render functions accept exactly two parameters: props and ref. %s",y.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),y!=null&&y.defaultProps!=null&&console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");var Y0={$$typeof:I0,render:y},F0;return Object.defineProperty(Y0,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(L0){F0=L0,y.name||y.displayName||(Object.defineProperty(y,"name",{value:L0}),y.displayName=L0)}}),Y0},Pa.isValidElement=C,Pa.lazy=function(y){y={_status:-1,_result:y};var Y0={$$typeof:t0,_payload:y,_init:s},F0={name:"lazy",start:-1,end:-1,value:null,owner:null,debugStack:Error("react-stack-top-frame"),debugTask:console.createTask?console.createTask("lazy()"):null};return y._ioInfo=F0,Y0._debugInfo=[{awaited:F0}],Y0},Pa.memo=function(y,Y0){y==null&&console.error("memo: The first argument must be a component. Instead received: %s",y===null?"null":typeof y),Y0={$$typeof:l0,type:y,compare:Y0===void 0?null:Y0};var F0;return Object.defineProperty(Y0,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(L0){F0=L0,y.name||y.displayName||(Object.defineProperty(y,"name",{value:L0}),y.displayName=L0)}}),Y0},Pa.startTransition=function(y){var Y0=W1.T,F0={};F0._updatedFibers=new Set,W1.T=F0;try{var L0=y(),k0=W1.S;k0!==null&&k0(F0,L0),typeof L0==="object"&&L0!==null&&typeof L0.then==="function"&&(W1.asyncTransitions++,L0.then(o,o),L0.then(W,v5))}catch(Y1){v5(Y1)}finally{Y0===null&&F0._updatedFibers&&(y=F0._updatedFibers.size,F0._updatedFibers.clear(),10{(function(){function Z(){if(E=!1,g){var c=Va.unstable_now();o=c;var Z0=!0;try{Z:{T=!1,C&&(C=!1,S(s),s=-1),L=!0;var $0=v;try{Y:{B(c);for(R=Q(_);R!==null&&!(R.expirationTime>c&&$());){var n=R.callback;if(typeof n==="function"){R.callback=null,v=R.priorityLevel;var z0=n(R.expirationTime<=c);if(c=Va.unstable_now(),typeof z0==="function"){R.callback=z0,B(c),Z0=!0;break Y}R===Q(_)&&z(_),B(c)}else z(_);R=Q(_)}if(R!==null)Z0=!0;else{var f=Q(F);f!==null&&U(W,f.startTime-c),Z0=!1}}break Z}finally{R=null,v=$0,L=!1}Z0=void 0}}finally{Z0?X0():g=!1}}}function Y(c,Z0){var $0=c.length;c.push(Z0);Z:for(;0<$0;){var n=$0-1>>>1,z0=c[n];if(0>>1;nq(N0,$0))M0q(I0,N0)?(c[n]=I0,c[M0]=$0,n=M0):(c[n]=N0,c[q0]=$0,n=q0);else if(M0q(I0,$0))c[n]=I0,c[M0]=$0,n=M0;else break Z}}return Z0}function q(c,Z0){var $0=c.sortIndex-Z0.sortIndex;return $0!==0?$0:c.id-Z0.id}function B(c){for(var Z0=Q(F);Z0!==null;){if(Z0.callback===null)z(F);else if(Z0.startTime<=c)z(F),Z0.sortIndex=Z0.expirationTime,Y(_,Z0);else break;Z0=Q(F)}}function W(c){if(C=!1,B(c),!T)if(Q(_)!==null)T=!0,g||(g=!0,X0());else{var Z0=Q(F);Z0!==null&&U(W,Z0.startTime-c)}}function $(){return E?!0:Va.unstable_now()-oc||125n?(c.sortIndex=$0,Y(F,c),Q(_)===null&&c===Q(F)&&(C?(S(s),s=-1):C=!0,U(W,$0-n))):(c.sortIndex=z0,Y(_,c),T||L||(T=!0,g||(g=!0,X0()))),c},Va.unstable_shouldYield=$,Va.unstable_wrapCallback=function(c){var Z0=v;return function(){var $0=v;v=Z0;try{return c.apply(this,arguments)}finally{v=$0}}},typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var uI=D3((Ra)=>{var mF=h(J0());(function(){function Z(){}function Y(w){return""+w}function Q(w,O,_){var F=3"u"&&console.error("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var k0=new MessageChannel;k0.port1.onmessage=L0,k0.port2.postMessage(void 0)}}return N4(y)}function W0(y){return 1 ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),{then:function(j0,O5){k0=!0,x0.then(function(q5){if(e(Y0,F0),F0===0){try{Z0(L0),X0(function(){return c(q5,j0,O5)})}catch(e5){W1.thrownErrors.push(e5)}if(0 ...)"))}),W1.actQueue=null),0W1.recentlyCreatedOwnerStacks++;return v(y,k0,L0,O(),j0?Error("react-stack-top-frame"):x2,j0?S1(w(y)):V6)},Va.createRef=function(){var y={current:null};return Object.seal(y),y},Va.forwardRef=function(y){y!=null&&y.$$typeof===l0?console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof y!=="function"?console.error("forwardRef requires a render function but was given %s.",y===null?"null":typeof y):y.length!==0&&y.length!==2&&console.error("forwardRef render functions accept exactly two parameters: props and ref. %s",y.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),y!=null&&y.defaultProps!=null&&console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");var Y0={$$typeof:I0,render:y},F0;return Object.defineProperty(Y0,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(L0){F0=L0,y.name||y.displayName||(Object.defineProperty(y,"name",{value:L0}),y.displayName=L0)}}),Y0},Va.isValidElement=C,Va.lazy=function(y){y={_status:-1,_result:y};var Y0={$$typeof:t0,_payload:y,_init:s},F0={name:"lazy",start:-1,end:-1,value:null,owner:null,debugStack:Error("react-stack-top-frame"),debugTask:console.createTask?console.createTask("lazy()"):null};return y._ioInfo=F0,Y0._debugInfo=[{awaited:F0}],Y0},Va.memo=function(y,Y0){y==null&&console.error("memo: The first argument must be a component. Instead received: %s",y===null?"null":typeof y),Y0={$$typeof:l0,type:y,compare:Y0===void 0?null:Y0};var F0;return Object.defineProperty(Y0,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(L0){F0=L0,y.name||y.displayName||(Object.defineProperty(y,"name",{value:L0}),y.displayName=L0)}}),Y0},Va.startTransition=function(y){var Y0=W1.T,F0={};F0._updatedFibers=new Set,W1.T=F0;try{var L0=y(),k0=W1.S;k0!==null&&k0(F0,L0),typeof L0==="object"&&L0!==null&&typeof L0.then==="function"&&(W1.asyncTransitions++,L0.then(o,o),L0.then(W,v5))}catch(Y1){v5(Y1)}finally{Y0===null&&F0._updatedFibers&&(y=F0._updatedFibers.size,F0._updatedFibers.clear(),10{(function(){function Z(){if(E=!1,g){var c=Ra.unstable_now();o=c;var Z0=!0;try{Z:{T=!1,C&&(C=!1,S(s),s=-1),L=!0;var $0=v;try{Y:{B(c);for(R=Q(_);R!==null&&!(R.expirationTime>c&&$());){var n=R.callback;if(typeof n==="function"){R.callback=null,v=R.priorityLevel;var z0=n(R.expirationTime<=c);if(c=Ra.unstable_now(),typeof z0==="function"){R.callback=z0,B(c),Z0=!0;break Y}R===Q(_)&&z(_),B(c)}else z(_);R=Q(_)}if(R!==null)Z0=!0;else{var f=Q(F);f!==null&&U(W,f.startTime-c),Z0=!1}}break Z}finally{R=null,v=$0,L=!1}Z0=void 0}}finally{Z0?X0():g=!1}}}function Y(c,Z0){var $0=c.length;c.push(Z0);Z:for(;0<$0;){var n=$0-1>>>1,z0=c[n];if(0>>1;nq(N0,$0))M0q(I0,N0)?(c[n]=I0,c[M0]=$0,n=M0):(c[n]=N0,c[q0]=$0,n=q0);else if(M0q(I0,$0))c[n]=I0,c[M0]=$0,n=M0;else break Z}}return Z0}function q(c,Z0){var $0=c.sortIndex-Z0.sortIndex;return $0!==0?$0:c.id-Z0.id}function B(c){for(var Z0=Q(F);Z0!==null;){if(Z0.callback===null)z(F);else if(Z0.startTime<=c)z(F),Z0.sortIndex=Z0.expirationTime,Y(_,Z0);else break;Z0=Q(F)}}function W(c){if(C=!1,B(c),!T)if(Q(_)!==null)T=!0,g||(g=!0,X0());else{var Z0=Q(F);Z0!==null&&U(W,Z0.startTime-c)}}function $(){return E?!0:Ra.unstable_now()-oc||125n?(c.sortIndex=$0,Y(F,c),Q(_)===null&&c===Q(F)&&(C?(S(s),s=-1):C=!0,U(W,$0-n))):(c.sortIndex=z0,Y(_,c),T||L||(T=!0,g||(g=!0,X0()))),c},Ra.unstable_shouldYield=$,Ra.unstable_wrapCallback=function(c){var Z0=v;return function(){var $0=v;v=Z0;try{return c.apply(this,arguments)}finally{v=$0}}},typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var uI=D3((La)=>{var mF=h(J0());(function(){function Z(){}function Y(w){return""+w}function Q(w,O,_){var F=3` tag.%s',_),typeof w==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var F=z(_,O.crossOrigin);$.d.L(w,_,{crossOrigin:F,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Ra.preloadModule=function(w,O){var _="";typeof w==="string"&&w||(_+=" The `href` argument encountered was "+q(w)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+q(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+q(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof w==="string"&&(O?(_=z(O.as,O.crossOrigin),$.d.m(w,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):$.d.m(w))},Ra.requestFormReset=function(w){$.d.r(w)},Ra.unstable_batchedUpdates=function(w,O){return w(O)},Ra.useFormState=function(w,O,_){return W().useFormState(w,O,_)},Ra.useFormStatus=function(){return W().useHostTransitionStatus()},Ra.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var b3=D3((vQ0,pI)=>{var La=h(uI());pI.exports=La});var cI=D3((Ta)=>{var a1=h(mI()),aQ=h(J0()),uF=h(b3());(function(){function Z(J,X){for(J=J.memoizedState;J!==null&&0=X.length)return G;var H=X[K],N=T2(J)?J.slice():E1({},J);return N[H]=Y(J[H],X,K+1,G),N}function Q(J,X,K){if(X.length!==K.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;G_8?console.error("Unexpected pop."):(X!==__[_8]&&console.error("Unexpected Fiber popped."),J.current=O_[_8],O_[_8]=null,__[_8]=null,_8--)}function W0(J,X,K){_8++,O_[_8]=J.current,__[_8]=K,J.current=X}function e(J){return J===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),J}function c(J,X){W0(I9,X,J),W0(aq,J,J),W0(E9,null,J);var K=X.nodeType;switch(K){case 9:case 11:K=K===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?RD(X):j8:j8;break;default:if(K=X.tagName,X=X.namespaceURI)X=RD(X),X=LD(X,K);else switch(K){case"svg":X=lQ;break;case"math":X=sG;break;default:X=j8}}K=K.toLowerCase(),K=OT(null,K),K={context:X,ancestorInfo:K},X0(E9,J),W0(E9,K,J)}function Z0(J){X0(E9,J),X0(aq,J),X0(I9,J)}function $0(){return e(E9.current)}function n(J){J.memoizedState!==null&&W0(t$,J,J);var X=e(E9.current),K=J.type,G=LD(X.context,K);K=OT(X.ancestorInfo,K),G={context:G,ancestorInfo:K},X!==G&&(W0(aq,J,J),W0(E9,G,J))}function z0(J){aq.current===J&&(X0(E9,J),X0(aq,J)),t$.current===J&&(X0(t$,J),hK._currentValue=IY)}function f(){}function q0(){if(sq===0){Zb=console.log,Yb=console.info,Jb=console.warn,Qb=console.error,Xb=console.group,zb=console.groupCollapsed,qb=console.groupEnd;var J={configurable:!0,enumerable:!0,value:f,writable:!0};Object.defineProperties(console,{info:J,log:J,warn:J,error:J,group:J,groupCollapsed:J,groupEnd:J})}sq++}function N0(){if(sq--,sq===0){var J={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:E1({},J,{value:Zb}),info:E1({},J,{value:Yb}),warn:E1({},J,{value:Jb}),error:E1({},J,{value:Qb}),group:E1({},J,{value:Xb}),groupCollapsed:E1({},J,{value:zb}),groupEnd:E1({},J,{value:qb})})}0>sq&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function M0(J){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,J=J.stack,Error.prepareStackTrace=X,J.startsWith(`Error: react-stack-top-frame +See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),w}typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(Error());var $={d:{f:Z,r:function(){throw Error("Invalid form element. requestFormReset must be passed a form that was rendered by React.")},D:Z,C:Z,L:Z,m:Z,X:Z,S:Z,M:Z},p:0,findDOMNode:null},U=Symbol.for("react.portal"),M=mF.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;typeof Map==="function"&&Map.prototype!=null&&typeof Map.prototype.forEach==="function"&&typeof Set==="function"&&Set.prototype!=null&&typeof Set.prototype.clear==="function"&&typeof Set.prototype.forEach==="function"||console.error("React depends on Map and Set built-in types. Make sure that you load a polyfill in older browsers. https://reactjs.org/link/react-polyfills"),La.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=$,La.createPortal=function(w,O){var _=2` tag.%s',_),typeof w==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){_=O.as;var F=z(_,O.crossOrigin);$.d.L(w,_,{crossOrigin:F,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},La.preloadModule=function(w,O){var _="";typeof w==="string"&&w||(_+=" The `href` argument encountered was "+q(w)+"."),O!==void 0&&typeof O!=="object"?_+=" The `options` argument encountered was "+q(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(_+=" The `as` option encountered was "+q(O.as)+"."),_&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',_),typeof w==="string"&&(O?(_=z(O.as,O.crossOrigin),$.d.m(w,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0})):$.d.m(w))},La.requestFormReset=function(w){$.d.r(w)},La.unstable_batchedUpdates=function(w,O){return w(O)},La.useFormState=function(w,O,_){return W().useFormState(w,O,_)},La.useFormStatus=function(){return W().useHostTransitionStatus()},La.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var b3=D3((CQ0,pI)=>{var Ta=h(uI());pI.exports=Ta});var cI=D3((va)=>{var a1=h(mI()),aQ=h(J0()),uF=h(b3());(function(){function Z(J,X){for(J=J.memoizedState;J!==null&&0=X.length)return G;var H=X[K],N=T2(J)?J.slice():E1({},J);return N[H]=Y(J[H],X,K+1,G),N}function Q(J,X,K){if(X.length!==K.length)console.warn("copyWithRename() expects paths of the same length");else{for(var G=0;G_8?console.error("Unexpected pop."):(X!==__[_8]&&console.error("Unexpected Fiber popped."),J.current=O_[_8],O_[_8]=null,__[_8]=null,_8--)}function W0(J,X,K){_8++,O_[_8]=J.current,__[_8]=K,J.current=X}function e(J){return J===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),J}function c(J,X){W0(I9,X,J),W0(aq,J,J),W0(E9,null,J);var K=X.nodeType;switch(K){case 9:case 11:K=K===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?RD(X):j8:j8;break;default:if(K=X.tagName,X=X.namespaceURI)X=RD(X),X=LD(X,K);else switch(K){case"svg":X=lQ;break;case"math":X=sG;break;default:X=j8}}K=K.toLowerCase(),K=OT(null,K),K={context:X,ancestorInfo:K},X0(E9,J),W0(E9,K,J)}function Z0(J){X0(E9,J),X0(aq,J),X0(I9,J)}function $0(){return e(E9.current)}function n(J){J.memoizedState!==null&&W0(t$,J,J);var X=e(E9.current),K=J.type,G=LD(X.context,K);K=OT(X.ancestorInfo,K),G={context:G,ancestorInfo:K},X!==G&&(W0(aq,J,J),W0(E9,G,J))}function z0(J){aq.current===J&&(X0(E9,J),X0(aq,J)),t$.current===J&&(X0(t$,J),hK._currentValue=IY)}function f(){}function q0(){if(sq===0){Zb=console.log,Yb=console.info,Jb=console.warn,Qb=console.error,Xb=console.group,zb=console.groupCollapsed,qb=console.groupEnd;var J={configurable:!0,enumerable:!0,value:f,writable:!0};Object.defineProperties(console,{info:J,log:J,warn:J,error:J,group:J,groupCollapsed:J,groupEnd:J})}sq++}function N0(){if(sq--,sq===0){var J={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:E1({},J,{value:Zb}),info:E1({},J,{value:Yb}),warn:E1({},J,{value:Jb}),error:E1({},J,{value:Qb}),group:E1({},J,{value:Xb}),groupCollapsed:E1({},J,{value:zb}),groupEnd:E1({},J,{value:qb})})}0>sq&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function M0(J){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,J=J.stack,Error.prepareStackTrace=X,J.startsWith(`Error: react-stack-top-frame `)&&(J=J.slice(29)),X=J.indexOf(` `),X!==-1&&(J=J.slice(X+1)),X=J.indexOf("react_stack_bottom_frame"),X!==-1&&(X=J.lastIndexOf(` `,X)),X!==-1)J=J.slice(0,X);else return"";return J}function I0(J){if(F_===void 0)try{throw Error()}catch(K){var X=K.stack.trim().match(/\n( *(at )?)/);F_=X&&X[1]||"",Kb=-1"u")return!1;var X=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(X.isDisabled)return!0;if(!X.supportsFiber)return console.error("The installed version of React DevTools is too old and will not work with the current version of React. Please update React DevTools. https://react.dev/link/react-devtools"),!0;try{MQ=X.inject(J),F7=X}catch(K){console.error("React instrumentation encountered an error: %o.",K)}return X.checkDCE?!0:!1}function S1(J){if(typeof hl==="function"&&gl(J),F7&&typeof F7.setStrictMode==="function")try{F7.setStrictMode(MQ,J)}catch(X){F3||(F3=!0,console.error("React instrumentation encountered an error: %o",X))}}function P6(J){return J>>>=0,J===0?32:31-(ml(J)/ul|0)|0}function e2(J){var X=J&42;if(X!==0)return X;switch(J&-J){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return J&261888;case 262144:case 524288:case 1048576:case 2097152:return J&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return J&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return console.error("Should have found matching lanes. This is a bug in React."),J}}function c4(J,X,K){var G=J.pendingLanes;if(G===0)return 0;var H=0,N=J.suspendedLanes,A=J.pingedLanes;J=J.warmLanes;var V=G&134217727;return V!==0?(G=V&~N,G!==0?H=e2(G):(A&=V,A!==0?H=e2(A):K||(K=V&~J,K!==0&&(H=e2(K))))):(V=G&~N,V!==0?H=e2(V):A!==0?H=e2(A):K||(K=G&~J,K!==0&&(H=e2(K)))),H===0?0:X!==0&&X!==H&&(X&N)===0&&(N=H&-H,K=X&-X,N>=K||N===32&&(K&4194048)!==0)?X:H}function x2(J,X){return(J.pendingLanes&~(J.suspendedLanes&~J.pingedLanes)&X)===0}function V6(J,X){switch(J){case 1:case 2:case 4:case 8:case 64:return X+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return X+5000;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return console.error("Should have found matching lanes. This is a bug in React."),-1}}function R6(){var J=ZG;return ZG<<=1,(ZG&62914560)===0&&(ZG=4194304),J}function Z7(J){for(var X=[],K=0;31>K;K++)X.push(J);return X}function v5(J,X){J.pendingLanes|=X,X!==268435456&&(J.suspendedLanes=0,J.pingedLanes=0,J.warmLanes=0)}function $2(J,X,K,G,H,N){var A=J.pendingLanes;J.pendingLanes=K,J.suspendedLanes=0,J.pingedLanes=0,J.warmLanes=0,J.expiredLanes&=K,J.entangledLanes&=K,J.errorRecoveryDisabledLanes&=K,J.shellSuspendCounter=0;var{entanglements:V,expirationTimes:D,hiddenUpdates:I}=J;for(K=A&~K;0"u")return null;try{return J.activeElement||J.body}catch(X){return J.body}}function S0(J){return J.replace(rl,function(X){return"\\"+X.charCodeAt(0).toString(16)+" "})}function c0(J,X){X.checked===void 0||X.defaultChecked===void 0||Mb||(console.error("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",L1()||"A component",X.type),Mb=!0),X.value===void 0||X.defaultValue===void 0||Ub||(console.error("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",L1()||"A component",X.type),Ub=!0)}function r0(J,X,K,G,H,N,A,V){if(J.name="",A!=null&&typeof A!=="function"&&typeof A!=="symbol"&&typeof A!=="boolean"?(w1(A,"type"),J.type=A):J.removeAttribute("type"),X!=null)if(A==="number"){if(X===0&&J.value===""||J.value!=X)J.value=""+t(X)}else J.value!==""+t(X)&&(J.value=""+t(X));else A!=="submit"&&A!=="reset"||J.removeAttribute("value");X!=null?d0(J,A,t(X)):K!=null?d0(J,A,t(K)):G!=null&&J.removeAttribute("value"),H==null&&N!=null&&(J.defaultChecked=!!N),H!=null&&(J.checked=H&&typeof H!=="function"&&typeof H!=="symbol"),V!=null&&typeof V!=="function"&&typeof V!=="symbol"&&typeof V!=="boolean"?(w1(V,"name"),J.name=""+t(V)):J.removeAttribute("name")}function J1(J,X,K,G,H,N,A,V){if(N!=null&&typeof N!=="function"&&typeof N!=="symbol"&&typeof N!=="boolean"&&(w1(N,"type"),J.type=N),X!=null||K!=null){if(!(N!=="submit"&&N!=="reset"||X!==void 0&&X!==null)){P0(J);return}K=K!=null?""+t(K):"",X=X!=null?""+t(X):K,V||X===J.value||(J.value=X),J.defaultValue=X}G=G!=null?G:H,G=typeof G!=="function"&&typeof G!=="symbol"&&!!G,J.checked=V?J.checked:!!G,J.defaultChecked=!!G,A!=null&&typeof A!=="function"&&typeof A!=="symbol"&&typeof A!=="boolean"&&(w1(A,"name"),J.name=A),P0(J)}function d0(J,X,K){X==="number"&&y0(J.ownerDocument)===J||J.defaultValue===""+K||(J.defaultValue=""+K)}function p1(J,X){X.value==null&&(typeof X.children==="object"&&X.children!==null?aQ.Children.forEach(X.children,function(K){K==null||typeof K==="string"||typeof K==="number"||typeof K==="bigint"||wb||(wb=!0,console.error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to