diff --git a/.config/nextest.toml b/.config/nextest.toml index 0978c7c48..fe7f4f778 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -30,7 +30,28 @@ leak-timeout = "500ms" [profile.ci] # CI runners are slower and more variable than dev machines; give tests room -# before flagging them as hung. Does not inherit default's per-package -# overrides — CI uses one uniform timeout for every test. +# before flagging them as hung. CI uses one uniform timeout for every test. +# +# Nextest falls back to `[[profile.default.overrides]]` when the active +# profile has no matching override for a given setting, so the per-package +# overrides below re-assert the CI timeout for packages narrowed down in +# profile.default. See +# https://nexte.st/docs/configuration/per-test-overrides/#override-precedence slow-timeout = { period = "30s", terminate-after = 4 } -leak-timeout = "2s" \ No newline at end of file +leak-timeout = "2s" + + [[profile.ci.overrides]] + filter = "package(fabro-cli)" + slow-timeout = { period = "30s", terminate-after = 4 } + + [[profile.ci.overrides]] + filter = "package(fabro-server)" + slow-timeout = { period = "30s", terminate-after = 4 } + + [[profile.ci.overrides]] + filter = "package(fabro-workflow)" + slow-timeout = { period = "30s", terminate-after = 4 } + + [[profile.ci.overrides]] + filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)" + slow-timeout = { period = "60s", terminate-after = 2 } diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 80b62e31f..76e5e9861 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -37,7 +37,7 @@ env: jobs: fmt: name: Format - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-x86-32-cores permissions: contents: read steps: @@ -52,7 +52,7 @@ jobs: clippy: name: Clippy - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-x86-32-cores permissions: contents: read steps: @@ -70,7 +70,7 @@ jobs: test: name: Test (Linux) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-x86-32-cores permissions: contents: read steps: diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 09623e14f..84a0e8496 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -34,7 +34,7 @@ permissions: {} jobs: typecheck: name: Typecheck - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-x86-32-cores permissions: contents: read steps: @@ -47,7 +47,7 @@ jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-x86-32-cores permissions: contents: read steps: @@ -60,7 +60,7 @@ jobs: build: name: Build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-x86-32-cores permissions: contents: read steps: diff --git a/AGENTS.md b/AGENTS.md index e58b31bdf..fb94300ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,8 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24) - `cd apps/fabro-web && bun run dev` — rebuild web assets on change for the Rust server; refresh the browser manually - `cd apps/fabro-web && bun test` — run tests - `cd apps/fabro-web && bun run typecheck` — type check -- `cd apps/fabro-web && bun run build` — production build +- `cd apps/fabro-web && bun run build` — production build (writes to `apps/fabro-web/dist/` only; does NOT update the bundled SPA that ships in the Rust binary) +- `scripts/refresh-fabro-spa.sh` — **run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job reruns this script and then `git diff --exit-code -- lib/crates/fabro-spa/assets` — if the committed bundle drifts from source (e.g. content-hashed filenames like `entry-.js` change), the check fails. `bun run build` on its own is not enough. ### Marketing site (apps/marketing) - `cd apps/marketing && bun run dev` — start Astro dev server diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 13d0602b5..2acf3c0e5 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { columnForStatus, mapRunListItem, mapRunSummaryToRunItem } from "./runs"; +import { + columnForStatus, + columnStatusDisplay, + isRunStatus, + mapRunListItem, + mapRunSummaryToRunItem, + runStatusDisplay, +} from "./runs"; describe("mapRunListItem", () => { test("trusts shared server fields for board items", () => { @@ -111,6 +118,18 @@ describe("mapRunSummaryToRunItem", () => { expect(item.workflow).toBe("unknown"); expect(item.repo).toBe("unknown"); }); + + test("recognizes canonical blocked and queued run statuses", () => { + expect(isRunStatus("queued")).toBe(true); + expect(isRunStatus("blocked")).toBe(true); + expect(runStatusDisplay).toHaveProperty("queued"); + expect(runStatusDisplay).toHaveProperty("blocked"); + }); + + test("uses blocked board column instead of waiting", () => { + expect(columnStatusDisplay).toHaveProperty("blocked"); + expect(columnStatusDisplay).not.toHaveProperty("waiting"); + }); }); describe("columnForStatus", () => { diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index aa9769ad2..711cca52a 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -31,14 +31,14 @@ export interface RunItem { sandboxId?: string; } -export type ColumnStatus = "initializing" | "running" | "waiting" | "succeeded" | "failed"; +export type ColumnStatus = "initializing" | "running" | "blocked" | "succeeded" | "failed"; -export const columnNames: Record = { - initializing: "Initializing", - running: "Running", - waiting: "Waiting", - succeeded: "Succeeded", - failed: "Failed", +export const columnStatusDisplay: Record = { + initializing: { label: "Initializing", dot: "bg-amber", text: "text-amber" }, + running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" }, + blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" }, + succeeded: { label: "Succeeded", dot: "bg-teal-300", text: "text-teal-300" }, + failed: { label: "Failed", dot: "bg-coral", text: "text-coral" }, }; export interface RunWithStatus extends RunItem { @@ -97,12 +97,14 @@ export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem { export function columnForStatus(status: string | null | undefined): ColumnStatus | null { switch (status) { case "submitted": + case "queued": case "starting": return "initializing"; case "running": - return "running"; case "paused": - return "waiting"; + return "running"; + case "blocked": + return "blocked"; case "succeeded": return "succeeded"; case "failed": @@ -120,18 +122,12 @@ export function deriveCiStatus(checks: CheckRun[]): CiStatus { return "passing"; } -export const statusColors: Record = { - initializing: { dot: "bg-amber", text: "text-amber" }, - running: { dot: "bg-teal-500", text: "text-teal-500" }, - waiting: { dot: "bg-amber", text: "text-amber" }, - succeeded: { dot: "bg-teal-300", text: "text-teal-300" }, - failed: { dot: "bg-coral", text: "text-coral" }, -}; - export type RunStatus = | "submitted" + | "queued" | "starting" | "running" + | "blocked" | "paused" | "removing" | "succeeded" @@ -140,8 +136,10 @@ export type RunStatus = export const runStatusDisplay: Record = { submitted: { label: "Submitted", dot: "bg-fg-muted", text: "text-fg-muted" }, + queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" }, starting: { label: "Starting", dot: "bg-amber", text: "text-amber" }, running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" }, + blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" }, paused: { label: "Paused", dot: "bg-amber", text: "text-amber" }, removing: { label: "Removing", dot: "bg-fg-muted", text: "text-fg-muted" }, succeeded: { label: "Succeeded", dot: "bg-mint", text: "text-mint" }, diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index ac2d6a3e9..974ca66ec 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -25,7 +25,7 @@ export async function loader({ request, params }: any) { if (!response.ok) return { run: null }; const summary: RunSummaryResponse = await response.json(); const item = mapRunSummaryToRunItem(summary); - const rawStatus = summary.status ?? "submitted"; + const rawStatus = summary.status; const display = isRunStatus(rawStatus) ? runStatusDisplay[rawStatus] : { label: rawStatus, dot: "bg-fg-muted", text: "text-fg-muted" }; diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx new file mode 100644 index 000000000..9c15a9c95 --- /dev/null +++ b/apps/fabro-web/app/routes/runs.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import type { BoardColumn, RunListItem } from "@qltysh/fabro-api-client"; + +import { buildBoardColumns, shouldRefreshBoardForEvent } from "./runs"; + +function boardRun(id: string, column: BoardColumn, questionText?: string): RunListItem { + return { + run_id: id, + goal: `Run ${id}`, + title: `Run ${id}`, + created_at: "2026-04-19T12:00:00Z", + status: column, + labels: {}, + repository: { name: "repo" }, + column, + ...(questionText ? { question: { text: questionText } } : {}), + }; +} + +describe("runs route board mapping", () => { + test("keeps blocked runs in the blocked lane and preserves question text", () => { + const columns = buildBoardColumns({ + columns: [ + { id: "initializing", name: "Initializing" }, + { id: "running", name: "Running" }, + { id: "blocked", name: "Blocked" }, + { id: "succeeded", name: "Succeeded" }, + { id: "failed", name: "Failed" }, + ], + data: [ + boardRun("paused-run", "running"), + boardRun("blocked-run", "blocked", "Older unresolved question?"), + ], + meta: { has_more: false }, + }); + + expect(columns.find((column) => column.id === "running")?.items.map((item) => item.id)).toContain("paused-run"); + expect(columns.find((column) => column.id === "blocked")?.items.map((item) => item.id)).toContain("blocked-run"); + expect(columns.find((column) => column.id === "blocked")?.items[0]?.question).toBe("Older unresolved question?"); + }); + + test("refreshes for blocked status and interview events", () => { + expect(shouldRefreshBoardForEvent("run.queued")).toBe(true); + expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true); + expect(shouldRefreshBoardForEvent("run.unblocked")).toBe(true); + expect(shouldRefreshBoardForEvent("interview.started")).toBe(true); + expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true); + expect(shouldRefreshBoardForEvent("run.created")).toBe(false); + }); +}); diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index b4f53c27e..e1af8b25e 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -18,7 +18,7 @@ import { arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { ciConfig, columnNames, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs"; +import { ciConfig, columnStatusDisplay, deriveCiStatus, mapRunListItem } from "../data/runs"; import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs"; import { apiPaginatedJson } from "../api"; import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client"; @@ -28,21 +28,20 @@ export function meta({}: any) { } interface ColumnStyle { - accent: string; - iconColor: string; iconType: "branch" | "pr"; actions: string[]; } -const columnStyles: Record = { - initializing: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: [] }, - running: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] }, - waiting: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] }, - succeeded: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: [] }, - failed: { accent: "bg-coral", iconColor: "text-coral", iconType: "branch", actions: [] }, +const columnStyles: Record = { + initializing: { iconType: "branch", actions: [] }, + running: { iconType: "branch", actions: ["Watch", "Steer"] }, + blocked: { iconType: "branch", actions: ["Answer Question"] }, + succeeded: { iconType: "pr", actions: [] }, + failed: { iconType: "branch", actions: [] }, }; -const defaultColumnStyle: ColumnStyle = { accent: "bg-fg-muted", iconColor: "text-fg-muted", iconType: "branch", actions: [] }; +const defaultColumnStyle: ColumnStyle = { iconType: "branch", actions: [] }; +const defaultColumnColors = { dot: "bg-fg-muted", text: "text-fg-muted" }; interface BoardRunsResponse { columns: { id: string; name: string }[]; @@ -50,36 +49,74 @@ interface BoardRunsResponse { meta: PaginatedBoardRunList["meta"]; } -export async function loader({ request }: any) { - const response = await apiPaginatedJson< - PaginatedBoardRunList["data"][number], - { columns: BoardRunsResponse["columns"] } - >("/boards/runs", { request }); - const apiRuns = response.data; +type Column = { + id: ColumnStatus; + name: string; + dot: string; + text: string; + iconType: "branch" | "pr"; + actions: string[]; + items: RunItem[]; +}; +const BOARD_STATUS_EVENTS = new Set([ + "run.submitted", + "run.queued", + "run.starting", + "run.running", + "run.removing", + "run.paused", + "run.unpaused", + "run.blocked", + "run.unblocked", + "run.completed", + "run.failed", + "interview.started", + "interview.completed", + "interview.timeout", + "interview.interrupted", +]); + +export function shouldRefreshBoardForEvent(event: string) { + return BOARD_STATUS_EVENTS.has(event); +} + +export function buildBoardColumns(response: BoardRunsResponse): Column[] { const grouped = new Map(); for (const col of response.columns) { grouped.set(col.id, []); } - for (const apiRun of apiRuns) { + for (const apiRun of response.data) { if (grouped.has(apiRun.column)) { grouped.get(apiRun.column)?.push(mapRunListItem(apiRun)); } } - const columns = response.columns.map((col) => ({ - id: col.id as ColumnStatus, - name: col.name, - ...(columnStyles[col.id] ?? defaultColumnStyle), - items: grouped.get(col.id) ?? [], - })); + return response.columns.map((col) => { + const id = col.id as ColumnStatus; + const colors = columnStatusDisplay[id] ?? defaultColumnColors; + return { + id, + name: col.name, + dot: colors.dot, + text: colors.text, + ...(columnStyles[id] ?? defaultColumnStyle), + items: grouped.get(col.id) ?? [], + }; + }); +} - return { columns }; +export async function loader({ request }: any) { + const response = await apiPaginatedJson< + PaginatedBoardRunList["data"][number], + { columns: BoardRunsResponse["columns"] } + >("/boards/runs", { request }); + return { columns: buildBoardColumns(response) }; } function boardLifecycleStatusLabel(run: Pick): string | null { if (run.lifecycleStatusLabel == null) return null; - if (run.column != null && columnNames[run.column] === run.lifecycleStatusLabel) { + if (run.column != null && columnStatusDisplay[run.column]?.label === run.lifecycleStatusLabel) { return null; } return run.lifecycleStatusLabel; @@ -418,22 +455,12 @@ function SortablePrCard({ ); } -type Column = { - id: ColumnStatus; - name: string; - accent: string; - iconColor: string; - iconType: "branch" | "pr"; - actions: string[]; - items: RunItem[]; -}; - function BoardColumn({ column }: { column: Column }) { const Icon = iconMap[column.iconType]; return (
-
+

