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 && (