From f73f2a53f393da16fc856b8c658bddf7dbcb5bbe Mon Sep 17 00:00:00 2001 From: "fabro-sh-0530[bot]" <281434857+fabro-sh-0530[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 15:34:33 -0400 Subject: [PATCH] =?UTF-8?q?Replace=20queued=20with=20pending/runnable=20an?= =?UTF-8?q?d=20add=20approval=20flow=20(web=20+=20API=20s=E2=80=A6=20(#371?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replaces the single `queued` pre-execution state with explicit `pending` and `runnable` states, and wires approve/deny actions for parent-generated child runs that require human approval before they can execute. This diff covers the web UI and OpenAPI spec layers of that change. ## What changed **Run status model** - `queued` is removed from all TypeScript types, display maps, column routing, and tests. - `pending` (awaiting approval) and `runnable` (eligible for the scheduler) replace it as distinct board columns and `RunStatus` variants with their own labels and colors (`runnable` gets cyan; `pending` stays muted). **Approval actions** - New `approveRun` / `denyRun` API calls in `run-actions.ts` invoke the new `POST /runs/{id}/approve` and `POST /runs/{id}/deny` endpoints. - `canApprove` predicate requires both `status.kind === "pending"` and `lifecycle.approval?.state === "pending"` — a run whose status is pending but has no approval record does not expose the action. - `useApproveRun` / `useDenyRun` mutations in `mutations.ts` follow the same pattern as `useCancelRun`. - `ActionsMenu` in `run-detail.tsx` gains Approve (lifecycle group) and Deny (destructive group) menu items. **Board and event plumbing** - `columnForStatus` now routes `pending → pending column` and `runnable → runnable column`; `submitted` stays in the pending column. - `BOARD_STATUS_EVENTS` and `RUN_SUMMARY_EVENTS` replace `run.queued` with `run.start_requested`, `run.pending`, `run.approved`, `run.denied`, and `run.runnable`. - The `pending` column is hidden when empty (same behaviour the old `queued` column had). **Waterfall phases (`run-phases.ts`)** - `queued` phase is removed; `pending` and `runnable` phases are added in order. - The submitted phase closes at `run.start_requested` rather than `run.queued`. - Each phase derives its timestamps from its own event rather than a single `firstTs` lookup, making multi-phase pre-execution timelines accurate. **OpenAPI spec** - `POST /api/v1/runs/{id}/approve` and `POST /api/v1/runs/{id}/deny` endpoints added with 200/404/409 responses. - `startRun` description updated to describe the pending/runnable branching behaviour. - `cancelRun` description updated to reference `pending`/`runnable` instead of `queued`. ### Plan Summary - **Task 3** (OpenAPI schema additions for approve/deny endpoints) — complete in this diff. - **Task 6** (Web UI surfaces: board columns, run-detail actions, waterfall phases, event subscriptions) — complete in this diff. - **Task 7** (doc cleanup: references to `queued` replaced in plans, brainstorms, and QA docs) — complete in this diff. ### Fabro Details
Ran 9 stages in 127m 37s for $104.98 | Stage | Duration | Cost | Retries | |---|---|---|---| | start | 0s | – | 0 | | toolchain | 2s | – | 0 | | preflight_compile | 2m 15s | – | 0 | | preflight_lint | 2m 29s | – | 0 | | implement | 92m 10s | $91.53 | 0 | | simplify_opus | 18m 35s | $10.65 | 0 | | simplify_gpt | 7m 36s | $2.81 | 0 | | verify | 3m 42s | – | 0 | | fmt | 3s | – | 0 | | **Total** | **127m 37s** | **$104.98** | **0** |
Ran ImplementPlan.fabro (12 nodes and 15 edges) ```dot digraph ImplementPlan { graph [ goal="Implement and simplify", model_stylesheet=" * { model: claude-opus-4-7; } " ] rankdir=LR start [shape=Mdiamond, label="Start"] exit [shape=Msquare, label="Exit"] toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0] preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0] preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0] fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3] implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"] simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"] simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"] verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"] fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3] fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0] start -> toolchain toolchain -> preflight_compile [condition="outcome=succeeded"] toolchain -> exit preflight_compile -> preflight_lint [condition="outcome=succeeded"] preflight_compile -> exit preflight_lint -> implement [condition="outcome=succeeded"] preflight_lint -> fix_lints fix_lints -> preflight_lint implement -> simplify_opus -> simplify_gpt -> verify verify -> fmt [condition="outcome=succeeded"] verify -> fixup fixup -> verify fmt -> exit } ```
⚒️ Generated with [Fabro](https://fabro.sh) --------- Co-authored-by: Fabro Co-authored-by: fabro Co-authored-by: Bryan Helmkamp --- apps/fabro-web/app/data/runs.test.ts | 18 +- apps/fabro-web/app/data/runs.ts | 22 +- apps/fabro-web/app/lib/board-events.ts | 6 +- apps/fabro-web/app/lib/mutations.test.ts | 8 +- apps/fabro-web/app/lib/mutations.ts | 10 + apps/fabro-web/app/lib/query-keys.ts | 2 + apps/fabro-web/app/lib/run-actions.test.ts | 30 +- apps/fabro-web/app/lib/run-actions.ts | 34 +- apps/fabro-web/app/lib/run-events.ts | 6 +- apps/fabro-web/app/lib/run-phases.test.ts | 81 +++-- apps/fabro-web/app/lib/run-phases.ts | 72 +++- apps/fabro-web/app/routes/run-detail.test.ts | 41 ++- apps/fabro-web/app/routes/run-detail.tsx | 97 +++++- .../app/routes/run-files.render.test.tsx | 3 +- .../app/routes/run-files/states.test.tsx | 5 +- .../fabro-web/app/routes/run-files/states.tsx | 2 +- apps/fabro-web/app/routes/runs.test.tsx | 26 +- apps/fabro-web/app/routes/runs.tsx | 5 +- .../app/routes/settings-resources.tsx | 2 +- ...9-web-ui-lifecycle-actions-requirements.md | 6 +- docs/internal/mcp-server-qa-test-plan.md | 4 +- ...process-run-workers-signal-control-plan.md | 6 +- .../2026-04-08-production-web-ui-test-plan.md | 2 +- docs/plans/2026-04-08-production-web-ui.md | 4 +- ...04-15-canonical-blocked-run-status-plan.md | 44 +-- ...04-19-001-feat-archived-run-status-plan.md | 2 +- ...-002-feat-web-ui-lifecycle-actions-plan.md | 6 +- .../2026-05-10-unified-run-type-shape-plan.md | 4 +- docs/public/api-reference/fabro-api.yaml | 165 +++++++++- docs/public/changelog/2026-03-04.mdx | 2 +- docs/public/changelog/2026-04-04.mdx | 4 +- docs/public/changelog/2026-05-04.mdx | 2 +- docs/public/reference/architecture.mdx | 10 +- docs/public/reference/server-operations.mdx | 12 +- .../2026-05-11-automations-end-to-end.md | 2 +- lib/crates/fabro-api/build.rs | 5 + lib/crates/fabro-api/src/lib.rs | 9 +- .../fabro-api/tests/run_summary_round_trip.rs | 50 ++- .../fabro-api/tests/status_round_trip.rs | 26 +- .../fabro-cli/src/commands/runs/list.rs | 6 +- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 33 +- lib/crates/fabro-cli/tests/it/cmd/dump.rs | 6 +- lib/crates/fabro-cli/tests/it/cmd/mcp.rs | 4 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 13 +- lib/crates/fabro-cli/tests/it/cmd/support.rs | 39 ++- .../fabro-cli/tests/it/scenario/lifecycle.rs | 3 +- lib/crates/fabro-server/src/demo/mod.rs | 31 +- lib/crates/fabro-server/src/server.rs | 111 ++++--- .../fabro-server/src/server/handler/events.rs | 4 + .../src/server/handler/lifecycle.rs | 263 +++++++++++++-- .../fabro-server/src/server/handler/mod.rs | 2 + .../fabro-server/src/server/handler/pair.rs | 3 +- .../fabro-server/src/server/handler/runs.rs | 3 +- .../fabro-server/src/server/handler/steer.rs | 3 +- .../fabro-server/src/server/handler/system.rs | 3 +- lib/crates/fabro-server/src/server/tests.rs | 283 ++++++++++++++-- .../fabro-server/tests/it/api/run_files.rs | 6 + .../fabro-server/tests/it/api/system.rs | 2 +- .../fabro-server/tests/it/scenario/sse.rs | 4 +- lib/crates/fabro-store/src/run_state.rs | 308 ++++++++++++++---- lib/crates/fabro-store/src/slate/mod.rs | 36 +- lib/crates/fabro-tool/src/common.rs | 11 +- lib/crates/fabro-tool/src/create.rs | 114 +++++-- lib/crates/fabro-tool/src/search.rs | 1 + lib/crates/fabro-types/src/lib.rs | 13 +- lib/crates/fabro-types/src/run_event/mod.rs | 108 +++++- lib/crates/fabro-types/src/run_event/run.rs | 50 ++- lib/crates/fabro-types/src/run_projection.rs | 9 +- lib/crates/fabro-types/src/run_summary.rs | 33 ++ lib/crates/fabro-types/src/status.rs | 214 ++++++------ .../fabro-workflow/src/event/convert.rs | 16 +- lib/crates/fabro-workflow/src/event/events.rs | 48 ++- lib/crates/fabro-workflow/src/event/names.rs | 6 +- .../fabro-workflow/src/event/stored_fields.rs | 5 + .../fabro-workflow/src/handler/llm/api.rs | 66 +++- .../fabro-workflow/src/operations/archive.rs | 12 + .../fabro-workflow/src/operations/retry.rs | 36 +- .../fabro-workflow/src/operations/start.rs | 34 +- .../src/pipeline/execute/tests.rs | 6 + .../src/pipeline/pull_request.rs | 12 + lib/crates/fabro-workflow/src/test_support.rs | 6 + .../src/.openapi-generator/FILES | 8 +- .../fabro-api-client/src/api/runs-api.ts | 180 +++++++++- .../src/models/board-column.ts | 3 +- .../src/models/deny-run-request.ts | 25 ++ .../src/models/failure-reason.ts | 1 + .../fabro-api-client/src/models/index.ts | 8 +- ...run-status-queued.ts => pending-reason.ts} | 12 +- .../src/models/run-approval-state.ts | 27 ++ .../src/models/run-approval.ts | 28 ++ .../src/models/run-lifecycle.ts | 4 + .../src/models/run-runnable-source.ts | 26 ++ .../src/models/run-status-pending.ts | 29 ++ .../src/models/run-status-runnable.ts | 25 ++ .../fabro-api-client/src/models/run-status.ts | 7 +- .../src/models/system-run-counts.ts | 2 +- 96 files changed, 2586 insertions(+), 600 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/deny-run-request.ts rename lib/packages/fabro-api-client/src/models/{run-status-queued.ts => pending-reason.ts} (59%) create mode 100644 lib/packages/fabro-api-client/src/models/run-approval-state.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-approval.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-runnable-source.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-pending.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-status-runnable.ts diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 009fd1855..98586b2c4 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -14,7 +14,7 @@ function makeRun(overrides: Partial = {}): Run { id: "01ABC", goal: "Fix the build", title: "Fix the build", - workflow: { slug: "fix_build", name: "Fix Build", graph_name: "FixBuild" }, + workflow: { slug: "fix_build", name: "Fix Build", graph_name: "FixBuild", node_count: 0, edge_count: 0 }, automation: null, repository: { name: "myrepo", origin_url: null, provider: "unknown" }, created_by: null, @@ -22,6 +22,7 @@ function makeRun(overrides: Partial = {}): Run { labels: {}, lifecycle: { status: { kind: "running" }, + approval: null, pending_control: null, queue_position: null, error: null, @@ -59,6 +60,7 @@ function withStatus(status: ApiRunStatus): Pick { return { lifecycle: { status, + approval: null, pending_control: null, queue_position: null, error: null, @@ -127,7 +129,7 @@ describe("mapRunToRunItem", () => { id: "01DEF", goal: "", title: "", - workflow: { slug: null, name: null, graph_name: null }, + workflow: { slug: null, name: null, graph_name: null, node_count: 0, edge_count: 0 }, source_directory: null, repository: { name: "unknown", origin_url: null, provider: "unknown" }, ...withStatus({ kind: "submitted" }), @@ -150,20 +152,22 @@ describe("mapRunToRunItem", () => { test("falls back to graph name and slug for workflow labels", () => { const graphFallback = mapRunToRunItem( - makeRun({ workflow: { slug: "fix_build", name: null, graph_name: "FixBuild" } }), + makeRun({ workflow: { slug: "fix_build", name: null, graph_name: "FixBuild", node_count: 0, edge_count: 0 } }), ); const slugFallback = mapRunToRunItem( - makeRun({ workflow: { slug: "fix_build", name: null, graph_name: null } }), + makeRun({ workflow: { slug: "fix_build", name: null, graph_name: null, node_count: 0, edge_count: 0 } }), ); expect(graphFallback.workflow).toBe("FixBuild"); expect(slugFallback.workflow).toBe("fix_build"); }); - test("recognizes canonical blocked and queued run statuses", () => { - expect(isRunStatus("queued")).toBe(true); + test("recognizes canonical blocked, pending, and runnable run statuses", () => { + expect(isRunStatus("pending")).toBe(true); + expect(isRunStatus("runnable")).toBe(true); expect(isRunStatus("blocked")).toBe(true); - expect(runStatusDisplay).toHaveProperty("queued"); + expect(runStatusDisplay).toHaveProperty("pending"); + expect(runStatusDisplay).toHaveProperty("runnable"); expect(runStatusDisplay).toHaveProperty("blocked"); }); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index ca024a43e..d72b88706 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -41,7 +41,8 @@ export interface RunItem { } export const columnStatuses = [ - BoardColumn.QUEUED, + BoardColumn.PENDING, + BoardColumn.RUNNABLE, BoardColumn.INITIALIZING, BoardColumn.RUNNING, BoardColumn.BLOCKED, @@ -52,7 +53,8 @@ export const columnStatuses = [ ] as const satisfies readonly BoardColumn[]; export const columnStatusDisplay: Record = { - queued: { label: "Queued", dot: "bg-fg-muted", text: "text-fg-muted" }, + pending: { label: "Pending", dot: "bg-fg-muted", text: "text-fg-muted" }, + runnable: { label: "Runnable", dot: "bg-cyan-500", text: "text-cyan-500" }, 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" }, @@ -113,8 +115,10 @@ export function mapRunToRunItem(run: Run): RunItem { export function columnForStatus(status: ApiRunStatus | null | undefined): BoardColumn | null { switch (status?.kind) { case "submitted": - case "queued": - return "queued"; + case "pending": + return "pending"; + case "runnable": + return "runnable"; case "starting": return "initializing"; case "running": @@ -141,7 +145,7 @@ export function columnForRun(run: Run): BoardColumn | null { export function toRunWithStatus(run: Run): RunWithStatus { const item = mapRunListItem(run); - const column = columnForRun(run) ?? "queued"; + const column = columnForRun(run) ?? "pending"; return { ...item, status: column, @@ -157,7 +161,8 @@ export function deriveCiStatus(checks: CheckRun[]): CiStatus { export type RunStatus = | "submitted" - | "queued" + | "pending" + | "runnable" | "starting" | "running" | "blocked" @@ -170,7 +175,8 @@ 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" }, + pending: { label: "Pending", dot: "bg-fg-muted", text: "text-fg-muted" }, + runnable: { label: "Runnable", dot: "bg-cyan-500", text: "text-cyan-500" }, 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" }, @@ -205,4 +211,4 @@ export const ciConfig: Record void } | null = null const useSWRMutationMock = mock((_key: unknown, _fetcher: unknown, options: unknown) => { lastMutationOptions = options as { onSuccess?: (result: unknown) => void }; - return {}; + return { + trigger: mock(), + isMutating: false, + reset: mock(), + }; }); mock.module("swr", () => ({ @@ -24,8 +28,10 @@ mock.module("./api-client", () => ({ })); mock.module("./run-actions", () => ({ + approveRun: mock(), archiveRun: mock(), cancelRun: mock(), + denyRun: mock(), isLifecycleActionError: () => false, retryRun: mock(), unarchiveRun: mock(), diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index dbbade092..0fb4db240 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -18,8 +18,10 @@ import { mutateRunListCaches } from "./board-cache"; import { queryKeys } from "./query-keys"; import type { LifecycleAction, LifecycleActionError } from "./run-actions"; import { + approveRun, archiveRun, cancelRun, + denyRun, isLifecycleActionError, retryRun, unarchiveRun, @@ -64,6 +66,14 @@ export function useCancelRun(id: string | undefined) { return useLifecycleMutation(id, "cancel", cancelRun); } +export function useApproveRun(id: string | undefined) { + return useLifecycleMutation(id, "approve", approveRun); +} + +export function useDenyRun(id: string | undefined) { + return useLifecycleMutation(id, "deny", denyRun); +} + export function useArchiveRun(id: string | undefined) { return useLifecycleMutation(id, "archive", archiveRun); } diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 7351388c4..da551a33f 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -78,6 +78,8 @@ export const queryKeys = { pullRequest: (id: string) => ["runs", "pull-request", id] as const, preview: (id: string) => ["runs", "preview", id] as const, cancel: (id: string) => ["runs", "cancel", id] as const, + approve: (id: string) => ["runs", "approve", id] as const, + deny: (id: string) => ["runs", "deny", id] as const, retry: (id: string) => ["runs", "retry", id] as const, archive: (id: string) => ["runs", "archive", id] as const, unarchive: (id: string) => ["runs", "unarchive", id] as const, diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index e754985c0..1f61f41a1 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -5,6 +5,7 @@ import type { Run, RunStatus } from "@qltysh/fabro-api-client"; import { archiveRun, canArchive, + canApprove, canCancel, canRetry, canUnarchive, @@ -29,7 +30,7 @@ function makeRun(status: RunStatus, archived = false): Run { id: "run-1", goal: "Fix the build", title: "Fix the build", - workflow: { slug: "fix_build", name: "Fix Build" }, + workflow: { slug: "fix_build", name: "Fix Build", graph_name: null, node_count: 0, edge_count: 0 }, automation: null, repository: null, created_by: null, @@ -37,6 +38,7 @@ function makeRun(status: RunStatus, archived = false): Run { labels: {}, lifecycle: { status, + approval: null, pending_control: null, queue_position: null, error: null, @@ -143,7 +145,7 @@ describe("run lifecycle actions", () => { stubGeneratedAxiosOnce({ status: 201, body: { - ...makeRun({ kind: "queued" }), + ...makeRun({ kind: "submitted" }), id: "run-2", retried_from: "run-1", }, @@ -152,7 +154,7 @@ describe("run lifecycle actions", () => { const result = await retryRun("run-1"); expect(result.id).toBe("run-2"); expect(result.retried_from).toBe("run-1"); - expect(result.lifecycle.status.kind).toBe("queued"); + expect(result.lifecycle.status.kind).toBe("submitted"); }); test("404 and 409 preserve the parsed error envelope", async () => { @@ -194,13 +196,16 @@ describe("run lifecycle actions", () => { test("mapError returns user-facing copy for lifecycle conflicts", () => { expect(mapError({ status: 409, errors: [] }, "cancel")).toBe("This run can no longer be cancelled."); + expect(mapError({ status: 409, errors: [] }, "approve")).toBe("This run is no longer pending approval."); + expect(mapError({ status: 409, errors: [] }, "deny")).toBe("This run is no longer pending approval."); expect(mapError({ status: 409, errors: [] }, "archive")).toBe("Only terminal runs can be archived."); expect(mapError({ status: 409, errors: [] }, "unarchive")).toBe("Active runs can't be unarchived."); }); test("status predicates align with the documented run statuses", () => { expect(canCancel("submitted")).toBe(true); - expect(canCancel("queued")).toBe(true); + expect(canCancel("pending")).toBe(true); + expect(canCancel("runnable")).toBe(true); expect(canCancel("starting")).toBe(true); expect(canCancel("running")).toBe(true); expect(canCancel("paused")).toBe(true); @@ -216,6 +221,23 @@ describe("run lifecycle actions", () => { expect(canUnarchive("failed")).toBe(false); }); + test("approval predicate requires pending status and pending approval state", () => { + expect(canApprove({ + ...makeRun({ kind: "pending", reason: "approval_required" }), + lifecycle: { + ...makeRun({ kind: "pending", reason: "approval_required" }).lifecycle, + approval: { + state: "pending", + requested_at: "2026-05-23T12:00:00Z", + decided_at: null, + denial_reason: null, + }, + }, + })).toBe(true); + expect(canApprove(makeRun({ kind: "pending", reason: "approval_required" }))).toBe(false); + expect(canApprove(makeRun({ kind: "runnable" }))).toBe(false); + }); + test("canRetry allows failed and dead runs except cancelled or archived runs", () => { expect(canRetry(makeRun({ kind: "failed", reason: "workflow_error" }))).toBe(true); expect(canRetry(makeRun({ kind: "dead" }))).toBe(true); diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index 6ec296436..a785feafa 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -9,7 +9,13 @@ import { } from "./api-client"; import type { RunStatus } from "../data/runs"; -export type LifecycleAction = "cancel" | "archive" | "unarchive" | "retry"; +export type LifecycleAction = + | "cancel" + | "approve" + | "deny" + | "archive" + | "unarchive" + | "retry"; export interface LifecycleActionError { status: number; @@ -18,7 +24,8 @@ export interface LifecycleActionError { const CANCELABLE_STATUSES = new Set([ "submitted", - "queued", + "pending", + "runnable", "starting", "running", "paused", @@ -35,6 +42,14 @@ export async function cancelRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "cancel", request); } +export async function approveRun(id: string, request?: Request): Promise { + return runLifecycleAction(id, "approve", request); +} + +export async function denyRun(id: string, request?: Request): Promise { + return runLifecycleAction(id, "deny", request); +} + export async function archiveRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "archive", request); } @@ -60,6 +75,10 @@ export function canCancel(status: string | null | undefined): boolean { return !!status && CANCELABLE_STATUSES.has(status as RunStatus); } +export function canApprove(run: Run | null | undefined): boolean { + return run?.lifecycle.status.kind === "pending" && run.lifecycle.approval?.state === "pending"; +} + export function canArchive(status: string | null | undefined): boolean { return !!status && ARCHIVABLE_STATUSES.has(status as RunStatus); } @@ -104,6 +123,9 @@ export function mapError(error: unknown, action: LifecycleAction): string { switch (action) { case "cancel": return "This run can no longer be cancelled."; + case "approve": + case "deny": + return "This run is no longer pending approval."; case "archive": return "Only terminal runs can be archived."; case "unarchive": @@ -122,6 +144,10 @@ export function mapError(error: unknown, action: LifecycleAction): string { switch (action) { case "cancel": return "Couldn't cancel the run right now. Try again."; + case "approve": + return "Couldn't approve the run right now. Try again."; + case "deny": + return "Couldn't deny the run right now. Try again."; case "archive": return "Couldn't archive the run right now. Try again."; case "unarchive": @@ -140,6 +166,10 @@ async function runLifecycleAction( switch (action) { case "cancel": return await apiData(() => runsApi.cancelRun(id, requestSignalOptions(request))); + case "approve": + return await apiData(() => runsApi.approveRun(id, requestSignalOptions(request))); + case "deny": + return await apiData(() => runsApi.denyRun(id, undefined, requestSignalOptions(request))); case "archive": return await apiData(() => runsApi.archiveRun(id, requestSignalOptions(request))); case "unarchive": diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 990cd9970..7ba5dd48b 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -36,7 +36,11 @@ const subscriptions = new Map(); const TERMINAL_EVENTS = new Set(["run.completed", "run.failed"]); const RUN_SUMMARY_EVENTS = new Set([ "run.submitted", - "run.queued", + "run.start_requested", + "run.pending", + "run.approved", + "run.denied", + "run.runnable", "run.starting", "run.running", "run.paused", diff --git a/apps/fabro-web/app/lib/run-phases.test.ts b/apps/fabro-web/app/lib/run-phases.test.ts index e138c70b6..a0ad5654e 100644 --- a/apps/fabro-web/app/lib/run-phases.test.ts +++ b/apps/fabro-web/app/lib/run-phases.test.ts @@ -4,8 +4,10 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; import { deriveRunPhases } from "./run-phases"; const CREATED = "2026-05-23T12:00:00.000Z"; -const T_QUEUED = "2026-05-23T12:00:01.000Z"; -const T_STARTING = "2026-05-23T12:00:03.000Z"; +const T_REQUESTED = "2026-05-23T12:00:01.000Z"; +const T_PENDING = "2026-05-23T12:00:02.000Z"; +const T_RUNNABLE = "2026-05-23T12:00:03.000Z"; +const T_STARTING = "2026-05-23T12:00:04.000Z"; const T_RUNNING = "2026-05-23T12:00:10.000Z"; function makeEvent(name: string, ts: string, seq: number): EventEnvelope { @@ -35,33 +37,11 @@ describe("deriveRunPhases", () => { ]); }); - test("closes submitted at run.queued and opens an in-progress queued phase", () => { - const phases = deriveRunPhases( - [makeEvent("run.queued", T_QUEUED, 1)], - CREATED, - ); - expect(phases).toEqual([ - { - kind: "submitted", - label: "Submitted", - startMs: Date.parse(CREATED), - endMs: Date.parse(T_QUEUED), - }, - { - kind: "queued", - label: "Queued", - startMs: Date.parse(T_QUEUED), - endMs: null, - }, - ]); - }); - - test("emits submitted, queued, and initializing through run.running", () => { + test("closes submitted at run.start_requested and opens pending when approval is required", () => { const phases = deriveRunPhases( [ - makeEvent("run.queued", T_QUEUED, 1), - makeEvent("run.starting", T_STARTING, 2), - makeEvent("run.running", T_RUNNING, 3), + makeEvent("run.start_requested", T_REQUESTED, 1), + makeEvent("run.pending", T_PENDING, 2), ], CREATED, ); @@ -70,12 +50,45 @@ describe("deriveRunPhases", () => { kind: "submitted", label: "Submitted", startMs: Date.parse(CREATED), - endMs: Date.parse(T_QUEUED), + endMs: Date.parse(T_REQUESTED), }, { - kind: "queued", - label: "Queued", - startMs: Date.parse(T_QUEUED), + kind: "pending", + label: "Pending", + startMs: Date.parse(T_PENDING), + endMs: null, + }, + ]); + }); + + test("emits submitted, pending, runnable, and initializing through run.running", () => { + const phases = deriveRunPhases( + [ + makeEvent("run.start_requested", T_REQUESTED, 1), + makeEvent("run.pending", T_PENDING, 2), + makeEvent("run.runnable", T_RUNNABLE, 3), + makeEvent("run.starting", T_STARTING, 4), + makeEvent("run.running", T_RUNNING, 5), + ], + CREATED, + ); + expect(phases).toEqual([ + { + kind: "submitted", + label: "Submitted", + startMs: Date.parse(CREATED), + endMs: Date.parse(T_REQUESTED), + }, + { + kind: "pending", + label: "Pending", + startMs: Date.parse(T_PENDING), + endMs: Date.parse(T_RUNNABLE), + }, + { + kind: "runnable", + label: "Runnable", + startMs: Date.parse(T_RUNNABLE), endMs: Date.parse(T_STARTING), }, { @@ -87,7 +100,7 @@ describe("deriveRunPhases", () => { ]); }); - test("skips the queued phase when there was no run.queued event", () => { + test("skips pending and runnable phases when those events are missing", () => { const phases = deriveRunPhases( [ makeEvent("run.starting", T_STARTING, 1), @@ -101,7 +114,7 @@ describe("deriveRunPhases", () => { expect(phases[1]!.endMs).toBe(Date.parse(T_RUNNING)); }); - test("uses run.starting as fallback end for submitted when queued is missing", () => { + test("uses run.starting as fallback end for submitted when pre-execution events are missing", () => { const phases = deriveRunPhases( [makeEvent("run.starting", T_STARTING, 1)], CREATED, @@ -112,7 +125,7 @@ describe("deriveRunPhases", () => { test("ignores unrelated events", () => { const phases = deriveRunPhases( [ - makeEvent("agent.message", T_QUEUED, 1), + makeEvent("agent.message", T_REQUESTED, 1), makeEvent("stage.started", T_STARTING, 2), ], CREATED, diff --git a/apps/fabro-web/app/lib/run-phases.ts b/apps/fabro-web/app/lib/run-phases.ts index fb1769ae1..839b6581d 100644 --- a/apps/fabro-web/app/lib/run-phases.ts +++ b/apps/fabro-web/app/lib/run-phases.ts @@ -1,6 +1,6 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; -export type RunPhaseKind = "submitted" | "queued" | "initializing"; +export type RunPhaseKind = "submitted" | "pending" | "runnable" | "initializing"; export interface RunPhase { kind: RunPhaseKind; @@ -11,7 +11,8 @@ export interface RunPhase { const PHASE_LABEL: Record = { submitted: "Submitted", - queued: "Queued", + pending: "Pending", + runnable: "Runnable", initializing: "Initializing", }; @@ -27,17 +28,45 @@ export function deriveRunPhases( const createdMs = Date.parse(createdAtIso); if (Number.isNaN(createdMs)) return []; - const firstTs = (name: string): number | null => { - if (!events) return null; - const event = events.find((e) => e.event === name); - if (!event) return null; - const ms = Date.parse(event.ts); - return Number.isNaN(ms) ? null : ms; - }; + let startRequestedMs: number | null = null; + let pendingMs: number | null = null; + let runnableMs: number | null = null; + let startingMs: number | null = null; + let runningMs: number | null = null; + let remaining = 5; - const queuedMs = firstTs("run.queued"); - const startingMs = firstTs("run.starting"); - const runningMs = firstTs("run.running"); + for (const event of events ?? []) { + if (remaining === 0) break; + let target: "startRequested" | "pending" | "runnable" | "starting" | "running" | null = null; + switch (event.event) { + case "run.start_requested": + if (startRequestedMs == null) target = "startRequested"; + break; + case "run.pending": + if (pendingMs == null) target = "pending"; + break; + case "run.runnable": + if (runnableMs == null) target = "runnable"; + break; + case "run.starting": + if (startingMs == null) target = "starting"; + break; + case "run.running": + if (runningMs == null) target = "running"; + break; + } + if (target == null) continue; + const ms = Date.parse(event.ts); + if (Number.isNaN(ms)) continue; + switch (target) { + case "startRequested": startRequestedMs = ms; break; + case "pending": pendingMs = ms; break; + case "runnable": runnableMs = ms; break; + case "starting": startingMs = ms; break; + case "running": runningMs = ms; break; + } + remaining -= 1; + } const phases: RunPhase[] = []; @@ -45,14 +74,23 @@ export function deriveRunPhases( kind: "submitted", label: PHASE_LABEL.submitted, startMs: createdMs, - endMs: queuedMs ?? startingMs ?? runningMs, + endMs: startRequestedMs ?? pendingMs ?? runnableMs ?? startingMs ?? runningMs, }); - if (queuedMs != null) { + if (pendingMs != null) { phases.push({ - kind: "queued", - label: PHASE_LABEL.queued, - startMs: queuedMs, + kind: "pending", + label: PHASE_LABEL.pending, + startMs: pendingMs, + endMs: runnableMs ?? startingMs ?? runningMs, + }); + } + + if (runnableMs != null) { + phases.push({ + kind: "runnable", + label: PHASE_LABEL.runnable, + startMs: runnableMs, endMs: startingMs ?? runningMs, }); } diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index 98ef24f7b..64cc380a7 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -58,7 +58,9 @@ const mutationState = () => ({ mock.module("../lib/mutations", () => ({ useArchiveRun: mutationState, + useApproveRun: mutationState, useCancelRun: mutationState, + useDenyRun: mutationState, useInterruptRun: mutationState, usePreviewRun: mutationState, useRetryRun: mutationState, @@ -91,18 +93,18 @@ function makeRunSummary( status === "succeeded" ? { kind: "succeeded", reason: "completed" } : status === "failed" - ? { kind: "failed", reason: "error" } + ? { kind: "failed", reason: "workflow_error" } : status === "dead" ? { kind: "dead" } : status === "blocked" - ? { kind: "blocked", reason: "interview", pending_question_id: null } + ? { kind: "blocked", blocked_reason: "human_input_required" } : { kind: status }; const archived = status === "archived"; return { id: "run_1", goal: "Run 1", title, - workflow: { slug: "default", name: "Default" }, + workflow: { slug: "default", name: "Default", graph_name: null, node_count: 0, edge_count: 0 }, automation: null, repository: { name: "fabro", origin_url: null, provider: "unknown" }, created_by: null, @@ -110,6 +112,7 @@ function makeRunSummary( labels: {}, lifecycle: { status: archived ? { kind: "succeeded", reason: "completed" } : apiStatus, + approval: null, pending_control: null, queue_position: null, error: null, @@ -223,7 +226,8 @@ function tabCountBadges(renderer: TestRenderer.ReactTestRenderer) { describe("lifecycleActionVisibility", () => { test("shows cancel for active cancellable states and hides it elsewhere", () => { expect(lifecycleActionVisibility("submitted").showPrimaryCancel).toBe(true); - expect(lifecycleActionVisibility("queued").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("pending").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("runnable").showPrimaryCancel).toBe(true); expect(lifecycleActionVisibility("starting").showPrimaryCancel).toBe(true); expect(lifecycleActionVisibility("running").showPrimaryCancel).toBe(true); expect(lifecycleActionVisibility("paused").showPrimaryCancel).toBe(true); @@ -293,7 +297,14 @@ describe("handleLifecycleToastResult", () => { const initialState: LifecycleToastState = { activeArchiveToastId: null, - lastProcessed: { cancel: null, archive: null, unarchive: null }, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, }; test("replaying the same cancel success result does not enqueue a duplicate toast", () => { @@ -359,7 +370,14 @@ describe("handleLifecycleToastResult", () => { }; const stateWithActiveToast: LifecycleToastState = { activeArchiveToastId: "toast-9", - lastProcessed: { cancel: null, archive: null, unarchive: null }, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, }; const nextState = handleLifecycleToastResult("unarchive", result, stateWithActiveToast, api); @@ -430,14 +448,21 @@ describe("RunDetail full-height child routes", () => { intent: "retry", ok: true, run: { - ...makeRunSummary("queued"), + ...makeRunSummary("runnable"), id: "run_retry", retried_from: "run_1", }, }; const initialState: LifecycleToastState = { activeArchiveToastId: null, - lastProcessed: { cancel: null, archive: null, unarchive: null, retry: null }, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, }; const next = handleLifecycleToastResult( diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index f86755066..f189d1e79 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -66,7 +66,9 @@ import { useDemoMode } from "../lib/demo-mode"; import { useSWRConfig } from "swr"; import { useArchiveRun, + useApproveRun, useCancelRun, + useDenyRun, useInterruptRun, usePreviewRun, useRetryRun, @@ -81,6 +83,7 @@ import { useRunToasts } from "../hooks/use-run-toasts"; import { useRun, useRunPullRequest, useRunQuestions, useRunState } from "../lib/queries"; import { canArchive, + canApprove, canCancel, canDelete, canRetry, @@ -160,7 +163,14 @@ type ToastApi = Pick, "push" | "dismiss">; const INITIAL_LIFECYCLE_TOAST_STATE: LifecycleToastState = { activeArchiveToastId: null, - lastProcessed: { cancel: null, archive: null, unarchive: null, retry: null }, + lastProcessed: { + cancel: null, + approve: null, + deny: null, + archive: null, + unarchive: null, + retry: null, + }, }; export function lifecycleActionVisibility(status: string | null | undefined) { @@ -401,6 +411,8 @@ export default function RunDetail({ params }: { params: { id: string } }) { const basePath = `/runs/${params.id}`; const previewMutation = usePreviewRun(params.id); const cancelMutation = useCancelRun(params.id); + const approveMutation = useApproveRun(params.id); + const denyMutation = useDenyRun(params.id); const archiveMutation = useArchiveRun(params.id); const unarchiveMutation = useUnarchiveRun(params.id); const retryMutation = useRetryRun(params.id); @@ -461,6 +473,24 @@ export default function RunDetail({ params }: { params: { id: string } }) { ); }, [archiveMutation.data, dismiss, push]); + useEffect(() => { + lifecycleToastStateRef.current = handleLifecycleToastResult( + "approve", + approveMutation.data, + lifecycleToastStateRef.current, + { push, dismiss }, + ); + }, [approveMutation.data, dismiss, push]); + + useEffect(() => { + lifecycleToastStateRef.current = handleLifecycleToastResult( + "deny", + denyMutation.data, + lifecycleToastStateRef.current, + { push, dismiss }, + ); + }, [denyMutation.data, dismiss, push]); + useEffect(() => { lifecycleToastStateRef.current = handleLifecycleToastResult( "unarchive", @@ -525,6 +555,9 @@ export default function RunDetail({ params }: { params: { id: string } }) { const visibility = lifecycleActionVisibility(run.lifecycleStatus); const previewPending = previewMutation.isMutating; const cancelPending = cancelMutation.isMutating; + const approvalActionVisible = canApprove(summary); + const approvePending = approveMutation.isMutating; + const denyPending = denyMutation.isMutating; const archivePending = archiveMutation.isMutating; const unarchivePending = unarchiveMutation.isMutating; const retryPending = retryMutation.isMutating; @@ -673,6 +706,12 @@ export default function RunDetail({ params }: { params: { id: string } }) { canArchive={visibility.showArchive} archivePending={archivePending} onArchive={() => void archiveMutation.trigger()} + canApprove={approvalActionVisible} + approvePending={approvePending} + onApprove={() => void approveMutation.trigger()} + canDeny={approvalActionVisible} + denyPending={denyPending} + onDeny={() => void denyMutation.trigger()} canRetry={!demoMode && canRetry(summary)} retryPending={retryPending} onRetry={() => void retryMutation.trigger()} @@ -865,6 +904,16 @@ export function handleLifecycleToastResult( return nextState; } + if (intent === "approve") { + toastApi.push({ message: "Run approved." }); + return nextState; + } + + if (intent === "deny") { + toastApi.push({ message: "Run denied." }); + return nextState; + } + if (intent === "retry") { toastApi.push({ message: "Retry started." }); navigate?.(`/runs/${result.run.id}`); @@ -925,6 +974,12 @@ interface ActionsMenuProps { canArchive: boolean; archivePending: boolean; onArchive: () => void; + canApprove: boolean; + approvePending: boolean; + onApprove: () => void; + canDeny: boolean; + denyPending: boolean; + onDeny: () => void; canRetry: boolean; retryPending: boolean; onRetry: () => void; @@ -945,6 +1000,8 @@ function ActionsMenu(props: ActionsMenuProps) { canFocusSteer, onFocusSteer, canPreview, previewPending, onPreview, canArchive, archivePending, onArchive, + canApprove, approvePending, onApprove, + canDeny, denyPending, onDeny, canRetry, retryPending, onRetry, canUnarchive, unarchivePending, onUnarchive, canDelete, deletePending, onDelete, @@ -953,11 +1010,19 @@ function ActionsMenu(props: ActionsMenuProps) { const hasOps = canPreview || canSendInterrupt || canFocusSteer; - const hasLifecycle = canRetry || canArchive || canUnarchive; - const hasDestructive = canCancel || canDelete; + const hasLifecycle = canApprove || canRetry || canArchive || canUnarchive; + const hasDestructive = canDeny || canCancel || canDelete; const hasAny = hasOps || hasLifecycle || hasDestructive; const anyPending = - previewPending || retryPending || archivePending || unarchivePending || deletePending || cancelPending || interruptPending; + previewPending || + approvePending || + retryPending || + archivePending || + unarchivePending || + denyPending || + deletePending || + cancelPending || + interruptPending; const separators = actionMenuSeparatorVisibility({ hasLifecycle, hasDestructive }); if (!hasAny) return null; @@ -1009,6 +1074,18 @@ function ActionsMenu(props: ActionsMenuProps) { {separators.afterOperations && (
)} + {canApprove && ( + + + + )} {canRetry && ( + + )} {canCancel && (