{column.name}

@@ -449,7 +476,7 @@ function BoardColumn({ column }: { column: Column }) { key={pr.id} pr={pr} icon={Icon} - iconColor={column.iconColor} + iconColor={column.text} actions={column.actions} /> ))} @@ -655,11 +682,6 @@ export default function Runs({ loaderData }: any) { const lowerQuery = query.toLowerCase(); const revalidator = useRevalidator(); - const STATUS_EVENTS = new Set([ - "run.submitted", "run.starting", "run.running", - "run.paused", "run.completed", "run.failed", - ]); - useEffect(() => { const source = new EventSource("/api/v1/attach"); let debounceTimer: ReturnType | undefined; @@ -667,7 +689,7 @@ export default function Runs({ loaderData }: any) { source.onmessage = (msg) => { try { const payload = JSON.parse(msg.data); - if (STATUS_EVENTS.has(payload.event)) { + if (shouldRefreshBoardForEvent(payload.event)) { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => revalidator.revalidate(), 500); } @@ -804,7 +826,7 @@ export default function Runs({ loaderData }: any) { {isCollapsed ? : } -
+

{col.name}

{col.items.length} diff --git a/apps/fabro-web/app/routes/workflow-runs.tsx b/apps/fabro-web/app/routes/workflow-runs.tsx index 105bf024c..88640b9c8 100644 --- a/apps/fabro-web/app/routes/workflow-runs.tsx +++ b/apps/fabro-web/app/routes/workflow-runs.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { Link, useParams } from "react-router"; -import { ciConfig, columnNames, columnForStatus, deriveCiStatus, mapRunSummaryToRunItem, statusColors } from "../data/runs"; +import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs"; import type { ColumnStatus, RunWithStatus } from "../data/runs"; import { apiJsonOrNull } from "../api"; import type { PaginatedRunList } from "@qltysh/fabro-api-client"; @@ -16,7 +16,7 @@ export async function loader({ request, params }: any) { return { ...mapRunSummaryToRunItem(r), status: column, - statusLabel: columnNames[column], + statusLabel: columnStatusDisplay[column].label, }; }) .filter((run): run is RunWithStatus => run != null); @@ -32,7 +32,7 @@ function GitPullRequestIcon({ className }: { className?: string }) { } function RunRow({ run }: { run: RunWithStatus }) { - const colors = statusColors[run.status]; + const colors = columnStatusDisplay[run.status]; return ( @@ -106,8 +106,8 @@ export default function WorkflowRuns({ loaderData }: any) { className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0" > - {(Object.entries(columnNames) as [ColumnStatus, string][]).map(([id, name]) => ( - + {(Object.entries(columnStatusDisplay) as [ColumnStatus, { label: string }][]).map(([id, { label }]) => ( + ))} diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 31ea52368..cf1f79dbf 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -2102,10 +2102,12 @@ components: - queued - starting - running - - completed - - failed - - cancelled + - blocked - paused + - removing + - succeeded + - failed + - dead RunManifest: description: Self-contained workflow run manifest. @@ -2484,6 +2486,10 @@ components: oneOf: - $ref: "#/components/schemas/StatusReason" - type: "null" + blocked_reason: + oneOf: + - $ref: "#/components/schemas/BlockedReason" + - type: "null" pending_control: oneOf: - $ref: "#/components/schemas/RunControlAction" @@ -2861,19 +2867,6 @@ components: items: $ref: "#/components/schemas/RunArtifactEntry" - InternalRunStatus: - description: Internal event-sourced run status. - type: string - enum: - - submitted - - starting - - running - - paused - - removing - - succeeded - - failed - - dead - StatusReason: description: Optional reason attached to a run status transition. type: string @@ -2890,6 +2883,12 @@ components: - sandbox_init_failed - sandbox_initializing + BlockedReason: + description: Specific reason a run is blocked on external intervention. + type: string + enum: + - human_input_required + RunControlAction: description: Run control action requested by the API. type: string @@ -2906,11 +2905,15 @@ components: - updated_at properties: status: - $ref: "#/components/schemas/InternalRunStatus" - reason: + $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 @@ -2965,6 +2968,16 @@ components: stderr: type: ["string", "null"] + PendingInterviewRecord: + description: Pending interview question plus the time it entered the unresolved set. + type: object + properties: + question: + $ref: "#/components/schemas/ApiQuestion" + started_at: + type: ["string", "null"] + format: date-time + RunProjection: description: Raw internal run projection derived from the event log. type: object @@ -3016,6 +3029,10 @@ components: pull_request: type: ["object", "null"] additionalProperties: true + pending_interviews: + type: object + additionalProperties: + $ref: "#/components/schemas/PendingInterviewRecord" nodes: type: object description: Map from StageId (`node_id@visit`) to NodeState. @@ -3030,6 +3047,7 @@ components: - goal - title - labels + - status - repository - created_at properties: @@ -3058,11 +3076,15 @@ components: type: string format: date-time status: - type: ["string", "null"] + $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" @@ -3085,7 +3107,7 @@ components: enum: - initializing - running - - waiting + - blocked - succeeded - failed diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index 432f59295..d03bdd5f8 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -514,11 +514,11 @@ mod tests { fn cancel_run_response(run_id: RunId) -> serde_json::Value { serde_json::json!({ "id": run_id, - "status": "cancelled", + "status": "failed", "error": null, "queue_position": null, "status_reason": "cancelled", - "pending_control": "cancel", + "pending_control": null, "created_at": "2026-04-05T12:00:00Z" }) } diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 5ef38e105..afdd89c77 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -16,11 +16,6 @@ use crate::command_context::CommandContext; use crate::server_runs::ServerSummaryLookup; use crate::shared::{format_duration_ms, format_usd_micros}; -#[cfg(test)] -const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_millis(500); -#[cfg(not(test))] -const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_secs(3); - pub(crate) async fn run( args: &WaitArgs, styles: &Styles, @@ -40,21 +35,8 @@ pub(crate) async fn run( .timeout .map(|secs| std::time::Instant::now() + std::time::Duration::from_secs(secs)); let interval = std::time::Duration::from_millis(args.interval); - let started_waiting_at = std::time::Instant::now(); - let final_status = loop { - let status = client - .get_run_state(&run_id) - .await? - .status - .map(|record| record.status); - let status = status.unwrap_or_else(|| { - if started_waiting_at.elapsed() < WAIT_STARTUP_GRACE { - RunStatus::Submitted - } else { - RunStatus::Dead - } - }); + let status = client.retrieve_run(&run_id).await?.status; if status.is_terminal() { break status; @@ -288,15 +270,4 @@ mod tests { assert!(status.is_terminal()); assert_eq!(status, RunStatus::Succeeded); } - - #[test] - fn missing_status_treated_as_dead() { - let status = match std::fs::read_to_string(std::path::Path::new("/nonexistent/status.json")) - { - Ok(data) => serde_json::from_str::(&data) - .map_or(RunStatus::Dead, |record| record.status), - Err(_) => RunStatus::Dead, - }; - assert_eq!(status, RunStatus::Dead); - } } diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index dafe0d418..86796cfb0 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -149,9 +149,9 @@ fn status_cell(status: RunStatus, use_color: bool) -> CellStruct { RunStatus::Succeeded => Some(Color::Green), RunStatus::Failed => Some(Color::Red), RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan), - RunStatus::Removing => Some(Color::Yellow), + RunStatus::Queued | RunStatus::Dead => Some(Color::Ansi256(8)), + RunStatus::Blocked | RunStatus::Removing => Some(Color::Yellow), RunStatus::Paused => Some(Color::Magenta), - RunStatus::Dead => Some(Color::Ansi256(8)), }; 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 018e7775a..6835d862f 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -373,9 +373,10 @@ mod tests { fn sample_status() -> RunStatusRecord { RunStatusRecord { - status: RunStatus::Running, - reason: Some(StatusReason::SandboxInitializing), - updated_at: dt("2026-03-27T12:05:00Z"), + status: RunStatus::Running, + status_reason: Some(StatusReason::SandboxInitializing), + blocked_reason: None, + updated_at: dt("2026-03-27T12:05:00Z"), } } @@ -520,7 +521,7 @@ mod tests { .await .unwrap(); append_event(&run, &run_id, &Event::RunRunning { - reason: status_record.reason, + reason: status_record.status_reason, }) .await .unwrap(); diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 9c98c4949..7a250b814 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -620,6 +620,17 @@ impl ServerStoreClient { Ok(all_runs) } + pub(crate) async fn retrieve_run(&self, run_id: &RunId) -> Result { + let response = self + .client + .retrieve_run() + .id(run_id.to_string()) + .send() + .await + .map_err(map_api_error)?; + convert_type(response.into_inner()) + } + pub(crate) async fn get_run_state(&self, run_id: &RunId) -> Result { let response = self .client diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index 8020f7882..9e6b1293f 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -63,7 +63,7 @@ impl ServerRunSummaryInfo { } pub(crate) fn status(&self) -> RunStatus { - self.summary.status.unwrap_or(RunStatus::Dead) + self.summary.status } pub(crate) fn status_reason(&self) -> Option { diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index a27a59b0b..d3c692ee2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -628,6 +628,12 @@ fn attach_json_errors_without_prompting_for_human_input() { "run_id": "[ULID]", "ts": "[TIMESTAMP]" }, + { + "event": "run.queued", + "id": "[EVENT_ID]", + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" + }, { "event": "run.starting", "id": "[EVENT_ID]", @@ -817,6 +823,15 @@ fn attach_json_errors_without_prompting_for_human_input() { "run_id": "[ULID]", "stage_id": "approve@1", "ts": "[TIMESTAMP]" + }, + { + "event": "run.blocked", + "id": "[EVENT_ID]", + "properties": { + "blocked_reason": "human_input_required" + }, + "run_id": "[ULID]", + "ts": "[TIMESTAMP]" } ] "#); diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index 6f2d0291e..f92161c37 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -741,5 +741,5 @@ fn worker_exits_after_sigterm_cancel_even_when_stdin_stays_open() { .status .expect("cancelled run should have a status record"); assert_eq!(status_record.status.to_string(), "failed"); - assert_eq!(status_record.reason, Some(StatusReason::Cancelled)); + assert_eq!(status_record.status_reason, Some(StatusReason::Cancelled)); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs index ff777d16f..e9df789a6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/store_dump.rs @@ -247,9 +247,9 @@ fn store_dump_exports_completed_run_snapshot() { assert_snapshot!(dump_file_summary(&output_dir), @" checkpoint.json - checkpoints/0012.json - checkpoints/0016.json - checkpoints/0020.json + checkpoints/0013.json + checkpoints/0017.json + checkpoints/0021.json conclusion.json events.jsonl graph.fabro diff --git a/lib/crates/fabro-cli/tests/it/cmd/wait.rs b/lib/crates/fabro-cli/tests/it/cmd/wait.rs index 548879852..5846d6074 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/wait.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/wait.rs @@ -1,6 +1,29 @@ use fabro_test::{fabro_snapshot, test_context}; +use httpmock::MockServer; +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 { + json!({ + "run_id": run_id, + "workflow_name": "Blocked Remote Workflow", + "workflow_slug": "blocked-remote-workflow", + "goal": "Wait for approval", + "title": "Wait for approval", + "labels": {}, + "host_repo_path": "/srv/repo", + "repository": { "name": "repo" }, + "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 + }) +} #[test] fn help() { @@ -114,3 +137,62 @@ fn wait_submitted_run_times_out() { error: Timed out after 1s waiting for run '[ULID]' "); } + +#[test] +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 list_runs = server.mock(|when, then| { + when.method("GET").path("/api/v1/runs"); + then.status(200) + .header("content-type", "application/json") + .body(json!({ "data": [summary.clone()], "meta": { "has_more": false } }).to_string()); + }); + let retrieve_run = server.mock(|when, then| { + when.method("GET") + .path(format!("/api/v1/runs/{}", run_id.as_str())); + then.status(200) + .header("content-type", "application/json") + .body(summary.to_string()); + }); + let run_state = server.mock(|when, then| { + when.method("GET") + .path(format!("/api/v1/runs/{}/state", run_id.as_str())); + then.status(200) + .header("content-type", "application/json") + .body("{}"); + }); + + let mut cmd = context.command(); + cmd.args([ + "wait", + "--server", + &format!("{}/api/v1", server.base_url()), + "--timeout", + "1", + "--interval", + "10", + run_id.as_str(), + ]); + + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Timed out after 1s waiting for run '[ULID]' + "); + list_runs.assert(); + assert!( + retrieve_run.calls() > 0, + "wait should keep polling the blocked run summary until timeout" + ); + assert_eq!( + run_state.calls(), + 0, + "wait should not fetch run state when the run never becomes terminal" + ); +} diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 3905f02f8..31f7bae85 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -260,7 +260,16 @@ pub(crate) async fn cancel_stub( State(_state): State>, Path(id): Path, ) -> Response { - (StatusCode::OK, Json(serde_json::json!({"id": id, "status": "cancelled", "created_at": "2026-03-06T14:30:00Z"}))).into_response() + ( + StatusCode::OK, + Json(serde_json::json!({ + "id": id, + "status": "failed", + "status_reason": "cancelled", + "created_at": "2026-03-06T14:30:00Z" + })), + ) + .into_response() } pub(crate) async fn pause_stub( @@ -664,7 +673,7 @@ mod runs { workflow_slug: &str, workflow_name: &str, goal: &str, - status: Option<&str>, + status: &str, created_at: &str, elapsed_secs: Option, status_reason: Option<&str>, @@ -687,8 +696,10 @@ mod runs { }, run_id: run_id.into(), start_time: Some(ts(created_at)), - status: status.map(str::to_string), + status: RunStatus::from_str(status) + .unwrap_or_else(|_| panic!("invalid demo run status: {status}")), status_reason, + blocked_reason: None, title: truncate_goal(goal), total_usd_micros, workflow_name: Some(workflow_name.into()), @@ -736,7 +747,7 @@ mod runs { run_id: summary.run_id, sandbox, start_time: summary.start_time, - status: summary.status.unwrap_or_default(), + status: summary.status.to_string(), status_reason: summary.status_reason, title: summary.title, total_usd_micros: summary.total_usd_micros, @@ -787,8 +798,8 @@ mod runs { name: "Running".into(), }, BoardColumnDefinition { - id: "waiting".into(), - name: "Waiting".into(), + id: "blocked".into(), + name: "Blocked".into(), }, BoardColumnDefinition { id: "succeeded".into(), @@ -809,7 +820,7 @@ mod runs { "implement", "Implement", "Add rate limiting to auth endpoints", - Some("running"), + "running", "2026-03-06T14:30:00Z", Some(420.0), None, @@ -823,7 +834,7 @@ mod runs { "implement", "Implement", "Migrate to React Router v7", - Some("running"), + "running", "2026-03-06T12:00:00Z", Some(8100.0), None, @@ -837,7 +848,7 @@ mod runs { "expand", "Expand", "Update OpenAPI spec for v3", - Some("starting"), + "starting", "2026-03-04T15:00:00Z", Some(4320.0), None, @@ -851,7 +862,7 @@ mod runs { "implement", "Implement", "Add pipeline event types", - Some("paused"), + "blocked", "2026-03-04T10:00:00Z", Some(1680.0), None, @@ -865,7 +876,7 @@ mod runs { "implement", "Implement", "Add dark mode toggle", - Some("failed"), + "failed", "2026-03-03T16:45:00Z", Some(2100.0), Some("workflow_error"), @@ -879,7 +890,7 @@ mod runs { "implement", "Implement", "Implement webhook retry logic", - Some("succeeded"), + "succeeded", "2026-02-28T14:00:00Z", Some(259200.0), Some("completed"), @@ -922,7 +933,7 @@ mod runs { ), board_item( take_summary(&mut summaries, "run-4"), - BoardColumn::Waiting, + BoardColumn::Blocked, Some(pull_request(0, 145, 23, 0, vec![])), Some(sandbox("sb-u1v2w3x4", 4, 8)), Some(RunQuestion { @@ -1237,7 +1248,7 @@ mod runs { "implement", "Implement", "Goal", - Some("failed"), + "failed", "2026-03-06T14:30:00Z", Some(1.0), Some("cancelled"), @@ -1257,7 +1268,7 @@ mod runs { "implement", "Implement", "Goal", - Some("failed"), + "failed", "2026-03-06T14:30:00Z", Some(1.0), Some("unexpected_reason"), @@ -1278,7 +1289,7 @@ mod runs { "implement", "Implement", &goal, - Some("running"), + "running", "2026-03-06T14:30:00Z", Some(1.0), None, diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 163dcfdb6..cb1359bea 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -25,14 +25,14 @@ use bytes::Bytes; pub use fabro_api::types::{ AggregateBilling, AggregateBillingTotals, ApiQuestion, ApiQuestionOption, AppendEventResponse, ArtifactEntry, ArtifactListResponse, BilledTokenCounts as ApiBilledTokenCounts, BillingByModel, - BillingStageRef, CompletionContentPart, CompletionMessage, CompletionMessageRole, - CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, - CreateSecretRequest, DeleteSecretRequest, DiskUsageResponse, DiskUsageRunRow, - DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope, ModelReference, PaginatedEventList, - PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, - PruneRunEntry, PruneRunsRequest, PruneRunsResponse, QuestionType as ApiQuestionType, - RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RunArtifactEntry, - RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, + BillingStageRef, BlockedReason as ApiBlockedReason, CompletionContentPart, CompletionMessage, + CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode, CompletionUsage, + CreateCompletionRequest, CreateSecretRequest, DeleteSecretRequest, DiskUsageResponse, + DiskUsageRunRow, DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope, ModelReference, + PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, + PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, + QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, + RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunControlAction as ApiRunControlAction, RunError, RunManifest, RunStage, RunStatus, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse, SecretType as ApiSecretType, ServerSettings, SshAccessRequest, SshAccessResponse, StageStatus as ApiStageStatus, @@ -68,7 +68,7 @@ use fabro_types::settings::{ InterpString, ServerSettings as ResolvedServerSettings, SettingsLayer, }; use fabro_types::{ - ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, + ActorRef, BlockedReason, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance, }; @@ -1233,6 +1233,7 @@ async fn get_system_info( RunStatus::Queued | RunStatus::Starting | RunStatus::Running + | RunStatus::Blocked | RunStatus::Paused ) }) @@ -2117,7 +2118,7 @@ async fn list_run_stages( Some(managed_run) => { let active = !matches!( managed_run.status, - RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled + RunStatus::Succeeded | RunStatus::Failed | RunStatus::Dead ); (managed_run.checkpoint.clone(), active) } @@ -2572,20 +2573,22 @@ fn test_secret_store_path() -> PathBuf { fn board_column(status: WorkflowRunStatus) -> Option<&'static str> { match status { - WorkflowRunStatus::Submitted | WorkflowRunStatus::Starting => Some("initializing"), - WorkflowRunStatus::Running => Some("running"), - WorkflowRunStatus::Paused => Some("waiting"), + WorkflowRunStatus::Submitted | WorkflowRunStatus::Queued | WorkflowRunStatus::Starting => { + Some("initializing") + } + WorkflowRunStatus::Running | WorkflowRunStatus::Paused => Some("running"), + WorkflowRunStatus::Blocked => Some("blocked"), WorkflowRunStatus::Succeeded => Some("succeeded"), WorkflowRunStatus::Failed | WorkflowRunStatus::Dead => Some("failed"), WorkflowRunStatus::Removing => None, } } -fn board_columns() -> serde_json::Value { +pub(crate) fn board_columns() -> serde_json::Value { serde_json::json!([ {"id": "initializing", "name": "Initializing"}, {"id": "running", "name": "Running"}, - {"id": "waiting", "name": "Waiting"}, + {"id": "blocked", "name": "Blocked"}, {"id": "succeeded", "name": "Succeeded"}, {"id": "failed", "name": "Failed"}, ]) @@ -2633,6 +2636,7 @@ fn summary_to_api_run_summary(summary: fabro_store::RunSummary) -> serde_json::V "start_time": summary.start_time.map(|time| time.to_rfc3339()), "status": summary.status, "status_reason": summary.status_reason.map(api_status_reason), + "blocked_reason": summary.blocked_reason.map(api_blocked_reason), "pending_control": summary.pending_control.map(api_pending_control), "duration_ms": summary.duration_ms, "elapsed_secs": elapsed_secs(summary.duration_ms), @@ -2673,11 +2677,20 @@ async fn board_run_metadata( } } - if let Some(question) = run_state.pending_interviews.values().next() { + if let Some((_, record)) = + run_state + .pending_interviews + .iter() + .min_by(|(left_id, left), (right_id, right)| { + left.started_at + .cmp(&right.started_at) + .then_with(|| left_id.cmp(right_id)) + }) + { metadata.insert( "question".to_string(), serde_json::json!({ - "text": question.question.text, + "text": record.question.text, }), ); } @@ -2713,8 +2726,7 @@ async fn list_board_runs( let board_summaries: Vec<_> = summaries .into_iter() .filter_map(|summary| { - let status = summary.status?; - let column = board_column(status)?; + let column = board_column(summary.status)?; Some((summary, column)) }) .collect(); @@ -2813,6 +2825,7 @@ async fn delete_run_internal(state: &Arc, id: RunId) -> Result<(), Res | RunStatus::Queued | RunStatus::Starting | RunStatus::Running + | RunStatus::Blocked | RunStatus::Paused ) { WORKER_CANCEL_GRACE @@ -3091,8 +3104,10 @@ fn failure_for_incomplete_run( fn should_reconcile_run_on_startup(status: WorkflowRunStatus) -> bool { matches!( status, - WorkflowRunStatus::Starting + WorkflowRunStatus::Queued + | WorkflowRunStatus::Starting | WorkflowRunStatus::Running + | WorkflowRunStatus::Blocked | WorkflowRunStatus::Paused | WorkflowRunStatus::Removing ) @@ -3108,10 +3123,7 @@ pub(crate) async fn reconcile_incomplete_runs_on_startup( let mut reconciled = 0usize; for summary in summaries { - let Some(status) = summary.status else { - continue; - }; - if !should_reconcile_run_on_startup(status) { + if !should_reconcile_run_on_startup(summary.status) { continue; } @@ -3310,20 +3322,24 @@ fn managed_run( } } -fn api_status_from_workflow( - status: WorkflowRunStatus, - reason: Option, -) -> RunStatus { +fn api_status_from_workflow(status: WorkflowRunStatus) -> RunStatus { match status { WorkflowRunStatus::Submitted => RunStatus::Submitted, + WorkflowRunStatus::Queued => RunStatus::Queued, WorkflowRunStatus::Starting => RunStatus::Starting, - WorkflowRunStatus::Running | WorkflowRunStatus::Removing => RunStatus::Running, + WorkflowRunStatus::Running => RunStatus::Running, + WorkflowRunStatus::Blocked => RunStatus::Blocked, WorkflowRunStatus::Paused => RunStatus::Paused, - WorkflowRunStatus::Succeeded => RunStatus::Completed, - WorkflowRunStatus::Failed if reason == Some(WorkflowStatusReason::Cancelled) => { - RunStatus::Cancelled - } - WorkflowRunStatus::Failed | WorkflowRunStatus::Dead => RunStatus::Failed, + WorkflowRunStatus::Removing => RunStatus::Removing, + WorkflowRunStatus::Succeeded => RunStatus::Succeeded, + WorkflowRunStatus::Failed => RunStatus::Failed, + WorkflowRunStatus::Dead => RunStatus::Dead, + } +} + +fn api_blocked_reason(reason: BlockedReason) -> ApiBlockedReason { + match reason { + BlockedReason::HumanInputRequired => ApiBlockedReason::HumanInputRequired, } } @@ -3361,13 +3377,18 @@ fn api_pending_control(action: RunControlAction) -> ApiRunControlAction { async fn load_run_status_metadata( state: &AppState, run_id: RunId, -) -> (Option, Option) { +) -> ( + Option, + Option, + Option, +) { match state.store.runs().find(&run_id).await { Ok(Some(summary)) => ( summary.status_reason.map(api_status_reason), + summary.blocked_reason.map(api_blocked_reason), summary.pending_control.map(api_pending_control), ), - _ => (None, None), + _ => (None, None, None), } } @@ -3401,21 +3422,25 @@ fn update_live_run_from_event(state: &Arc, run_id: RunId, event: &RunE }; match &event.body { + EventBody::RunQueued(_) => managed_run.status = RunStatus::Queued, EventBody::RunStarting(_) => managed_run.status = RunStatus::Starting, EventBody::RunRunning(_) | EventBody::RunUnpaused(_) => { managed_run.status = RunStatus::Running; } + EventBody::RunBlocked(_) if managed_run.status != RunStatus::Paused => { + managed_run.status = RunStatus::Blocked; + } + EventBody::RunUnblocked(_) if managed_run.status != RunStatus::Paused => { + managed_run.status = RunStatus::Running; + } EventBody::RunPaused(_) => managed_run.status = RunStatus::Paused, + EventBody::RunRemoving(_) => managed_run.status = RunStatus::Removing, EventBody::RunCompleted(_) => { - managed_run.status = RunStatus::Completed; + managed_run.status = RunStatus::Succeeded; managed_run.error = None; } EventBody::RunFailed(props) => { - managed_run.status = if props.reason == Some(WorkflowStatusReason::Cancelled) { - RunStatus::Cancelled - } else { - RunStatus::Failed - }; + managed_run.status = RunStatus::Failed; managed_run.error = Some(props.error.clone()); } _ => {} @@ -3848,6 +3873,7 @@ async fn create_run( ( StatusCode::CREATED, Json(RunStatusResponse { + blocked_reason: None, id: run_id.to_string(), status: RunStatus::Submitted, error: None, @@ -3977,7 +4003,11 @@ async fn start_run( if let Some(managed_run) = runs.get(&id) { if matches!( managed_run.status, - RunStatus::Queued | RunStatus::Starting | RunStatus::Running + RunStatus::Queued + | RunStatus::Starting + | RunStatus::Running + | RunStatus::Blocked + | RunStatus::Paused ) { return ApiError::new( StatusCode::CONFLICT, @@ -4014,7 +4044,7 @@ async fn start_run( } else if let Some(record) = run_state.status.as_ref() { if !matches!( record.status, - WorkflowRunStatus::Submitted | WorkflowRunStatus::Starting + WorkflowRunStatus::Submitted | WorkflowRunStatus::Queued | WorkflowRunStatus::Starting ) { return ApiError::new( StatusCode::CONFLICT, @@ -4048,6 +4078,11 @@ async fn start_run( } }; let dot_source = run_state.graph_source.unwrap_or_default(); + if let Err(err) = + workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunQueued).await + { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } { let mut runs = state.runs.lock().expect("runs lock poisoned"); @@ -4071,6 +4106,7 @@ async fn start_run( ( StatusCode::OK, Json(RunStatusResponse { + blocked_reason: None, id: id.to_string(), status: RunStatus::Queued, error: None, @@ -4342,11 +4378,11 @@ 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::Completed; + managed_run.status = RunStatus::Succeeded; } Err(WorkflowError::Cancelled) => { info!(run_id = %run_id, "Run cancelled"); - managed_run.status = RunStatus::Cancelled; + managed_run.status = RunStatus::Failed; } Err(e) => { error!(run_id = %run_id, error = %e, "Run failed"); @@ -4356,7 +4392,7 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { }, Err(WorkflowError::Cancelled) => { info!(run_id = %run_id, "Run cancelled"); - managed_run.status = RunStatus::Cancelled; + managed_run.status = RunStatus::Failed; } Err(e) => { error!(run_id = %run_id, error = %e, "Run failed"); @@ -4366,7 +4402,7 @@ async fn execute_run_in_process(state: Arc, run_id: RunId) { }, ExecutionResult::CancelledBySignal => { info!(run_id = %run_id, "Run cancelled"); - managed_run.status = RunStatus::Cancelled; + managed_run.status = RunStatus::Failed; } } managed_run.checkpoint = checkpoint; @@ -4608,7 +4644,7 @@ 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 = api_status_from_workflow(status.status, status.reason); + managed_run.status = api_status_from_workflow(status.status); } else if !wait_status.success() { managed_run.status = RunStatus::Failed; } @@ -4646,7 +4682,13 @@ pub fn spawn_scheduler(state: Arc) { let active = runs .values() .filter(|r| { - r.status == RunStatus::Starting || r.status == RunStatus::Running + matches!( + r.status, + RunStatus::Starting + | RunStatus::Running + | RunStatus::Blocked + | RunStatus::Paused + ) }) .count(); if active >= state.max_concurrent_runs { @@ -5912,6 +5954,7 @@ async fn cancel_run( | RunStatus::Queued | RunStatus::Starting | RunStatus::Running + | RunStatus::Blocked | RunStatus::Paused => { let use_cancel_signal = !matches!( managed_run.answer_transport, @@ -5920,8 +5963,8 @@ 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::Cancelled; - RunStatus::Cancelled + managed_run.status = RunStatus::Failed; + RunStatus::Failed } else { managed_run.status }; @@ -5987,11 +6030,13 @@ async fn cancel_run( .into_response(); } } - let (status_reason, pending_control) = load_run_status_metadata(state.as_ref(), id).await; + let (status_reason, blocked_reason, pending_control) = + load_run_status_metadata(state.as_ref(), id).await; ( StatusCode::OK, Json(RunStatusResponse { + blocked_reason, id: id.to_string(), status: response_status, error: None, @@ -6004,6 +6049,26 @@ async fn cancel_run( .into_response() } +/// How `pause_run` should enact the transition, chosen from the current run +/// status. +enum PauseMode { + /// Worker is running; ask it to pause via SIGUSR1. Status flips to + /// `Paused` once the worker acknowledges. + Signal { worker_pid: u32 }, + /// Worker is blocked on a human gate; flip to `Paused` directly by + /// appending `RunPaused` ourselves. + AppendEvent, +} + +/// How `unpause_run` should enact the transition. +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 }, +} + async fn pause_run( subject: AuthenticatedSubject, State(state): State>, @@ -6020,11 +6085,18 @@ async fn pause_run( .into_response(); } }; - let (created_at, worker_pid) = { + 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::Running => { - (managed_run.created_at, managed_run.worker_pid) + let Some(worker_pid) = managed_run.worker_pid else { + return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.") + .into_response(); + }; + (managed_run.created_at, PauseMode::Signal { worker_pid }) + } + Some(managed_run) if managed_run.status == RunStatus::Blocked => { + (managed_run.created_at, PauseMode::AppendEvent) } Some(_) => { return ApiError::new(StatusCode::CONFLICT, "Run is not pausable.").into_response(); @@ -6040,9 +6112,6 @@ async fn pause_run( ) .into_response(); } - let Some(worker_pid) = worker_pid else { - return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.").into_response(); - }; if let Err(err) = append_control_request( state.as_ref(), id, @@ -6053,15 +6122,35 @@ async fn pause_run( { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); } - #[cfg(unix)] - fabro_proc::sigusr1(worker_pid); - let (status_reason, pending_control) = load_run_status_metadata(state.as_ref(), id).await; + let response_status = match mode { + PauseMode::Signal { worker_pid } => { + #[cfg(unix)] + fabro_proc::sigusr1(worker_pid); + #[cfg(not(unix))] + let _ = worker_pid; + RunStatus::Running + } + PauseMode::AppendEvent => { + if let Some(response) = + synchronous_transition(state.as_ref(), id, RunStatus::Paused, |events| { + events.push(workflow_event::Event::RunPaused); + }) + .await + { + return response; + } + RunStatus::Paused + } + }; + let (status_reason, blocked_reason, pending_control) = + load_run_status_metadata(state.as_ref(), id).await; ( StatusCode::OK, Json(RunStatusResponse { id: id.to_string(), - status: RunStatus::Running, + blocked_reason, + status: response_status, error: None, queue_position: None, status_reason, @@ -6088,11 +6177,28 @@ async fn unpause_run( .into_response(); } }; - let (created_at, worker_pid) = { + 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 => { - (managed_run.created_at, managed_run.worker_pid) + let mode = if let Some(blocked_reason) = paused_blocked_reason { + UnpauseMode::AppendEvents { blocked_reason } + } else { + 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(); @@ -6108,9 +6214,6 @@ async fn unpause_run( ) .into_response(); } - let Some(worker_pid) = worker_pid else { - return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.").into_response(); - }; if let Err(err) = append_control_request( state.as_ref(), id, @@ -6121,15 +6224,36 @@ async fn unpause_run( { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); } - #[cfg(unix)] - fabro_proc::sigusr2(worker_pid); - let (status_reason, pending_control) = load_run_status_metadata(state.as_ref(), id).await; + let response_status = match mode { + UnpauseMode::Signal { worker_pid } => { + #[cfg(unix)] + fabro_proc::sigusr2(worker_pid); + #[cfg(not(unix))] + let _ = worker_pid; + RunStatus::Paused + } + 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 + { + return response; + } + RunStatus::Blocked + } + }; + let (status_reason, blocked_reason, pending_control) = + load_run_status_metadata(state.as_ref(), id).await; ( StatusCode::OK, Json(RunStatusResponse { id: id.to_string(), - status: RunStatus::Paused, + blocked_reason, + status: response_status, error: None, queue_position: None, status_reason, @@ -6140,6 +6264,40 @@ async fn unpause_run( .into_response() } +/// Persist a synchronous pause/unpause transition: append the caller-supplied +/// events to the run store and mirror the new status in the in-memory run map. +/// Returns `Some(Response)` on error, `None` on success. +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 { + Ok(run_store) => run_store, + Err(err) => { + return Some( + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + ); + } + }; + let mut events = Vec::new(); + append_events(&mut events); + for event in events { + if let Err(err) = workflow_event::append_event(&run_store, &id, &event).await { + return Some( + 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; + } + } + None +} + async fn list_models( _auth: AuthenticatedService, State(_state): State>, @@ -7172,6 +7330,31 @@ type = "http" } } + async fn append_raw_run_event( + state: &Arc, + run_id: RunId, + seq_hint: &str, + ts: &str, + event: &str, + properties: serde_json::Value, + node_id: Option<&str>, + ) { + let run_store = state.store.open_run(&run_id).await.unwrap(); + let payload = fabro_store::EventPayload::new( + json!({ + "id": format!("evt-{seq_hint}"), + "ts": ts, + "run_id": run_id, + "event": event, + "node_id": node_id, + "properties": properties, + }), + &run_id, + ) + .unwrap(); + run_store.append_event(&payload).await.unwrap(); + } + #[tokio::test] async fn test_model_unknown_returns_404() { let app = test_app_with(); @@ -7588,6 +7771,60 @@ slug = "fabro" assert!(body["nodes"].is_object()); } + #[tokio::test] + async fn get_run_state_exposes_pending_interviews() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + let run_id = fixtures::RUN_1; + + 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 }, + ]) + .await; + append_raw_run_event( + &state, + run_id, + "pending-question", + "2026-04-19T12:00:00Z", + "interview.started", + json!({ + "question_id": "q-1", + "question": "Approve deploy?", + "stage": "gate", + "question_type": "multiple_choice", + "options": [], + "allow_freeform": false, + "context_display": null, + "timeout_seconds": null, + }), + Some("gate"), + ) + .await; + + let req = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}/state"))) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + assert_eq!( + body["pending_interviews"]["q-1"]["question"]["text"].as_str(), + Some("Approve deploy?") + ); + assert_eq!( + body["pending_interviews"]["q-1"]["question"]["stage"].as_str(), + Some("gate") + ); + } + #[tokio::test] async fn get_run_state_includes_provenance_from_user_agent() { let state = create_app_state(); @@ -8124,6 +8361,18 @@ slug = "fabro" assert_eq!(response.status(), StatusCode::OK); let body = body_json(response.into_body()).await; assert_eq!(body["status"], "queued"); + + let status = state + .store + .open_run_reader(&run_id.parse::().unwrap()) + .await + .unwrap() + .state() + .await + .unwrap() + .status + .unwrap(); + assert_eq!(status.status.to_string(), "queued"); } #[tokio::test] @@ -8719,7 +8968,7 @@ 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, WorkflowRunStatus::Failed); - assert_eq!(status.reason, Some(WorkflowStatusReason::Cancelled)); + assert_eq!(status.status_reason, Some(WorkflowStatusReason::Cancelled)); } #[tokio::test] @@ -8837,6 +9086,55 @@ level = "debug" assert_eq!(item["pending_control"].as_str(), Some("pause")); } + #[tokio::test] + async fn pause_run_immediately_pauses_blocked_run() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + 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-blocked", + "2026-04-19T12:00:00Z", + "run.blocked", + json!({ "blocked_reason": "human_input_required" }), + None, + ) + .await; + + { + 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.worker_pid = Some(u32::MAX); + } + + let req = Request::builder() + .method("POST") + .uri(api(&format!("/runs/{run_id}/pause"))) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + assert_eq!(body["status"].as_str(), Some("paused")); + assert_eq!( + body["blocked_reason"].as_str(), + Some("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, WorkflowRunStatus::Paused); + assert_eq!( + summary.blocked_reason, + Some(BlockedReason::HumanInputRequired) + ); + assert_eq!(summary.pending_control, None); + } + #[tokio::test] async fn unpause_run_sets_pending_control() { let state = create_app_state(); @@ -8866,6 +9164,65 @@ level = "debug" assert_eq!(summary.pending_control, Some(RunControlAction::Unpause)); } + #[tokio::test] + async fn unpause_run_returns_blocked_when_human_gate_is_still_unresolved() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + 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-paused", + "2026-04-19T12:00:00Z", + "run.paused", + json!({}), + None, + ) + .await; + append_raw_run_event( + &state, + run_id, + "paused-blocked-status", + "2026-04-19T12:00:01Z", + "run.blocked", + json!({ "blocked_reason": "human_input_required" }), + None, + ) + .await; + + { + 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.worker_pid = Some(u32::MAX); + } + + let req = Request::builder() + .method("POST") + .uri(api(&format!("/runs/{run_id}/unpause"))) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + assert_eq!(body["status"].as_str(), Some("blocked")); + assert_eq!( + body["blocked_reason"].as_str(), + Some("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, WorkflowRunStatus::Blocked); + assert_eq!( + summary.blocked_reason, + Some(BlockedReason::HumanInputRequired) + ); + assert_eq!(summary.pending_control, None); + } + #[tokio::test] async fn startup_reconciliation_marks_inflight_runs_terminal() { let state = create_app_state(); @@ -8921,7 +9278,10 @@ level = "debug" .unwrap(); let run_2_status = run_2.status.unwrap(); assert_eq!(run_2_status.status, WorkflowRunStatus::Failed); - assert_eq!(run_2_status.reason, Some(WorkflowStatusReason::Terminated)); + assert_eq!( + run_2_status.status_reason, + Some(WorkflowStatusReason::Terminated) + ); let run_3 = state .store @@ -8933,7 +9293,10 @@ level = "debug" .unwrap(); let run_3_status = run_3.status.unwrap(); assert_eq!(run_3_status.status, WorkflowRunStatus::Failed); - assert_eq!(run_3_status.reason, Some(WorkflowStatusReason::Cancelled)); + assert_eq!( + run_3_status.status_reason, + Some(WorkflowStatusReason::Cancelled) + ); assert_eq!(run_3.pending_control, None); } @@ -9005,7 +9368,10 @@ level = "debug" .unwrap(); let run_status = run_state.status.unwrap(); assert_eq!(run_status.status, WorkflowRunStatus::Failed); - assert_eq!(run_status.reason, Some(WorkflowStatusReason::Terminated)); + assert_eq!( + run_status.status_reason, + Some(WorkflowStatusReason::Terminated) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -9031,7 +9397,39 @@ timeout = "30s" let run_id = run_id_str.parse::().unwrap(); let runner = tokio::spawn(execute_run(Arc::clone(&state), run_id)); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let mut live_status_before_cancel = None; + for _ in 0..50 { + live_status_before_cancel = { + let runs = state.runs.lock().expect("runs lock poisoned"); + runs.get(&run_id).map(|run| run.status) + }; + if matches!( + live_status_before_cancel, + Some( + RunStatus::Queued + | RunStatus::Starting + | RunStatus::Running + | RunStatus::Blocked + | RunStatus::Paused + ) + ) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!( + matches!( + live_status_before_cancel, + Some( + RunStatus::Queued + | RunStatus::Starting + | RunStatus::Running + | RunStatus::Blocked + | RunStatus::Paused + ) + ), + "run should become cancellable before finishing, saw {live_status_before_cancel:?}" + ); let req = Request::builder() .method("POST") @@ -9039,13 +9437,19 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response_status = response.status(); + let response_body = body_json(response.into_body()).await; + assert_eq!( + response_status, + StatusCode::OK, + "unexpected cancel response body: {response_body}; live status before cancel: {live_status_before_cancel:?}" + ); runner.await.unwrap(); 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::Cancelled); + assert_eq!(managed_run.status, RunStatus::Failed); drop(runs); let run_store = state.store.open_run_reader(&run_id).await.unwrap(); @@ -9054,7 +9458,7 @@ timeout = "30s" for _ in 0..50 { if let Some(record) = run_store.state().await.unwrap().status { if record.status == WorkflowRunStatus::Failed - && record.reason == Some(WorkflowStatusReason::Cancelled) + && record.status_reason == Some(WorkflowStatusReason::Cancelled) { status_record = Some(record); break; @@ -9065,7 +9469,10 @@ timeout = "30s" let status_record = status_record.expect("status record should be persisted"); assert_eq!(status_record.status, WorkflowRunStatus::Failed); - assert_eq!(status_record.reason, Some(WorkflowStatusReason::Cancelled)); + assert_eq!( + status_record.status_reason, + Some(WorkflowStatusReason::Cancelled) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -9348,6 +9755,82 @@ timeout = "30s" assert!(!found, "removing run should not appear on the board"); } + #[tokio::test] + async fn get_run_exposes_canonical_operator_statuses() { + let state = create_app_state(); + let app = build_router(Arc::clone(&state), AuthMode::Disabled); + + let succeeded_id = fixtures::RUN_1; + let removing_id = fixtures::RUN_2; + let blocked_id = fixtures::RUN_3; + + 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::WorkflowRunCompleted { + duration_ms: 1000, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + billing: None, + }, + ]) + .await; + + 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 }, + ]) + .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 }, + ]) + .await; + append_raw_run_event( + &state, + blocked_id, + "status-blocked", + "2026-04-19T12:00:00Z", + "run.blocked", + json!({ "blocked_reason": "human_input_required" }), + None, + ) + .await; + + for (run_id, expected_status) in [ + (succeeded_id, "succeeded"), + (removing_id, "removing"), + (blocked_id, "blocked"), + ] { + let req = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}"))) + .body(Body::empty()) + .unwrap(); + let response = app.clone().oneshot(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response.into_body()).await; + assert_eq!(body["status"].as_str(), Some(expected_status)); + } + } + #[tokio::test] async fn boards_runs_maps_statuses_to_columns() { let state = create_app_state(); @@ -9355,6 +9838,7 @@ timeout = "30s" let paused_id = fixtures::RUN_1; let succeeded_id = fixtures::RUN_2; + let blocked_id = fixtures::RUN_3; create_durable_run_with_events(&state, paused_id, &[ workflow_event::Event::RunSubmitted { @@ -9385,6 +9869,63 @@ 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 }, + ]) + .await; + append_raw_run_event( + &state, + blocked_id, + "blocked-question-1", + "2026-04-19T12:00:00Z", + "interview.started", + json!({ + "question_id": "q-older", + "question": "Older unresolved question?", + "stage": "gate", + "question_type": "multiple_choice", + "options": [], + "allow_freeform": false, + "context_display": null, + "timeout_seconds": null, + }), + Some("gate"), + ) + .await; + append_raw_run_event( + &state, + blocked_id, + "blocked-question-2", + "2026-04-19T12:00:01Z", + "interview.started", + json!({ + "question_id": "q-newer", + "question": "Newer unresolved question?", + "stage": "gate", + "question_type": "multiple_choice", + "options": [], + "allow_freeform": false, + "context_display": null, + "timeout_seconds": null, + }), + Some("gate"), + ) + .await; + append_raw_run_event( + &state, + blocked_id, + "blocked-status", + "2026-04-19T12:00:02Z", + "run.blocked", + json!({ "blocked_reason": "human_input_required" }), + None, + ) + .await; let req = Request::builder() .method("GET") @@ -9400,7 +9941,7 @@ timeout = "30s" .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["column"].as_str().unwrap(), "waiting"); + assert_eq!(paused_item["column"].as_str().unwrap(), "running"); let succeeded_item = data .iter() @@ -9409,10 +9950,22 @@ timeout = "30s" assert_eq!(succeeded_item["status"].as_str().unwrap(), "succeeded"); 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["column"].as_str().unwrap(), "blocked"); + assert_eq!( + blocked_item["question"]["text"].as_str(), + Some("Older unresolved question?") + ); + // Verify columns are included in the response let columns = body["columns"].as_array().expect("columns should be array"); assert!(!columns.is_empty()); - assert!(columns.iter().any(|c| c["id"].as_str() == Some("waiting"))); + assert!(columns.iter().any(|c| c["id"].as_str() == Some("running"))); + assert!(columns.iter().any(|c| c["id"].as_str() == Some("blocked"))); assert!( columns .iter() @@ -9497,10 +10050,7 @@ timeout = "30s" .parse::() .unwrap(); - for (run_id, sandbox_id) in [ - (first_run_id, "sb-first"), - (second_run_id, "sb-second"), - ] { + 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 }, diff --git a/lib/crates/fabro-spa/assets/assets/entry-arnv5m0e.js b/lib/crates/fabro-spa/assets/assets/entry-ez8gc920.js similarity index 56% rename from lib/crates/fabro-spa/assets/assets/entry-arnv5m0e.js rename to lib/crates/fabro-spa/assets/assets/entry-ez8gc920.js index 15b93a4bb..488bbd58a 100644 --- a/lib/crates/fabro-spa/assets/assets/entry-arnv5m0e.js +++ b/lib/crates/fabro-spa/assets/assets/entry-ez8gc920.js @@ -1,4 +1,4 @@ -import{X as h,Y as q8,Z as l5,_ as S}from"./chunk-q07bg6gn.js";var n=q8((Ed,DW)=>{(function(){function Z(E,s){Object.defineProperty(z.prototype,E,{get:function(){console.warn("%s(...) is deprecated in plain JavaScript React classes. %s",s[0],s[1])}})}function Y(E){if(E===null||typeof E!=="object")return null;return E=u1&&E[u1]||E["@@iterator"],typeof E==="function"?E:null}function X(E,s){E=(E=E.constructor)&&(E.displayName||E.name)||"ReactClass";var F0=E+"."+s;T0[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.",s,E),T0[F0]=!0)}function z(E,s,F0){this.props=E,this.context=s,this.refs=o5,this.updater=F0||c1}function B(){}function U(E,s,F0){this.props=E,this.context=s,this.refs=o5,this.updater=F0||c1}function W(){}function $(E){return""+E}function G(E){try{$(E);var s=!1}catch(x0){s=!0}if(s){s=console;var F0=s.error,P0=typeof Symbol==="function"&&Symbol.toStringTag&&E[Symbol.toStringTag]||E.constructor.name||"Object";return F0.call(s,"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",P0),$(E)}}function w(E){if(E==null)return null;if(typeof E==="function")return E.$$typeof===S6?null:E.displayName||E.name||null;if(typeof E==="string")return E;switch(E){case N0:return"Fragment";case B0:return"Profiler";case I:return"StrictMode";case O1:return"Suspense";case w0:return"SuspenseList";case a1:return"Activity"}if(typeof E==="object")switch(typeof E.tag==="number"&&console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),E.$$typeof){case e:return"Portal";case W0:return E.displayName||"Context";case G0:return(E._context.displayName||"Context")+".Consumer";case S0:var s=E.render;return E=E.displayName,E||(E=s.displayName||s.name||"",E=E!==""?"ForwardRef("+E+")":"ForwardRef"),E;case $1:return s=E.displayName||null,s!==null?s:w(E.type)||"Memo";case L1:s=E._payload,E=E._init;try{return w(E(s))}catch(F0){}}return null}function N(E){if(E===N0)return"<>";if(typeof E==="object"&&E!==null&&E.$$typeof===L1)return"<...>";try{var s=w(E);return s?"<"+s+">":"<...>"}catch(F0){return"<...>"}}function O(){var E=Y1.A;return E===null?null:E.getOwner()}function A(){return Error("react-stack-top-frame")}function V(E){if(X4.call(E,"key")){var s=Object.getOwnPropertyDescriptor(E,"key").get;if(s&&s.isReactWarning)return!1}return E.key!==void 0}function P(E,s){function F0(){z6||(z6=!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)",s))}F0.isReactWarning=!0,Object.defineProperty(E,"key",{get:F0,configurable:!0})}function L(){var E=w(this.type);return D4[E]||(D4[E]=!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.")),E=this.props.ref,E!==void 0?E:null}function D(E,s,F0,P0,x0,a0){var f0=F0.ref;return E={$$typeof:z0,type:E,key:s,props:F0,_owner:P0},(f0!==void 0?f0:null)!==null?Object.defineProperty(E,"ref",{enumerable:!1,get:L}):Object.defineProperty(E,"ref",{enumerable:!1,value:null}),E._store={},Object.defineProperty(E._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:0}),Object.defineProperty(E,"_debugInfo",{configurable:!1,enumerable:!1,writable:!0,value:null}),Object.defineProperty(E,"_debugStack",{configurable:!1,enumerable:!1,writable:!0,value:x0}),Object.defineProperty(E,"_debugTask",{configurable:!1,enumerable:!1,writable:!0,value:a0}),Object.freeze&&(Object.freeze(E.props),Object.freeze(E)),E}function v(E,s){return s=D(E.type,s,E.props,E._owner,E._debugStack,E._debugTask),E._store&&(s._store.validated=E._store.validated),s}function R(E){T(E)?E._store&&(E._store.validated=1):typeof E==="object"&&E!==null&&E.$$typeof===L1&&(E._payload.status==="fulfilled"?T(E._payload.value)&&E._payload.value._store&&(E._payload.value._store.validated=1):E._store&&(E._store.validated=1))}function T(E){return typeof E==="object"&&E!==null&&E.$$typeof===z0}function y(E){var s={"=":"=0",":":"=2"};return"$"+E.replace(/[=:]/g,function(F0){return s[F0]})}function f(E,s){return typeof E==="object"&&E!==null&&E.key!=null?(G(E.key),y(""+E.key)):s.toString(36)}function k(E){switch(E.status){case"fulfilled":return E.value;case"rejected":throw E.reason;default:switch(typeof E.status==="string"?E.then(W,W):(E.status="pending",E.then(function(s){E.status==="pending"&&(E.status="fulfilled",E.value=s)},function(s){E.status==="pending"&&(E.status="rejected",E.reason=s)})),E.status){case"fulfilled":return E.value;case"rejected":throw E.reason}}throw E}function x(E,s,F0,P0,x0){var a0=typeof E;if(a0==="undefined"||a0==="boolean")E=null;var f0=!1;if(E===null)f0=!0;else switch(a0){case"bigint":case"string":case"number":f0=!0;break;case"object":switch(E.$$typeof){case z0:case e:f0=!0;break;case L1:return f0=E._init,x(f0(E._payload),s,F0,P0,x0)}}if(f0){f0=E,x0=x0(f0);var o0=P0===""?"."+f(f0,0):P0;return U1(x0)?(F0="",o0!=null&&(F0=o0.replace(i2,"$&/")+"/"),x(x0,s,F0,"",function(H5){return H5})):x0!=null&&(T(x0)&&(x0.key!=null&&(f0&&f0.key===x0.key||G(x0.key)),F0=v(x0,F0+(x0.key==null||f0&&f0.key===x0.key?"":(""+x0.key).replace(i2,"$&/")+"/")+o0),P0!==""&&f0!=null&&T(f0)&&f0.key==null&&f0._store&&!f0._store.validated&&(F0._store.validated=2),x0=F0),s.push(x0)),1}if(f0=0,o0=P0===""?".":P0+":",U1(E))for(var b0=0;b0{(function(){function Z(E,s){Object.defineProperty(z.prototype,E,{get:function(){console.warn("%s(...) is deprecated in plain JavaScript React classes. %s",s[0],s[1])}})}function Y(E){if(E===null||typeof E!=="object")return null;return E=u1&&E[u1]||E["@@iterator"],typeof E==="function"?E:null}function X(E,s){E=(E=E.constructor)&&(E.displayName||E.name)||"ReactClass";var F0=E+"."+s;T0[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.",s,E),T0[F0]=!0)}function z(E,s,F0){this.props=E,this.context=s,this.refs=o5,this.updater=F0||c1}function B(){}function U(E,s,F0){this.props=E,this.context=s,this.refs=o5,this.updater=F0||c1}function W(){}function $(E){return""+E}function G(E){try{$(E);var s=!1}catch(x0){s=!0}if(s){s=console;var F0=s.error,P0=typeof Symbol==="function"&&Symbol.toStringTag&&E[Symbol.toStringTag]||E.constructor.name||"Object";return F0.call(s,"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",P0),$(E)}}function w(E){if(E==null)return null;if(typeof E==="function")return E.$$typeof===S6?null:E.displayName||E.name||null;if(typeof E==="string")return E;switch(E){case N0:return"Fragment";case B0:return"Profiler";case I:return"StrictMode";case O1:return"Suspense";case w0:return"SuspenseList";case a1:return"Activity"}if(typeof E==="object")switch(typeof E.tag==="number"&&console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),E.$$typeof){case e:return"Portal";case W0:return E.displayName||"Context";case G0:return(E._context.displayName||"Context")+".Consumer";case S0:var s=E.render;return E=E.displayName,E||(E=s.displayName||s.name||"",E=E!==""?"ForwardRef("+E+")":"ForwardRef"),E;case $1:return s=E.displayName||null,s!==null?s:w(E.type)||"Memo";case L1:s=E._payload,E=E._init;try{return w(E(s))}catch(F0){}}return null}function N(E){if(E===N0)return"<>";if(typeof E==="object"&&E!==null&&E.$$typeof===L1)return"<...>";try{var s=w(E);return s?"<"+s+">":"<...>"}catch(F0){return"<...>"}}function O(){var E=Y1.A;return E===null?null:E.getOwner()}function A(){return Error("react-stack-top-frame")}function _(E){if(X4.call(E,"key")){var s=Object.getOwnPropertyDescriptor(E,"key").get;if(s&&s.isReactWarning)return!1}return E.key!==void 0}function P(E,s){function F0(){z6||(z6=!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)",s))}F0.isReactWarning=!0,Object.defineProperty(E,"key",{get:F0,configurable:!0})}function L(){var E=w(this.type);return D4[E]||(D4[E]=!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.")),E=this.props.ref,E!==void 0?E:null}function D(E,s,F0,P0,x0,a0){var f0=F0.ref;return E={$$typeof:z0,type:E,key:s,props:F0,_owner:P0},(f0!==void 0?f0:null)!==null?Object.defineProperty(E,"ref",{enumerable:!1,get:L}):Object.defineProperty(E,"ref",{enumerable:!1,value:null}),E._store={},Object.defineProperty(E._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:0}),Object.defineProperty(E,"_debugInfo",{configurable:!1,enumerable:!1,writable:!0,value:null}),Object.defineProperty(E,"_debugStack",{configurable:!1,enumerable:!1,writable:!0,value:x0}),Object.defineProperty(E,"_debugTask",{configurable:!1,enumerable:!1,writable:!0,value:a0}),Object.freeze&&(Object.freeze(E.props),Object.freeze(E)),E}function T(E,s){return s=D(E.type,s,E.props,E._owner,E._debugStack,E._debugTask),E._store&&(s._store.validated=E._store.validated),s}function C(E){R(E)?E._store&&(E._store.validated=1):typeof E==="object"&&E!==null&&E.$$typeof===L1&&(E._payload.status==="fulfilled"?R(E._payload.value)&&E._payload.value._store&&(E._payload.value._store.validated=1):E._store&&(E._store.validated=1))}function R(E){return typeof E==="object"&&E!==null&&E.$$typeof===z0}function y(E){var s={"=":"=0",":":"=2"};return"$"+E.replace(/[=:]/g,function(F0){return s[F0]})}function f(E,s){return typeof E==="object"&&E!==null&&E.key!=null?(G(E.key),y(""+E.key)):s.toString(36)}function k(E){switch(E.status){case"fulfilled":return E.value;case"rejected":throw E.reason;default:switch(typeof E.status==="string"?E.then(W,W):(E.status="pending",E.then(function(s){E.status==="pending"&&(E.status="fulfilled",E.value=s)},function(s){E.status==="pending"&&(E.status="rejected",E.reason=s)})),E.status){case"fulfilled":return E.value;case"rejected":throw E.reason}}throw E}function x(E,s,F0,P0,x0){var a0=typeof E;if(a0==="undefined"||a0==="boolean")E=null;var f0=!1;if(E===null)f0=!0;else switch(a0){case"bigint":case"string":case"number":f0=!0;break;case"object":switch(E.$$typeof){case z0:case e:f0=!0;break;case L1:return f0=E._init,x(f0(E._payload),s,F0,P0,x0)}}if(f0){f0=E,x0=x0(f0);var o0=P0===""?"."+f(f0,0):P0;return U1(x0)?(F0="",o0!=null&&(F0=o0.replace(i2,"$&/")+"/"),x(x0,s,F0,"",function(M5){return M5})):x0!=null&&(R(x0)&&(x0.key!=null&&(f0&&f0.key===x0.key||G(x0.key)),F0=T(x0,F0+(x0.key==null||f0&&f0.key===x0.key?"":(""+x0.key).replace(i2,"$&/")+"/")+o0),P0!==""&&f0!=null&&R(f0)&&f0.key==null&&f0._store&&!f0._store.validated&&(F0._store.validated=2),x0=F0),s.push(x0)),1}if(f0=0,o0=P0===""?".":P0+":",U1(E))for(var b0=0;b0 import('./MyComponent')) @@ -10,67 +10,67 @@ 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.`),E}function i(){Y1.asyncTransitions--}function K0(E){if(z4===null)try{var s=("require"+Math.random()).slice(0,7);z4=(DW&&DW[s]).call(DW,"timers").setImmediate}catch(F0){z4=function(P0){q2===!1&&(q2=!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 x0=new MessageChannel;x0.port1.onmessage=P0,x0.port2.postMessage(void 0)}}return z4(E)}function $0(E){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(b0,H5){x0=!0,f0.then(function(Q5){if(H0(s,F0),F0===0){try{q0(P0),K0(function(){return o(Q5,b0,H5)})}catch(t5){Y1.thrownErrors.push(t5)}if(0 ...)"))}),Y1.actQueue=null),0Y1.recentlyCreatedOwnerStacks++;return D(E,x0,P0,O(),b0?Error("react-stack-top-frame"):y2,b0?E1(N(E)):q6)},Ed.createRef=function(){var E={current:null};return Object.seal(E),E},Ed.forwardRef=function(E){E!=null&&E.$$typeof===$1?console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof E!=="function"?console.error("forwardRef requires a render function but was given %s.",E===null?"null":typeof E):E.length!==0&&E.length!==2&&console.error("forwardRef render functions accept exactly two parameters: props and ref. %s",E.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),E!=null&&E.defaultProps!=null&&console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");var s={$$typeof:S0,render:E},F0;return Object.defineProperty(s,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(P0){F0=P0,E.name||E.displayName||(Object.defineProperty(E,"name",{value:P0}),E.displayName=P0)}}),s},Ed.isValidElement=T,Ed.lazy=function(E){E={_status:-1,_result:E};var s={$$typeof:L1,_payload:E,_init:Z0},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 E._ioInfo=F0,s._debugInfo=[{awaited:F0}],s},Ed.memo=function(E,s){E==null&&console.error("memo: The first argument must be a component. Instead received: %s",E===null?"null":typeof E),s={$$typeof:$1,type:E,compare:s===void 0?null:s};var F0;return Object.defineProperty(s,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(P0){F0=P0,E.name||E.displayName||(Object.defineProperty(E,"name",{value:P0}),E.displayName=P0)}}),s},Ed.startTransition=function(E){var s=Y1.T,F0={};F0._updatedFibers=new Set,Y1.T=F0;try{var P0=E(),x0=Y1.S;x0!==null&&x0(F0,P0),typeof P0==="object"&&P0!==null&&typeof P0.then==="function"&&(Y1.asyncTransitions++,P0.then(i,i),P0.then(W,L5))}catch(a0){L5(a0)}finally{s===null&&F0._updatedFibers&&(E=F0._updatedFibers.size,F0._updatedFibers.clear(),10{(function(){function Z(){if(y=!1,c){var o=yd.unstable_now();i=o;var q0=!0;try{Z:{R=!1,T&&(T=!1,k(Z0),Z0=-1),v=!0;var z0=D;try{Y:{U(o);for(L=X(A);L!==null&&!(L.expirationTime>o&&$());){var e=L.callback;if(typeof e==="function"){L.callback=null,D=L.priorityLevel;var N0=e(L.expirationTime<=o);if(o=yd.unstable_now(),typeof N0==="function"){L.callback=N0,U(o),q0=!0;break Y}L===X(A)&&z(A),U(o)}else z(A);L=X(A)}if(L!==null)q0=!0;else{var I=X(V);I!==null&&G(W,I.startTime-o),q0=!1}}break Z}finally{L=null,D=z0,v=!1}q0=void 0}}finally{q0?K0():c=!1}}}function Y(o,q0){var z0=o.length;o.push(q0);Z:for(;0>>1,N0=o[e];if(0>>1;eB(G0,z0))W0B(S0,G0)?(o[e]=S0,o[W0]=z0,e=W0):(o[e]=G0,o[B0]=z0,e=B0);else if(W0B(S0,z0))o[e]=S0,o[W0]=z0,e=W0;else break Z}}return q0}function B(o,q0){var z0=o.sortIndex-q0.sortIndex;return z0!==0?z0:o.id-q0.id}function U(o){for(var q0=X(V);q0!==null;){if(q0.callback===null)z(V);else if(q0.startTime<=o)z(V),q0.sortIndex=q0.expirationTime,Y(A,q0);else break;q0=X(V)}}function W(o){if(T=!1,U(o),!R)if(X(A)!==null)R=!0,c||(c=!0,K0());else{var q0=X(V);q0!==null&&G(W,q0.startTime-o)}}function $(){return y?!0:yd.unstable_now()-io||125e?(o.sortIndex=z0,Y(V,o),X(A)===null&&o===X(V)&&(T?(k(Z0),Z0=-1):T=!0,G(W,z0-e))):(o.sortIndex=N0,Y(A,o),R||v||(R=!0,c||(c=!0,K0()))),o},yd.unstable_shouldYield=$,yd.unstable_wrapCallback=function(o){var q0=D;return function(){var z0=D;D=q0;try{return o.apply(this,arguments)}finally{D=z0}}},typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var lD=q8((xd)=>{var TO=h(n());(function(){function Z(){}function Y(N){return""+N}function X(N,O,A){var V=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 x0=new MessageChannel;x0.port1.onmessage=P0,x0.port2.postMessage(void 0)}}return z4(E)}function $0(E){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(b0,M5){x0=!0,f0.then(function(Q5){if(M0(s,F0),F0===0){try{q0(P0),K0(function(){return o(Q5,b0,M5)})}catch(t5){Y1.thrownErrors.push(t5)}if(0 ...)"))}),Y1.actQueue=null),0Y1.recentlyCreatedOwnerStacks++;return D(E,x0,P0,O(),b0?Error("react-stack-top-frame"):y2,b0?E1(N(E)):q6)},Id.createRef=function(){var E={current:null};return Object.seal(E),E},Id.forwardRef=function(E){E!=null&&E.$$typeof===$1?console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof E!=="function"?console.error("forwardRef requires a render function but was given %s.",E===null?"null":typeof E):E.length!==0&&E.length!==2&&console.error("forwardRef render functions accept exactly two parameters: props and ref. %s",E.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),E!=null&&E.defaultProps!=null&&console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");var s={$$typeof:S0,render:E},F0;return Object.defineProperty(s,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(P0){F0=P0,E.name||E.displayName||(Object.defineProperty(E,"name",{value:P0}),E.displayName=P0)}}),s},Id.isValidElement=R,Id.lazy=function(E){E={_status:-1,_result:E};var s={$$typeof:L1,_payload:E,_init:Z0},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 E._ioInfo=F0,s._debugInfo=[{awaited:F0}],s},Id.memo=function(E,s){E==null&&console.error("memo: The first argument must be a component. Instead received: %s",E===null?"null":typeof E),s={$$typeof:$1,type:E,compare:s===void 0?null:s};var F0;return Object.defineProperty(s,"displayName",{enumerable:!1,configurable:!0,get:function(){return F0},set:function(P0){F0=P0,E.name||E.displayName||(Object.defineProperty(E,"name",{value:P0}),E.displayName=P0)}}),s},Id.startTransition=function(E){var s=Y1.T,F0={};F0._updatedFibers=new Set,Y1.T=F0;try{var P0=E(),x0=Y1.S;x0!==null&&x0(F0,P0),typeof P0==="object"&&P0!==null&&typeof P0.then==="function"&&(Y1.asyncTransitions++,P0.then(i,i),P0.then(W,L5))}catch(a0){L5(a0)}finally{s===null&&F0._updatedFibers&&(E=F0._updatedFibers.size,F0._updatedFibers.clear(),10{(function(){function Z(){if(y=!1,c){var o=jd.unstable_now();i=o;var q0=!0;try{Z:{C=!1,R&&(R=!1,k(Z0),Z0=-1),T=!0;var z0=D;try{Y:{U(o);for(L=X(A);L!==null&&!(L.expirationTime>o&&$());){var e=L.callback;if(typeof e==="function"){L.callback=null,D=L.priorityLevel;var N0=e(L.expirationTime<=o);if(o=jd.unstable_now(),typeof N0==="function"){L.callback=N0,U(o),q0=!0;break Y}L===X(A)&&z(A),U(o)}else z(A);L=X(A)}if(L!==null)q0=!0;else{var I=X(_);I!==null&&G(W,I.startTime-o),q0=!1}}break Z}finally{L=null,D=z0,T=!1}q0=void 0}}finally{q0?K0():c=!1}}}function Y(o,q0){var z0=o.length;o.push(q0);Z:for(;0>>1,N0=o[e];if(0>>1;eB(G0,z0))W0B(S0,G0)?(o[e]=S0,o[W0]=z0,e=W0):(o[e]=G0,o[B0]=z0,e=B0);else if(W0B(S0,z0))o[e]=S0,o[W0]=z0,e=W0;else break Z}}return q0}function B(o,q0){var z0=o.sortIndex-q0.sortIndex;return z0!==0?z0:o.id-q0.id}function U(o){for(var q0=X(_);q0!==null;){if(q0.callback===null)z(_);else if(q0.startTime<=o)z(_),q0.sortIndex=q0.expirationTime,Y(A,q0);else break;q0=X(_)}}function W(o){if(R=!1,U(o),!C)if(X(A)!==null)C=!0,c||(c=!0,K0());else{var q0=X(_);q0!==null&&G(W,q0.startTime-o)}}function $(){return y?!0:jd.unstable_now()-io||125e?(o.sortIndex=z0,Y(_,o),X(A)===null&&o===X(_)&&(R?(k(Z0),Z0=-1):R=!0,G(W,z0-e))):(o.sortIndex=N0,Y(A,o),C||T||(C=!0,c||(c=!0,K0()))),o},jd.unstable_shouldYield=$,jd.unstable_wrapCallback=function(o){var q0=D;return function(){var z0=D;D=q0;try{return o.apply(this,arguments)}finally{D=z0}}},typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var aD=q8((kd)=>{var yO=h(n());(function(){function Z(){}function Y(N){return""+N}function X(N,O,A){var _=3` tag.%s',A),typeof N==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){A=O.as;var V=z(A,O.crossOrigin);$.d.L(N,A,{crossOrigin:V,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})}},xd.preloadModule=function(N,O){var A="";typeof N==="string"&&N||(A+=" The `href` argument encountered was "+B(N)+"."),O!==void 0&&typeof O!=="object"?A+=" The `options` argument encountered was "+B(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(A+=" The `as` option encountered was "+B(O.as)+"."),A&&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',A),typeof N==="string"&&(O?(A=z(O.as,O.crossOrigin),$.d.m(N,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0})):$.d.m(N))},xd.requestFormReset=function(N){$.d.r(N)},xd.unstable_batchedUpdates=function(N,O){return N(O)},xd.useFormState=function(N,O,A){return W().useFormState(N,O,A)},xd.useFormStatus=function(){return W().useHostTransitionStatus()},xd.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var W3=q8((_60,rD)=>{var Sd=h(lD());rD.exports=Sd});var aD=q8((fd)=>{var p1=h(pD()),oQ=h(n()),DO=h(W3());(function(){function Z(Q,J){for(Q=Q.memoizedState;Q!==null&&0=J.length)return K;var H=J[q],M=_2(Q)?Q.slice():v1({},Q);return M[H]=Y(Q[H],J,q+1,K),M}function X(Q,J,q){if(J.length!==q.length)console.warn("copyWithRename() expects paths of the same length");else{for(var K=0;Kc3?console.error("Unexpected pop."):(J!==zN[c3]&&console.error("Unexpected Fiber popped."),Q.current=XN[c3],XN[c3]=null,zN[c3]=null,c3--)}function $0(Q,J,q){c3++,XN[c3]=Q.current,zN[c3]=q,Q.current=J}function H0(Q){return Q===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),Q}function o(Q,J){$0(e8,J,Q),$0(yz,Q,Q),$0(n8,null,Q);var q=J.nodeType;switch(q){case 9:case 11:q=q===9?"#document":"#fragment",J=(J=J.documentElement)?(J=J.namespaceURI)?TC(J):J8:J8;break;default:if(q=J.tagName,J=J.namespaceURI)J=TC(J),J=DC(J,q);else switch(q){case"svg":J=iQ;break;case"math":J=_W;break;default:J=J8}}q=q.toLowerCase(),q=_P(null,q),q={context:J,ancestorInfo:q},K0(n8,Q),$0(n8,q,Q)}function q0(Q){K0(n8,Q),K0(yz,Q),K0(e8,Q)}function z0(){return H0(n8.current)}function e(Q){Q.memoizedState!==null&&$0(RK,Q,Q);var J=H0(n8.current),q=Q.type,K=DC(J.context,q);q=_P(J.ancestorInfo,q),K={context:K,ancestorInfo:q},J!==K&&($0(yz,Q,Q),$0(n8,K,Q))}function N0(Q){yz.current===Q&&(K0(n8,Q),K0(yz,Q)),RK.current===Q&&(K0(RK,Q),Pq._currentValue=aZ)}function I(){}function B0(){if(xz===0){Xv=console.log,zv=console.info,qv=console.warn,Bv=console.error,Uv=console.group,Kv=console.groupCollapsed,Wv=console.groupEnd;var Q={configurable:!0,enumerable:!0,value:I,writable:!0};Object.defineProperties(console,{info:Q,log:Q,warn:Q,error:Q,group:Q,groupCollapsed:Q,groupEnd:Q})}xz++}function G0(){if(xz--,xz===0){var Q={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:v1({},Q,{value:Xv}),info:v1({},Q,{value:zv}),warn:v1({},Q,{value:qv}),error:v1({},Q,{value:Bv}),group:v1({},Q,{value:Uv}),groupCollapsed:v1({},Q,{value:Kv}),groupEnd:v1({},Q,{value:Wv})})}0>xz&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function W0(Q){var J=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,Q=Q.stack,Error.prepareStackTrace=J,Q.startsWith(`Error: react-stack-top-frame +See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),N}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},G=Symbol.for("react.portal"),w=yO.__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"),kd.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=$,kd.createPortal=function(N,O){var A=2` tag.%s',A),typeof N==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){A=O.as;var _=z(A,O.crossOrigin);$.d.L(N,A,{crossOrigin:_,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})}},kd.preloadModule=function(N,O){var A="";typeof N==="string"&&N||(A+=" The `href` argument encountered was "+B(N)+"."),O!==void 0&&typeof O!=="object"?A+=" The `options` argument encountered was "+B(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(A+=" The `as` option encountered was "+B(O.as)+"."),A&&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',A),typeof N==="string"&&(O?(A=z(O.as,O.crossOrigin),$.d.m(N,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0})):$.d.m(N))},kd.requestFormReset=function(N){$.d.r(N)},kd.unstable_batchedUpdates=function(N,O){return N(O)},kd.useFormState=function(N,O,A){return W().useFormState(N,O,A)},kd.useFormStatus=function(){return W().useHostTransitionStatus()},kd.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var W3=q8((y60,iD)=>{var ud=h(aD());iD.exports=ud});var sD=q8((gd)=>{var p1=h(rD()),tQ=h(n()),xO=h(W3());(function(){function Z(Q,J){for(Q=Q.memoizedState;Q!==null&&0=J.length)return K;var M=J[q],H=V2(Q)?Q.slice():v1({},Q);return H[M]=Y(Q[M],J,q+1,K),H}function X(Q,J,q){if(J.length!==q.length)console.warn("copyWithRename() expects paths of the same length");else{for(var K=0;Kc3?console.error("Unexpected pop."):(J!==KN[c3]&&console.error("Unexpected Fiber popped."),Q.current=UN[c3],UN[c3]=null,KN[c3]=null,c3--)}function $0(Q,J,q){c3++,UN[c3]=Q.current,KN[c3]=q,Q.current=J}function M0(Q){return Q===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),Q}function o(Q,J){$0(e8,J,Q),$0(xz,Q,Q),$0(n8,null,Q);var q=J.nodeType;switch(q){case 9:case 11:q=q===9?"#document":"#fragment",J=(J=J.documentElement)?(J=J.namespaceURI)?bC(J):J8:J8;break;default:if(q=J.tagName,J=J.namespaceURI)J=bC(J),J=EC(J,q);else switch(q){case"svg":J=sQ;break;case"math":J=PW;break;default:J=J8}}q=q.toLowerCase(),q=LP(null,q),q={context:J,ancestorInfo:q},K0(n8,Q),$0(n8,q,Q)}function q0(Q){K0(n8,Q),K0(xz,Q),K0(e8,Q)}function z0(){return M0(n8.current)}function e(Q){Q.memoizedState!==null&&$0(CK,Q,Q);var J=M0(n8.current),q=Q.type,K=EC(J.context,q);q=LP(J.ancestorInfo,q),K={context:K,ancestorInfo:q},J!==K&&($0(xz,Q,Q),$0(n8,K,Q))}function N0(Q){xz.current===Q&&(K0(n8,Q),K0(xz,Q)),CK.current===Q&&(K0(CK,Q),Lq._currentValue=aZ)}function I(){}function B0(){if(Sz===0){qv=console.log,Bv=console.info,Uv=console.warn,Kv=console.error,Wv=console.group,$v=console.groupCollapsed,Gv=console.groupEnd;var Q={configurable:!0,enumerable:!0,value:I,writable:!0};Object.defineProperties(console,{info:Q,log:Q,warn:Q,error:Q,group:Q,groupCollapsed:Q,groupEnd:Q})}Sz++}function G0(){if(Sz--,Sz===0){var Q={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:v1({},Q,{value:qv}),info:v1({},Q,{value:Bv}),warn:v1({},Q,{value:Uv}),error:v1({},Q,{value:Kv}),group:v1({},Q,{value:Wv}),groupCollapsed:v1({},Q,{value:$v}),groupEnd:v1({},Q,{value:Gv})})}0>Sz&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function W0(Q){var J=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,Q=Q.stack,Error.prepareStackTrace=J,Q.startsWith(`Error: react-stack-top-frame `)&&(Q=Q.slice(29)),J=Q.indexOf(` `),J!==-1&&(Q=Q.slice(J+1)),J=Q.indexOf("react_stack_bottom_frame"),J!==-1&&(J=Q.lastIndexOf(` -`,J)),J!==-1)Q=Q.slice(0,J);else return"";return Q}function S0(Q){if(qN===void 0)try{throw Error()}catch(q){var J=q.stack.trim().match(/\n( *(at )?)/);qN=J&&J[1]||"",$v=-1)":-1F||b[M]!==l[F]){var r=` -`+b[M].replace(" at new "," at ");return Q.displayName&&r.includes("")&&(r=r.replace("",Q.displayName)),typeof Q==="function"&&UN.set(Q,r),r}while(1<=M&&0<=F);break}}}finally{BN=!1,X0.H=K,G0(),Error.prepareStackTrace=q}return b=(b=Q?Q.displayName||Q.name:"")?S0(b):"",typeof Q==="function"&&UN.set(Q,b),b}function w0(Q,J){switch(Q.tag){case 26:case 27:case 5:return S0(Q.type);case 16:return S0("Lazy");case 13:return Q.child!==J&&J!==null?S0("Suspense Fallback"):S0("Suspense");case 19:return S0("SuspenseList");case 0:case 15:return O1(Q.type,!1);case 11:return O1(Q.type.render,!1);case 1:return O1(Q.type,!0);case 31:return S0("Activity");default:return""}}function $1(Q){try{var J="",q=null;do{J+=w0(Q,q);var K=Q._debugInfo;if(K)for(var H=K.length-1;0<=H;H--){var M=K[H];if(typeof M.name==="string"){var F=J;Z:{var{name:_,env:C,debugLocation:b}=M;if(b!=null){var l=W0(b),r=l.lastIndexOf(` -`),m=r===-1?l:l.slice(r+1);if(m.indexOf(_)!==-1){var Y0=` -`+m;break Z}}Y0=S0(_+(C?" ["+C+"]":""))}J=F+Y0}}q=Q,Q=Q.return}while(Q);return J}catch(L0){return` +`+WN+Q+Mv}function O1(Q,J){if(!Q||$N)return"";var q=GN.get(Q);if(q!==void 0)return q;$N=!0,q=Error.prepareStackTrace,Error.prepareStackTrace=void 0;var K=null;K=X0.H,X0.H=null,B0();try{var M={DetermineComponentFrameRoot:function(){try{if(J){var m=function(){throw Error()};if(Object.defineProperty(m.prototype,"props",{set:function(){throw Error()}}),typeof Reflect==="object"&&Reflect.construct){try{Reflect.construct(m,[])}catch(L0){var Y0=L0}Reflect.construct(Q,[],m)}else{try{m.call()}catch(L0){Y0=L0}Q.call(m.prototype)}}else{try{throw Error()}catch(L0){Y0=L0}(m=Q())&&typeof m.catch==="function"&&m.catch(function(){})}}catch(L0){if(L0&&Y0&&typeof L0.stack==="string")return[L0.stack,Y0.stack]}return[null,null]}};M.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var H=Object.getOwnPropertyDescriptor(M.DetermineComponentFrameRoot,"name");H&&H.configurable&&Object.defineProperty(M.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var F=M.DetermineComponentFrameRoot(),V=F[0],v=F[1];if(V&&v){var b=V.split(` +`),l=v.split(` +`);for(F=H=0;HF||b[H]!==l[F]){var r=` +`+b[H].replace(" at new "," at ");return Q.displayName&&r.includes("")&&(r=r.replace("",Q.displayName)),typeof Q==="function"&&GN.set(Q,r),r}while(1<=H&&0<=F);break}}}finally{$N=!1,X0.H=K,G0(),Error.prepareStackTrace=q}return b=(b=Q?Q.displayName||Q.name:"")?S0(b):"",typeof Q==="function"&&GN.set(Q,b),b}function w0(Q,J){switch(Q.tag){case 26:case 27:case 5:return S0(Q.type);case 16:return S0("Lazy");case 13:return Q.child!==J&&J!==null?S0("Suspense Fallback"):S0("Suspense");case 19:return S0("SuspenseList");case 0:case 15:return O1(Q.type,!1);case 11:return O1(Q.type.render,!1);case 1:return O1(Q.type,!0);case 31:return S0("Activity");default:return""}}function $1(Q){try{var J="",q=null;do{J+=w0(Q,q);var K=Q._debugInfo;if(K)for(var M=K.length-1;0<=M;M--){var H=K[M];if(typeof H.name==="string"){var F=J;Z:{var{name:V,env:v,debugLocation:b}=H;if(b!=null){var l=W0(b),r=l.lastIndexOf(` +`),m=r===-1?l:l.slice(r+1);if(m.indexOf(V)!==-1){var Y0=` +`+m;break Z}}Y0=S0(V+(v?" ["+v+"]":""))}J=F+Y0}}q=Q,Q=Q.return}while(Q);return J}catch(L0){return` Error generating stack: `+L0.message+` -`+L0.stack}}function L1(Q){return(Q=Q?Q.displayName||Q.name:"")?S0(Q):""}function a1(){if(M4===null)return null;var Q=M4._debugOwner;return Q!=null?Z0(Q):null}function u1(){if(M4===null)return"";var Q=M4;try{var J="";switch(Q.tag===6&&(Q=Q.return),Q.tag){case 26:case 27:case 5:J+=S0(Q.type);break;case 13:J+=S0("Suspense");break;case 19:J+=S0("SuspenseList");break;case 31:J+=S0("Activity");break;case 30:case 0:case 15:case 1:Q._debugOwner||J!==""||(J+=L1(Q.type));break;case 11:Q._debugOwner||J!==""||(J+=L1(Q.type.render))}for(;Q;)if(typeof Q.tag==="number"){var q=Q;Q=q._debugOwner;var K=q._debugStack;if(Q&&K){var H=W0(K);H!==""&&(J+=` -`+H)}}else if(Q.debugStack!=null){var M=Q.debugStack;(Q=Q.owner)&&M&&(J+=` -`+W0(M))}else break;var F=J}catch(_){F=` -Error generating stack: `+_.message+` -`+_.stack}return F}function T0(Q,J,q,K,H,M,F){var _=M4;c1(Q);try{return Q!==null&&Q._debugTask?Q._debugTask.run(J.bind(null,q,K,H,M,F)):J(q,K,H,M,F)}finally{c1(_)}throw Error("runWithFiberInDEV should never be called in production. This is a bug in React.")}function c1(Q){X0.getCurrentStack=Q===null?null:u1,Z3=!1,M4=Q}function s5(Q){return typeof Symbol==="function"&&Symbol.toStringTag&&Q[Symbol.toStringTag]||Q.constructor.name||"Object"}function o5(Q){try{return G5(Q),!1}catch(J){return!0}}function G5(Q){return""+Q}function U1(Q,J){if(o5(Q))return console.error("The provided `%s` attribute is an unsupported type %s. This value must be coerced to a string before using it here.",J,s5(Q)),G5(Q)}function S6(Q,J){if(o5(Q))return console.error("The provided `%s` CSS property is an unsupported type %s. This value must be coerced to a string before using it here.",J,s5(Q)),G5(Q)}function Y1(Q){if(o5(Q))return console.error("Form field values (value, checked, defaultValue, or defaultChecked props) must be strings, not %s. This value must be coerced to a string before using it here.",s5(Q)),G5(Q)}function X4(Q){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u")return!1;var J=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(J.isDisabled)return!0;if(!J.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{NQ=J.inject(Q),H7=J}catch(q){console.error("React instrumentation encountered an error: %o.",q)}return J.checkDCE?!0:!1}function E1(Q){if(typeof ah==="function"&&ih(Q),H7&&typeof H7.setStrictMode==="function")try{H7.setStrictMode(NQ,Q)}catch(J){Y3||(Y3=!0,console.error("React instrumentation encountered an error: %o",J))}}function z6(Q){return Q>>>=0,Q===0?32:31-(sh(Q)/oh|0)|0}function a2(Q){var J=Q&42;if(J!==0)return J;switch(Q&-Q){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 Q&261888;case 262144:case 524288:case 1048576:case 2097152:return Q&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return Q&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."),Q}}function D4(Q,J,q){var K=Q.pendingLanes;if(K===0)return 0;var H=0,M=Q.suspendedLanes,F=Q.pingedLanes;Q=Q.warmLanes;var _=K&134217727;return _!==0?(K=_&~M,K!==0?H=a2(K):(F&=_,F!==0?H=a2(F):q||(q=_&~Q,q!==0&&(H=a2(q))))):(_=K&~M,_!==0?H=a2(_):F!==0?H=a2(F):q||(q=K&~Q,q!==0&&(H=a2(q)))),H===0?0:J!==0&&J!==H&&(J&M)===0&&(M=H&-H,q=J&-J,M>=q||M===32&&(q&4194048)!==0)?J:H}function y2(Q,J){return(Q.pendingLanes&~(Q.suspendedLanes&~Q.pingedLanes)&J)===0}function q6(Q,J){switch(Q){case 1:case 2:case 4:case 8:case 64:return J+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 J+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 B6(){var Q=TK;return TK<<=1,(TK&62914560)===0&&(TK=4194304),Q}function i2(Q){for(var J=[],q=0;31>q;q++)J.push(Q);return J}function L5(Q,J){Q.pendingLanes|=J,J!==268435456&&(Q.suspendedLanes=0,Q.pingedLanes=0,Q.warmLanes=0)}function q2(Q,J,q,K,H,M){var F=Q.pendingLanes;Q.pendingLanes=q,Q.suspendedLanes=0,Q.pingedLanes=0,Q.warmLanes=0,Q.expiredLanes&=q,Q.entangledLanes&=q,Q.errorRecoveryDisabledLanes&=q,Q.shellSuspendCounter=0;var{entanglements:_,expirationTimes:C,hiddenUpdates:b}=Q;for(q=F&~q;0"u")return null;try{return Q.activeElement||Q.body}catch(J){return Q.body}}function y0(Q){return Q.replace(Ym,function(J){return"\\"+J.charCodeAt(0).toString(16)+" "})}function h0(Q,J){J.checked===void 0||J.defaultChecked===void 0||Ov||(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",a1()||"A component",J.type),Ov=!0),J.value===void 0||J.defaultValue===void 0||Nv||(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",a1()||"A component",J.type),Nv=!0)}function c0(Q,J,q,K,H,M,F,_){if(Q.name="",F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"?(U1(F,"type"),Q.type=F):Q.removeAttribute("type"),J!=null)if(F==="number"){if(J===0&&Q.value===""||Q.value!=J)Q.value=""+a(J)}else Q.value!==""+a(J)&&(Q.value=""+a(J));else F!=="submit"&&F!=="reset"||Q.removeAttribute("value");J!=null?m0(Q,F,a(J)):q!=null?m0(Q,F,a(q)):K!=null&&Q.removeAttribute("value"),H==null&&M!=null&&(Q.defaultChecked=!!M),H!=null&&(Q.checked=H&&typeof H!=="function"&&typeof H!=="symbol"),_!=null&&typeof _!=="function"&&typeof _!=="symbol"&&typeof _!=="boolean"?(U1(_,"name"),Q.name=""+a(_)):Q.removeAttribute("name")}function i0(Q,J,q,K,H,M,F,_){if(M!=null&&typeof M!=="function"&&typeof M!=="symbol"&&typeof M!=="boolean"&&(U1(M,"type"),Q.type=M),J!=null||q!=null){if(!(M!=="submit"&&M!=="reset"||J!==void 0&&J!==null)){V0(Q);return}q=q!=null?""+a(q):"",J=J!=null?""+a(J):q,_||J===Q.value||(Q.value=J),Q.defaultValue=J}K=K!=null?K:H,K=typeof K!=="function"&&typeof K!=="symbol"&&!!K,Q.checked=_?Q.checked:!!K,Q.defaultChecked=!!K,F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"&&(U1(F,"name"),Q.name=F),V0(Q)}function m0(Q,J,q){J==="number"&&E0(Q.ownerDocument)===Q||Q.defaultValue===""+q||(Q.defaultValue=""+q)}function g1(Q,J){J.value==null&&(typeof J.children==="object"&&J.children!==null?oQ.Children.forEach(J.children,function(q){q==null||typeof q==="string"||typeof q==="number"||typeof q==="bigint"||Av||(Av=!0,console.error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to