Replace queued with pending/runnable and add approval flow (web + API s… (#371)

## 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

<details>
<summary>Ran 9 stages in 127m 37s for $104.98</summary>

| 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** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```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
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: fabro <fabro@anthropic.com>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
fabro-sh-0530[bot] 2026-05-23 15:34:33 -04:00 committed by GitHub
parent fd63f4b523
commit f73f2a53f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
96 changed files with 2586 additions and 600 deletions

View file

@ -14,7 +14,7 @@ function makeRun(overrides: Partial<Run> = {}): 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> = {}): 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<Run, "lifecycle"> {
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");
});

View file

@ -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<BoardColumn, { label: string; dot: string; text: string }> = {
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<RunStatus, { label: string; dot: string; text: string }> = {
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<CiStatus, { label: string; dot: string; text: stri
passing: { label: "Passing", dot: "bg-mint", text: "text-mint" },
failing: { label: "Changes needed", dot: "bg-coral", text: "text-coral" },
pending: { label: "Pending", dot: "bg-amber", text: "text-amber" },
};
};

View file

@ -23,7 +23,11 @@ interface BoardEventOptions {
const BOARD_STATUS_EVENTS = new Set([
"run.submitted",
"run.queued",
"run.start_requested",
"run.pending",
"run.approved",
"run.denied",
"run.runnable",
"run.starting",
"run.running",
"run.removing",

View file

@ -5,7 +5,11 @@ let lastMutationOptions: { onSuccess?: (result: unknown) => 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(),

View file

@ -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);
}

View file

@ -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,

View file

@ -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);

View file

@ -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<RunStatus>([
"submitted",
"queued",
"pending",
"runnable",
"starting",
"running",
"paused",
@ -35,6 +42,14 @@ export async function cancelRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "cancel", request);
}
export async function approveRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "approve", request);
}
export async function denyRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "deny", request);
}
export async function archiveRun(id: string, request?: Request): Promise<Run> {
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":

View file

@ -36,7 +36,11 @@ const subscriptions = new Map<string, SharedEventSubscription>();
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",

View file

@ -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,

View file

@ -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<RunPhaseKind, string> = {
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,
});
}

View file

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

View file

@ -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<ReturnType<typeof useToast>, "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 && (
<div className="my-1 h-px bg-line" role="separator" />
)}
{canApprove && (
<MenuItem>
<button
type="button"
onClick={onApprove}
disabled={approvePending}
className={MENU_ITEM_CLASS}
>
{approvePending ? "Approving…" : "Approve"}
</button>
</MenuItem>
)}
{canRetry && (
<MenuItem>
<button
@ -1048,6 +1125,18 @@ function ActionsMenu(props: ActionsMenuProps) {
{separators.beforeDestructive && (
<div className="my-1 h-px bg-line" role="separator" />
)}
{canDeny && (
<MenuItem>
<button
type="button"
onClick={onDeny}
disabled={denyPending}
className={MENU_ITEM_DANGER_CLASS}
>
{denyPending ? "Denying…" : "Deny"}
</button>
</MenuItem>
)}
{canCancel && (
<MenuItem>
<button

View file

@ -47,7 +47,7 @@ mock.module("../lib/queries", () => ({
id: "run_1",
goal: "Run 1",
title: "Run 1",
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,
@ -55,6 +55,7 @@ mock.module("../lib/queries", () => ({
labels: {},
lifecycle: {
status: { kind: currentRunStatus },
approval: null,
pending_control: null,
queue_position: null,
error: null,

View file

@ -15,7 +15,7 @@ function renderToJson(element: React.ReactElement): any {
describe("deriveEmptyKind", () => {
// Pre-work states → R4(a) "starting"
test.each(["submitted", "Submitted", "starting", "queued"])(
test.each(["submitted", "Submitted", "pending", "runnable", "starting"])(
"%s maps to R4(a) 'starting'",
(status) => {
expect(
@ -110,7 +110,8 @@ describe("deriveEmptyKind", () => {
// alongside the decision table below.
for (const status of [
"submitted",
"queued",
"pending",
"runnable",
"starting",
"running",
"blocked",

View file

@ -58,7 +58,7 @@ export function deriveEmptyKind(args: {
const s = runStatus.toLowerCase();
// Pre-work states: run has no base_sha / hasn't started producing a diff.
if (s === "submitted" || s === "queued" || s === "starting") {
if (s === "submitted" || s === "pending" || s === "runnable" || s === "starting") {
return "starting";
}

View file

@ -11,13 +11,15 @@ import {
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
const status =
column === "blocked"
? { kind: "blocked" as const, reason: "interview", pending_question_id: null }
? { kind: "blocked" as const, blocked_reason: "human_input_required" as const }
: column === "succeeded"
? { kind: "succeeded" as const, reason: "completed" }
: column === "failed"
? { kind: "failed" as const, reason: "error" }
: column === "queued"
? { kind: "queued" as const }
: column === "failed"
? { kind: "failed" as const, reason: "workflow_error" as const }
: column === "pending"
? { kind: "pending" as const, reason: "approval_required" as const }
: column === "runnable"
? { kind: "runnable" as const }
: column === "initializing"
? { kind: "starting" as const }
: { kind: "running" as const };
@ -25,7 +27,7 @@ function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
id,
goal: `Run ${id}`,
title: `Run ${id}`,
workflow: { slug: "test", name: "Test" },
workflow: { slug: "test", name: "Test", graph_name: null, node_count: 0, edge_count: 0 },
automation: null,
repository: { name: "repo", origin_url: null, provider: "unknown" },
created_by: null,
@ -33,6 +35,7 @@ function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
labels: {},
lifecycle: {
status,
approval: null,
pending_control: null,
queue_position: null,
error: null,
@ -88,7 +91,8 @@ describe("runs route board mapping", () => {
);
expect(columns.map((column) => column.id)).toEqual([
"queued",
"pending",
"runnable",
"initializing",
"running",
"blocked",
@ -126,7 +130,8 @@ describe("runs route board mapping", () => {
);
expect(placeArchivedColumnLast(columns, true).map((column) => column.id)).toEqual([
"queued",
"pending",
"runnable",
"initializing",
"running",
"blocked",
@ -137,7 +142,10 @@ describe("runs route board mapping", () => {
});
test("refreshes for blocked status and interview events", () => {
expect(shouldRefreshBoardForEvent("run.queued")).toBe(true);
expect(shouldRefreshBoardForEvent("run.pending")).toBe(true);
expect(shouldRefreshBoardForEvent("run.runnable")).toBe(true);
expect(shouldRefreshBoardForEvent("run.approved")).toBe(true);
expect(shouldRefreshBoardForEvent("run.denied")).toBe(true);
expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.unblocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.archived")).toBe(true);

View file

@ -51,7 +51,8 @@ interface ColumnStyle {
}
const columnStyles: Record<BoardColumn, ColumnStyle> = {
queued: { actions: [] },
pending: { actions: [] },
runnable: { actions: [] },
initializing: { actions: [] },
running: { actions: [] },
blocked: { actions: ["Answer Question"] },
@ -1492,7 +1493,7 @@ export default function Runs() {
0,
);
const visibleColumns = placeArchivedColumnLast(filteredColumns, includeArchived).filter(
(col) => col.id !== "queued" || col.items.length > 0,
(col) => col.id !== "pending" || col.items.length > 0,
);
return (

View file

@ -67,7 +67,7 @@ function RunsPanel() {
<Panel title="Runs">
<Row
title="Active"
help="Runs currently queued or executing against the scheduler ceiling."
help="Runs currently pending, runnable, or executing against the scheduler ceiling."
>
<UsageMeter percent={percent} label={`${active} / ${max} active`} />
</Row>

View file

@ -28,7 +28,7 @@ Most server infrastructure already exists — `POST /runs/{id}/{cancel,archive,u
**State-aware visibility**
- R5. Only show an action when the run's current status makes it valid:
- **cancel (primary)**: visible as a primary affordance when status is `submitted`, `queued`, `starting`, `running`, or `paused`. Not shown as primary when `blocked` — see R6. The server also accepts cancel on `blocked` runs; that path is only reachable via the secondary surface from R6.
- **cancel (primary)**: visible as a primary affordance when status is `submitted`, `runnable`, `starting`, `running`, or `paused`. Not shown as primary when `blocked` — see R6. The server also accepts cancel on `blocked` runs; that path is only reachable via the secondary surface from R6.
- **archive**: visible only when status is terminal (`succeeded`, `failed`, `dead`) AND not already archived.
- **unarchive**: visible only when `archived`.
- R6. When a run is `blocked` (waiting on an HITL question), do not show cancel as a primary action. Instead, show an inline notice with the pending question text (from `GET /api/v1/runs/{id}/questions`, whose `ApiQuestion.text` field is already human-readable) and the instruction: "Answer this question via `fabro` CLI to continue." Cancel remains reachable via an overflow/secondary affordance (e.g., a "…" menu) for users who truly want to abandon the run. This protects the common case (non-CLI teammate accidentally cancelling work that was waiting for them) without fully hiding the escape hatch.
@ -110,8 +110,8 @@ Two toast patterns, matched to the reversibility of each action:
- The per-run `/attach` SSE stream terminates on `RunCompleted` / `RunFailed`. Archive/unarchive events fire on already-terminal runs, so the detail page must either reconnect to a non-terminating channel, refetch on the successful archive/unarchive response, or listen at a layer above `/attach`. Planning should pick an approach.
- **Run detail page is not yet SSE-subscribed.** `apps/fabro-web/app/routes/run-detail.tsx` currently fetches the run once via the React Router loader and does not subscribe to `/api/v1/runs/{id}/attach`. Wiring this subscription at the detail-page level (the owner of `run.status` that drives R5 visibility) is net-new work for R7. Individual tab components (stage-sidebar, run-files) already have per-run SSE subscriptions that can be used as a pattern.
- **No undo-capable toast system exists yet.** The only Toast in `apps/fabro-web` is a read-only live-region banner (`apps/fabro-web/app/routes/run-files/states.tsx`) with local `useState`/`setTimeout`. R8 + R9 + R12 together require a shared toast component with: a countdown, action-button slot, programmatic dismiss, multi-toast coexistence, polite aria-live, and focus-pauses-countdown behavior. This is net-new UI infrastructure.
- **Cancel semantics.** For `submitted` and `queued` runs, cancel synchronously flips lifecycle `status` to `failed` with `status_reason: cancelled` and returns that on the response. For `starting`/`running`/`blocked`/`paused` runs, cancel returns 200 with unchanged status and the transition lands asynchronously via the workflow engine. The UI should treat cancel as "request accepted" and rely on SSE for the final status flip — R10 covers this implicitly, but the plan should make the optimistic-UI behavior explicit (e.g., the cancel affordance stays in its disabled/pending state until either the response body carries the synchronous `failed`/`cancelled` result or the SSE-driven reconciliation arrives).
- **Per-run `/attach` stream has silent termination paths beyond the terminal-event case.** `attach_event_is_terminal` only matches `RunCompleted | RunFailed`, but the task that drives the stream can also exit without a terminal marker if the store read errors, if the run projection becomes non-active mid-replay, or if cancel lands on a queued run that never transitioned to running (covered by the `cancel_before_run_transitions_to_running_returns_empty_attach_stream` test in `fabro-server/src/server.rs`). R7 and R10 must therefore not assume a terminal event will always land: the plan needs a fallback refetch path for "SSE stream ended without a terminal marker" and for "SSE channel unreachable during an undo window."
- **Cancel semantics.** For `submitted` and `runnable` runs, cancel synchronously flips lifecycle `status` to `failed` with `status_reason: cancelled` and returns that on the response. For `starting`/`running`/`blocked`/`paused` runs, cancel returns 200 with unchanged status and the transition lands asynchronously via the workflow engine. The UI should treat cancel as "request accepted" and rely on SSE for the final status flip — R10 covers this implicitly, but the plan should make the optimistic-UI behavior explicit (e.g., the cancel affordance stays in its disabled/pending state until either the response body carries the synchronous `failed`/`cancelled` result or the SSE-driven reconciliation arrives).
- **Per-run `/attach` stream has silent termination paths beyond the terminal-event case.** `attach_event_is_terminal` only matches `RunCompleted | RunFailed`, but the task that drives the stream can also exit without a terminal marker if the store read errors, if the run projection becomes non-active mid-replay, or if cancel lands on a runnable run that never transitioned to running (covered by the `cancel_before_run_transitions_to_running_returns_empty_attach_stream` test in `fabro-server/src/server.rs`). R7 and R10 must therefore not assume a terminal event will always land: the plan needs a fallback refetch path for "SSE stream ended without a terminal marker" and for "SSE channel unreachable during an undo window."
- Authorization is a non-issue today (single-user / trusted deployment assumption). If multi-tenant auth lands, the action affordances will need to respect it, but that's a separate workstream.
## Outstanding Questions

View file

@ -43,7 +43,7 @@ None currently open.
Source: `run_tools/create.rs:124`
### Happy path
- [x] **C1** Create one run from an existing workflow (e.g. `gh-list`); default `start=true` → expect `started=true`, `status` in `{queued, starting, running}`. — **PASS**. `status=queued`.
- [x] **C1** Create one run from an existing workflow (e.g. `gh-list`); default `start=true` → expect `start_requested=true`, `status` in `{runnable, starting, running}`. — **PASS**. `status=runnable`.
- [x] **C2** Create with `start=false` → expect `started=false`, `status=submitted`. — **PASS**. Run `01KRC4MP2NEQS9GJDE9FJ0EECH` kept as fixture for I3.
- [x] **C3** Batch create 5 runs in one call → all return; result preserves array order. — **PASS**. ULIDs monotonically increasing.
@ -181,7 +181,7 @@ Source: `run_tools/interact.rs:201`
- [x] **I2** Non-existent run → fuzzy match error. — **PASS**.
#### `start`
- [x] **I3** Non-started run (from C2) → `start` transitions to `queued`. Second `start``an engine process is still running for this run — cannot start`. — **PASS**.
- [x] **I3** Non-started run (from C2) → `start` transitions to `runnable`. Second `start``start has already been requested for this run`. — **PASS**.
#### `message` (steer)
- [ ] **I4** Steer a running LLM agent — **DEFERRED** (requires an active LLM agent stage; would burn LLM tokens; can be exercised manually once the answer bug below is resolved).

View file

@ -60,7 +60,7 @@ The revised plan therefore needs to solve four things explicitly:
- Control priority and conflict rules
- Priority is `cancel > pause > unpause`.
- `pending_control` remains a single value and later accepted requests overwrite lower-priority pending requests.
- `cancel` is accepted from `submitted`, `queued`, `starting`, `running`, or `paused`.
- `cancel` is accepted from `submitted`, `runnable`, `starting`, `running`, or `paused`.
- A `cancel` request overwrites a pending `pause` or `unpause`.
- `pause` is accepted only when observed status is `running` and `pending_control` is `null`.
- `unpause` is accepted only when observed status is `paused` and `pending_control` is `null`.
@ -209,9 +209,9 @@ The revised plan therefore needs to solve four things explicitly:
- server appends worker-streamed events in order and SSE reflects the appended stream
- worker stderr is captured into the per-run log file
- Cancel tests
- starting a queued run spawns a worker subprocess and records PID and PGID
- starting a runnable run spawns a worker subprocess and records PID and PGID
- `POST /runs/{id}/cancel` appends `run.cancel.requested`, sets `pending_control=cancel`, sends `SIGTERM`, and later converges to durable `failed` with `status_reason=cancelled`
- cancelling a submitted or queued run reaches durable `failed/cancelled` without spawning a worker
- cancelling a submitted or runnable run reaches durable `failed/cancelled` without spawning a worker
- an unresponsive worker is escalated from worker `SIGTERM` to process-group `SIGKILL`
- Pause and unpause tests
- `POST /runs/{id}/pause` on a running worker appends `run.pause.requested`, sets `pending_control=pause`, and later projects `paused`

View file

@ -76,7 +76,7 @@
### Test 5: Real `/boards/runs` excludes non-board statuses
- **Name:** Runs with statuses that don't map to board columns (Submitted, Queued, Starting, Failed, Cancelled) are excluded from the board response
- **Name:** Runs with statuses that don't map to board columns (Submitted, Runnable, Starting, Failed, Cancelled) are excluded from the board response
- **Type:** boundary
- **Disposition:** new
- **Harness:** Rust HTTP integration harness

View file

@ -16,7 +16,7 @@
The OpenAPI spec declares `/boards/runs` returns `PaginatedRunList` containing `RunListItem` objects. However, the real `list_board_runs` handler currently returns `RunStatusResponse` objects (id, status, error, queue_position, created_at). The UI's runs board, run-detail, and run-overview loaders all consume `/boards/runs` and expect `RunListItem` fields (repository, title, workflow, status as `BoardColumn`, pull_request, timings, sandbox, question).
**Decision:** Enrich the real `list_board_runs` handler to return `RunListItem`-shaped data by pulling `goal` (as title), `workflow_slug`/`workflow_name`, `host_repo_path` (as repository name), `duration_ms` (as timing), and `total_usd_micros` from `RunSummary`. Map `RunStatus` lifecycle values to `BoardColumn` values: `Running` -> `"working"`, `Paused` -> `"pending"`, `Completed` -> `"merge"`, everything else (`Submitted`, `Queued`, `Starting`, `Failed`, `Cancelled`) -> excluded from the board (they are not actionable board items).
**Decision:** Enrich the real `list_board_runs` handler to return `RunListItem`-shaped data by pulling `goal` (as title), `workflow_slug`/`workflow_name`, `host_repo_path` (as repository name), `duration_ms` (as timing), and `total_usd_micros` from `RunSummary`. Map `RunStatus` lifecycle values to `BoardColumn` values: `Running` -> `"working"`, `Paused` -> `"pending"`, `Completed` -> `"merge"`, everything else (`Submitted`, `Runnable`, `Starting`, `Failed`, `Cancelled`) -> excluded from the board (they are not actionable board items).
**Justification:** This aligns the real handler with the OpenAPI spec and avoids bifurcating the UI's data layer into two incompatible response shapes. The store already has the needed fields. Fields not available from the store (pull_request, sandbox, checks, question) are left `null`/absent -- the UI already handles their optionality with `?.` chains.
@ -347,7 +347,7 @@ Replace the `list_board_runs` handler body with logic that:
- `Running` -> `"working"`
- `Paused` -> `"pending"`
- `Completed` -> `"merge"`
- All others (`Submitted`, `Queued`, `Starting`, `Failed`, `Cancelled`) -> excluded from board
- All others (`Submitted`, `Runnable`, `Starting`, `Failed`, `Cancelled`) -> excluded from board
4. Constructs `RunListItem`-shaped JSON for each included run:
```rust

View file

@ -8,7 +8,7 @@
- Make `/api/v1/runs`, `/api/v1/runs/{id}`, mutation responses, and `/api/v1/runs/{id}/state` use one truthful operator vocabulary.
- Keep `/api/v1/boards/runs` explicitly lossy and web-optimized.
- Add `BlockedReason`, starting with `human_input_required`.
- Add explicit lifecycle events for `run.queued`, `run.blocked`, and `run.unblocked`.
- Add explicit lifecycle events for `run.runnable`, `run.blocked`, and `run.unblocked`.
- No alerting/email work in this pass.
## Scope And Decisions
@ -18,7 +18,7 @@
Use one shared run status vocabulary across the durable projection, operator APIs, generated clients, and CLI:
- `submitted`
- `queued`
- `runnable`
- `starting`
- `running`
- `blocked`
@ -60,7 +60,7 @@ Board columns after this change:
Board mapping rules:
- `submitted`, `queued`, `starting` -> `initializing`
- `submitted`, `runnable`, `starting` -> `initializing`
- `running`, `paused` -> `running`
- `blocked` -> `blocked`
- `succeeded` -> `succeeded`
@ -92,7 +92,7 @@ Update the shared contract in:
Required changes:
- Collapse OpenAPI `RunStatus` and `InternalRunStatus` into one shared `RunStatus` schema with the canonical operator vocabulary above.
- Add `Queued` and `Blocked` variants to the Rust `RunStatus` enum in `status.rs`. Update `is_active()` to include both (they are incomplete active states). Update `is_terminal()`, `can_transition_to()`, `Display`, and `FromStr` accordingly.
- Add `Runnable` and `Blocked` variants to the Rust `RunStatus` enum in `status.rs`. Update `is_active()` to include both (they are incomplete active states). Update `is_terminal()`, `can_transition_to()`, `Display`, and `FromStr` accordingly.
- This is an intentional breaking API change: remove public `completed` and `cancelled`, add public `blocked`, `removing`, `succeeded`, and `dead`, and rename `RunStatusRecord.reason` to `status_reason` with no compatibility layer.
- Add `BlockedReason` schema with initial value `human_input_required`.
- Add `blocked_reason` to:
@ -118,7 +118,7 @@ Add event-backed lifecycle support in:
Add new explicit lifecycle events:
- `run.queued`
- `run.runnable`
- `run.blocked`
- `run.unblocked`
@ -126,11 +126,11 @@ Payload decisions:
- `run.blocked` carries `blocked_reason`.
- `run.unblocked` is a minimal effect event; it does not repeat `blocked_reason`.
- `run.queued` mirrors existing status-transition event style.
- `run.runnable` mirrors existing status-transition event style.
Event ordering and rules:
- Emit `run.queued` from the server `start`/`resume` path at the moment the run is inserted into managed queued state. Persist it to the durable run event log there; do not synthesize `queued` later in projection replay.
- Emit `run.runnable` from the server `start`/`resume` path at the moment the run is inserted into managed runnable state. Persist it to the durable run event log there; do not synthesize `runnable` later in projection replay.
- Emit `run.started` later, when execution begins.
- Keep `run.starting` and `run.running` as the worker bootstrap/execution transitions.
- `run.blocked` and `run.unblocked` must be durable `run.*` events appended through the normal workflow event sink, not SSE-only notifications and not projection-synthesized state.
@ -164,8 +164,8 @@ Pause/unpause decisions:
Transition helper updates in `status.rs`:
- add `submitted -> queued`
- add `queued -> starting`
- add `submitted -> runnable`
- add `runnable -> starting`
- add `running -> blocked`
- add `blocked -> running`
- add `blocked -> paused` (immediate pause from blocked)
@ -206,7 +206,7 @@ Operator API changes:
- Include `blocked_reason` alongside `status_reason` and `pending_control`.
- Because `ManagedRun.status` uses the generated API `RunStatus`, this enum collapse intentionally requires broad match-arm updates throughout `lib/crates/fabro-server/src/server.rs`, `lib/crates/fabro-server/src/demo/mod.rs`, and generated client consumers.
- Return the actual current status from mutation endpoints rather than a target status:
- `start` returns `queued`
- `start` returns `runnable`
- `pause` from `blocked` returns `paused`
- `unpause` back to unresolved human input returns `blocked`
- cooperative `pause` from `running` still returns `running` with `pending_control=pause` until the worker reaches a pause point
@ -219,11 +219,11 @@ Raw state endpoint changes:
Live managed-run reconciliation:
- Update `update_live_run_from_event()` in `server.rs` for `run.queued`, `run.blocked`, and `run.unblocked`.
- Update `update_live_run_from_event()` in `server.rs` for `run.runnable`, `run.blocked`, and `run.unblocked`.
- Keep `Blocked` treated as an incomplete active state for shutdown/startup handling in this pass.
- Allow blocked runs to be cancelled through the existing cancel endpoint.
- Update `pause_run` to accept `Blocked` in addition to `Running`, implementing the immediate-pause path (appending `run.paused` directly rather than sending a control signal to the worker).
- Update `should_reconcile_run_on_startup` to include `Blocked` and `Queued`.
- Update `should_reconcile_run_on_startup` to include `Blocked` and `Runnable`.
### 4. Web Board Projection And UI
@ -237,7 +237,7 @@ Update the web-only board projection in:
Required changes:
- Replace `waiting` with `blocked` in the board projection and UI types.
- Add `queued` and `blocked` to the `RunStatus` type and `runStatusDisplay` record in `apps/fabro-web/app/data/runs.ts` with appropriate labels and colors.
- Add `runnable` and `blocked` to the `RunStatus` type and `runStatusDisplay` record in `apps/fabro-web/app/data/runs.ts` with appropriate labels and colors.
- Update OpenAPI `BoardColumn`, server board responses, and web `ColumnStatus` types to remove `working`, `review`, and `merge`.
- Keep board columns `initializing | running | blocked | succeeded | failed`.
- Map statuses per the board contract above.
@ -252,7 +252,7 @@ Board refresh behavior:
- Preserve the current status-refresh triggers and add the new ones. `STATUS_EVENTS` in `apps/fabro-web/app/routes/runs.tsx` should include:
- `run.submitted`
- `run.queued`
- `run.runnable`
- `run.starting`
- `run.running`
- `run.removing`
@ -283,7 +283,7 @@ Update CLI consumers in:
Required changes:
- Treat `/api/v1/runs` as truthful and stop inventing fallback status in `server_runs.rs`.
- Add display/color handling for `Queued` and `Blocked`.
- Add display/color handling for `Runnable` and `Blocked`.
- Keep `Succeeded` as the success exit state for CLI wait behavior in this pass.
- Keep `Dead` as a real displayable terminal state when it is actually present.
- Stop using `Dead` as a synthetic fallback for missing server summary status now that `status` is non-null.
@ -294,10 +294,10 @@ Required changes:
### Shared Types And Event Model
- `lib/crates/fabro-types/src/run_event/mod.rs`
- round-trip serialization for `run.queued`, `run.blocked`, and `run.unblocked`
- round-trip serialization for `run.runnable`, `run.blocked`, and `run.unblocked`
- `run.blocked` payload includes `blocked_reason`
- `lib/crates/fabro-types/src/status.rs`
- transition tests for `submitted -> queued`, `running -> blocked`, and `blocked -> running`
- transition tests for `submitted -> runnable`, `running -> blocked`, and `blocked -> running`
- paused overlay path still flows through explicit event order rather than a direct `paused -> blocked` transition
- `lib/crates/fabro-workflow/src/handler/human.rs`
- first pending interview emits `interview.started` then durable `run.blocked`
@ -309,7 +309,7 @@ Required changes:
### Durable Projection And Server
- `lib/crates/fabro-store/src/run_state.rs`
- `run.queued` sets `status=Queued`
- `run.runnable` sets `status=Runnable`
- `run.blocked` sets `status=Blocked` and `blocked_reason=HumanInputRequired`
- `run.unblocked` while status is `Blocked` clears `blocked_reason` and restores `Running`
- paused-over-blocked preserves `blocked_reason` while `status=Paused`
@ -325,25 +325,25 @@ Required changes:
- blocked runs are cancellable
- startup/shutdown handling still treats blocked runs as incomplete active work in this pass
- `/api/v1/runs/{id}/state` includes `pending_interviews`
- `start`/`resume` append durable `run.queued` when enqueueing
- `start`/`resume` append durable `run.runnable` when enqueueing
- board response emits `blocked` column, blocked question text, paused-in-running, removing off-board, and dead-in-failed
### Web UI
- `apps/fabro-web/app/data/runs.test.ts`
- accepts `blocked`, `queued`, `removing`, `succeeded`, and `dead`
- accepts `blocked`, `runnable`, `removing`, `succeeded`, and `dead`
- removes dependency on `waiting`
- `apps/fabro-web/app/routes/runs.test.tsx`
- blocked runs render in the `blocked` lane
- paused runs remain in the `running` lane
- blocked card shows oldest unresolved question text
- `STATUS_EVENTS` retains `run.starting` and `run.running` while adding the new blocked/queued events
- `STATUS_EVENTS` retains `run.starting` and `run.running` while adding the new blocked/runnable events
- question text refreshes correctly on `interview.*` events without a status change
### CLI
- `lib/crates/fabro-cli/src/commands/runs/list.rs`
- `Queued` and `Blocked` render with expected labels/colors
- `Runnable` and `Blocked` render with expected labels/colors
- `Dead` remains renderable when actually returned by the API
- `lib/crates/fabro-cli/src/commands/run/wait.rs`
- `Succeeded` remains the success exit state

View file

@ -141,7 +141,7 @@ None. All patterns are grounded in the repo.
- **Whether `POST /runs/{id}/blobs` should reject on archived** — low-risk (content-addressed, append-only). Leave unguarded unless the mutation audit finds a user-facing path through it.
- **`StatusFilter` enum shape on the server side** — currently `RunningOnly | All`; whether to add a third variant or extend `All` is a small decision the implementer makes while touching `run_lookup.rs`.
- **Color choice for archived in `status_cell()`** — gray (`Color::Ansi256(8)`, matching `Dead`) is the obvious pick; leave final call to the implementer.
- **Unification of `InternalRunStatus` and the public `RunStatus` enums (explicitly deferred as a follow-up).** The repo carries two overlapping status enums: the event-sourced engine view (`InternalRunStatus` in OpenAPI / `RunStatus` in `fabro-types`) and the queue-manager view (public OpenAPI `RunStatus` / a separate Rust `RunStatus` referenced around `lib/crates/fabro-server/src/server.rs:1233, 2813, 3322-3324, 3980, 4058, 4075, 4106, 4345-4387, 5912-5924`). They map via an explicit translation at `server.rs:3322-3324` (`Succeeded → Completed`, `Failed + reason=Cancelled → Cancelled`). The web UI at `apps/fabro-web/app/data/runs.ts:131-139` redefines the engine enum shape inline rather than consuming the public one — a strong signal the engine view is what actually matters to consumers. Unification would fold the queue-specific `Queued` and `Cancelled` variants into the engine enum (via a new engine variant and/or a reason-driven display mapping), migrate every `managed_run.status` mutation in `server.rs` (~20 sites), and remove the public OpenAPI `RunStatus` in favor of the unified enum. This is a real, cross-cutting refactor — scoped separately. **Action:** once the archive feature ships, start a dedicated `/ce:brainstorm` for the unification design; do not bundle it into archive.
- **Unification of `InternalRunStatus` and the public `RunStatus` enums (explicitly deferred as a follow-up).** The repo carries two overlapping status enums: the event-sourced engine view (`InternalRunStatus` in OpenAPI / `RunStatus` in `fabro-types`) and the scheduler view (public OpenAPI `RunStatus` / a separate Rust `RunStatus` referenced around `lib/crates/fabro-server/src/server.rs:1233, 2813, 3322-3324, 3980, 4058, 4075, 4106, 4345-4387, 5912-5924`). They map via an explicit translation at `server.rs:3322-3324` (`Succeeded → Completed`, `Failed + reason=Cancelled → Cancelled`). The web UI at `apps/fabro-web/app/data/runs.ts:131-139` redefines the engine enum shape inline rather than consuming the public one — a strong signal the engine view is what actually matters to consumers. Unification would fold the pre-execution `Runnable` and `Cancelled` variants into the engine enum (via a new engine variant and/or a reason-driven display mapping), migrate every `managed_run.status` mutation in `server.rs` (~20 sites), and remove the public OpenAPI `RunStatus` in favor of the unified enum. This is a real, cross-cutting refactor — scoped separately. **Action:** once the archive feature ships, start a dedicated `/ce:brainstorm` for the unification design; do not bundle it into archive.
## High-Level Technical Design

View file

@ -41,7 +41,7 @@ All IDs reference the origin document, but this plan is the source of truth for
- **R3** Do not expose checkpoint operations (resume, rewind, fork) or HITL question answering in this pass.
- **R4** Do not expose these actions on board kanban cards or as bulk selection in this pass.
- **R5** State-aware visibility:
cancel visible for `submitted|queued|starting|running|paused` as the primary affordance; archive for terminal non-archived runs; unarchive for archived runs.
cancel visible for `submitted|runnable|starting|running|paused` as the primary affordance; archive for terminal non-archived runs; unarchive for archived runs.
- **R6** Blocked runs hide the primary cancel button and instead show an inline notice with question text plus CLI guidance. Cancel remains reachable through a de-emphasized secondary affordance inside that notice.
- **R7** The detail page subscribes to the run's SSE stream so affordances update live without a manual refresh.
- **R8** Cancel fires immediately. There is no client-side pending timer, no undo window, and no pre-fire `GET /runs/{id}` recheck in this plan.
@ -77,7 +77,7 @@ All IDs reference the origin document, but this plan is the source of truth for
- **API helpers:** `apps/fabro-web/app/api.ts` exports both `apiJson` and `apiFetch`. `apiJson` discards the response body on non-2xx. That is incompatible with lifecycle actions because these flows need the server error envelope for 404/409 handling. Lifecycle mutation helpers in this plan therefore use `apiFetch` and parse the body manually.
- **Status taxonomy:** `apps/fabro-web/app/data/runs.ts` exports `RunStatus`, `runStatusDisplay`, and `mapRunSummaryToRunItem`. Use those status strings rather than open-coding new ones.
- **Blocked question data:** `GET /api/v1/runs/{id}/questions` returns a `PaginatedApiQuestionList` in `docs/api-reference/fabro-api.yaml`. This plan intentionally shows the **first** pending question's `text` when the run is blocked; the blocked notice is informational only and does not attempt multi-question navigation or answering.
- **Server cancel semantics:** for `submitted`/`queued`, cancel may synchronously return a terminal failed/cancelled state; for `starting`/`running`/`blocked`/`paused`, the response may keep the same lifecycle status and the eventual transition lands later via SSE.
- **Server cancel semantics:** for `submitted`/`runnable`, cancel may synchronously return a terminal failed/cancelled state; for `starting`/`running`/`blocked`/`paused`, the response may keep the same lifecycle status and the eventual transition lands later via SSE.
- **Per-run `/attach` stream limitations:** the stream closes on terminal run events and has a few other silent-termination paths. This plan accepts the existing already-terminal stale-tab limitation for archive/unarchive instead of changing the server subscription contract.
### Institutional Learnings
@ -409,7 +409,7 @@ graph TB
- `SECONDARY_BUTTON_CLASS` from `apps/fabro-web/app/components/ui.tsx`
**Test scenarios:**
- Cancel renders for `submitted`, `queued`, `starting`, `running`, and `paused`.
- Cancel renders for `submitted`, `runnable`, `starting`, `running`, and `paused`.
- Cancel is hidden for `blocked`, `succeeded`, `failed`, `dead`, and `archived`.
- Submitting cancel sends `intent=cancel` through the route action.
- While the cancel submission is pending, only that button disables.

View file

@ -68,7 +68,7 @@ type RunLifecycle = {
type RunStatus =
| { kind: "submitted" }
| { kind: "queued" }
| { kind: "runnable" }
| { kind: "starting" }
| { kind: "running" }
| { kind: "blocked"; blocked_reason: BlockedReason }
@ -141,7 +141,7 @@ Use these derivation rules:
- Board column:
- `run.lifecycle.archived` -> `archived`
- `submitted` / `queued` -> `queued`
- `submitted` / `runnable` -> `runnable`
- `starting` -> `initializing`
- `running` / `paused` -> `running`
- `blocked` -> `blocked`

View file

@ -1431,7 +1431,7 @@ paths:
operationId: cancelRun
tags: [Runs]
summary: Cancel Run
description: Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
description: Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
@ -1829,7 +1829,7 @@ paths:
operationId: startRun
tags: [Runs]
summary: Start Run
description: Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
description: Requests start for a submitted run. User-created runs become runnable; parent-generated child runs may become pending until approved. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
@ -1864,6 +1864,80 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/approve:
post:
operationId: approveRun
tags: [Runs]
summary: Approve Run
description: Approves a pending run that requires pre-execution approval and makes it runnable.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: Run approved
content:
application/json:
schema:
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Run is not pending approval
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/deny:
post:
operationId: denyRun
tags: [Runs]
summary: Deny Run
description: Denies a pending run that requires pre-execution approval and fails it with `approval_denied`.
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/DenyRunRequest"
responses:
"200":
description: Run denied
content:
application/json:
schema:
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Run is not pending approval
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/retry:
post:
operationId: retryRun
@ -1871,14 +1945,14 @@ paths:
summary: Retry Run
description: >
Creates a fresh run from the failed or dead source run's captured
durable definition, records `retried_from` on the new run, and queues it
for execution. The source run is left unchanged. Cancelled, active,
durable definition, records `retried_from` on the new run, and schedules
it for execution. The source run is left unchanged. Cancelled, active,
succeeded, and archived runs are not retryable.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"201":
description: New retry run created and queued
description: New retry run created and scheduled for execution
content:
application/json:
schema:
@ -6228,7 +6302,8 @@ components:
`RunLifecycle.archived` so terminal status payloads remain intact.
oneOf:
- $ref: "#/components/schemas/RunStatusSubmitted"
- $ref: "#/components/schemas/RunStatusQueued"
- $ref: "#/components/schemas/RunStatusPending"
- $ref: "#/components/schemas/RunStatusRunnable"
- $ref: "#/components/schemas/RunStatusStarting"
- $ref: "#/components/schemas/RunStatusRunning"
- $ref: "#/components/schemas/RunStatusBlocked"
@ -6241,7 +6316,8 @@ components:
propertyName: kind
mapping:
submitted: "#/components/schemas/RunStatusSubmitted"
queued: "#/components/schemas/RunStatusQueued"
pending: "#/components/schemas/RunStatusPending"
runnable: "#/components/schemas/RunStatusRunnable"
starting: "#/components/schemas/RunStatusStarting"
running: "#/components/schemas/RunStatusRunning"
blocked: "#/components/schemas/RunStatusBlocked"
@ -6261,7 +6337,20 @@ components:
enum:
- submitted
RunStatusQueued:
RunStatusPending:
type: object
required:
- kind
- reason
properties:
kind:
type: string
enum:
- pending
reason:
$ref: "#/components/schemas/PendingReason"
RunStatusRunnable:
type: object
required:
- kind
@ -6269,7 +6358,7 @@ components:
kind:
type: string
enum:
- queued
- runnable
RunStatusStarting:
type: object
@ -6378,6 +6467,7 @@ components:
enum:
- workflow_error
- cancelled
- approval_denied
- terminated
- transient_infra
- budget_exhausted
@ -6385,6 +6475,19 @@ components:
- bootstrap_failed
- sandbox_init_failed
PendingReason:
description: Reason a pre-execution run is pending instead of runnable.
type: string
enum:
- approval_required
RunRunnableSource:
description: Source that made a run runnable.
type: string
enum:
- start_requested
- approved
FailureCategory:
description: Product-level classification for grouping and retry policy.
type: string
@ -6981,6 +7084,15 @@ components:
description: Resume from checkpoint instead of starting from submitted state.
default: false
DenyRunRequest:
description: Request body for denying a pending run approval request.
type: object
properties:
reason:
type: string
description: Optional human-readable reason for denying execution. Empty or whitespace-only values are stored as absent.
example: Not approved for execution
UpdateRunRequest:
description: Request body for updating mutable run metadata.
type: object
@ -8588,10 +8700,14 @@ components:
RunLifecycle:
type: object
required: [status, pending_control, queue_position, error, archived, archived_at]
required: [status, approval, pending_control, queue_position, error, archived, archived_at]
properties:
status:
$ref: "#/components/schemas/RunStatus"
approval:
oneOf:
- $ref: "#/components/schemas/RunApproval"
- type: "null"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
@ -8608,6 +8724,30 @@ components:
type: ["string", "null"]
format: date-time
RunApproval:
description: Pre-execution approval state for runs that require one-time human approval.
type: object
required: [state, requested_at, decided_at, denial_reason]
properties:
state:
$ref: "#/components/schemas/RunApprovalState"
requested_at:
type: string
format: date-time
decided_at:
type: ["string", "null"]
format: date-time
denial_reason:
type: ["string", "null"]
RunApprovalState:
description: State of a run's pre-execution approval request.
type: string
enum:
- pending
- approved
- denied
RunModel:
type: object
required: [provider, name]
@ -8739,7 +8879,8 @@ components:
`status=archived` is equivalent to opting archived runs in.
type: string
enum:
- queued
- pending
- runnable
- initializing
- running
- blocked
@ -11412,7 +11553,7 @@ components:
active:
type: integer
format: int64
description: Runs currently queued or executing.
description: Runs currently pending, runnable, or executing.
SystemResourcesResponse:
description: Server-visible runtime resource usage for the active Fabro process environment.

View file

@ -5,7 +5,7 @@ date: "2026-03-04"
## Concurrency limiter
Set `max_concurrent_runs` in server config to control how many runs execute simultaneously. Additional runs queue automatically with `queued` and `starting` states visible in the UI and API.
Set `max_concurrent_runs` in server config to control how many runs execute simultaneously. Additional start-requested runs wait as `runnable` before moving to `starting`, with both states visible in the UI and API.
Previously, starting too many runs at once could overwhelm the machine. Now excess runs wait in a queue and start as capacity frees up.

View file

@ -5,13 +5,13 @@ date: "2026-04-04"
## Separate run create and start
The `POST /api/v1/runs` endpoint now creates a run in `submitted` status without immediately queuing it. A new `POST /api/v1/runs/{id}/start` endpoint transitions the run to `queued` and notifies the scheduler. This two-step lifecycle gives API consumers more control — you can inspect or modify a run's configuration between creation and execution.
The `POST /api/v1/runs` endpoint now creates a run in `submitted` status without immediately making it runnable. A new `POST /api/v1/runs/{id}/start` endpoint transitions the run to `runnable` and notifies the scheduler. This two-step lifecycle gives API consumers more control — you can inspect or modify a run's configuration between creation and execution.
## More
<Accordion title="API">
- `POST /api/v1/runs` now returns a run in `submitted` status
- New `POST /api/v1/runs/{id}/start` endpoint queues a submitted run for execution
- New `POST /api/v1/runs/{id}/start` endpoint makes a submitted run runnable for execution
- Removed unused `GET /api/v1/runs/{id}/context` endpoint
</Accordion>

View file

@ -43,7 +43,7 @@ Built-in Fabro workflows were also refreshed to use the newer defaults in their
<Accordion title="Improvements">
- Run and board event streams are coordinated across browser tabs to reduce duplicate SSE subscriptions
- The run board now separates `Queued` runs from `Initializing` runs
- The run board now separates pre-execution runs from `Initializing` runs
- Daytona credential probes reuse HTTP clients and avoid extra proxy setup work
</Accordion>

View file

@ -46,10 +46,12 @@ Key server config options:
### Run lifecycle
1. **Submit** — `POST /api/v1/runs` with a Graphviz workflow source. The run is created with status `Queued` and the response returns immediately with the run ID.
2. **Schedule** — A background scheduler promotes queued runs to `Running` in FIFO order, up to the concurrency limit.
3. **Execute** — The engine walks the graph, streaming events to all subscribers.
4. **Complete** — The run transitions to `Completed`, `Failed`, or `Cancelled`.
1. **Submit** — `POST /api/v1/runs` with a Graphviz workflow source. The run is created with status `submitted` and the response returns immediately with the run ID.
2. **Start request** — `POST /api/v1/runs/{id}/start` moves normal runs to `runnable`. Parent-generated child runs may move to `pending` with `approval_required`.
3. **Approve if needed** — `POST /api/v1/runs/{id}/approve` moves an approval-gated run to `runnable`; `deny` fails it with `approval_denied`.
4. **Schedule** — A background scheduler promotes `runnable` runs to `running` in FIFO order, up to the concurrency limit.
5. **Execute** — The engine walks the graph, streaming events to all subscribers.
6. **Complete** — The run transitions to `succeeded`, `failed`, or `dead`.
### Event streaming

View file

@ -61,14 +61,16 @@ Workflows are submitted via the REST API and executed in the background. The exa
curl -X POST http://localhost:3000/api/v1/runs
```
The server returns immediately with a run ID. A background scheduler promotes queued runs to `Running` in FIFO order, up to the concurrency limit.
The server returns immediately with a run ID. After a start request, a background scheduler promotes `runnable` runs to `running` in FIFO order, up to the concurrency limit. Parent-generated child runs can remain `pending` until a user approves them.
## Run lifecycle
1. **Submit** — `POST /api/v1/runs` creates the run with status `Queued`.
2. **Schedule** — The scheduler picks up queued runs up to `max_concurrent_runs`.
3. **Execute** — The engine walks the graph, streaming events to all subscribers.
4. **Complete** — The run transitions to `Completed`, `Failed`, or `Cancelled`.
1. **Submit** — `POST /api/v1/runs` creates the run with status `submitted`.
2. **Start request** — `POST /api/v1/runs/{id}/start` makes normal runs `runnable`; parent-generated child runs may become `pending` with `approval_required`.
3. **Approve if needed** — Approving a pending child run makes it `runnable`; denying it fails with `approval_denied`.
4. **Schedule** — The scheduler picks up `runnable` runs up to `max_concurrent_runs`.
5. **Execute** — The engine walks the graph, streaming events to all subscribers.
6. **Complete** — The run transitions to `succeeded`, `failed`, or `dead`.
## Web UI

View file

@ -88,7 +88,7 @@ DELETE /api/v1/automations/{id}
## Stage 6: Scheduler And Run Creation
- [ ] Add an automation scheduler service in `fabro-server` separate from the existing queued-run scheduler.
- [ ] Add an automation scheduler service in `fabro-server` separate from the existing runnable-run scheduler.
- [ ] On startup and settings reload, evaluate enabled automations, compute due schedules, and create runs for due entries.
- [ ] Add server-side materialization for automation targets: clone or fetch the configured GitHub repo/ref into a temporary workspace, resolve the workflow slug with existing project workflow discovery rules, build a `RunManifest`, then reuse the existing run creation/start path.
- [ ] Set run provenance to identify the automation ID and system actor.

View file

@ -187,6 +187,7 @@ fn main() {
settings.with_interface(InterfaceStyle::Builder);
let replacements: &[(&str, &str, &[TypeImpl])] = &[
("RunStatus", "fabro_types::status::RunStatus", &[]),
("PendingReason", "fabro_types::status::PendingReason", &[]),
("SuccessReason", "fabro_types::status::SuccessReason", &[]),
("FailureReason", "fabro_types::status::FailureReason", &[]),
("FailureCategory", "fabro_types::FailureCategory", &[]),
@ -200,6 +201,10 @@ fn main() {
&[],
),
("Run", "fabro_types::Run", &[]),
("RunApproval", "fabro_types::RunApproval", &[]),
("RunApprovalState", "fabro_types::RunApprovalState", &[]),
("RunRunnableSource", "fabro_types::RunRunnableSource", &[]),
("RunSize", "fabro_types::RunSize", &[]),
("DiffSummary", "fabro_types::DiffSummary", &[]),
("RepositoryRef", "fabro_types::RepositoryRef", &[]),
("WorkflowSettings", "fabro_types::WorkflowSettings", &[]),

View file

@ -29,7 +29,7 @@ pub mod types {
WebhookStrategy,
};
pub use fabro_types::status::{
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason,
BlockedReason, FailureReason, PendingReason, RunControlAction, RunStatus, SuccessReason,
};
pub use fabro_types::{
ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
@ -41,9 +41,10 @@ pub mod types {
PairTranscriptEntry, PairTranscriptResponse, PendingInterviewRecord, PreRunPushOutcome,
Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus,
PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse,
QuestionType, RepositoryRef, Run, RunClientProvenance, RunEvent, RunEventDetailContentKind,
RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance,
RunSandbox, RunSandboxRuntime, RunServerProvenance, SandboxDetails, SandboxNetwork,
QuestionType, RepositoryRef, Run, RunApproval, RunApprovalState, RunClientProvenance,
RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure,
RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox,
RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxNetwork,
SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources,
SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps,
SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, SessionMessage,

View file

@ -2,12 +2,17 @@ use std::any::{TypeId, type_name};
use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use fabro_api::types::{RepositoryRef as ApiRepositoryRef, Run as ApiRun};
use fabro_api::types::{
RepositoryRef as ApiRepositoryRef, Run as ApiRun, RunApproval as ApiRunApproval,
RunApprovalState as ApiRunApprovalState, RunRunnableSource as ApiRunRunnableSource,
RunSize as ApiRunSize,
};
use fabro_types::status::{RunStatus, SuccessReason};
use fabro_types::{
AskFabro, AskFabroUnavailableReason, DiffSummary, PullRequestLink, RepositoryProvider,
RepositoryRef, Run, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin, RunSize,
RunTimestamps, RunTiming, WorkflowRef, fixtures,
RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary, RunId, RunLifecycle,
RunLinks, RunOrigin, RunRunnableSource, RunSize, RunTimestamps, RunTiming, WorkflowRef,
fixtures,
};
use serde_json::json;
@ -15,6 +20,42 @@ use serde_json::json;
fn run_summary_reuses_domain_types() {
assert_same_type::<ApiRun, Run>();
assert_same_type::<ApiRepositoryRef, RepositoryRef>();
assert_same_type::<ApiRunApproval, RunApproval>();
assert_same_type::<ApiRunApprovalState, RunApprovalState>();
assert_same_type::<ApiRunRunnableSource, RunRunnableSource>();
assert_same_type::<ApiRunSize, RunSize>();
}
#[test]
fn approval_json_matches_openapi_shape() {
let requested_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap();
let decided_at = Utc.with_ymd_and_hms(2026, 5, 23, 12, 1, 0).unwrap();
assert_eq!(
serde_json::to_value(RunApproval {
state: RunApprovalState::Denied,
requested_at,
decided_at: Some(decided_at),
denial_reason: Some("Not approved for execution".to_string()),
})
.unwrap(),
json!({
"state": "denied",
"requested_at": "2026-05-23T12:00:00Z",
"decided_at": "2026-05-23T12:01:00Z",
"denial_reason": "Not approved for execution"
})
);
assert_eq!(
serde_json::to_value(RunApprovalState::Pending).unwrap(),
json!("pending")
);
assert_eq!(
serde_json::to_value(RunRunnableSource::Approved).unwrap(),
json!("approved")
);
assert_eq!(serde_json::to_value(RunSize::Xs).unwrap(), json!("XS"));
}
#[test]
@ -49,6 +90,7 @@ fn run_summary_json_matches_openapi_shape() {
status: RunStatus::Succeeded {
reason: SuccessReason::PartialSuccess,
},
approval: None,
pending_control: None,
queue_position: None,
error: None,
@ -122,6 +164,7 @@ fn run_summary_json_matches_openapi_shape() {
"kind": "succeeded",
"reason": "partial_success"
},
"approval": null,
"pending_control": null,
"queue_position": null,
"error": null,
@ -237,6 +280,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.timestamps.created_at, created_at);
assert_eq!(summary.timestamps.last_event_at, None);
assert_eq!(summary.lifecycle.status, RunStatus::Running);
assert_eq!(summary.lifecycle.approval, None);
assert_eq!(summary.lifecycle.pending_control, None);
assert_eq!(summary.timing.map(|t| t.wall_time_ms), None);
assert_eq!(summary.billing, None);

View file

@ -2,11 +2,11 @@ use std::any::{TypeId, type_name};
use fabro_api::types::{
BlockedReason as ApiBlockedReason, FailureReason as ApiFailureReason,
RunControlAction as ApiRunControlAction, RunStatus as ApiRunStatus,
SuccessReason as ApiSuccessReason,
PendingReason as ApiPendingReason, RunControlAction as ApiRunControlAction,
RunStatus as ApiRunStatus, SuccessReason as ApiSuccessReason,
};
use fabro_types::status::{
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason,
BlockedReason, FailureReason, PendingReason, RunControlAction, RunStatus, SuccessReason,
};
use serde::Serialize;
use serde_json::{Value, json};
@ -16,6 +16,7 @@ fn status_family_reuses_domain_types() {
assert_same_type::<ApiRunStatus, RunStatus>();
assert_same_type::<ApiSuccessReason, SuccessReason>();
assert_same_type::<ApiFailureReason, FailureReason>();
assert_same_type::<ApiPendingReason, PendingReason>();
assert_same_type::<ApiBlockedReason, BlockedReason>();
assert_same_type::<ApiRunControlAction, RunControlAction>();
}
@ -29,9 +30,18 @@ fn run_status_json_matches_openapi_shape() {
}),
);
assert_json(
RunStatus::Queued,
RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
},
json!({
"kind": "queued"
"kind": "pending",
"reason": "approval_required"
}),
);
assert_json(
RunStatus::Runnable,
json!({
"kind": "runnable"
}),
);
assert_json(
@ -104,6 +114,7 @@ fn success_reason_json_tokens_match_openapi() {
fn failure_reason_json_tokens_match_openapi() {
assert_string_json(FailureReason::WorkflowError, "workflow_error");
assert_string_json(FailureReason::Cancelled, "cancelled");
assert_string_json(FailureReason::ApprovalDenied, "approval_denied");
assert_string_json(FailureReason::Terminated, "terminated");
assert_string_json(FailureReason::TransientInfra, "transient_infra");
assert_string_json(FailureReason::BudgetExhausted, "budget_exhausted");
@ -112,6 +123,11 @@ fn failure_reason_json_tokens_match_openapi() {
assert_string_json(FailureReason::SandboxInitFailed, "sandbox_init_failed");
}
#[test]
fn pending_reason_json_tokens_match_openapi() {
assert_string_json(PendingReason::ApprovalRequired, "approval_required");
}
#[test]
fn blocked_reason_json_tokens_match_openapi() {
assert_string_json(BlockedReason::HumanInputRequired, "human_input_required");

View file

@ -173,8 +173,10 @@ fn status_cell(status: RunStatus, use_color: bool) -> CellStruct {
let color = match status {
RunStatus::Succeeded { .. } => Some(Color::Green),
RunStatus::Failed { .. } => Some(Color::Red),
RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan),
RunStatus::Queued | RunStatus::Dead => Some(Color::Ansi256(8)),
RunStatus::Running | RunStatus::Starting | RunStatus::Runnable => Some(Color::Cyan),
RunStatus::Submitted | RunStatus::Pending { .. } | RunStatus::Dead => {
Some(Color::Ansi256(8))
}
RunStatus::Blocked { .. } | RunStatus::Removing => Some(Color::Yellow),
RunStatus::Paused { .. } => Some(Color::Magenta),
};

View file

@ -1036,9 +1036,38 @@ fn attach_json_errors_without_prompting_for_human_input() {
"ts": "[TIMESTAMP]"
},
{
"event": "run.queued",
"actor": {
"auth_method": "dev_token",
"identity": {
"issuer": "fabro:dev",
"subject": "dev"
},
"kind": "user",
"login": "dev"
},
"event": "run.start_requested",
"id": "[EVENT_ID]",
"properties": {},
"properties": {
"resume": false
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"auth_method": "dev_token",
"identity": {
"issuer": "fabro:dev",
"subject": "dev"
},
"kind": "user",
"login": "dev"
},
"event": "run.runnable",
"id": "[EVENT_ID]",
"properties": {
"source": "start_requested"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},

View file

@ -273,9 +273,9 @@ fn dump_exports_completed_run_snapshot() {
");
assert_snapshot!(dump_file_summary(&output_dir), @"
checkpoints/0013.json
checkpoints/0017.json
checkpoints/0021.json
checkpoints/0014.json
checkpoints/0018.json
checkpoints/0022.json
events.jsonl
graph.fabro
run.json

View file

@ -578,7 +578,7 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
)
.await;
let run_id = create["runs"][0]["run_id"].as_str().unwrap().to_string();
assert_eq!(create["runs"][0]["started"], true);
assert_eq!(create["runs"][0]["start_requested"], true);
let search = call_tool_json(
&client,
@ -600,7 +600,7 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
"workflow_name": null,
"workflow_graph_name": "Simple",
"workflow_slug": "simple",
"status": "queued",
"status": "runnable",
"archived": false,
"created_at": "[TIMESTAMP]",
"started_at": null,

View file

@ -15,7 +15,8 @@ use crate::support::{run_output_filters, run_projection_json, unique_run_id};
fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
let status = match status {
"submitted" => serde_json::json!({ "kind": "submitted" }),
"queued" => serde_json::json!({ "kind": "queued" }),
"pending" => serde_json::json!({ "kind": "pending", "reason": "approval_required" }),
"runnable" => serde_json::json!({ "kind": "runnable" }),
other => panic!("unsupported test status {other:?}"),
};
remote_run_summary_json(
@ -162,7 +163,7 @@ fn detach_uses_explicit_server_target_and_prints_remote_run_id() {
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
let workflow = context.install_fixture("simple.fabro");
@ -214,7 +215,7 @@ fn run_parent_resolves_parent_and_sends_parent_id_in_manifest() {
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
let workflow = context.install_fixture("simple.fabro");
@ -264,7 +265,7 @@ fn detach_uses_configured_server_target_without_server_flag() {
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
context.set_http_target(&server.base_url());
@ -490,7 +491,7 @@ fn detach_cli_server_target_overrides_configured_server_target() {
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
context.set_http_target(&config_server.base_url());
@ -546,7 +547,7 @@ fn remote_foreground_run_consumes_paginated_events_and_prints_server_backed_summ
.path(format!("/api/v1/runs/{run_id}/start"));
then.status(200)
.header("Content-Type", "application/json")
.body(run_status_response(run_id.as_str(), "queued").to_string());
.body(run_status_response(run_id.as_str(), "runnable").to_string());
});
let first_page = server.mock(|when, then| {
when.method("GET")

View file

@ -507,7 +507,8 @@ pub(crate) fn wait_for_status(run_dir: &Path, expected: &[&str]) -> String {
} else {
match state.status {
fabro_types::RunStatus::Submitted => "submitted",
fabro_types::RunStatus::Queued => "queued",
fabro_types::RunStatus::Pending { .. } => "pending",
fabro_types::RunStatus::Runnable => "runnable",
fabro_types::RunStatus::Starting => "starting",
fabro_types::RunStatus::Running => "running",
fabro_types::RunStatus::Blocked { .. } => "blocked",
@ -1045,6 +1046,15 @@ async fn append_seeded_simple_completion_events(
}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.runnable",
serde_json::json!({ "source": "start_requested" }),
)
.await;
append_run_event(
client,
base_url,
@ -1205,6 +1215,15 @@ async fn append_seeded_git_completion_events(
}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.runnable",
serde_json::json!({ "source": "start_requested" }),
)
.await;
append_run_event(
client,
base_url,
@ -1314,6 +1333,15 @@ async fn append_seeded_git_noop_events(
}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.runnable",
serde_json::json!({ "source": "start_requested" }),
)
.await;
append_run_event(
client,
base_url,
@ -1374,6 +1402,15 @@ async fn append_seeded_artifact_run_events(
}),
)
.await;
append_run_event(
client,
base_url,
&run.run_id,
None,
"run.runnable",
serde_json::json!({ "source": "start_requested" }),
)
.await;
append_run_event(
client,
base_url,

View file

@ -154,7 +154,8 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
} else {
match state.status {
fabro_types::RunStatus::Submitted => "submitted",
fabro_types::RunStatus::Queued => "queued",
fabro_types::RunStatus::Pending { .. } => "pending",
fabro_types::RunStatus::Runnable => "runnable",
fabro_types::RunStatus::Starting => "starting",
fabro_types::RunStatus::Running => "running",
fabro_types::RunStatus::Blocked { .. } => "blocked",

View file

@ -106,7 +106,7 @@ pub(crate) async fn start_run_stub(
(
StatusCode::OK,
Json(
serde_json::json!({"id": id, "status": "queued", "created_at": "2026-03-06T14:30:00Z"}),
serde_json::json!({"id": id, "status": "runnable", "created_at": "2026-03-06T14:30:00Z"}),
),
)
.into_response()
@ -518,6 +518,22 @@ pub(crate) async fn cancel_stub(
.into_response()
}
pub(crate) async fn deny_run_stub(
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
(
StatusCode::OK,
Json(serde_json::json!({
"id": id,
"status": { "kind": "failed", "reason": "approval_denied" },
"created_at": "2026-03-06T14:30:00Z"
})),
)
.into_response()
}
pub(crate) async fn pause_stub(
_auth: RequiredUser,
State(_state): State<Arc<AppState>>,
@ -1028,8 +1044,8 @@ mod runs {
};
use fabro_types::settings::{InterpString, ProjectNamespace, WorkflowNamespace};
use fabro_types::{
RepositoryRef, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin, RunSize,
RunTimestamps, StageId, WorkflowRef, WorkflowSettings,
PendingReason, RepositoryRef, RunBillingSummary, RunId, RunLifecycle, RunLinks, RunOrigin,
RunSize, RunTimestamps, StageId, WorkflowRef, WorkflowSettings,
};
use super::ts;
@ -1113,6 +1129,7 @@ mod runs {
lifecycle: RunLifecycle {
status: parse_run_status(status, status_reason)
.unwrap_or_else(|| panic!("invalid demo run status: {status}")),
approval: None,
pending_control,
queue_position: None,
error: None,
@ -1146,7 +1163,10 @@ mod runs {
fn parse_run_status(status: &str, status_reason: Option<&str>) -> Option<RunStatus> {
match status {
"submitted" => Some(RunStatus::Submitted),
"queued" => Some(RunStatus::Queued),
"pending" => Some(RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
}),
"runnable" => Some(RunStatus::Runnable),
"starting" => Some(RunStatus::Starting),
"running" => Some(RunStatus::Running),
"blocked" => Some(RunStatus::Blocked {
@ -1181,6 +1201,7 @@ mod runs {
match reason {
"workflow_error" => Some(FailureReason::WorkflowError),
"cancelled" => Some(FailureReason::Cancelled),
"approval_denied" => Some(FailureReason::ApprovalDenied),
"terminated" => Some(FailureReason::Terminated),
"transient_infra" => Some(FailureReason::TransientInfra),
"budget_exhausted" => Some(FailureReason::BudgetExhausted),
@ -1288,7 +1309,7 @@ mod runs {
"implement",
"Implement",
"Add audit log retention policy",
"queued",
"runnable",
"2026-03-06T14:35:00Z",
None,
None,

View file

@ -27,8 +27,8 @@ pub use fabro_api::types::{
CloseRunPullRequestResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole,
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
CreateRunPullRequestRequest, CreateSecretRequest, DeleteRunResponse, DeleteRunSandbox,
DeleteSecretRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, ForkRequest,
ForkResponse, LinkRunPullRequestRequest, MergeRunPullRequestRequest,
DeleteSecretRequest, DenyRunRequest, DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow,
ForkRequest, ForkResponse, LinkRunPullRequestRequest, MergeRunPullRequestRequest,
MergeRunPullRequestResponse, ModelReference, PaginatedEventList, PaginatedRunList,
PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, Provider,
ProviderList, PruneRunEntry, PruneRunsRequest, PruneRunsResponse, RenderWorkflowGraphDirection,
@ -82,8 +82,9 @@ use fabro_types::settings::server::{
use fabro_types::settings::{InterpString, RunNamespace};
use fabro_types::{
AgentBackend, AskFabro, AskFabroUnavailableReason, EventBody, InterviewQuestionRecord, PairId,
PairMessageId, PairTarget, Principal, PullRequestLink, QuestionType, RunBlobId,
RunControlAction, RunEvent, RunId, ServerSettings, SessionCapability, StageModelUsage,
PairMessageId, PairTarget, PendingReason, Principal, PullRequestLink, QuestionType, RunBlobId,
RunControlAction, RunEvent, RunId, RunRunnableSource, ServerSettings, SessionCapability,
StageModelUsage,
};
use fabro_util::error::{
SharedError, collect_causes, render_compact_with_causes, render_with_causes,
@ -215,7 +216,6 @@ struct ManagedRun {
status: RunStatus,
error: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
enqueued_at: Instant,
// Populated when running:
answer_transport: Option<RunAnswerTransport>,
accepted_questions: HashSet<String>,
@ -2450,12 +2450,12 @@ fn remove_run_dir(run_dir: &std::path::Path) -> std::io::Result<()> {
#[cfg(test)]
fn compute_queue_positions(runs: &HashMap<RunId, ManagedRun>) -> HashMap<RunId, i64> {
let mut queued: Vec<(&RunId, &ManagedRun)> = runs
let mut runnable: Vec<(&RunId, &ManagedRun)> = runs
.iter()
.filter(|(_, r)| r.status == RunStatus::Queued)
.filter(|(_, r)| r.status == RunStatus::Runnable)
.collect();
queued.sort_by_key(|(_, r)| r.created_at);
queued
runnable.sort_by_key(|(_, r)| r.created_at);
runnable
.into_iter()
.enumerate()
.map(|(i, (id, _))| (*id, i64::try_from(i + 1).unwrap()))
@ -2631,8 +2631,7 @@ fn failure_for_incomplete_run(
fn should_reconcile_run_on_startup(status: RunStatus) -> bool {
matches!(
status,
RunStatus::Queued
| RunStatus::Starting
RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. }
@ -2879,7 +2878,6 @@ fn managed_run(
status,
error: None,
created_at,
enqueued_at: Instant::now(),
answer_transport: None,
accepted_questions: HashSet::new(),
active_api_targets: HashMap::new(),
@ -2943,7 +2941,12 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
match &event.body {
EventBody::RunSubmitted(_) => managed_run.status = RunStatus::Submitted,
EventBody::RunQueued(_) => managed_run.status = RunStatus::Queued,
EventBody::RunPending(props) => {
managed_run.status = RunStatus::Pending {
reason: props.reason,
};
}
EventBody::RunRunnable(_) => managed_run.status = RunStatus::Runnable,
EventBody::RunStarting(_) => managed_run.status = RunStatus::Starting,
EventBody::RunRunning(_) => managed_run.status = RunStatus::Running,
EventBody::RunBlocked(props) => {
@ -3430,7 +3433,7 @@ fn answer_from_request(
}
}
/// Execute a single run: transitions queued → starting → running →
/// Execute a single run: transitions runnable → starting → running →
/// completed/failed/cancelled.
async fn execute_run(state: Arc<AppState>, run_id: RunId) {
if state.is_shutting_down() {
@ -3447,10 +3450,10 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
// Transition to Starting and set up cancel infrastructure
let (cancel_rx, run_dir, event_tx, cancel_token, execution_mode, queued_for) = {
let (cancel_rx, run_dir, event_tx, cancel_token, execution_mode) = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
let managed_run = match runs.get_mut(&run_id) {
Some(r) if r.status == RunStatus::Queued => r,
Some(r) if r.status == RunStatus::Runnable => r,
_ => return,
};
let Some(run_dir) = managed_run.run_dir.clone() else {
@ -3472,10 +3475,8 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
managed_run.event_tx.clone(),
cancel_token,
managed_run.execution_mode,
managed_run.enqueued_at.elapsed(),
)
};
let _ = queued_for;
// Create interviewer and event plumbing (this is the "provisioning" phase)
let interviewer = Arc::new(ControlInterviewer::new());
@ -3741,7 +3742,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
return;
}
let managed_run = match runs.get_mut(&run_id) {
Some(run) if run.status == RunStatus::Queued => run,
Some(run) if run.status == RunStatus::Runnable => run,
_ => return,
};
let Some(run_dir) = managed_run.run_dir.clone() else {
@ -4005,7 +4006,7 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
state.scheduler_notify.notify_one();
}
/// Background task that promotes queued runs when capacity is available.
/// Background task that promotes runnable runs when capacity is available.
pub fn spawn_scheduler(state: Arc<AppState>) {
tokio::spawn(async move {
loop {
@ -4016,43 +4017,45 @@ pub fn spawn_scheduler(state: Arc<AppState>) {
if state.is_shutting_down() {
break;
}
// Promote as many queued runs as capacity allows
loop {
let runs_to_start = {
let runs = state.runs.lock().expect("runs lock poisoned");
let active = runs
.values()
.filter(|r| {
matches!(
r.status,
RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. }
)
})
.count();
let available = state.max_concurrent_runs.saturating_sub(active);
if available == 0 {
Vec::new()
} else {
let mut runnable: Vec<_> = runs
.iter()
.filter(|(_, r)| r.status == RunStatus::Runnable)
.map(|(id, r)| (*id, r.created_at))
.collect();
runnable.sort_by_key(|(_, created_at)| *created_at);
runnable
.into_iter()
.take(available)
.map(|(id, _)| id)
.collect::<Vec<_>>()
}
};
for id in runs_to_start {
if state.is_shutting_down() {
break;
}
let run_to_start = {
let runs = state.runs.lock().expect("runs lock poisoned");
let active = runs
.values()
.filter(|r| {
matches!(
r.status,
RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
| RunStatus::Paused { .. }
)
})
.count();
if active >= state.max_concurrent_runs {
break;
}
runs.iter()
.filter(|(_, r)| r.status == RunStatus::Queued)
.min_by_key(|(_, r)| r.created_at)
.map(|(id, _)| *id)
};
match run_to_start {
Some(id) => {
let state_clone = Arc::clone(&state);
tokio::spawn(
execute_run(state_clone, id)
.instrument(tracing::info_span!("run", id = %id)),
);
}
None => break,
}
let state_clone = Arc::clone(&state);
tokio::spawn(
execute_run(state_clone, id).instrument(tracing::info_span!("run", id = %id)),
);
}
}
});

View file

@ -685,6 +685,10 @@ mod stage_events_tests {
workflow_event::Event::RunSubmitted {
definition_blob: None,
},
workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
},
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
] {

View file

@ -3,13 +3,14 @@ use std::sync::Arc;
use chrono::Utc;
use super::super::{
ApiError, AppState, FailureReason, ForkRequest, ForkResponse, HeaderMap, IntoResponse, Json,
Path, Principal, RequireRunScopedOrRunTools, RequiredUser, Response, RewindRequest,
RewindResponse, Router, RunAnswerTransport, RunControlAction, RunExecutionMode, RunId,
RunStatus, StartRunRequest, State, StatusCode, Storage, TimelineEntryResponse,
WORKER_CANCEL_GRACE, WorkflowError, append_control_request, durable_run_status, get,
load_pending_control, managed_run, operations, parse_run_id_path, persist_cancelled_run_status,
post, reject_if_archived, sleep, update_live_run_from_event, workflow_event,
ApiError, AppState, DenyRunRequest, FailureReason, ForkRequest, ForkResponse, HeaderMap,
IntoResponse, Json, Path, PendingReason, Principal, RequireRunScopedOrRunTools, RequiredUser,
Response, RewindRequest, RewindResponse, Router, RunAnswerTransport, RunControlAction,
RunExecutionMode, RunId, RunRunnableSource, RunStatus, StartRunRequest, State, StatusCode,
Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError, append_control_request,
clear_live_run_state, durable_run_status, get, load_pending_control, managed_run, operations,
parse_run_id_path, persist_cancelled_run_status, post, reject_if_archived, sleep,
update_live_run_from_event, workflow_event,
};
use super::runs::run_provenance;
@ -17,6 +18,8 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs/{id}/cancel", post(cancel_run))
.route("/runs/{id}/start", post(start_run))
.route("/runs/{id}/approve", post(approve_run))
.route("/runs/{id}/deny", post(deny_run))
.route("/runs/{id}/pause", post(pause_run))
.route("/runs/{id}/unpause", post(unpause_run))
.route("/runs/{id}/archive", post(archive_run))
@ -40,7 +43,7 @@ async fn run_response(state: &AppState, id: RunId, status: StatusCode) -> Respon
}
async fn start_run(
RequireRunScopedOrRunTools(id, _actor): RequireRunScopedOrRunTools,
RequireRunScopedOrRunTools(id, actor): RequireRunScopedOrRunTools,
State(state): State<Arc<AppState>>,
body: Option<Json<StartRunRequest>>,
) -> Response {
@ -49,19 +52,25 @@ async fn start_run(
}
let resume = body.is_some_and(|Json(req)| req.resume);
match queue_run_start(state.as_ref(), id, resume).await {
match queue_run_start(state.as_ref(), id, resume, actor).await {
Ok(()) => run_response(state.as_ref(), id, StatusCode::OK).await,
Err(err) => err.into_response(),
}
}
async fn queue_run_start(state: &AppState, id: RunId, resume: bool) -> Result<(), ApiError> {
async fn queue_run_start(
state: &AppState,
id: RunId,
resume: bool,
actor: Principal,
) -> Result<(), ApiError> {
{
let runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get(&id) {
if matches!(
managed_run.status,
RunStatus::Queued
RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
@ -71,6 +80,11 @@ async fn queue_run_start(state: &AppState, id: RunId, resume: bool) -> Result<()
StatusCode::CONFLICT,
if resume {
"an engine process is still running for this run — cannot resume"
} else if matches!(
managed_run.status,
RunStatus::Pending { .. } | RunStatus::Runnable
) {
"start has already been requested for this run"
} else {
"an engine process is still running for this run — cannot start"
},
@ -101,10 +115,7 @@ async fn queue_run_start(state: &AppState, id: RunId, resume: bool) -> Result<()
}
} else {
let status = run_state.status;
if !matches!(
status,
RunStatus::Submitted | RunStatus::Queued | RunStatus::Starting
) {
if !matches!(status, RunStatus::Submitted) {
return Err(ApiError::new(
StatusCode::CONFLICT,
format!("cannot start run: status is {status}, expected submitted"),
@ -117,14 +128,45 @@ async fn queue_run_start(state: &AppState, id: RunId, resume: bool) -> Result<()
.root()
.to_path_buf();
let dot_source = run_state.spec.graph_source.clone().unwrap_or_default();
let approval_required = !resume
&& matches!(
&actor,
Principal::Worker { run_id } if run_state.parent_id == Some(*run_id)
);
if let Err(err) =
workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunQueued).await
workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunStartRequested {
resume,
actor: Some(actor.clone()),
})
.await
{
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
));
}
let (next_status, next_event) = if approval_required {
(
RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
},
workflow_event::Event::RunPending {
reason: PendingReason::ApprovalRequired,
actor: Some(actor),
},
)
} else {
(RunStatus::Runnable, workflow_event::Event::RunRunnable {
source: RunRunnableSource::StartRequested,
actor: Some(actor),
})
};
if let Err(err) = workflow_event::append_event(&run_store, &id, &next_event).await {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
));
}
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
@ -132,7 +174,7 @@ async fn queue_run_start(state: &AppState, id: RunId, resume: bool) -> Result<()
id,
managed_run(
dot_source,
RunStatus::Queued,
next_status,
id.created_at(),
run_dir,
if resume {
@ -144,10 +186,160 @@ async fn queue_run_start(state: &AppState, id: RunId, resume: bool) -> Result<()
);
}
state.scheduler_notify.notify_one();
if !approval_required {
state.scheduler_notify.notify_one();
}
Ok(())
}
async fn approve_run(
RequiredUser(user): RequiredUser,
Path(id): Path<String>,
State(state): State<Arc<AppState>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let Ok(run_store) = state.store.open_run(&id).await else {
return ApiError::not_found("Run not found.").into_response();
};
let run_state = match run_store.state().await {
Ok(state) => state,
Err(err) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load run state: {err}"),
)
.into_response();
}
};
if !matches!(run_state.status, RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
}) {
return ApiError::new(StatusCode::CONFLICT, "Run is not pending approval.").into_response();
}
let actor = Some(Principal::User(user));
for event in [
workflow_event::Event::RunApproved {
actor: actor.clone(),
},
workflow_event::Event::RunRunnable {
source: RunRunnableSource::Approved,
actor,
},
] {
if let Err(err) = workflow_event::append_event(&run_store, &id, &event).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
}
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&id) {
managed_run.status = RunStatus::Runnable;
} else {
let run_dir = Storage::new(state.server_storage_dir())
.run_scratch(&id)
.root()
.to_path_buf();
let dot_source = run_state.spec.graph_source.clone().unwrap_or_default();
runs.insert(
id,
managed_run(
dot_source,
RunStatus::Runnable,
id.created_at(),
run_dir,
RunExecutionMode::Start,
),
);
}
}
state.scheduler_notify.notify_one();
run_response(state.as_ref(), id, StatusCode::OK).await
}
async fn deny_run(
RequiredUser(user): RequiredUser,
Path(id): Path<String>,
State(state): State<Arc<AppState>>,
body: Option<Json<DenyRunRequest>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let reason = body
.and_then(|Json(req)| req.reason)
.map(|reason| reason.trim().to_string())
.filter(|reason| !reason.is_empty());
let message = reason
.clone()
.unwrap_or_else(|| "Not approved for execution".to_string());
let Ok(run_store) = state.store.open_run(&id).await else {
return ApiError::not_found("Run not found.").into_response();
};
let run_state = match run_store.state().await {
Ok(state) => state,
Err(err) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to load run state: {err}"),
)
.into_response();
}
};
if !matches!(run_state.status, RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
}) {
return ApiError::new(StatusCode::CONFLICT, "Run is not pending approval.").into_response();
}
let actor = Some(Principal::User(user));
let denied_event = workflow_event::Event::RunDenied {
reason: reason.clone(),
actor,
};
if let Err(err) = workflow_event::append_event(&run_store, &id, &denied_event).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
let failure_event = workflow_event::Event::workflow_run_failed_from_error(
&WorkflowError::engine(message.clone()),
fabro_types::RunTiming::default(),
FailureReason::ApprovalDenied,
None,
None,
None,
None,
);
if let Err(err) = workflow_event::append_event(&run_store, &id, &failure_event).await {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
if let Some(managed_run) = runs.get_mut(&id) {
managed_run.status = RunStatus::Failed {
reason: FailureReason::ApprovalDenied,
};
managed_run.error = Some(message);
clear_live_run_state(managed_run);
}
}
run_response(state.as_ref(), id, StatusCode::OK).await
}
fn schedule_worker_kill(state: Arc<AppState>, run_id: RunId, worker_pid: u32) {
tokio::spawn(async move {
sleep(WORKER_CANCEL_GRACE).await;
@ -181,7 +373,8 @@ async fn cancel_run(
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
RunStatus::Submitted
| RunStatus::Queued
| RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
@ -190,8 +383,10 @@ async fn cancel_run(
managed_run.answer_transport,
Some(RunAnswerTransport::InProcess { .. })
);
let persist_cancelled_status =
matches!(managed_run.status, RunStatus::Submitted | RunStatus::Queued);
let persist_cancelled_status = matches!(
managed_run.status,
RunStatus::Submitted | RunStatus::Pending { .. } | RunStatus::Runnable
);
if persist_cancelled_status {
managed_run.status = RunStatus::Failed {
reason: FailureReason::Cancelled,
@ -218,7 +413,7 @@ async fn cancel_run(
let Some((persist_cancelled_status, answer_transport, cancel_token, cancel_tx, worker_pid)) =
cancel_target
else {
return unmanaged_cancel_response(state.as_ref(), id).await;
return unmanaged_cancel_response(state.as_ref(), id, actor, pending_control).await;
};
if pending_control != Some(RunControlAction::Cancel) {
@ -261,13 +456,33 @@ async fn cancel_run(
run_response(state.as_ref(), id, StatusCode::OK).await
}
async fn unmanaged_cancel_response(state: &AppState, id: RunId) -> Response {
async fn unmanaged_cancel_response(
state: &AppState,
id: RunId,
actor: Principal,
pending_control: Option<RunControlAction>,
) -> Response {
match durable_run_status(state, id).await {
Ok(Some(status)) if status.is_terminal() => ApiError::new(
StatusCode::CONFLICT,
"Run is already terminal and cannot be cancelled.",
)
.into_response(),
Ok(Some(RunStatus::Submitted | RunStatus::Pending { .. } | RunStatus::Runnable)) => {
if pending_control != Some(RunControlAction::Cancel) {
if let Err(err) =
append_control_request(state, id, RunControlAction::Cancel, Some(actor)).await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
}
match persist_cancelled_run_status(state, id).await {
Ok(()) => run_response(state, id, StatusCode::OK).await,
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response(),
}
}
Ok(Some(_)) => {
ApiError::new(StatusCode::CONFLICT, "Run is not cancellable.").into_response()
}
@ -587,7 +802,7 @@ async fn retry_run(
match Box::pin(operations::retry_run(&state.store, &input)).await {
Ok(outcome) => {
let new_run_id = outcome.new_run_id;
if let Err(err) = queue_run_start(state.as_ref(), new_run_id, false).await {
if let Err(err) = queue_run_start(state.as_ref(), new_run_id, false, actor).await {
return err.into_response();
}
run_response(state.as_ref(), new_run_id, StatusCode::CREATED).await

View file

@ -51,6 +51,8 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/checkpoint", get(demo::checkpoint_stub))
.route("/runs/{id}/cancel", post(demo::cancel_stub))
.route("/runs/{id}/start", post(demo::start_run_stub))
.route("/runs/{id}/approve", post(demo::start_run_stub))
.route("/runs/{id}/deny", post(demo::deny_run_stub))
.route("/runs/{id}/pause", post(demo::pause_stub))
.route("/runs/{id}/unpause", post(demo::unpause_stub))
.route("/runs/{id}/graph", get(demo::get_run_graph))

View file

@ -497,7 +497,8 @@ fn reject_unpairable_status(status: RunStatus) -> Result<(), Response> {
"run_not_pairable",
)),
RunStatus::Submitted
| RunStatus::Queued
| RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Paused { .. }
| RunStatus::Failed { .. }

View file

@ -137,7 +137,8 @@ pub(crate) fn board_column(status: RunStatus, archived: bool) -> BoardColumn {
return BoardColumn::Archived;
}
match status {
RunStatus::Submitted | RunStatus::Queued => BoardColumn::Queued,
RunStatus::Submitted | RunStatus::Pending { .. } => BoardColumn::Pending,
RunStatus::Runnable => BoardColumn::Runnable,
RunStatus::Starting => BoardColumn::Initializing,
RunStatus::Running | RunStatus::Paused { .. } => BoardColumn::Running,
RunStatus::Blocked { .. } => BoardColumn::Blocked,

View file

@ -86,7 +86,8 @@ async fn control_run(
.into_response();
}
RunStatus::Submitted
| RunStatus::Queued
| RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Paused { .. } => {
return ApiError::with_code(

View file

@ -51,7 +51,8 @@ async fn get_system_info(_auth: RequiredUser, State(state): State<Arc<AppState>>
.filter(|run| {
matches!(
run.status,
RunStatus::Queued
RunStatus::Pending { .. }
| RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }

View file

@ -2882,21 +2882,52 @@ async fn create_durable_run_with_events(
let has_starting = events
.iter()
.any(|event| matches!(event, workflow_event::Event::RunStarting));
let has_runnable = events
.iter()
.any(|event| matches!(event, workflow_event::Event::RunRunnable { .. }));
let has_running = events
.iter()
.any(|event| matches!(event, workflow_event::Event::RunRunning));
let mut inserted_runnable = has_runnable;
let mut inserted_starting = has_starting;
for event in events {
if needs_running
&& !has_starting
if !inserted_runnable
&& matches!(
event,
workflow_event::Event::WorkflowRunCompleted { .. }
workflow_event::Event::RunStarting
| workflow_event::Event::RunRunning
| workflow_event::Event::RunBlocked { .. }
| workflow_event::Event::RunPaused
| workflow_event::Event::WorkflowRunCompleted { .. }
| workflow_event::Event::WorkflowRunFailed { .. }
)
{
workflow_event::append_event(
&run_store,
&run_id,
&workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
},
)
.await
.unwrap();
inserted_runnable = true;
}
if !inserted_starting
&& matches!(
event,
workflow_event::Event::RunRunning
| workflow_event::Event::RunBlocked { .. }
| workflow_event::Event::RunPaused
| workflow_event::Event::WorkflowRunCompleted { .. }
| workflow_event::Event::WorkflowRunFailed { .. }
)
{
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunStarting)
.await
.unwrap();
inserted_starting = true;
}
if needs_running
&& !has_running
@ -3140,6 +3171,12 @@ channel = "#deploys"
Some("Deploy workflow"),
);
let run_store = create_slack_notification_run(&state, run_id, settings, "deploy", None).await;
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunStarting)
.await
.unwrap();
@ -3198,6 +3235,12 @@ channel = "#deploys"
Some("Deploy workflow"),
);
let run_store = create_slack_notification_run(&state, run_id, settings, "deploy", None).await;
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunStarting)
.await
.unwrap();
@ -3354,6 +3397,12 @@ channel = "#deploys"
Some("Deploy workflow"),
);
let run_store = create_slack_notification_run(&state, run_id, settings, "deploy", None).await;
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunStarting)
.await
.unwrap();
@ -4654,6 +4703,12 @@ async fn append_raw_run_event(
async fn create_unreadable_durable_run(state: &Arc<AppState>, run_id: RunId) {
let run_store = state.store.create_run(&run_id).await.unwrap();
append_default_run_created(&run_store, run_id).await;
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunStarting)
.await
.unwrap();
@ -7069,6 +7124,12 @@ async fn cache_backed_run_endpoints_reflect_events_appended_after_warmup() {
state.store.warm_projection_cache().await.unwrap();
let run_store = state.store.open_run(&run_id).await.unwrap();
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
workflow_event::append_event(&run_store, &run_id, &workflow_event::Event::RunStarting)
.await
.unwrap();
@ -8509,7 +8570,7 @@ async fn create_run_rejects_invalid_titles() {
}
#[tokio::test]
async fn start_run_transitions_to_queued() {
async fn start_run_transitions_to_runnable() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
@ -8532,7 +8593,7 @@ async fn start_run_transitions_to_queued() {
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(run_json_status(&body)["kind"], "queued");
assert_eq!(run_json_status(&body)["kind"], "runnable");
assert_eq!(body["title"], "Test");
let status = state
@ -8544,7 +8605,153 @@ async fn start_run_transitions_to_queued() {
.await
.unwrap()
.status;
assert_eq!(status, RunStatus::Queued);
assert_eq!(status, RunStatus::Runnable);
}
#[tokio::test]
async fn worker_started_child_run_requires_approval_before_becoming_runnable() {
let (state, app) = jwt_auth_app();
let user_jwt = issue_test_user_jwt();
let parent_run_id = create_run_with_bearer(&app, &user_jwt).await;
let worker_token = issue_test_run_tools_worker_token(&parent_run_id);
let mut child_manifest = minimal_manifest_json(MINIMAL_DOT);
child_manifest["parent_id"] = json!(parent_run_id.to_string());
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
"/runs",
&worker_token,
&child_manifest,
))
.await
.unwrap();
let child_body = response_json!(response, StatusCode::CREATED).await;
let child_run_id = child_body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
&format!("/runs/{child_run_id}/start"),
&worker_token,
&json!({ "resume": false }),
))
.await
.unwrap();
let pending_body = response_json!(response, StatusCode::OK).await;
assert_eq!(
run_json_status(&pending_body),
&json!({
"kind": "pending",
"reason": "approval_required"
})
);
assert_eq!(
pending_body["lifecycle"]["approval"]["state"].as_str(),
Some("pending")
);
{
let runs = state.runs.lock().expect("runs lock poisoned");
assert_eq!(
runs.get(&child_run_id).map(|run| run.status),
Some(RunStatus::Pending {
reason: fabro_types::PendingReason::ApprovalRequired,
})
);
}
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
&format!("/runs/{child_run_id}/approve"),
&user_jwt,
Body::empty(),
))
.await
.unwrap();
let approved_body = response_json!(response, StatusCode::OK).await;
assert_eq!(
run_json_status(&approved_body),
&json!({ "kind": "runnable" })
);
assert_eq!(
approved_body["lifecycle"]["approval"]["state"].as_str(),
Some("approved")
);
assert!(
approved_body["lifecycle"]["approval"]["decided_at"]
.as_str()
.is_some()
);
let runs = state.runs.lock().expect("runs lock poisoned");
assert_eq!(
runs.get(&child_run_id).map(|run| run.status),
Some(RunStatus::Runnable)
);
}
#[tokio::test]
async fn denying_pending_child_run_fails_with_approval_denied() {
let (_state, app) = jwt_auth_app();
let user_jwt = issue_test_user_jwt();
let parent_run_id = create_run_with_bearer(&app, &user_jwt).await;
let worker_token = issue_test_run_tools_worker_token(&parent_run_id);
let mut child_manifest = minimal_manifest_json(MINIMAL_DOT);
child_manifest["parent_id"] = json!(parent_run_id.to_string());
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
"/runs",
&worker_token,
&child_manifest,
))
.await
.unwrap();
let child_body = response_json!(response, StatusCode::CREATED).await;
let child_run_id = child_body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
&format!("/runs/{child_run_id}/start"),
&worker_token,
&json!({ "resume": false }),
))
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
let response = app
.clone()
.oneshot(json_bearer_request(
Method::POST,
&format!("/runs/{child_run_id}/deny"),
&user_jwt,
&json!({ "reason": " " }),
))
.await
.unwrap();
let denied_body = response_json!(response, StatusCode::OK).await;
assert_eq!(
run_json_status(&denied_body),
&json!({
"kind": "failed",
"reason": "approval_denied"
})
);
assert_eq!(
denied_body["lifecycle"]["approval"]["state"].as_str(),
Some("denied")
);
assert!(denied_body["lifecycle"]["approval"]["denial_reason"].is_null());
}
#[tokio::test]
@ -8605,7 +8812,10 @@ async fn patch_run_title_updates_active_and_archived_runs() {
let run_store = state.store.open_run(&run_id).await.unwrap();
for event in [
workflow_event::Event::RunQueued,
workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
},
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
] {
@ -8699,7 +8909,7 @@ async fn start_run_conflict_when_not_submitted() {
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap();
// Start it (transitions to queued)
// Start it (transitions to runnable)
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/start")))
@ -8764,7 +8974,7 @@ async fn retry_failed_run_creates_and_queues_new_run() {
assert_eq!(body["retried_from"], source_run_id.to_string());
assert_eq!(body["created_by"]["kind"], "user");
assert_eq!(body["created_by"]["login"], "dev");
assert_eq!(run_json_status(&body)["kind"], "queued");
assert_eq!(run_json_status(&body)["kind"], "runnable");
let source_store = state.store.open_run(&source_run_id).await.unwrap();
assert_eq!(
@ -8787,7 +8997,7 @@ async fn retry_failed_run_creates_and_queues_new_run() {
.await
.unwrap();
assert_eq!(new_state.retried_from, Some(source_run_id));
assert_eq!(new_state.status, RunStatus::Queued);
assert_eq!(new_state.status, RunStatus::Runnable);
assert!(new_state.checkpoints.is_empty());
}
@ -10593,7 +10803,7 @@ level = "debug"
}
#[tokio::test]
async fn cancel_queued_run_succeeds() {
async fn cancel_runnable_run_succeeds() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
@ -10741,7 +10951,7 @@ async fn pause_run_sets_pending_control_on_board_response() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(run_json_status(&body)["kind"], "queued");
assert_eq!(run_json_status(&body)["kind"], "runnable");
assert_eq!(run_json_pending_control(&body).as_str(), Some("pause"));
// Verify pending_control via /runs/{id} (board no longer includes this field)
@ -10754,7 +10964,7 @@ async fn pause_run_sets_pending_control_on_board_response() {
let body = body_json(response.into_body()).await;
assert_eq!(run_json_pending_control(&body).as_str(), Some("pause"));
// Verify the run appears in the runs list (store has Submitted status)
// Verify the run appears in the runs list with runnable status.
let req = Request::builder()
.method("GET")
.uri(api("/runs"))
@ -10861,7 +11071,7 @@ async fn unpause_run_sets_pending_control() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(run_json_status(&body)["kind"], "queued");
assert_eq!(run_json_status(&body)["kind"], "runnable");
assert_eq!(run_json_pending_control(&body).as_str(), Some("unpause"));
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
@ -11130,7 +11340,7 @@ id = "local"
if matches!(
live_status_before_cancel,
Some(
RunStatus::Queued
RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
@ -11145,7 +11355,7 @@ id = "local"
matches!(
live_status_before_cancel,
Some(
RunStatus::Queued
RunStatus::Runnable
| RunStatus::Starting
| RunStatus::Running
| RunStatus::Blocked { .. }
@ -11242,15 +11452,15 @@ async fn cancel_before_run_transitions_to_running_returns_empty_attach_stream()
}
#[tokio::test]
async fn queue_position_reported_for_queued_runs() {
async fn queue_position_reported_for_runnable_runs() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
// Create and start two runs (no scheduler, both stay queued)
// Create and start two runs (no scheduler, both stay runnable)
let first_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
let second_run_id = create_and_start_run(&app, MINIMAL_DOT).await;
// Queue position is tracked in memory even when queued runs are also
// Queue position is tracked in memory even when runnable runs are also
// visible on the board.
let runs = state.runs.lock().expect("runs lock poisoned");
let positions = compute_queue_positions(&runs);
@ -11292,7 +11502,7 @@ async fn concurrency_limit_respected() {
}
#[tokio::test]
async fn submit_answer_to_queued_run_returns_conflict() {
async fn submit_answer_to_unstarted_run_returns_conflict() {
let state = test_app_state();
let app = crate::test_support::build_test_router(state);
@ -11307,7 +11517,7 @@ async fn submit_answer_to_queued_run_returns_conflict() {
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().to_string();
// Try to submit an answer to a queued run
// Try to submit an answer to a run with no active worker.
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/questions/q1/answer")))
@ -12151,9 +12361,9 @@ async fn list_runs_status_filter_accepts_repeated_values() {
])
.await;
// Queued run (BoardColumn::Queued via Submitted)
let queued_id = fixtures::RUN_3;
create_durable_run_with_events(&state, queued_id, &[workflow_event::Event::RunSubmitted {
// Pending run (BoardColumn::Pending via Submitted)
let pending_id = fixtures::RUN_3;
create_durable_run_with_events(&state, pending_id, &[workflow_event::Event::RunSubmitted {
definition_blob: None,
}])
.await;
@ -12174,7 +12384,7 @@ async fn list_runs_status_filter_accepts_repeated_values() {
.collect();
assert!(ids.contains(&running_id.to_string().as_str()));
assert!(!ids.contains(&succeeded_id.to_string().as_str()));
assert!(!ids.contains(&queued_id.to_string().as_str()));
assert!(!ids.contains(&pending_id.to_string().as_str()));
// Repeated values: running + succeeded.
let req = Request::builder()
@ -12192,7 +12402,7 @@ async fn list_runs_status_filter_accepts_repeated_values() {
.collect();
assert!(ids.contains(&running_id.to_string().as_str()));
assert!(ids.contains(&succeeded_id.to_string().as_str()));
assert!(!ids.contains(&queued_id.to_string().as_str()));
assert!(!ids.contains(&pending_id.to_string().as_str()));
}
#[tokio::test]
@ -12258,10 +12468,11 @@ async fn list_runs_sort_by_status_groups_by_bucket() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
// BoardColumn enum order: queued < initializing < running < blocked <
// succeeded < failed < archived < removing. Use three distinct buckets.
let queued_id = fixtures::RUN_1;
create_durable_run_with_events(&state, queued_id, &[workflow_event::Event::RunSubmitted {
// BoardColumn enum order: pending < runnable < initializing < running <
// blocked < succeeded < failed < archived < removing. Use three distinct
// buckets.
let pending_id = fixtures::RUN_1;
create_durable_run_with_events(&state, pending_id, &[workflow_event::Event::RunSubmitted {
definition_blob: None,
}])
.await;
@ -12297,7 +12508,7 @@ async fn list_runs_sort_by_status_groups_by_bucket() {
])
.await;
// sort=status asc: queued < running < succeeded.
// sort=status asc: pending < running < succeeded.
let req = Request::builder()
.method("GET")
.uri(api("/runs?sort=status&direction=asc"))
@ -12313,7 +12524,7 @@ async fn list_runs_sort_by_status_groups_by_bucket() {
.map(str::to_string)
.collect();
assert_eq!(observed, vec![
queued_id.to_string(),
pending_id.to_string(),
running_id.to_string(),
succeeded_id.to_string(),
]);
@ -12331,14 +12542,18 @@ async fn filtered_global_events_streams_only_matching_run_ids() {
.send(test_event_envelope(
1,
run_two,
EventBody::RunQueued(fabro_types::run_event::RunStatusEffectProps::default()),
EventBody::RunRunnable(fabro_types::run_event::RunRunnableProps {
source: fabro_types::RunRunnableSource::StartRequested,
}),
))
.unwrap();
event_tx
.send(test_event_envelope(
2,
run_one,
EventBody::RunQueued(fabro_types::run_event::RunStatusEffectProps::default()),
EventBody::RunRunnable(fabro_types::run_event::RunRunnableProps {
source: fabro_types::RunRunnableSource::StartRequested,
}),
))
.unwrap();
drop(event_tx);

View file

@ -78,6 +78,12 @@ async fn append_completed_run_with_final_patch(
})
.await
.expect("append RunCreated");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.expect("append RunRunnable");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunStarting)
.await
.expect("append RunStarting");

View file

@ -264,7 +264,7 @@ async fn test_app_state_with_options_respects_max_concurrent_runs() {
second_questions["data"]
.as_array()
.is_some_and(std::vec::Vec::is_empty),
"second run should still be queued while the first waits at the human gate: {second_questions}"
"second run should still be waiting for scheduler capacity while the first waits at the human gate: {second_questions}"
);
}

View file

@ -58,7 +58,7 @@ async fn sse_stream_contains_expected_event_types() {
create_and_start_run_from_manifest(&app, minimal_manifest_json_with_dry_run(SIMPLE_DOT))
.await;
wait_for_run_status_not_in(&app, &run_id, &["queued", "starting"]).await;
wait_for_run_status_not_in(&app, &run_id, &["runnable", "starting"]).await;
// Get SSE stream
let req = Request::builder()
@ -104,7 +104,7 @@ async fn sse_stream_contains_expected_event_types() {
}
// Because we subscribe while the run is only guaranteed to be past
// "queued", a live stream should include at least one stage event.
// "runnable", a live stream should include at least one stage event.
// If the run completes before we attach with no unread events, an empty
// stream is still a valid 200 response.
if !event_types.is_empty() {

View file

@ -10,12 +10,13 @@ use fabro_types::settings::run::{EnvironmentProvider, RunEnvironmentSettings};
use fabro_types::{
ActivatedSkill, AskFabro, BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination,
Conclusion, EventBody, FailureSignature, InterviewQuestionRecord, McpServerProjection,
McpServerStatus, Outcome, PendingInterviewRecord, PullRequestLink, RepositoryRef, Run,
RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks,
RunModel, RunOrigin, RunProjection, RunSandbox, RunSandboxRuntime, RunSize, RunSpec, RunStatus,
RunTimestamps, SandboxProvider, StageCompletion, StageHandler, StageId, StageModelUsage,
StageOutcome, StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus,
TodoListProjection, TodoProjection, WorkflowRef, first_event_seq,
McpServerStatus, Outcome, PendingInterviewRecord, PendingReason, PullRequestLink,
RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary, RunControlAction,
RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, RunProjection,
RunSandbox, RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, SandboxProvider,
StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, StageProjection,
StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListProjection,
TodoProjection, WorkflowRef, first_event_seq,
};
use fabro_util::error::render_compact_with_causes;
@ -71,8 +72,38 @@ impl RunProjectionReducer for RunProjection {
EventBody::RunSubmitted(props) => {
self.spec.definition_blob = props.definition_blob;
}
EventBody::RunQueued(_) => {
self.try_apply_status(RunStatus::Queued, ts)?;
EventBody::RunPending(props) => {
self.try_apply_status(
RunStatus::Pending {
reason: props.reason,
},
ts,
)?;
if props.reason == PendingReason::ApprovalRequired {
self.approval = Some(RunApproval {
state: RunApprovalState::Pending,
requested_at: ts,
decided_at: None,
denial_reason: None,
});
}
}
EventBody::RunApproved(_) => {
if let Some(approval) = &mut self.approval {
approval.state = RunApprovalState::Approved;
approval.decided_at = Some(ts);
approval.denial_reason = None;
}
}
EventBody::RunDenied(props) => {
if let Some(approval) = &mut self.approval {
approval.state = RunApprovalState::Denied;
approval.decided_at = Some(ts);
approval.denial_reason.clone_from(&props.reason);
}
}
EventBody::RunRunnable(_) => {
self.try_apply_status(RunStatus::Runnable, ts)?;
}
EventBody::RunStarting(_) => {
self.try_apply_status(RunStatus::Starting, ts)?;
@ -821,6 +852,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
labels: state.spec.labels.clone(),
lifecycle: RunLifecycle {
status: state.status,
approval: state.approval.clone(),
pending_control: state.pending_control,
queue_position: None,
error: None,
@ -1073,10 +1105,10 @@ mod tests {
use fabro_types::{
AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint,
CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail,
FailureReason, Graph, McpServerStatus, Outcome, PullRequestLink, QuestionType,
ReasoningEffort, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec,
RunStatus, Speed, StageModelUsage, StageOutcome, StageState, SubAgentStatus, SuccessReason,
WorkflowSettings, first_event_seq, fixtures,
FailureReason, Graph, McpServerStatus, Outcome, PendingReason, PullRequestLink,
QuestionType, ReasoningEffort, RunApprovalState, RunBlobId, RunControlAction, RunDiff,
RunEvent, RunSize, RunSpec, RunStatus, Speed, StageModelUsage, StageOutcome, StageState,
SubAgentStatus, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
};
use serde_json::json;
@ -1165,10 +1197,18 @@ mod tests {
fn running_projection() -> RunProjection {
let mut state = initialized_projection();
state
.apply_event(&test_raw_event(1, "run.starting", &json!({}), None))
.apply_event(&test_raw_event(
1,
"run.runnable",
&json!({ "source": "start_requested" }),
None,
))
.unwrap();
state
.apply_event(&test_raw_event(2, "run.running", &json!({}), None))
.apply_event(&test_raw_event(2, "run.starting", &json!({}), None))
.unwrap();
state
.apply_event(&test_raw_event(3, "run.running", &json!({}), None))
.unwrap();
state
}
@ -1285,6 +1325,15 @@ mod tests {
.apply_event(&test_raw_event_at(
2,
"2026-04-07T12:00:00Z",
"run.runnable",
&json!({ "source": "start_requested" }),
None,
))
.unwrap();
state
.apply_event(&test_raw_event_at(
3,
"2026-04-07T12:00:00Z",
"run.starting",
&json!({}),
None,
@ -1292,16 +1341,16 @@ mod tests {
.unwrap();
state
.apply_event(&test_raw_event_at(
3,
4,
"2026-04-07T12:00:01Z",
"run.running",
&json!({}),
None,
))
.unwrap();
state.stage_entry("plan", 1, first_event_seq(4)).timing =
state.stage_entry("plan", 1, first_event_seq(5)).timing =
Some(fabro_types::StageTiming::new(2_000, 700, 300));
state.stage_entry("code", 1, first_event_seq(5)).timing =
state.stage_entry("code", 1, first_event_seq(6)).timing =
Some(fabro_types::StageTiming::new(3_000, 50, 200));
let conclusion_timing = fabro_types::RunTiming::new(
@ -1315,7 +1364,7 @@ mod tests {
500,
);
let mut completed = test_event(
6,
7,
EventBody::RunCompleted(RunCompletedProps {
timing: conclusion_timing,
artifact_count: 0,
@ -1345,7 +1394,13 @@ mod tests {
#[test]
fn last_event_at_tracks_most_recent_event_timestamp() {
let mut state = initialized_projection();
let later = test_raw_event_at(2, "2026-04-20T12:05:30Z", "run.starting", &json!({}), None);
let later = test_raw_event_at(
2,
"2026-04-20T12:05:30Z",
"run.start_requested",
&json!({ "resume": false }),
None,
);
state.apply_event(&later).unwrap();
@ -2055,30 +2110,58 @@ mod tests {
}
#[test]
fn queued_and_blocked_events_drive_projection_and_summary_fields() {
fn pending_runnable_and_blocked_events_drive_projection_and_summary_fields() {
let mut state = initialized_projection();
state
.apply_event(&test_raw_event(1, "run.queued", &json!({}), None))
.apply_event(&test_raw_event(
1,
"run.pending",
&json!({ "reason": "approval_required" }),
None,
))
.unwrap();
assert_eq!(state.status(), RunStatus::Queued);
assert_eq!(state.status(), RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
});
assert_eq!(
state.approval.as_ref().map(|approval| approval.state),
Some(RunApprovalState::Pending)
);
state
.apply_event(&test_raw_event(2, "run.starting", &json!({}), None))
.apply_event(&test_raw_event(2, "run.approved", &json!({}), None))
.unwrap();
state
.apply_event(&test_raw_event(3, "run.running", &json!({}), None))
.apply_event(&test_raw_event(
3,
"run.runnable",
&json!({ "source": "approved" }),
None,
))
.unwrap();
assert_eq!(state.status(), RunStatus::Runnable);
assert_eq!(
state.approval.as_ref().map(|approval| approval.state),
Some(RunApprovalState::Approved)
);
state
.apply_event(&test_raw_event(4, "run.starting", &json!({}), None))
.unwrap();
state
.apply_event(&test_raw_event(5, "run.running", &json!({}), None))
.unwrap();
state
.apply_event(&test_event(
4,
6,
EventBody::RunPaused(RunControlEffectProps::default()),
None,
))
.unwrap();
state
.apply_event(&test_raw_event(
5,
7,
"run.blocked",
&json!({ "blocked_reason": "human_input_required" }),
None,
@ -2106,6 +2189,133 @@ mod tests {
"prior_block": "human_input_required"
})
);
assert_eq!(
summary_json["lifecycle"]["approval"]["state"],
json!("approved")
);
}
#[test]
fn approval_denial_projection_records_decision_then_failure() {
let mut state = initialized_projection();
state
.apply_event(&test_raw_event_at(
1,
"2026-05-23T12:00:00Z",
"run.start_requested",
&json!({ "resume": false }),
None,
))
.unwrap();
assert_eq!(state.status(), RunStatus::Submitted);
assert!(state.approval.is_none());
state
.apply_event(&test_raw_event_at(
2,
"2026-05-23T12:00:01Z",
"run.pending",
&json!({ "reason": "approval_required" }),
None,
))
.unwrap();
let approval = state.approval.as_ref().expect("approval should be pending");
assert_eq!(state.status(), RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
});
assert_eq!(approval.state, RunApprovalState::Pending);
assert_eq!(
approval.requested_at.to_rfc3339(),
"2026-05-23T12:00:01+00:00"
);
assert_eq!(approval.decided_at, None);
state
.apply_event(&test_raw_event_at(
3,
"2026-05-23T12:00:02Z",
"run.denied",
&json!({ "reason": "Not approved for execution" }),
None,
))
.unwrap();
let approval = state.approval.as_ref().expect("approval should be denied");
assert_eq!(state.status(), RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
});
assert_eq!(approval.state, RunApprovalState::Denied);
assert_eq!(
approval.denial_reason.as_deref(),
Some("Not approved for execution")
);
assert_eq!(
approval.decided_at.map(|ts| ts.to_rfc3339()).as_deref(),
Some("2026-05-23T12:00:02+00:00")
);
state
.apply_event(&test_raw_event(
4,
"run.failed",
&json!({
"failure": {
"reason": "approval_denied",
"detail": {
"message": "Not approved for execution",
"category": "deterministic"
}
},
"timing": {
"wall_time_ms": 0,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
}
}),
None,
))
.unwrap();
assert_eq!(state.status(), RunStatus::Failed {
reason: FailureReason::ApprovalDenied,
});
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
assert_eq!(
summary_json["lifecycle"]["approval"],
json!({
"state": "denied",
"requested_at": "2026-05-23T12:00:01Z",
"decided_at": "2026-05-23T12:00:02Z",
"denial_reason": "Not approved for execution"
})
);
assert_eq!(
summary_json["lifecycle"]["status"],
json!({ "kind": "failed", "reason": "approval_denied" })
);
}
#[test]
fn runnable_projection_without_approval_has_null_summary_approval() {
let mut state = initialized_projection();
state
.apply_event(&test_raw_event(
1,
"run.runnable",
&json!({ "source": "start_requested" }),
None,
))
.unwrap();
assert_eq!(state.status(), RunStatus::Runnable);
assert!(state.approval.is_none());
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
assert_eq!(
summary_json["lifecycle"]["status"],
json!({ "kind": "runnable" })
);
assert!(summary_json["lifecycle"]["approval"].is_null());
}
#[test]
@ -2453,14 +2663,7 @@ mod tests {
#[test]
fn patch_bearing_events_roll_up_diff_summary_without_blanking_prior_value() {
let mut state = initialized_projection();
state
.apply_event(&test_raw_event(1, "run.starting", &json!({}), None))
.unwrap();
state
.apply_event(&test_raw_event(2, "run.running", &json!({}), None))
.unwrap();
let mut state = running_projection();
state
.apply_event(&test_raw_event(
3,
@ -2531,13 +2734,7 @@ mod tests {
})
);
let mut failed_state = initialized_projection();
failed_state
.apply_event(&test_raw_event(1, "run.starting", &json!({}), None))
.unwrap();
failed_state
.apply_event(&test_raw_event(2, "run.running", &json!({}), None))
.unwrap();
let mut failed_state = running_projection();
failed_state
.apply_event(&test_raw_event(
3,
@ -2865,6 +3062,15 @@ mod tests {
.apply_event(&test_raw_event_at(
1,
"2026-04-07T12:00:00Z",
"run.runnable",
&json!({ "source": "start_requested" }),
None,
))
.unwrap();
state
.apply_event(&test_raw_event_at(
2,
"2026-04-07T12:00:30Z",
"run.starting",
&json!({}),
None,
@ -2872,7 +3078,7 @@ mod tests {
.unwrap();
state
.apply_event(&test_raw_event_at(
2,
3,
"2026-04-07T12:01:00Z",
"run.running",
&json!({}),
@ -2883,7 +3089,7 @@ mod tests {
state
.apply_event(&test_raw_event_at(
3,
4,
"2026-04-07T12:02:00Z",
"run.running",
&json!({}),
@ -2897,13 +3103,7 @@ mod tests {
#[test]
fn paused_over_blocked_round_trips_back_to_blocked() {
let mut state = initialized_projection();
state
.apply_event(&test_raw_event(1, "run.starting", &json!({}), None))
.unwrap();
state
.apply_event(&test_raw_event(2, "run.running", &json!({}), None))
.unwrap();
let mut state = running_projection();
state
.apply_event(&test_raw_event(
3,
@ -2936,13 +3136,7 @@ mod tests {
fn run_archived_on_non_terminal_projection_is_rejected() {
use fabro_types::run_event::RunArchivedProps;
let mut state = initialized_projection();
state
.apply_event(&test_raw_event(1, "run.starting", &json!({}), None))
.unwrap();
state
.apply_event(&test_raw_event(2, "run.running", &json!({}), None))
.unwrap();
let mut state = running_projection();
let err = state
.apply_event(&test_event(

View file

@ -641,6 +641,14 @@ mod tests {
run.append_event(&event_payload(
label,
"2026-03-27T12:00:01Z",
"run.runnable",
&serde_json::json!({ "source": "start_requested" }),
))
.await
.unwrap();
run.append_event(&event_payload(
label,
"2026-03-27T12:00:02Z",
"run.starting",
&serde_json::json!({}),
))
@ -648,7 +656,7 @@ mod tests {
.unwrap();
run.append_event(&event_payload(
label,
"2026-03-27T12:00:02Z",
"2026-03-27T12:00:03Z",
"run.running",
&serde_json::json!({}),
))
@ -1088,6 +1096,14 @@ mod tests {
let state = reader.state().await.unwrap();
assert_eq!(state.spec.run_id, test_run_id("run-1"));
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:01Z",
"run.runnable",
&serde_json::json!({ "source": "start_requested" }),
))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:02Z",
@ -1120,7 +1136,7 @@ mod tests {
.unwrap();
let recent = reader.list_events_from_with_limit(4, 10).await.unwrap();
assert_eq!(recent.len(), 1);
assert_eq!(recent.len(), 2);
assert_eq!(recent[0].seq, 4);
}
@ -1163,7 +1179,7 @@ mod tests {
);
assert_eq!(entries[0].summary.lifecycle.status, RunStatus::Running);
assert_eq!(entries[0].projection.spec().run_id, test_run_id("run-2"));
assert_eq!(entries[0].last_seq, 3);
assert_eq!(entries[0].last_seq, 4);
let filtered = reopened
.list_cached_runs(
@ -1296,6 +1312,14 @@ mod tests {
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
store.warm_projection_cache().await.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:01Z",
"run.runnable",
&serde_json::json!({ "source": "start_requested" }),
))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:01Z",
@ -1372,7 +1396,7 @@ mod tests {
.unwrap()
.unwrap();
assert_eq!(cached.summary.lifecycle.status, RunStatus::Running);
assert_eq!(cached.last_seq, 6);
assert_eq!(cached.last_seq, 7);
assert_eq!(
cached
.projection
@ -1446,8 +1470,8 @@ mod tests {
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:01Z",
"run.queued",
&serde_json::json!({}),
"run.runnable",
&serde_json::json!({ "source": "start_requested" }),
))
.await
.unwrap();

View file

@ -7,7 +7,7 @@ use chrono::{DateTime, NaiveDate, Utc};
use fabro_api::types;
use fabro_types::{
PairId, PairMessageRecord, PairMessageRequest, PairRecord, PairTranscriptResponse, Run, RunId,
RunPairStatusResponse, RunStatus, StageId,
RunPairStatusResponse, StageId,
};
use fabro_util::exit::{self, ExitClass};
use schemars::JsonSchema;
@ -250,7 +250,7 @@ pub(crate) fn run_summary_result(run: &Run) -> RunSummaryResult {
workflow_name: run.workflow.name.clone(),
workflow_graph_name: run.workflow.graph_name.clone(),
workflow_slug: run.workflow.slug.clone(),
status: run_status_kind(run.lifecycle.status).to_string(),
status: run.lifecycle.status.kind().to_string(),
archived: run.lifecycle.archived,
created_at: run.timestamps.created_at.to_rfc3339(),
started_at: run
@ -284,10 +284,6 @@ pub(crate) fn parse_datetime_filter(name: &str, raw: &str) -> ToolResult<DateTim
Ok(DateTime::from_naive_utc_and_offset(datetime, Utc))
}
pub(crate) fn run_status_kind(status: RunStatus) -> &'static str {
status.kind().into()
}
fn format_tool_error(err: &anyhow::Error) -> String {
let mut rendered = format!("{err:#}");
if exit::exit_class_for(err) == Some(ExitClass::AuthRequired)
@ -301,7 +297,7 @@ fn format_tool_error(err: &anyhow::Error) -> String {
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunTimestamps, WorkflowRef};
use fabro_types::{RunLifecycle, RunLinks, RunOrigin, RunStatus, RunTimestamps, WorkflowRef};
use super::*;
@ -328,6 +324,7 @@ mod tests {
labels: HashMap::new(),
lifecycle: RunLifecycle {
status: RunStatus::Submitted,
approval: None,
pending_control: None,
queue_position: None,
error: None,

View file

@ -392,12 +392,12 @@ pub struct CreateRunsResult {
#[derive(Debug, Serialize, JsonSchema)]
pub struct CreatedRunResult {
pub run_id: String,
pub parent_id: Option<String>,
pub children_count: u64,
pub workflow: String,
pub started: bool,
pub status: String,
pub run_id: String,
pub parent_id: Option<String>,
pub children_count: u64,
pub workflow: String,
pub start_requested: bool,
pub status: String,
}
#[derive(Debug, Clone, Copy, Default)]
@ -446,8 +446,8 @@ pub async fn create_runs_with_options(
.create_run_from_spec(&spec, &cwd, user_settings_path, parent_id)
.await
.map_err(|err| ToolError::from_anyhow(&err))?;
let started = spec.start.unwrap_or(true);
let summary = if started {
let start_requested = spec.start.unwrap_or(true);
let summary = if start_requested {
backend
.start_run(&run_id, false)
.await
@ -463,8 +463,8 @@ pub async fn create_runs_with_options(
parent_id: summary.parent_id.map(|parent_id| parent_id.to_string()),
children_count: summary.children_count,
workflow: spec.workflow,
started,
status: common::run_status_kind(summary.lifecycle.status).to_string(),
start_requested,
status: summary.lifecycle.status.kind().to_string(),
});
}
Ok(CreateRunsResult { runs: created })
@ -492,9 +492,9 @@ async fn resolve_parent_run_id(
}
pub fn create_runs_text(result: &CreateRunsResult) -> String {
let started = result.runs.iter().filter(|run| run.started).count();
let start_requested = result.runs.iter().filter(|run| run.start_requested).count();
format!(
"created {} Fabro run(s), started {started}",
"created {} Fabro run(s), start requested for {start_requested}",
result.runs.len()
)
}
@ -676,6 +676,7 @@ mod tests {
parent_id,
created_parent_ids: Mutex::new(Vec::new()),
resolved_selectors: Mutex::new(Vec::new()),
started_run_ids: Mutex::new(Vec::new()),
});
let params = ValidatedCreateRuns::try_from(FabroRunCreateParams {
runs: vec![
@ -726,6 +727,7 @@ mod tests {
parent_id,
created_parent_ids: Mutex::new(Vec::new()),
resolved_selectors: Mutex::new(Vec::new()),
started_run_ids: Mutex::new(Vec::new()),
});
let runs: Vec<CreateRunSpecInput> = (0..2)
.map(|_| {
@ -775,6 +777,7 @@ mod tests {
parent_id,
created_parent_ids: Mutex::new(Vec::new()),
resolved_selectors: Mutex::new(Vec::new()),
started_run_ids: Mutex::new(Vec::new()),
});
let params = ValidatedCreateRuns::try_from(FabroRunCreateParams {
runs: vec![
@ -818,11 +821,72 @@ mod tests {
assert!(backend.resolved_selectors.lock().unwrap().is_empty());
}
#[tokio::test]
async fn create_runs_defaults_to_start_request_and_reports_pending_child_status() {
let temp = tempfile::tempdir().expect("tempdir should be created");
let settings = temp.path().join("settings.toml");
let child_id = run_id("01KRBZW5C00000000000000001");
let parent_id = run_id("01KRBZW4DW0000000000000002");
let backend = Arc::new(MockCreateBackend {
child_id,
parent_id,
created_parent_ids: Mutex::new(Vec::new()),
resolved_selectors: Mutex::new(Vec::new()),
started_run_ids: Mutex::new(Vec::new()),
});
let params = ValidatedCreateRuns::try_from(FabroRunCreateParams {
runs: vec![
CreateRunSpec {
workflow: "simple.fabro".to_string(),
cwd: None,
run_id: None,
parent_id: Some(parent_id.to_string()),
goal: None,
goal_file: None,
inputs: HashMap::new(),
labels: HashMap::new(),
dry_run: Some(true),
auto_approve: Some(true),
model: None,
provider: None,
environment: None,
preserve_sandbox: None,
start: None,
}
.into(),
],
})
.expect("create params should validate");
let result = create_runs(backend.clone(), temp.path(), &settings, params)
.await
.expect("run should be created and start requested");
assert!(result.runs[0].start_requested);
assert_eq!(result.runs[0].status, "pending");
assert_eq!(backend.started_run_ids.lock().unwrap().as_slice(), &[
child_id
]);
assert_eq!(
create_runs_text(&result),
"created 1 Fabro run(s), start requested for 1"
);
}
fn run_id(raw: &str) -> RunId {
raw.parse().expect("test run id should parse")
}
fn run(run_id: RunId, parent_id: Option<RunId>, children_count: u64) -> Run {
run_with_status(run_id, parent_id, children_count, RunStatus::Submitted)
}
fn run_with_status(
run_id: RunId,
parent_id: Option<RunId>,
children_count: u64,
status: RunStatus,
) -> Run {
Run {
id: run_id,
parent_id,
@ -842,12 +906,13 @@ mod tests {
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {
status: RunStatus::Submitted,
status,
approval: None,
pending_control: None,
queue_position: None,
error: None,
archived: false,
archived_at: None,
queue_position: None,
error: None,
archived: false,
archived_at: None,
},
sandbox: None,
models: Vec::new(),
@ -876,6 +941,7 @@ mod tests {
parent_id: RunId,
created_parent_ids: Mutex<Vec<Option<RunId>>>,
resolved_selectors: Mutex<Vec<String>>,
started_run_ids: Mutex<Vec<RunId>>,
}
#[async_trait]
@ -905,8 +971,18 @@ mod tests {
Ok(run(self.child_id, Some(self.parent_id), 0))
}
async fn start_run(&self, _run_id: &RunId, _resume: bool) -> anyhow::Result<Run> {
unreachable!("test uses start=false")
async fn start_run(&self, run_id: &RunId, resume: bool) -> anyhow::Result<Run> {
assert_eq!(*run_id, self.child_id);
assert!(!resume);
self.started_run_ids.lock().unwrap().push(*run_id);
Ok(run_with_status(
self.child_id,
Some(self.parent_id),
0,
RunStatus::Pending {
reason: fabro_types::PendingReason::ApprovalRequired,
},
))
}
async fn cancel_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {

View file

@ -449,6 +449,7 @@ mod tests {
labels: HashMap::from([("group".to_string(), group.to_string())]),
lifecycle: RunLifecycle {
status: RunStatus::Submitted,
approval: None,
pending_control: None,
queue_position: None,
error: None,

View file

@ -98,7 +98,7 @@ pub use run_event::{
AgentMcpToolSummary, AgentMemoryFileProps, AgentSkillActivationSource, AgentSkillSummary,
EventBody, ExecOutputTail, InterviewOption, MetadataSnapshotFailureKind, MetadataSnapshotPhase,
RunEvent, RunNoticeCode, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason,
SessionCapability, TodoCreatedProps, TodoDeletedProps, TodoUpdatedProps,
RunRunnableSource, SessionCapability, TodoCreatedProps, TodoDeletedProps, TodoUpdatedProps,
};
pub use run_failure::RunFailure;
pub use run_id::{RunId, fixtures};
@ -109,9 +109,9 @@ pub use run_projection::{
};
pub use run_sandbox::{RunSandbox, RunSandboxRuntime};
pub use run_summary::{
AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunBillingSummary, RunError,
RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind, RunSize, RunTimestamps,
WorkflowRef,
AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunApproval, RunApprovalState,
RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind,
RunSize, RunTimestamps, WorkflowRef,
};
pub use run_title::{
MAX_RUN_TITLE_CHARS, RunTitleError, infer_run_title, normalize_explicit_run_title,
@ -135,9 +135,8 @@ pub use stage_handler::StageHandler;
pub use stage_id::{InvalidStageVisit, ParallelBranchId, StageId};
pub use start::StartRecord;
pub use status::{
BlockedReason, FailureReason, InvalidTransition, ParseFailureReasonError,
ParseSuccessReasonError, RunControlAction, RunStatus, RunStatusKind, SuccessReason,
TerminalStatus,
BlockedReason, FailureReason, InvalidTransition, PendingReason, RunControlAction, RunStatus,
RunStatusKind, SuccessReason, TerminalStatus,
};
pub use steering::SteeringMessage;
pub use timing::{RunTiming, StageTiming};

View file

@ -60,8 +60,16 @@ pub enum EventBody {
RunStarted(RunStartedProps),
#[serde(rename = "run.submitted")]
RunSubmitted(RunSubmittedProps),
#[serde(rename = "run.queued")]
RunQueued(RunStatusEffectProps),
#[serde(rename = "run.start_requested")]
RunStartRequested(RunStartRequestedProps),
#[serde(rename = "run.pending")]
RunPending(RunPendingProps),
#[serde(rename = "run.approved")]
RunApproved(RunApprovedProps),
#[serde(rename = "run.denied")]
RunDenied(RunDeniedProps),
#[serde(rename = "run.runnable")]
RunRunnable(RunRunnableProps),
#[serde(rename = "run.starting")]
RunStarting(RunStatusTransitionProps),
#[serde(rename = "run.running")]
@ -421,7 +429,11 @@ impl EventBody {
Self::RunCreated(_) => "run.created",
Self::RunStarted(_) => "run.started",
Self::RunSubmitted(_) => "run.submitted",
Self::RunQueued(_) => "run.queued",
Self::RunStartRequested(_) => "run.start_requested",
Self::RunPending(_) => "run.pending",
Self::RunApproved(_) => "run.approved",
Self::RunDenied(_) => "run.denied",
Self::RunRunnable(_) => "run.runnable",
Self::RunStarting(_) => "run.starting",
Self::RunRunning(_) => "run.running",
Self::RunInterrupt(_) => "run.interrupt",
@ -603,7 +615,11 @@ fn is_known_event_name(event: &str) -> bool {
"run.created"
| "run.started"
| "run.submitted"
| "run.queued"
| "run.start_requested"
| "run.pending"
| "run.approved"
| "run.denied"
| "run.runnable"
| "run.starting"
| "run.running"
| "run.interrupt"
@ -940,7 +956,8 @@ mod tests {
use super::*;
use crate::{
AuthMethod, Edge, Graph, IdpIdentity, Node, RunBlobId, WorkflowSettings, fixtures,
AuthMethod, Edge, Graph, IdpIdentity, Node, PendingReason, RunBlobId, WorkflowSettings,
fixtures,
};
fn user_principal(login: &str) -> Principal {
@ -1106,6 +1123,69 @@ mod tests {
assert_eq!(parsed.to_value().unwrap(), line);
}
#[test]
fn pre_execution_lifecycle_events_round_trip() {
let cases = [
(
EventBody::RunStartRequested(RunStartRequestedProps { resume: false }),
json!("run.start_requested"),
json!({ "resume": false }),
),
(
EventBody::RunPending(RunPendingProps {
reason: PendingReason::ApprovalRequired,
}),
json!("run.pending"),
json!({ "reason": "approval_required" }),
),
(
EventBody::RunApproved(RunApprovedProps::default()),
json!("run.approved"),
json!({}),
),
(
EventBody::RunDenied(RunDeniedProps {
reason: Some("Not approved for execution".to_string()),
}),
json!("run.denied"),
json!({ "reason": "Not approved for execution" }),
),
(
EventBody::RunRunnable(RunRunnableProps {
source: RunRunnableSource::Approved,
}),
json!("run.runnable"),
json!({ "source": "approved" }),
),
];
for (body, event_name, properties) in cases {
let event = RunEvent {
id: format!("evt_{}", event_name.as_str().unwrap()),
ts: DateTime::parse_from_rfc3339("2026-05-23T12:00:00Z")
.unwrap()
.with_timezone(&Utc),
run_id: fixtures::RUN_1,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: Some(Principal::System {
system_kind: crate::SystemActorKind::Engine,
}),
body,
};
let value = event.to_value().unwrap();
assert_eq!(value["event"], event_name);
assert_eq!(value["properties"], properties);
assert_eq!(RunEvent::from_value(value).unwrap(), event);
}
}
#[test]
fn agent_interrupt_injected_round_trips_with_stage_session_and_actor() {
let line = json!({
@ -1421,7 +1501,15 @@ mod tests {
#[test]
fn canonical_run_lifecycle_events_are_known() {
for event in ["run.queued", "run.blocked", "run.unblocked"] {
for event in [
"run.start_requested",
"run.pending",
"run.approved",
"run.denied",
"run.runnable",
"run.blocked",
"run.unblocked",
] {
assert!(
is_known_event_name(event),
"{event} should be a known event"
@ -1517,14 +1605,14 @@ mod tests {
}
#[test]
fn run_queued_and_unblocked_round_trip_as_typed_events() {
fn run_runnable_and_unblocked_round_trip_as_typed_events() {
for value in [
json!({
"id": "evt_run_queued",
"id": "evt_run_runnable",
"ts": "2026-04-19T12:00:00.000Z",
"run_id": fixtures::RUN_1,
"event": "run.queued",
"properties": {}
"event": "run.runnable",
"properties": { "source": "start_requested" }
}),
json!({
"id": "evt_run_unblocked",

View file

@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel};
use crate::status::{BlockedReason, SuccessReason};
use crate::status::{BlockedReason, PendingReason, SuccessReason};
use crate::{
DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId, RunControlAction,
RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings,
@ -139,6 +139,54 @@ pub struct RunSubmittedProps {
pub definition_blob: Option<RunBlobId>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStartRequestedProps {
pub resume: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunPendingProps {
pub reason: PendingReason,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunApprovedProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunDeniedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum RunRunnableSource {
StartRequested,
Approved,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunRunnableProps {
pub source: RunRunnableSource,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunControlRequestedProps {
pub action: RunControlAction,

View file

@ -9,9 +9,9 @@ use crate::run_event::{AgentSessionActivatedProps, StagePromptProps};
use crate::{
AgentBackend, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,
BilledTokenCounts, Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition,
ModelRef, PullRequestLink, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec, RunStatus,
RunTiming, StageCompletion, StageHandler, StageId, StageState, StageTiming, StartRecord,
TodoListProjection,
ModelRef, PullRequestLink, RunApproval, RunControlAction, RunDiff, RunId, RunSandbox, RunSpec,
RunStatus, RunTiming, StageCompletion, StageHandler, StageId, StageState, StageTiming,
StartRecord, TodoListProjection,
};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@ -26,6 +26,8 @@ pub struct RunProjection {
pub start: Option<StartRecord>,
pub status: RunStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval: Option<RunApproval>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived_at: Option<DateTime<Utc>>,
pub status_updated_at: DateTime<Utc>,
pub last_event_at: DateTime<Utc>,
@ -297,6 +299,7 @@ impl RunProjection {
web_url: None,
start: None,
status: RunStatus::Submitted,
approval: None,
archived_at: None,
status_updated_at: created_at,
last_event_at: created_at,

View file

@ -139,6 +139,8 @@ pub struct RunModel {
pub struct RunLifecycle {
pub status: RunStatus,
#[serde(default)]
pub approval: Option<RunApproval>,
#[serde(default)]
pub pending_control: Option<RunControlAction>,
#[serde(default)]
pub queue_position: Option<u32>,
@ -149,6 +151,37 @@ pub struct RunLifecycle {
pub archived_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunApproval {
pub state: RunApprovalState,
pub requested_at: DateTime<Utc>,
#[serde(default)]
pub decided_at: Option<DateTime<Utc>>,
#[serde(default)]
pub denial_reason: Option<String>,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum RunApprovalState {
Pending,
Approved,
Denied,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunError {
pub message: String,

View file

@ -1,5 +1,4 @@
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, IntoStaticStr};
@ -21,7 +20,8 @@ use strum::{Display, EnumString, IntoStaticStr};
#[strum(serialize_all = "snake_case")]
pub enum RunStatusKind {
Submitted,
Queued,
Pending,
Runnable,
Starting,
Running,
Blocked,
@ -36,7 +36,8 @@ pub enum RunStatusKind {
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RunStatus {
Submitted,
Queued,
Pending { reason: PendingReason },
Runnable,
Starting,
Running,
Blocked { blocked_reason: BlockedReason },
@ -75,7 +76,8 @@ impl RunStatus {
matches!(
self,
Self::Submitted
| Self::Queued
| Self::Pending { .. }
| Self::Runnable
| Self::Starting
| Self::Running
| Self::Blocked { .. }
@ -120,9 +122,10 @@ impl RunStatus {
}
matches!(
(self, to),
(Self::Submitted, Self::Queued | Self::Starting)
(Self::Submitted, Self::Pending { .. } | Self::Runnable)
| (
Self::Queued
Self::Pending { .. }
| Self::Runnable
| Self::Starting
| Self::Running
| Self::Blocked { .. }
@ -131,9 +134,16 @@ impl RunStatus {
| Self::Failed { .. },
Self::Submitted
)
| (Self::Queued, Self::Starting)
| (Self::Submitted | Self::Queued, Self::Failed {
reason: FailureReason::Cancelled,
| (Self::Pending { .. }, Self::Runnable)
| (Self::Runnable, Self::Starting)
| (
Self::Submitted | Self::Pending { .. } | Self::Runnable,
Self::Failed {
reason: FailureReason::Cancelled,
}
)
| (Self::Pending { .. }, Self::Failed {
reason: FailureReason::ApprovalDenied,
})
| (
Self::Starting | Self::Paused { .. } | Self::Blocked { .. },
@ -141,7 +151,6 @@ impl RunStatus {
)
| (
Self::Starting
| Self::Queued
| Self::Running
| Self::Blocked { .. }
| Self::Paused { .. }
@ -175,7 +184,8 @@ impl From<RunStatus> for RunStatusKind {
fn from(status: RunStatus) -> Self {
match status {
RunStatus::Submitted => Self::Submitted,
RunStatus::Queued => Self::Queued,
RunStatus::Pending { .. } => Self::Pending,
RunStatus::Runnable => Self::Runnable,
RunStatus::Starting => Self::Starting,
RunStatus::Running => Self::Running,
RunStatus::Blocked { .. } => Self::Blocked,
@ -192,7 +202,8 @@ impl fmt::Display for RunStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Submitted => f.write_str("submitted"),
Self::Queued => f.write_str("queued"),
Self::Pending { reason } => write!(f, "pending({reason})"),
Self::Runnable => f.write_str("runnable"),
Self::Starting => f.write_str("starting"),
Self::Running => f.write_str("running"),
Self::Blocked { blocked_reason } => write!(f, "blocked({blocked_reason})"),
@ -221,50 +232,44 @@ impl fmt::Display for InvalidTransition {
impl std::error::Error for InvalidTransition {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
Display,
EnumString,
IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum PendingReason {
ApprovalRequired,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString, IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum SuccessReason {
Completed,
PartialSuccess,
}
impl fmt::Display for SuccessReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Completed => "completed",
Self::PartialSuccess => "partial_success",
})
}
}
impl FromStr for SuccessReason {
type Err = ParseSuccessReasonError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"completed" => Ok(Self::Completed),
"partial_success" => Ok(Self::PartialSuccess),
_ => Err(ParseSuccessReasonError(s.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct ParseSuccessReasonError(String);
impl fmt::Display for ParseSuccessReasonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid success reason: {:?}", self.0)
}
}
impl std::error::Error for ParseSuccessReasonError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString, IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum FailureReason {
WorkflowError,
Cancelled,
ApprovalDenied,
Terminated,
TransientInfra,
BudgetExhausted,
@ -273,50 +278,6 @@ pub enum FailureReason {
SandboxInitFailed,
}
impl fmt::Display for FailureReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::WorkflowError => "workflow_error",
Self::Cancelled => "cancelled",
Self::Terminated => "terminated",
Self::TransientInfra => "transient_infra",
Self::BudgetExhausted => "budget_exhausted",
Self::LaunchFailed => "launch_failed",
Self::BootstrapFailed => "bootstrap_failed",
Self::SandboxInitFailed => "sandbox_init_failed",
})
}
}
impl FromStr for FailureReason {
type Err = ParseFailureReasonError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"workflow_error" => Ok(Self::WorkflowError),
"cancelled" => Ok(Self::Cancelled),
"terminated" => Ok(Self::Terminated),
"transient_infra" => Ok(Self::TransientInfra),
"budget_exhausted" => Ok(Self::BudgetExhausted),
"launch_failed" => Ok(Self::LaunchFailed),
"bootstrap_failed" => Ok(Self::BootstrapFailed),
"sandbox_init_failed" => Ok(Self::SandboxInitFailed),
_ => Err(ParseFailureReasonError(s.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct ParseFailureReasonError(String);
impl fmt::Display for ParseFailureReasonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid failure reason: {:?}", self.0)
}
}
impl std::error::Error for ParseFailureReasonError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TerminalStatus {
@ -344,20 +305,15 @@ impl From<TerminalStatus> for RunStatus {
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, EnumString, IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum BlockedReason {
HumanInputRequired,
}
impl fmt::Display for BlockedReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::HumanInputRequired => "human_input_required",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunControlAction {
@ -370,18 +326,27 @@ pub enum RunControlAction {
mod tests {
use std::str::FromStr;
use super::{BlockedReason, FailureReason, InvalidTransition, RunStatus, SuccessReason};
use super::{
BlockedReason, FailureReason, InvalidTransition, PendingReason, RunStatus, SuccessReason,
};
#[test]
fn queued_and_blocked_are_active() {
let queued = RunStatus::Queued;
fn pending_runnable_and_blocked_are_active() {
let pending = RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
};
let runnable = RunStatus::Runnable;
let blocked = RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
};
assert_eq!(queued.to_string(), "queued");
assert!(queued.is_active());
assert!(!queued.is_terminal());
assert_eq!(pending.to_string(), "pending(approval_required)");
assert!(pending.is_active());
assert!(!pending.is_terminal());
assert_eq!(runnable.to_string(), "runnable");
assert!(runnable.is_active());
assert!(!runnable.is_terminal());
assert_eq!(blocked.to_string(), "blocked(human_input_required)");
assert!(blocked.is_active());
@ -391,7 +356,10 @@ mod tests {
#[test]
fn canonical_blocked_transitions_are_allowed() {
let submitted = RunStatus::Submitted;
let queued = RunStatus::Queued;
let pending = RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
};
let runnable = RunStatus::Runnable;
let running = RunStatus::Running;
let blocked = RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
@ -403,18 +371,30 @@ mod tests {
reason: FailureReason::WorkflowError,
};
assert!(submitted.can_transition_to(queued));
assert!(submitted.can_transition_to(RunStatus::Starting));
assert!(submitted.can_transition_to(pending));
assert!(submitted.can_transition_to(runnable));
assert!(!submitted.can_transition_to(RunStatus::Starting));
assert!(submitted.can_transition_to(RunStatus::Failed {
reason: FailureReason::Cancelled,
}));
assert!(queued.can_transition_to(RunStatus::Submitted));
assert!(pending.can_transition_to(RunStatus::Submitted));
assert!(runnable.can_transition_to(RunStatus::Submitted));
assert!(failed.can_transition_to(RunStatus::Submitted));
assert!(queued.can_transition_to(RunStatus::Starting));
assert!(queued.can_transition_to(RunStatus::Failed {
assert!(pending.can_transition_to(runnable));
assert!(runnable.can_transition_to(RunStatus::Starting));
assert!(pending.can_transition_to(RunStatus::Failed {
reason: FailureReason::Cancelled,
}));
assert!(queued.can_transition_to(RunStatus::Failed {
assert!(pending.can_transition_to(RunStatus::Failed {
reason: FailureReason::ApprovalDenied,
}));
assert!(!pending.can_transition_to(RunStatus::Failed {
reason: FailureReason::Terminated,
}));
assert!(runnable.can_transition_to(RunStatus::Failed {
reason: FailureReason::Cancelled,
}));
assert!(!runnable.can_transition_to(RunStatus::Failed {
reason: FailureReason::Terminated,
}));
assert!(running.can_transition_to(blocked));
@ -434,6 +414,11 @@ mod tests {
let failure = FailureReason::from_str("cancelled").expect("cancelled should parse");
assert_eq!(failure, FailureReason::Cancelled);
assert_eq!(failure.to_string(), "cancelled");
let pending =
PendingReason::from_str("approval_required").expect("approval_required should parse");
assert_eq!(pending, PendingReason::ApprovalRequired);
assert_eq!(pending.to_string(), "approval_required");
}
#[test]
@ -441,7 +426,10 @@ mod tests {
let removing = RunStatus::Removing;
for status in [
RunStatus::Submitted,
RunStatus::Queued,
RunStatus::Pending {
reason: PendingReason::ApprovalRequired,
},
RunStatus::Runnable,
RunStatus::Starting,
RunStatus::Running,
RunStatus::Blocked {

View file

@ -84,7 +84,21 @@ fn event_body_from_event(event: &Event) -> EventBody {
definition_blob: *definition_blob,
})
}
Event::RunQueued => EventBody::RunQueued(fabro_types::RunStatusEffectProps::default()),
Event::RunStartRequested { resume, .. } => {
EventBody::RunStartRequested(fabro_types::RunStartRequestedProps { resume: *resume })
}
Event::RunPending { reason, .. } => {
EventBody::RunPending(fabro_types::RunPendingProps { reason: *reason })
}
Event::RunApproved { .. } => {
EventBody::RunApproved(fabro_types::RunApprovedProps::default())
}
Event::RunDenied { reason, .. } => EventBody::RunDenied(fabro_types::RunDeniedProps {
reason: reason.clone(),
}),
Event::RunRunnable { source, .. } => {
EventBody::RunRunnable(fabro_types::RunRunnableProps { source: *source })
}
Event::RunStarting => {
EventBody::RunStarting(fabro_types::RunStatusTransitionProps::default())
}

View file

@ -3,9 +3,9 @@ use std::collections::BTreeMap;
use ::fabro_types::{
BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason,
ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget,
ParallelBranchId, Principal, PullRequestLink, RunBlobId, RunFailure, RunId, RunNoticeLevel,
RunPairEndedReason, RunPairFailedReason, RunProvenance, RunTiming, SandboxProvider, StageId,
StageTiming, SuccessReason, run_event as fabro_types,
ParallelBranchId, PendingReason, Principal, PullRequestLink, RunBlobId, RunFailure, RunId,
RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, RunRunnableSource,
RunTiming, SandboxProvider, StageId, StageTiming, SuccessReason, run_event as fabro_types,
};
use fabro_agent::{AgentEvent, SandboxEvent};
use fabro_model::{ReasoningEffort, Speed};
@ -71,7 +71,31 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
definition_blob: Option<RunBlobId>,
},
RunQueued,
RunStartRequested {
resume: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunPending {
reason: PendingReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunApproved {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunDenied {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunRunnable {
source: RunRunnableSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunStarting,
RunRunning,
RunInterrupt {
@ -787,8 +811,20 @@ impl Event {
Self::RunSubmitted { definition_blob } => {
info!(?definition_blob, "Run submitted");
}
Self::RunQueued => {
info!("Run queued");
Self::RunStartRequested { resume, .. } => {
info!(resume, "Run start requested");
}
Self::RunPending { reason, .. } => {
info!(?reason, "Run pending");
}
Self::RunApproved { .. } => {
info!("Run approved");
}
Self::RunDenied { reason, .. } => {
info!(?reason, "Run denied");
}
Self::RunRunnable { source, .. } => {
info!(?source, "Run runnable");
}
Self::RunStarting => {
info!("Run starting");

View file

@ -8,7 +8,11 @@ pub fn event_name(event: &Event) -> &'static str {
Event::RunCreated { .. } => "run.created",
Event::WorkflowRunStarted { .. } => "run.started",
Event::RunSubmitted { .. } => "run.submitted",
Event::RunQueued => "run.queued",
Event::RunStartRequested { .. } => "run.start_requested",
Event::RunPending { .. } => "run.pending",
Event::RunApproved { .. } => "run.approved",
Event::RunDenied { .. } => "run.denied",
Event::RunRunnable { .. } => "run.runnable",
Event::RunStarting => "run.starting",
Event::RunRunning => "run.running",
Event::RunInterrupt { .. } => "run.interrupt",

View file

@ -61,6 +61,11 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
..StoredEventFields::default()
},
Event::RunCancelRequested { actor }
| Event::RunStartRequested { actor, .. }
| Event::RunPending { actor, .. }
| Event::RunApproved { actor }
| Event::RunDenied { actor, .. }
| Event::RunRunnable { actor, .. }
| Event::RunPauseRequested { actor }
| Event::RunUnpauseRequested { actor }
| Event::RunInterrupt { actor }

View file

@ -1558,6 +1558,32 @@ mod tests {
]);
}
#[tokio::test]
async fn agent_run_create_defaults_to_start_request_and_reports_pending_child() {
let (services, backend) = fabro_run_tool_services();
let mut registry = ToolRegistry::new();
register_fabro_run_tools(&mut registry, &services);
let tool = registry
.get(fabro_tool::FABRO_RUN_CREATE_TOOL_NAME)
.expect("create tool should be registered");
let output = (tool.executor)(
serde_json::json!({
"runs": [{
"workflow": "child.fabro"
}]
}),
tool_context(),
)
.await
.expect("create tool should succeed");
assert!(output.contains("created 1 Fabro run(s), start requested for 1"));
assert_eq!(backend.started_run_ids.lock().unwrap().as_slice(), &[
child_run_id()
]);
}
#[tokio::test]
async fn agent_run_create_rejects_conflicting_parent_id() {
let mut registry = ToolRegistry::new();
@ -1643,6 +1669,7 @@ mod tests {
let backend = Arc::new(MockRunToolBackend {
child_id: child_run_id(),
created_parent_ids: Mutex::new(Vec::new()),
started_run_ids: Mutex::new(Vec::new()),
});
let services = FabroRunToolServices {
backend: backend.clone(),
@ -1678,6 +1705,17 @@ mod tests {
}
fn run(run_id: RunId, parent_id: Option<RunId>, children_count: u64) -> Run {
run_with_status(run_id, parent_id, children_count, RunStatus::Succeeded {
reason: SuccessReason::Completed,
})
}
fn run_with_status(
run_id: RunId,
parent_id: Option<RunId>,
children_count: u64,
status: RunStatus,
) -> Run {
Run {
id: run_id,
parent_id,
@ -1697,14 +1735,13 @@ mod tests {
origin: RunOrigin::default(),
labels: HashMap::new(),
lifecycle: RunLifecycle {
status: RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
status,
approval: None,
pending_control: None,
queue_position: None,
error: None,
archived: false,
archived_at: None,
queue_position: None,
error: None,
archived: false,
archived_at: None,
},
sandbox: None,
models: Vec::new(),
@ -1731,6 +1768,7 @@ mod tests {
struct MockRunToolBackend {
child_id: RunId,
created_parent_ids: Mutex<Vec<Option<RunId>>>,
started_run_ids: Mutex<Vec<RunId>>,
}
#[async_trait]
@ -1756,8 +1794,18 @@ mod tests {
Ok(run(self.child_id, Some(current_run_id()), 0))
}
async fn start_run(&self, _run_id: &RunId, _resume: bool) -> anyhow::Result<Run> {
unreachable!("agent create test uses start=false")
async fn start_run(&self, run_id: &RunId, resume: bool) -> anyhow::Result<Run> {
assert_eq!(*run_id, self.child_id);
assert!(!resume);
self.started_run_ids.lock().unwrap().push(*run_id);
Ok(run_with_status(
self.child_id,
Some(current_run_id()),
0,
RunStatus::Pending {
reason: fabro_types::PendingReason::ApprovalRequired,
},
))
}
async fn cancel_run(&self, _run_id: &RunId) -> anyhow::Result<Run> {

View file

@ -153,6 +153,7 @@ mod tests {
async fn seed_succeeded(store: &Database, run_id: &RunId) {
let run_store = store.create_run(run_id).await.unwrap();
seed_created(&run_store, run_id).await;
seed_runnable(&run_store, run_id).await;
event::append_event(&run_store, run_id, &Event::RunStarting)
.await
.unwrap();
@ -177,6 +178,7 @@ mod tests {
async fn seed_failed(store: &Database, run_id: &RunId) {
let run_store = store.create_run(run_id).await.unwrap();
seed_created(&run_store, run_id).await;
seed_runnable(&run_store, run_id).await;
event::append_event(&run_store, run_id, &Event::RunStarting)
.await
.unwrap();
@ -200,6 +202,7 @@ mod tests {
async fn seed_running(store: &Database, run_id: &RunId) {
let run_store = store.create_run(run_id).await.unwrap();
seed_created(&run_store, run_id).await;
seed_runnable(&run_store, run_id).await;
event::append_event(&run_store, run_id, &Event::RunStarting)
.await
.unwrap();
@ -234,6 +237,15 @@ mod tests {
.unwrap();
}
async fn seed_runnable(run_store: &fabro_store::RunDatabase, run_id: &RunId) {
event::append_event(run_store, run_id, &Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
}
async fn current_status(store: &Database, run_id: &RunId) -> RunStatus {
let run_store = store.open_run_reader(run_id).await.unwrap();
run_store.state().await.unwrap().status

View file

@ -123,8 +123,8 @@ mod tests {
use fabro_store::{Database, RunProjectionReducer};
use fabro_types::{
AuthMethod, DirtyStatus, ForkSourceRef, GitContext, Graph, IdpIdentity, PreRunPushOutcome,
Principal, PullRequestLink, RunBlobId, RunServerProvenance, RunTiming, UserPrincipal,
WorkflowSettings, fixtures,
Principal, PullRequestLink, RunBlobId, RunRunnableSource, RunServerProvenance, RunTiming,
UserPrincipal, WorkflowSettings, fixtures,
};
use object_store::memory::InMemory;
@ -204,13 +204,27 @@ mod tests {
.unwrap();
}
async fn append_failed(store: &fabro_store::RunDatabase, run_id: RunId, reason: FailureReason) {
async fn append_runnable(store: &fabro_store::RunDatabase, run_id: RunId) {
event::append_event(store, &run_id, &Event::RunRunnable {
source: RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
}
async fn append_started(store: &fabro_store::RunDatabase, run_id: RunId) {
append_runnable(store, run_id).await;
event::append_event(store, &run_id, &Event::RunStarting)
.await
.unwrap();
event::append_event(store, &run_id, &Event::RunRunning)
.await
.unwrap();
}
async fn append_failed(store: &fabro_store::RunDatabase, run_id: RunId, reason: FailureReason) {
append_started(store, run_id).await;
let event = Event::workflow_run_failed_from_error(
&Error::engine("boom"),
RunTiming::wall_only(10),
@ -416,12 +430,7 @@ mod tests {
let succeeded = fixtures::RUN_1;
let succeeded_store = store.create_run(&succeeded).await.unwrap();
append_created(&succeeded_store, succeeded, None, None).await;
event::append_event(&succeeded_store, &succeeded, &Event::RunStarting)
.await
.unwrap();
event::append_event(&succeeded_store, &succeeded, &Event::RunRunning)
.await
.unwrap();
append_started(&succeeded_store, succeeded).await;
event::append_event(&succeeded_store, &succeeded, &Event::WorkflowRunCompleted {
timing: RunTiming::wall_only(10),
artifact_count: 0,
@ -444,9 +453,12 @@ mod tests {
})
.await
.unwrap();
event::append_event(&active_store, &active, &Event::RunQueued)
.await
.unwrap();
event::append_event(&active_store, &active, &Event::RunRunnable {
source: RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
let cancelled = fixtures::RUN_3;
let cancelled_store = store.create_run(&cancelled).await.unwrap();

View file

@ -22,7 +22,7 @@ use fabro_types::settings::run::{
TlsMode as ResolvedTlsMode,
};
use fabro_types::settings::{InterpString, ModelRegistry, ResolvedModelRef};
use fabro_types::{ManifestPath, RunId};
use fabro_types::{ManifestPath, RunId, RunRunnableSource};
use fabro_vault::Vault;
use tokio::runtime::Handle;
use tokio::sync::RwLock as AsyncRwLock;
@ -139,12 +139,34 @@ pub async fn start(run_dir: &Path, services: StartServices) -> Result<Started, E
let status = state.status;
if !matches!(
status,
RunStatus::Submitted | RunStatus::Queued | RunStatus::Starting
RunStatus::Submitted | RunStatus::Runnable | RunStatus::Starting
) {
return Err(Error::Precondition(format!(
"cannot start run: status is {status}, expected submitted"
"cannot start run: status is {status}, expected submitted or runnable"
)));
}
if matches!(status, RunStatus::Submitted) {
append_event_to_sink(
&services.event_sink,
&services.run_id,
&Event::RunStartRequested {
resume: false,
actor: None,
},
)
.await
.map_err(|err| Error::engine(err.to_string()))?;
append_event_to_sink(
&services.event_sink,
&services.run_id,
&Event::RunRunnable {
source: RunRunnableSource::StartRequested,
actor: None,
},
)
.await
.map_err(|err| Error::engine(err.to_string()))?;
}
Box::pin(execute_persisted_run(run_dir, None, services)).await
}
@ -1696,6 +1718,12 @@ reasoning = false
})
.await
.unwrap();
crate::event::append_event(&run_store, &fixtures::RUN_1, &Event::RunRunnable {
source: RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
crate::event::append_event(&run_store, &fixtures::RUN_1, &Event::RunStarting)
.await
.unwrap();

View file

@ -217,6 +217,12 @@ async fn seed_created_and_starting(
})
.await
.unwrap();
append_event(run_store, &run_options.run_id, &Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
append_event(run_store, &run_options.run_id, &Event::RunStarting)
.await
.unwrap();

View file

@ -1596,6 +1596,12 @@ mod tests {
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunStarting)
.await
.unwrap();
@ -1886,6 +1892,12 @@ mod tests {
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunStarting)
.await
.unwrap();

View file

@ -133,6 +133,12 @@ async fn initialized(
})
.await
.expect("failed to seed run.created event in run store");
append_event(&run_store, &run_options.run_id, &Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.expect("failed to seed run.runnable event in run store");
append_event(&run_store, &run_options.run_id, &Event::RunStarting)
.await
.expect("failed to seed run.starting event in run store");

View file

@ -75,6 +75,7 @@ models/delete-run-sandbox.ts
models/delete-secret-request.ts
models/demo-toggle-request.ts
models/demo-toggle-response.ts
models/deny-run-request.ts
models/dev-token-login-request.ts
models/dev-token-login-response.ts
models/diagnostics-check.ts
@ -218,6 +219,7 @@ models/pair-transcript-tool-call.ts
models/pair-transcript-user-message.ts
models/pair-transcript-warning.ts
models/pending-interview-record.ts
models/pending-reason.ts
models/pre-run-push-outcome-failed.ts
models/pre-run-push-outcome-not-attempted.ts
models/pre-run-push-outcome-skipped-no-remote.ts
@ -272,6 +274,8 @@ models/rewind-response.ts
models/root-response-urls.ts
models/root-response.ts
models/run-agent-settings.ts
models/run-approval-state.ts
models/run-approval.ts
models/run-artifact-entry.ts
models/run-artifact-list-response.ts
models/run-billing-stage.ts
@ -320,6 +324,7 @@ models/run-projection.ts
models/run-provenance.ts
models/run-question.ts
models/run-reference.ts
models/run-runnable-source.ts
models/run-sandbox-runtime.ts
models/run-sandbox.ts
models/run-scm-settings.ts
@ -331,8 +336,9 @@ models/run-status-blocked.ts
models/run-status-dead.ts
models/run-status-failed.ts
models/run-status-paused.ts
models/run-status-queued.ts
models/run-status-pending.ts
models/run-status-removing.ts
models/run-status-runnable.ts
models/run-status-running.ts
models/run-status-starting.ts
models/run-status-submitted.ts

View file

@ -30,6 +30,8 @@ import type { CreateRunPullRequestRequest } from '../models';
// @ts-ignore
import type { DeleteRunResponse } from '../models';
// @ts-ignore
import type { DenyRunRequest } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { ForkRequest } from '../models';
@ -74,6 +76,46 @@ import type { ValidateResponse } from '../models';
*/
export const RunsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Approves a pending run that requires pre-execution approval and makes it runnable.
* @summary Approve Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
approveRun: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('approveRun', 'id', id)
const localVarPath = `/api/v1/runs/{id}/approve`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
@ -115,7 +157,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
* Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
* @summary Cancel Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -325,6 +367,49 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Denies a pending run that requires pre-execution approval and fails it with `approval_denied`.
* @summary Deny Run
* @param {string} id Unique run identifier (ULID).
* @param {DenyRunRequest} [denyRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
denyRun: async (id: string, denyRunRequest?: DenyRunRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('denyRun', 'id', id)
const localVarPath = `/api/v1/runs/{id}/deny`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(denyRunRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
@ -904,7 +989,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and queues it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1028,7 +1113,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* Requests start for a submitted run. User-created runs become runnable; parent-generated child runs may become pending until approved. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
@ -1325,6 +1410,19 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
export const RunsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = RunsApiAxiosParamCreator(configuration)
return {
/**
* Approves a pending run that requires pre-execution approval and makes it runnable.
* @summary Approve Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async approveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.approveRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.approveRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
@ -1339,7 +1437,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
* Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
* @summary Cancel Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1405,6 +1503,20 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.deleteRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Denies a pending run that requires pre-execution approval and fails it with `approval_denied`.
* @summary Deny Run
* @param {string} id Unique run identifier (ULID).
* @param {DenyRunRequest} [denyRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async denyRun(id: string, denyRunRequest?: DenyRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.denyRun(id, denyRunRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.denyRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
@ -1586,7 +1698,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and queues it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1626,7 +1738,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* Requests start for a submitted run. User-created runs become runnable; parent-generated child runs may become pending until approved. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
@ -1727,6 +1839,16 @@ export const RunsApiFp = function(configuration?: Configuration) {
export const RunsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = RunsApiFp(configuration)
return {
/**
* Approves a pending run that requires pre-execution approval and makes it runnable.
* @summary Approve Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
approveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.approveRun(id, options).then((request) => request(axios, basePath));
},
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
@ -1738,7 +1860,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.archiveRun(id, options).then((request) => request(axios, basePath));
},
/**
* Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
* Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
* @summary Cancel Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1789,6 +1911,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
deleteRun(id: string, force?: boolean, options?: RawAxiosRequestConfig): AxiosPromise<DeleteRunResponse> {
return localVarFp.deleteRun(id, force, options).then((request) => request(axios, basePath));
},
/**
* Denies a pending run that requires pre-execution approval and fails it with `approval_denied`.
* @summary Deny Run
* @param {string} id Unique run identifier (ULID).
* @param {DenyRunRequest} [denyRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
denyRun(id: string, denyRunRequest?: DenyRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.denyRun(id, denyRunRequest, options).then((request) => request(axios, basePath));
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
@ -1931,7 +2064,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.retrieveRunGraphSource(id, options).then((request) => request(axios, basePath));
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and queues it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1962,7 +2095,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.runPreflight(runManifest, options).then((request) => request(axios, basePath));
},
/**
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* Requests start for a submitted run. User-created runs become runnable; parent-generated child runs may become pending until approved. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
@ -2040,6 +2173,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* RunsApi - object-oriented interface
*/
export class RunsApi extends BaseAPI {
/**
* Approves a pending run that requires pre-execution approval and makes it runnable.
* @summary Approve Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public approveRun(id: string, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).approveRun(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
@ -2052,7 +2196,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Cancels a running or queued run. Returns 409 if the run has already completed or been cancelled.
* Cancels a pending, runnable, or running run. Returns 409 if the run has already completed or been cancelled.
* @summary Cancel Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -2108,6 +2252,18 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).deleteRun(id, force, options).then((request) => request(this.axios, this.basePath));
}
/**
* Denies a pending run that requires pre-execution approval and fails it with `approval_denied`.
* @summary Deny Run
* @param {string} id Unique run identifier (ULID).
* @param {DenyRunRequest} [denyRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public denyRun(id: string, denyRunRequest?: DenyRunRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).denyRun(id, denyRunRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
@ -2263,7 +2419,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and queues it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -2297,7 +2453,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* Requests start for a submitted run. User-created runs become runnable; parent-generated child runs may become pending until approved. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]

View file

@ -19,7 +19,8 @@
*/
export const BoardColumn = {
QUEUED: 'queued',
PENDING: 'pending',
RUNNABLE: 'runnable',
INITIALIZING: 'initializing',
RUNNING: 'running',
BLOCKED: 'blocked',

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Request body for denying a pending run approval request.
*/
export interface DenyRunRequest {
/**
* Optional human-readable reason for denying execution. Empty or whitespace-only values are stored as absent.
*/
'reason'?: string;
}

View file

@ -21,6 +21,7 @@
export const FailureReason = {
WORKFLOW_ERROR: 'workflow_error',
CANCELLED: 'cancelled',
APPROVAL_DENIED: 'approval_denied',
TERMINATED: 'terminated',
TRANSIENT_INFRA: 'transient_infra',
BUDGET_EXHAUSTED: 'budget_exhausted',

View file

@ -52,6 +52,7 @@ export * from './delete-run-sandbox';
export * from './delete-secret-request';
export * from './demo-toggle-request';
export * from './demo-toggle-response';
export * from './deny-run-request';
export * from './dev-token-login-request';
export * from './dev-token-login-response';
export * from './diagnostics-check';
@ -194,6 +195,7 @@ export * from './pair-transcript-tool-call';
export * from './pair-transcript-user-message';
export * from './pair-transcript-warning';
export * from './pending-interview-record';
export * from './pending-reason';
export * from './pre-run-push-outcome';
export * from './pre-run-push-outcome-failed';
export * from './pre-run-push-outcome-not-attempted';
@ -249,6 +251,8 @@ export * from './root-response';
export * from './root-response-urls';
export * from './run';
export * from './run-agent-settings';
export * from './run-approval';
export * from './run-approval-state';
export * from './run-artifact-entry';
export * from './run-artifact-list-response';
export * from './run-billing';
@ -297,6 +301,7 @@ export * from './run-projection';
export * from './run-provenance';
export * from './run-question';
export * from './run-reference';
export * from './run-runnable-source';
export * from './run-sandbox';
export * from './run-sandbox-runtime';
export * from './run-scm-settings';
@ -309,8 +314,9 @@ export * from './run-status-blocked';
export * from './run-status-dead';
export * from './run-status-failed';
export * from './run-status-paused';
export * from './run-status-queued';
export * from './run-status-pending';
export * from './run-status-removing';
export * from './run-status-runnable';
export * from './run-status-running';
export * from './run-status-starting';
export * from './run-status-submitted';

View file

@ -14,12 +14,12 @@
export interface RunStatusQueued {
'kind': RunStatusQueuedKindEnum;
}
/**
* Reason a pre-execution run is pending instead of runnable.
*/
export const RunStatusQueuedKindEnum = {
QUEUED: 'queued'
export const PendingReason = {
APPROVAL_REQUIRED: 'approval_required'
} as const;
export type RunStatusQueuedKindEnum = typeof RunStatusQueuedKindEnum[keyof typeof RunStatusQueuedKindEnum];
export type PendingReason = typeof PendingReason[keyof typeof PendingReason];

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* State of a run\'s pre-execution approval request.
*/
export const RunApprovalState = {
PENDING: 'pending',
APPROVED: 'approved',
DENIED: 'denied'
} as const;
export type RunApprovalState = typeof RunApprovalState[keyof typeof RunApprovalState];

View file

@ -0,0 +1,28 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { RunApprovalState } from './run-approval-state';
/**
* Pre-execution approval state for runs that require one-time human approval.
*/
export interface RunApproval {
'state': RunApprovalState;
'requested_at': string;
'decided_at': string | null;
'denial_reason': string | null;
}

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { RunApproval } from './run-approval';
// May contain unused imports in some cases
// @ts-ignore
import type { RunControlAction } from './run-control-action';
@ -25,6 +28,7 @@ import type { RunStatus } from './run-status';
export interface RunLifecycle {
'status': RunStatus;
'approval': RunApproval | null;
'pending_control': RunControlAction | null;
'queue_position': number | null;
'error': RunError | null;

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Source that made a run runnable.
*/
export const RunRunnableSource = {
START_REQUESTED: 'start_requested',
APPROVED: 'approved'
} as const;
export type RunRunnableSource = typeof RunRunnableSource[keyof typeof RunRunnableSource];

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { PendingReason } from './pending-reason';
export interface RunStatusPending {
'kind': RunStatusPendingKindEnum;
'reason': PendingReason;
}
export const RunStatusPendingKindEnum = {
PENDING: 'pending'
} as const;
export type RunStatusPendingKindEnum = typeof RunStatusPendingKindEnum[keyof typeof RunStatusPendingKindEnum];

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface RunStatusRunnable {
'kind': RunStatusRunnableKindEnum;
}
export const RunStatusRunnableKindEnum = {
RUNNABLE: 'runnable'
} as const;
export type RunStatusRunnableKindEnum = typeof RunStatusRunnableKindEnum[keyof typeof RunStatusRunnableKindEnum];

View file

@ -33,12 +33,15 @@ import type { RunStatusFailed } from './run-status-failed';
import type { RunStatusPaused } from './run-status-paused';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatusQueued } from './run-status-queued';
import type { RunStatusPending } from './run-status-pending';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatusRemoving } from './run-status-removing';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatusRunnable } from './run-status-runnable';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatusRunning } from './run-status-running';
// May contain unused imports in some cases
// @ts-ignore
@ -54,4 +57,4 @@ import type { RunStatusSucceeded } from './run-status-succeeded';
* @type RunStatus
* Execution status of a run. Archive state is represented separately on `RunLifecycle.archived` so terminal status payloads remain intact.
*/
export type RunStatus = { kind: 'blocked' } & RunStatusBlocked | { kind: 'dead' } & RunStatusDead | { kind: 'failed' } & RunStatusFailed | { kind: 'paused' } & RunStatusPaused | { kind: 'queued' } & RunStatusQueued | { kind: 'removing' } & RunStatusRemoving | { kind: 'running' } & RunStatusRunning | { kind: 'starting' } & RunStatusStarting | { kind: 'submitted' } & RunStatusSubmitted | { kind: 'succeeded' } & RunStatusSucceeded;
export type RunStatus = { kind: 'blocked' } & RunStatusBlocked | { kind: 'dead' } & RunStatusDead | { kind: 'failed' } & RunStatusFailed | { kind: 'paused' } & RunStatusPaused | { kind: 'pending' } & RunStatusPending | { kind: 'removing' } & RunStatusRemoving | { kind: 'runnable' } & RunStatusRunnable | { kind: 'running' } & RunStatusRunning | { kind: 'starting' } & RunStatusStarting | { kind: 'submitted' } & RunStatusSubmitted | { kind: 'succeeded' } & RunStatusSucceeded;

View file

@ -23,7 +23,7 @@ export interface SystemRunCounts {
*/
'total'?: number;
/**
* Runs currently queued or executing.
* Runs currently pending, runnable, or executing.
*/
'active'?: number;
}