refactor(runs): blocked status canonicalization cleanup (#165)

## Summary

Stacked cleanup of the `canonicalize blocked run status` work (local
commit `d13cdf374`) plus reconciliation with origin's `canonicalize
paginated run list responses` (origin commit `8ab689da7`). Both efforts
ran in parallel and diverged on the column name (`blocked` vs `waiting`)
and on how the board response is shaped — this PR converges them,
keeping `blocked` as the canonical column id while adopting origin's
`column` field on `RunListItem` and `StoreRunSummary` shape.

Also fixes a production-worker regression introduced by the
canonicalization: the worker's start-precondition only accepted
`Submitted | Starting`, so once runs started transitioning through
`Queued` on the way to `Starting`, every subprocess-worker run failed
with `Precondition failed: cannot start run: status is Queued`. That
cascaded into ~90 failing CLI/server integration tests locally.

## Commits

1. `f65843168` refactor(runs): simplify blocked status follow-ups
2. `1492d956c` chore: resolve clippy warnings
3. `676fd9f44` first merge of origin/main
4. `23fc92a2f` **fix(runs): allow Queued status in start precondition**
← the cascade-fix
5. `36b507a83` refactor: simplify pause/unpause + dedupe web status
tables
6. `8d8d27748` refactor(workflow): encapsulate BlockedStateTracker
inside HumanHandler
7. `1c17fda35` second merge of origin/main — resolves waiting vs blocked
8. `4cd3ef7b1` refactor(workflow): Mutex<usize> → AtomicUsize
9. `2e5a58e8a` fix(demo): align run-4 lifecycle status with Blocked
board column

## Test plan

- [x] fmt, clippy, build, doctests all clean
- [x] `cargo nextest run --workspace` — **4092/4092 pass**
- [x] `bun test` — **26/26 pass**, typecheck + production build clean
- [x] Manual CLI repro of the Queued-precondition fix
- [x] Browser smoke test: all 5 columns render with correct
labels/colors, demo run-4 appears in Blocked lane with question text
intact

## Known follow-up (not blocking)

A "paused-while-blocked" run (status `Paused` + `blocked_reason: Some`)
lands in the `running` column because the visible status chooses
`Paused` over `Blocked`. The pending question is not prominent on the
board. Addressing it would require `board_column()` to branch on
`(status, blocked_reason)` rather than just `status` — worth a separate
ticket.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 14:53:46 -04:00 committed by GitHub
parent e8d0f75be9
commit b8af65a9c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1848 additions and 463 deletions

View file

@ -30,7 +30,28 @@ leak-timeout = "500ms"
[profile.ci]
# CI runners are slower and more variable than dev machines; give tests room
# before flagging them as hung. Does not inherit default's per-package
# overrides — CI uses one uniform timeout for every test.
# before flagging them as hung. CI uses one uniform timeout for every test.
#
# Nextest falls back to `[[profile.default.overrides]]` when the active
# profile has no matching override for a given setting, so the per-package
# overrides below re-assert the CI timeout for packages narrowed down in
# profile.default. See
# https://nexte.st/docs/configuration/per-test-overrides/#override-precedence
slow-timeout = { period = "30s", terminate-after = 4 }
leak-timeout = "2s"
leak-timeout = "2s"
[[profile.ci.overrides]]
filter = "package(fabro-cli)"
slow-timeout = { period = "30s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "package(fabro-server)"
slow-timeout = { period = "30s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "package(fabro-workflow)"
slow-timeout = { period = "30s", terminate-after = 4 }
[[profile.ci.overrides]]
filter = "package(twin-openai) & test(debug_page_renders_in_headless_chrome)"
slow-timeout = { period = "60s", terminate-after = 2 }

View file

@ -37,7 +37,7 @@ env:
jobs:
fmt:
name: Format
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
steps:
@ -52,7 +52,7 @@ jobs:
clippy:
name: Clippy
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
steps:
@ -70,7 +70,7 @@ jobs:
test:
name: Test (Linux)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
steps:

View file

@ -34,7 +34,7 @@ permissions: {}
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
steps:
@ -47,7 +47,7 @@ jobs:
test:
name: Test
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
steps:
@ -60,7 +60,7 @@ jobs:
build:
name: Build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-x86-32-cores
permissions:
contents: read
steps:

View file

@ -21,7 +21,8 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
- `cd apps/fabro-web && bun run dev` — rebuild web assets on change for the Rust server; refresh the browser manually
- `cd apps/fabro-web && bun test` — run tests
- `cd apps/fabro-web && bun run typecheck` — type check
- `cd apps/fabro-web && bun run build` — production build
- `cd apps/fabro-web && bun run build` — production build (writes to `apps/fabro-web/dist/` only; does NOT update the bundled SPA that ships in the Rust binary)
- `scripts/refresh-fabro-spa.sh`**run this before committing any TypeScript change in `apps/fabro-web/` or `lib/packages/fabro-api-client/`**. It runs the production build and then copies `dist/` into `lib/crates/fabro-spa/assets/` (which is tracked in git). CI's TypeScript `Build` job reruns this script and then `git diff --exit-code -- lib/crates/fabro-spa/assets` — if the committed bundle drifts from source (e.g. content-hashed filenames like `entry-<hash>.js` change), the check fails. `bun run build` on its own is not enough.
### Marketing site (apps/marketing)
- `cd apps/marketing && bun run dev` — start Astro dev server

View file

@ -1,5 +1,12 @@
import { describe, expect, test } from "bun:test";
import { columnForStatus, mapRunListItem, mapRunSummaryToRunItem } from "./runs";
import {
columnForStatus,
columnStatusDisplay,
isRunStatus,
mapRunListItem,
mapRunSummaryToRunItem,
runStatusDisplay,
} from "./runs";
describe("mapRunListItem", () => {
test("trusts shared server fields for board items", () => {
@ -111,6 +118,18 @@ describe("mapRunSummaryToRunItem", () => {
expect(item.workflow).toBe("unknown");
expect(item.repo).toBe("unknown");
});
test("recognizes canonical blocked and queued run statuses", () => {
expect(isRunStatus("queued")).toBe(true);
expect(isRunStatus("blocked")).toBe(true);
expect(runStatusDisplay).toHaveProperty("queued");
expect(runStatusDisplay).toHaveProperty("blocked");
});
test("uses blocked board column instead of waiting", () => {
expect(columnStatusDisplay).toHaveProperty("blocked");
expect(columnStatusDisplay).not.toHaveProperty("waiting");
});
});
describe("columnForStatus", () => {

View file

@ -31,14 +31,14 @@ export interface RunItem {
sandboxId?: string;
}
export type ColumnStatus = "initializing" | "running" | "waiting" | "succeeded" | "failed";
export type ColumnStatus = "initializing" | "running" | "blocked" | "succeeded" | "failed";
export const columnNames: Record<ColumnStatus, string> = {
initializing: "Initializing",
running: "Running",
waiting: "Waiting",
succeeded: "Succeeded",
failed: "Failed",
export const columnStatusDisplay: Record<ColumnStatus, { label: string; dot: string; text: string }> = {
initializing: { label: "Initializing", dot: "bg-amber", text: "text-amber" },
running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" },
blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" },
succeeded: { label: "Succeeded", dot: "bg-teal-300", text: "text-teal-300" },
failed: { label: "Failed", dot: "bg-coral", text: "text-coral" },
};
export interface RunWithStatus extends RunItem {
@ -97,12 +97,14 @@ export function mapRunSummaryToRunItem(summary: RunSummaryResponse): RunItem {
export function columnForStatus(status: string | null | undefined): ColumnStatus | null {
switch (status) {
case "submitted":
case "queued":
case "starting":
return "initializing";
case "running":
return "running";
case "paused":
return "waiting";
return "running";
case "blocked":
return "blocked";
case "succeeded":
return "succeeded";
case "failed":
@ -120,18 +122,12 @@ export function deriveCiStatus(checks: CheckRun[]): CiStatus {
return "passing";
}
export const statusColors: Record<ColumnStatus, { dot: string; text: string }> = {
initializing: { dot: "bg-amber", text: "text-amber" },
running: { dot: "bg-teal-500", text: "text-teal-500" },
waiting: { dot: "bg-amber", text: "text-amber" },
succeeded: { dot: "bg-teal-300", text: "text-teal-300" },
failed: { dot: "bg-coral", text: "text-coral" },
};
export type RunStatus =
| "submitted"
| "queued"
| "starting"
| "running"
| "blocked"
| "paused"
| "removing"
| "succeeded"
@ -140,8 +136,10 @@ export type RunStatus =
export const runStatusDisplay: Record<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" },
starting: { label: "Starting", dot: "bg-amber", text: "text-amber" },
running: { label: "Running", dot: "bg-teal-500", text: "text-teal-500" },
blocked: { label: "Blocked", dot: "bg-amber", text: "text-amber" },
paused: { label: "Paused", dot: "bg-amber", text: "text-amber" },
removing: { label: "Removing", dot: "bg-fg-muted", text: "text-fg-muted" },
succeeded: { label: "Succeeded", dot: "bg-mint", text: "text-mint" },

View file

@ -25,7 +25,7 @@ export async function loader({ request, params }: any) {
if (!response.ok) return { run: null };
const summary: RunSummaryResponse = await response.json();
const item = mapRunSummaryToRunItem(summary);
const rawStatus = summary.status ?? "submitted";
const rawStatus = summary.status;
const display = isRunStatus(rawStatus)
? runStatusDisplay[rawStatus]
: { label: rawStatus, dot: "bg-fg-muted", text: "text-fg-muted" };

View file

@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import type { BoardColumn, RunListItem } from "@qltysh/fabro-api-client";
import { buildBoardColumns, shouldRefreshBoardForEvent } from "./runs";
function boardRun(id: string, column: BoardColumn, questionText?: string): RunListItem {
return {
run_id: id,
goal: `Run ${id}`,
title: `Run ${id}`,
created_at: "2026-04-19T12:00:00Z",
status: column,
labels: {},
repository: { name: "repo" },
column,
...(questionText ? { question: { text: questionText } } : {}),
};
}
describe("runs route board mapping", () => {
test("keeps blocked runs in the blocked lane and preserves question text", () => {
const columns = buildBoardColumns({
columns: [
{ id: "initializing", name: "Initializing" },
{ id: "running", name: "Running" },
{ id: "blocked", name: "Blocked" },
{ id: "succeeded", name: "Succeeded" },
{ id: "failed", name: "Failed" },
],
data: [
boardRun("paused-run", "running"),
boardRun("blocked-run", "blocked", "Older unresolved question?"),
],
meta: { has_more: false },
});
expect(columns.find((column) => column.id === "running")?.items.map((item) => item.id)).toContain("paused-run");
expect(columns.find((column) => column.id === "blocked")?.items.map((item) => item.id)).toContain("blocked-run");
expect(columns.find((column) => column.id === "blocked")?.items[0]?.question).toBe("Older unresolved question?");
});
test("refreshes for blocked status and interview events", () => {
expect(shouldRefreshBoardForEvent("run.queued")).toBe(true);
expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.unblocked")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.started")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true);
expect(shouldRefreshBoardForEvent("run.created")).toBe(false);
});
});

View file

@ -18,7 +18,7 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ciConfig, columnNames, statusColors, deriveCiStatus, mapRunListItem } from "../data/runs";
import { ciConfig, columnStatusDisplay, deriveCiStatus, mapRunListItem } from "../data/runs";
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { apiPaginatedJson } from "../api";
import type { PaginatedBoardRunList } from "@qltysh/fabro-api-client";
@ -28,21 +28,20 @@ export function meta({}: any) {
}
interface ColumnStyle {
accent: string;
iconColor: string;
iconType: "branch" | "pr";
actions: string[];
}
const columnStyles: Record<string, ColumnStyle> = {
initializing: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: [] },
running: { accent: "bg-teal-500", iconColor: "text-teal-500", iconType: "branch", actions: ["Watch", "Steer"] },
waiting: { accent: "bg-amber", iconColor: "text-amber", iconType: "branch", actions: ["Answer Question"] },
succeeded: { accent: "bg-teal-300", iconColor: "text-teal-300", iconType: "pr", actions: [] },
failed: { accent: "bg-coral", iconColor: "text-coral", iconType: "branch", actions: [] },
const columnStyles: Record<ColumnStatus, ColumnStyle> = {
initializing: { iconType: "branch", actions: [] },
running: { iconType: "branch", actions: ["Watch", "Steer"] },
blocked: { iconType: "branch", actions: ["Answer Question"] },
succeeded: { iconType: "pr", actions: [] },
failed: { iconType: "branch", actions: [] },
};
const defaultColumnStyle: ColumnStyle = { accent: "bg-fg-muted", iconColor: "text-fg-muted", iconType: "branch", actions: [] };
const defaultColumnStyle: ColumnStyle = { iconType: "branch", actions: [] };
const defaultColumnColors = { dot: "bg-fg-muted", text: "text-fg-muted" };
interface BoardRunsResponse {
columns: { id: string; name: string }[];
@ -50,36 +49,74 @@ interface BoardRunsResponse {
meta: PaginatedBoardRunList["meta"];
}
export async function loader({ request }: any) {
const response = await apiPaginatedJson<
PaginatedBoardRunList["data"][number],
{ columns: BoardRunsResponse["columns"] }
>("/boards/runs", { request });
const apiRuns = response.data;
type Column = {
id: ColumnStatus;
name: string;
dot: string;
text: string;
iconType: "branch" | "pr";
actions: string[];
items: RunItem[];
};
const BOARD_STATUS_EVENTS = new Set([
"run.submitted",
"run.queued",
"run.starting",
"run.running",
"run.removing",
"run.paused",
"run.unpaused",
"run.blocked",
"run.unblocked",
"run.completed",
"run.failed",
"interview.started",
"interview.completed",
"interview.timeout",
"interview.interrupted",
]);
export function shouldRefreshBoardForEvent(event: string) {
return BOARD_STATUS_EVENTS.has(event);
}
export function buildBoardColumns(response: BoardRunsResponse): Column[] {
const grouped = new Map<string, RunItem[]>();
for (const col of response.columns) {
grouped.set(col.id, []);
}
for (const apiRun of apiRuns) {
for (const apiRun of response.data) {
if (grouped.has(apiRun.column)) {
grouped.get(apiRun.column)?.push(mapRunListItem(apiRun));
}
}
const columns = response.columns.map((col) => ({
id: col.id as ColumnStatus,
name: col.name,
...(columnStyles[col.id] ?? defaultColumnStyle),
items: grouped.get(col.id) ?? [],
}));
return response.columns.map((col) => {
const id = col.id as ColumnStatus;
const colors = columnStatusDisplay[id] ?? defaultColumnColors;
return {
id,
name: col.name,
dot: colors.dot,
text: colors.text,
...(columnStyles[id] ?? defaultColumnStyle),
items: grouped.get(col.id) ?? [],
};
});
}
return { columns };
export async function loader({ request }: any) {
const response = await apiPaginatedJson<
PaginatedBoardRunList["data"][number],
{ columns: BoardRunsResponse["columns"] }
>("/boards/runs", { request });
return { columns: buildBoardColumns(response) };
}
function boardLifecycleStatusLabel(run: Pick<RunItem, "column" | "lifecycleStatusLabel">): string | null {
if (run.lifecycleStatusLabel == null) return null;
if (run.column != null && columnNames[run.column] === run.lifecycleStatusLabel) {
if (run.column != null && columnStatusDisplay[run.column]?.label === run.lifecycleStatusLabel) {
return null;
}
return run.lifecycleStatusLabel;
@ -418,22 +455,12 @@ function SortablePrCard({
);
}
type Column = {
id: ColumnStatus;
name: string;
accent: string;
iconColor: string;
iconType: "branch" | "pr";
actions: string[];
items: RunItem[];
};
function BoardColumn({ column }: { column: Column }) {
const Icon = iconMap[column.iconType];
return (
<div className="flex min-w-0 flex-col">
<div className="mb-4 flex items-center gap-3">
<div className={`h-2.5 w-2.5 rounded-full ${column.accent}`} />
<div className={`h-2.5 w-2.5 rounded-full ${column.dot}`} />
<h3 className="text-sm font-semibold tracking-wide text-fg-2">
{column.name}
</h3>
@ -449,7 +476,7 @@ function BoardColumn({ column }: { column: Column }) {
key={pr.id}
pr={pr}
icon={Icon}
iconColor={column.iconColor}
iconColor={column.text}
actions={column.actions}
/>
))}
@ -655,11 +682,6 @@ export default function Runs({ loaderData }: any) {
const lowerQuery = query.toLowerCase();
const revalidator = useRevalidator();
const STATUS_EVENTS = new Set([
"run.submitted", "run.starting", "run.running",
"run.paused", "run.completed", "run.failed",
]);
useEffect(() => {
const source = new EventSource("/api/v1/attach");
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
@ -667,7 +689,7 @@ export default function Runs({ loaderData }: any) {
source.onmessage = (msg) => {
try {
const payload = JSON.parse(msg.data);
if (STATUS_EVENTS.has(payload.event)) {
if (shouldRefreshBoardForEvent(payload.event)) {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => revalidator.revalidate(), 500);
}
@ -804,7 +826,7 @@ export default function Runs({ loaderData }: any) {
{isCollapsed
? <ChevronRightIcon className="size-3.5 text-fg-muted" />
: <ChevronDownIcon className="size-3.5 text-fg-muted" />}
<div className={`h-2.5 w-2.5 rounded-full ${col.accent}`} />
<div className={`h-2.5 w-2.5 rounded-full ${col.dot}`} />
<h3 className="text-sm font-semibold tracking-wide text-fg-2">{col.name}</h3>
<span className="rounded-full bg-overlay px-2 py-0.5 font-mono text-xs text-fg-muted">
{col.items.length}

View file

@ -1,7 +1,7 @@
import { useState } from "react";
import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { Link, useParams } from "react-router";
import { ciConfig, columnNames, columnForStatus, deriveCiStatus, mapRunSummaryToRunItem, statusColors } from "../data/runs";
import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
import type { ColumnStatus, RunWithStatus } from "../data/runs";
import { apiJsonOrNull } from "../api";
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
@ -16,7 +16,7 @@ export async function loader({ request, params }: any) {
return {
...mapRunSummaryToRunItem(r),
status: column,
statusLabel: columnNames[column],
statusLabel: columnStatusDisplay[column].label,
};
})
.filter((run): run is RunWithStatus => run != null);
@ -32,7 +32,7 @@ function GitPullRequestIcon({ className }: { className?: string }) {
}
function RunRow({ run }: { run: RunWithStatus }) {
const colors = statusColors[run.status];
const colors = columnStatusDisplay[run.status];
return (
<Link to={`/runs/${run.id}`} className="grid items-center rounded-md border border-line bg-panel/80 px-4 py-3 transition-all duration-200 hover:border-line-strong hover:bg-panel" style={{ gridColumn: "1 / -1", gridTemplateColumns: "subgrid" }}>
<span className="flex items-center gap-2 pr-2">
@ -106,8 +106,8 @@ export default function WorkflowRuns({ loaderData }: any) {
className="appearance-none rounded-md border border-line bg-panel/80 py-2 pl-3 pr-8 text-sm text-fg-2 outline-none transition-colors focus:border-focus focus:ring-0"
>
<option value="all">All statuses</option>
{(Object.entries(columnNames) as [ColumnStatus, string][]).map(([id, name]) => (
<option key={id} value={id}>{name}</option>
{(Object.entries(columnStatusDisplay) as [ColumnStatus, { label: string }][]).map(([id, { label }]) => (
<option key={id} value={id}>{label}</option>
))}
</select>
<ChevronDownIcon className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />

View file

@ -2102,10 +2102,12 @@ components:
- queued
- starting
- running
- completed
- failed
- cancelled
- blocked
- paused
- removing
- succeeded
- failed
- dead
RunManifest:
description: Self-contained workflow run manifest.
@ -2484,6 +2486,10 @@ components:
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
blocked_reason:
oneOf:
- $ref: "#/components/schemas/BlockedReason"
- type: "null"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
@ -2861,19 +2867,6 @@ components:
items:
$ref: "#/components/schemas/RunArtifactEntry"
InternalRunStatus:
description: Internal event-sourced run status.
type: string
enum:
- submitted
- starting
- running
- paused
- removing
- succeeded
- failed
- dead
StatusReason:
description: Optional reason attached to a run status transition.
type: string
@ -2890,6 +2883,12 @@ components:
- sandbox_init_failed
- sandbox_initializing
BlockedReason:
description: Specific reason a run is blocked on external intervention.
type: string
enum:
- human_input_required
RunControlAction:
description: Run control action requested by the API.
type: string
@ -2906,11 +2905,15 @@ components:
- updated_at
properties:
status:
$ref: "#/components/schemas/InternalRunStatus"
reason:
$ref: "#/components/schemas/RunStatus"
status_reason:
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
blocked_reason:
oneOf:
- $ref: "#/components/schemas/BlockedReason"
- type: "null"
updated_at:
type: string
format: date-time
@ -2965,6 +2968,16 @@ components:
stderr:
type: ["string", "null"]
PendingInterviewRecord:
description: Pending interview question plus the time it entered the unresolved set.
type: object
properties:
question:
$ref: "#/components/schemas/ApiQuestion"
started_at:
type: ["string", "null"]
format: date-time
RunProjection:
description: Raw internal run projection derived from the event log.
type: object
@ -3016,6 +3029,10 @@ components:
pull_request:
type: ["object", "null"]
additionalProperties: true
pending_interviews:
type: object
additionalProperties:
$ref: "#/components/schemas/PendingInterviewRecord"
nodes:
type: object
description: Map from StageId (`node_id@visit`) to NodeState.
@ -3030,6 +3047,7 @@ components:
- goal
- title
- labels
- status
- repository
- created_at
properties:
@ -3058,11 +3076,15 @@ components:
type: string
format: date-time
status:
type: ["string", "null"]
$ref: "#/components/schemas/RunStatus"
status_reason:
oneOf:
- $ref: "#/components/schemas/StatusReason"
- type: "null"
blocked_reason:
oneOf:
- $ref: "#/components/schemas/BlockedReason"
- type: "null"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
@ -3085,7 +3107,7 @@ components:
enum:
- initializing
- running
- waiting
- blocked
- succeeded
- failed

View file

@ -514,11 +514,11 @@ mod tests {
fn cancel_run_response(run_id: RunId) -> serde_json::Value {
serde_json::json!({
"id": run_id,
"status": "cancelled",
"status": "failed",
"error": null,
"queue_position": null,
"status_reason": "cancelled",
"pending_control": "cancel",
"pending_control": null,
"created_at": "2026-04-05T12:00:00Z"
})
}

View file

@ -16,11 +16,6 @@ use crate::command_context::CommandContext;
use crate::server_runs::ServerSummaryLookup;
use crate::shared::{format_duration_ms, format_usd_micros};
#[cfg(test)]
const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
#[cfg(not(test))]
const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_secs(3);
pub(crate) async fn run(
args: &WaitArgs,
styles: &Styles,
@ -40,21 +35,8 @@ pub(crate) async fn run(
.timeout
.map(|secs| std::time::Instant::now() + std::time::Duration::from_secs(secs));
let interval = std::time::Duration::from_millis(args.interval);
let started_waiting_at = std::time::Instant::now();
let final_status = loop {
let status = client
.get_run_state(&run_id)
.await?
.status
.map(|record| record.status);
let status = status.unwrap_or_else(|| {
if started_waiting_at.elapsed() < WAIT_STARTUP_GRACE {
RunStatus::Submitted
} else {
RunStatus::Dead
}
});
let status = client.retrieve_run(&run_id).await?.status;
if status.is_terminal() {
break status;
@ -288,15 +270,4 @@ mod tests {
assert!(status.is_terminal());
assert_eq!(status, RunStatus::Succeeded);
}
#[test]
fn missing_status_treated_as_dead() {
let status = match std::fs::read_to_string(std::path::Path::new("/nonexistent/status.json"))
{
Ok(data) => serde_json::from_str::<RunStatusRecord>(&data)
.map_or(RunStatus::Dead, |record| record.status),
Err(_) => RunStatus::Dead,
};
assert_eq!(status, RunStatus::Dead);
}
}

View file

@ -149,9 +149,9 @@ fn status_cell(status: RunStatus, use_color: bool) -> CellStruct {
RunStatus::Succeeded => Some(Color::Green),
RunStatus::Failed => Some(Color::Red),
RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan),
RunStatus::Removing => Some(Color::Yellow),
RunStatus::Queued | RunStatus::Dead => Some(Color::Ansi256(8)),
RunStatus::Blocked | RunStatus::Removing => Some(Color::Yellow),
RunStatus::Paused => Some(Color::Magenta),
RunStatus::Dead => Some(Color::Ansi256(8)),
};
text.cell()
.bold(use_color && color != Some(Color::Ansi256(8)))

View file

@ -373,9 +373,10 @@ mod tests {
fn sample_status() -> RunStatusRecord {
RunStatusRecord {
status: RunStatus::Running,
reason: Some(StatusReason::SandboxInitializing),
updated_at: dt("2026-03-27T12:05:00Z"),
status: RunStatus::Running,
status_reason: Some(StatusReason::SandboxInitializing),
blocked_reason: None,
updated_at: dt("2026-03-27T12:05:00Z"),
}
}
@ -520,7 +521,7 @@ mod tests {
.await
.unwrap();
append_event(&run, &run_id, &Event::RunRunning {
reason: status_record.reason,
reason: status_record.status_reason,
})
.await
.unwrap();

View file

@ -620,6 +620,17 @@ impl ServerStoreClient {
Ok(all_runs)
}
pub(crate) async fn retrieve_run(&self, run_id: &RunId) -> Result<RunSummary> {
let response = self
.client
.retrieve_run()
.id(run_id.to_string())
.send()
.await
.map_err(map_api_error)?;
convert_type(response.into_inner())
}
pub(crate) async fn get_run_state(&self, run_id: &RunId) -> Result<RunProjection> {
let response = self
.client

View file

@ -63,7 +63,7 @@ impl ServerRunSummaryInfo {
}
pub(crate) fn status(&self) -> RunStatus {
self.summary.status.unwrap_or(RunStatus::Dead)
self.summary.status
}
pub(crate) fn status_reason(&self) -> Option<StatusReason> {

View file

@ -628,6 +628,12 @@ fn attach_json_errors_without_prompting_for_human_input() {
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "run.queued",
"id": "[EVENT_ID]",
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"event": "run.starting",
"id": "[EVENT_ID]",
@ -817,6 +823,15 @@ fn attach_json_errors_without_prompting_for_human_input() {
"run_id": "[ULID]",
"stage_id": "approve@1",
"ts": "[TIMESTAMP]"
},
{
"event": "run.blocked",
"id": "[EVENT_ID]",
"properties": {
"blocked_reason": "human_input_required"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
}
]
"#);

View file

@ -741,5 +741,5 @@ fn worker_exits_after_sigterm_cancel_even_when_stdin_stays_open() {
.status
.expect("cancelled run should have a status record");
assert_eq!(status_record.status.to_string(), "failed");
assert_eq!(status_record.reason, Some(StatusReason::Cancelled));
assert_eq!(status_record.status_reason, Some(StatusReason::Cancelled));
}

View file

@ -247,9 +247,9 @@ fn store_dump_exports_completed_run_snapshot() {
assert_snapshot!(dump_file_summary(&output_dir), @"
checkpoint.json
checkpoints/0012.json
checkpoints/0016.json
checkpoints/0020.json
checkpoints/0013.json
checkpoints/0017.json
checkpoints/0021.json
conclusion.json
events.jsonl
graph.fabro

View file

@ -1,6 +1,29 @@
use fabro_test::{fabro_snapshot, test_context};
use httpmock::MockServer;
use serde_json::json;
use super::support::{setup_completed_fast_dry_run, setup_created_fast_dry_run};
use crate::support::unique_run_id;
fn remote_run_summary(run_id: &str, status: &str) -> serde_json::Value {
json!({
"run_id": run_id,
"workflow_name": "Blocked Remote Workflow",
"workflow_slug": "blocked-remote-workflow",
"goal": "Wait for approval",
"title": "Wait for approval",
"labels": {},
"host_repo_path": "/srv/repo",
"repository": { "name": "repo" },
"start_time": "2026-04-19T12:00:00Z",
"created_at": "2026-04-19T12:00:00Z",
"status": status,
"status_reason": null,
"blocked_reason": null,
"duration_ms": null,
"total_usd_micros": null
})
}
#[test]
fn help() {
@ -114,3 +137,62 @@ fn wait_submitted_run_times_out() {
error: Timed out after 1s waiting for run '[ULID]'
");
}
#[test]
fn wait_blocked_run_times_out_without_treating_it_as_terminal() {
let context = test_context!();
let run_id = unique_run_id();
let server = MockServer::start();
let summary = remote_run_summary(run_id.as_str(), "blocked");
let list_runs = server.mock(|when, then| {
when.method("GET").path("/api/v1/runs");
then.status(200)
.header("content-type", "application/json")
.body(json!({ "data": [summary.clone()], "meta": { "has_more": false } }).to_string());
});
let retrieve_run = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{}", run_id.as_str()));
then.status(200)
.header("content-type", "application/json")
.body(summary.to_string());
});
let run_state = server.mock(|when, then| {
when.method("GET")
.path(format!("/api/v1/runs/{}/state", run_id.as_str()));
then.status(200)
.header("content-type", "application/json")
.body("{}");
});
let mut cmd = context.command();
cmd.args([
"wait",
"--server",
&format!("{}/api/v1", server.base_url()),
"--timeout",
"1",
"--interval",
"10",
run_id.as_str(),
]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: Timed out after 1s waiting for run '[ULID]'
");
list_runs.assert();
assert!(
retrieve_run.calls() > 0,
"wait should keep polling the blocked run summary until timeout"
);
assert_eq!(
run_state.calls(),
0,
"wait should not fetch run state when the run never becomes terminal"
);
}

View file

@ -260,7 +260,16 @@ pub(crate) async fn cancel_stub(
State(_state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
(StatusCode::OK, Json(serde_json::json!({"id": id, "status": "cancelled", "created_at": "2026-03-06T14:30:00Z"}))).into_response()
(
StatusCode::OK,
Json(serde_json::json!({
"id": id,
"status": "failed",
"status_reason": "cancelled",
"created_at": "2026-03-06T14:30:00Z"
})),
)
.into_response()
}
pub(crate) async fn pause_stub(
@ -664,7 +673,7 @@ mod runs {
workflow_slug: &str,
workflow_name: &str,
goal: &str,
status: Option<&str>,
status: &str,
created_at: &str,
elapsed_secs: Option<f64>,
status_reason: Option<&str>,
@ -687,8 +696,10 @@ mod runs {
},
run_id: run_id.into(),
start_time: Some(ts(created_at)),
status: status.map(str::to_string),
status: RunStatus::from_str(status)
.unwrap_or_else(|_| panic!("invalid demo run status: {status}")),
status_reason,
blocked_reason: None,
title: truncate_goal(goal),
total_usd_micros,
workflow_name: Some(workflow_name.into()),
@ -736,7 +747,7 @@ mod runs {
run_id: summary.run_id,
sandbox,
start_time: summary.start_time,
status: summary.status.unwrap_or_default(),
status: summary.status.to_string(),
status_reason: summary.status_reason,
title: summary.title,
total_usd_micros: summary.total_usd_micros,
@ -787,8 +798,8 @@ mod runs {
name: "Running".into(),
},
BoardColumnDefinition {
id: "waiting".into(),
name: "Waiting".into(),
id: "blocked".into(),
name: "Blocked".into(),
},
BoardColumnDefinition {
id: "succeeded".into(),
@ -809,7 +820,7 @@ mod runs {
"implement",
"Implement",
"Add rate limiting to auth endpoints",
Some("running"),
"running",
"2026-03-06T14:30:00Z",
Some(420.0),
None,
@ -823,7 +834,7 @@ mod runs {
"implement",
"Implement",
"Migrate to React Router v7",
Some("running"),
"running",
"2026-03-06T12:00:00Z",
Some(8100.0),
None,
@ -837,7 +848,7 @@ mod runs {
"expand",
"Expand",
"Update OpenAPI spec for v3",
Some("starting"),
"starting",
"2026-03-04T15:00:00Z",
Some(4320.0),
None,
@ -851,7 +862,7 @@ mod runs {
"implement",
"Implement",
"Add pipeline event types",
Some("paused"),
"blocked",
"2026-03-04T10:00:00Z",
Some(1680.0),
None,
@ -865,7 +876,7 @@ mod runs {
"implement",
"Implement",
"Add dark mode toggle",
Some("failed"),
"failed",
"2026-03-03T16:45:00Z",
Some(2100.0),
Some("workflow_error"),
@ -879,7 +890,7 @@ mod runs {
"implement",
"Implement",
"Implement webhook retry logic",
Some("succeeded"),
"succeeded",
"2026-02-28T14:00:00Z",
Some(259200.0),
Some("completed"),
@ -922,7 +933,7 @@ mod runs {
),
board_item(
take_summary(&mut summaries, "run-4"),
BoardColumn::Waiting,
BoardColumn::Blocked,
Some(pull_request(0, 145, 23, 0, vec![])),
Some(sandbox("sb-u1v2w3x4", 4, 8)),
Some(RunQuestion {
@ -1237,7 +1248,7 @@ mod runs {
"implement",
"Implement",
"Goal",
Some("failed"),
"failed",
"2026-03-06T14:30:00Z",
Some(1.0),
Some("cancelled"),
@ -1257,7 +1268,7 @@ mod runs {
"implement",
"Implement",
"Goal",
Some("failed"),
"failed",
"2026-03-06T14:30:00Z",
Some(1.0),
Some("unexpected_reason"),
@ -1278,7 +1289,7 @@ mod runs {
"implement",
"Implement",
&goal,
Some("running"),
"running",
"2026-03-06T14:30:00Z",
Some(1.0),
None,

File diff suppressed because it is too large Load diff

View file

@ -61,7 +61,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-arnv5m0e.js"></script>
<script type="module" src="/assets/entry-ez8gc920.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>

View file

@ -8,10 +8,10 @@ use fabro_types::run_event::{
RunFailedProps, StageCompletedProps, StagePromptProps,
};
use fabro_types::{
BilledModelUsage, Checkpoint, Conclusion, EventBody, FailureSignature, InterviewQuestionRecord,
InterviewQuestionType, NodeStatusRecord, Outcome, PullRequestRecord, Retro, RunControlAction,
RunEvent, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, StageStatus,
StartRecord, StatusReason,
BilledModelUsage, BlockedReason, Checkpoint, Conclusion, EventBody, FailureSignature,
InterviewQuestionRecord, InterviewQuestionType, NodeStatusRecord, Outcome, PullRequestRecord,
Retro, RunControlAction, RunEvent, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord,
StageStatus, StartRecord, StatusReason,
};
use serde_json::Value;
@ -113,12 +113,55 @@ impl RunProjection {
}
self.status = Some(run_status_record(RunStatus::Submitted, props.reason, ts));
}
EventBody::RunQueued(_) => {
self.status = Some(run_status_record(RunStatus::Queued, None, ts));
}
EventBody::RunStarting(props) => {
self.status = Some(run_status_record(RunStatus::Starting, props.reason, ts));
}
EventBody::RunRunning(props) => {
self.status = Some(run_status_record(RunStatus::Running, props.reason, ts));
}
EventBody::RunBlocked(props) => {
let visible_status = if self
.status
.as_ref()
.is_some_and(|status| status.status == RunStatus::Paused)
{
RunStatus::Paused
} else {
RunStatus::Blocked
};
self.status = Some(run_status_record_with_blocked_reason(
visible_status,
None,
Some(props.blocked_reason),
ts,
));
}
EventBody::RunUnblocked(_) => {
self.status = Some(match self.status.as_ref() {
Some(status) if status.status == RunStatus::Paused => {
run_status_record_with_blocked_reason(
RunStatus::Paused,
status.status_reason,
None,
ts,
)
}
Some(status) => run_status_record_with_blocked_reason(
if status.status == RunStatus::Blocked {
RunStatus::Running
} else {
status.status
},
status.status_reason,
None,
ts,
),
None => run_status_record(RunStatus::Running, None, ts),
});
}
EventBody::RunRemoving(props) => {
self.status = Some(run_status_record(RunStatus::Removing, props.reason, ts));
}
@ -132,11 +175,25 @@ impl RunProjection {
self.pending_control = Some(RunControlAction::Unpause);
}
EventBody::RunPaused(_) => {
self.status = Some(run_status_record(RunStatus::Paused, None, ts));
self.status = Some(run_status_record_with_blocked_reason(
RunStatus::Paused,
None,
self.status
.as_ref()
.and_then(|status| status.blocked_reason),
ts,
));
self.pending_control = None;
}
EventBody::RunUnpaused(_) => {
self.status = Some(run_status_record(RunStatus::Running, None, ts));
self.status = Some(run_status_record_with_blocked_reason(
RunStatus::Running,
None,
self.status
.as_ref()
.and_then(|status| status.blocked_reason),
ts,
));
self.pending_control = None;
}
EventBody::RunCompleted(props) => {
@ -377,8 +434,15 @@ impl RunProjection {
.unwrap_or_default(),
host_repo_path: self.run.as_ref().and_then(|run| run.host_repo_path.clone()),
start_time: self.start.as_ref().map(|start| start.start_time),
status: self.status.as_ref().map(|status| status.status),
status_reason: self.status.as_ref().and_then(|status| status.reason),
status: self
.status
.as_ref()
.map_or(RunStatus::Submitted, |status| status.status),
status_reason: self.status.as_ref().and_then(|status| status.status_reason),
blocked_reason: self
.status
.as_ref()
.and_then(|status| status.blocked_reason),
pending_control: self.pending_control,
duration_ms: self
.conclusion
@ -423,12 +487,22 @@ impl RunProjection {
fn run_status_record(
status: RunStatus,
reason: Option<StatusReason>,
status_reason: Option<StatusReason>,
updated_at: DateTime<Utc>,
) -> RunStatusRecord {
run_status_record_with_blocked_reason(status, status_reason, None, updated_at)
}
fn run_status_record_with_blocked_reason(
status: RunStatus,
status_reason: Option<StatusReason>,
blocked_reason: Option<BlockedReason>,
updated_at: DateTime<Utc>,
) -> RunStatusRecord {
RunStatusRecord {
status,
reason,
status_reason,
blocked_reason,
updated_at,
}
}
@ -581,7 +655,9 @@ mod tests {
use std::collections::HashMap;
use chrono::Utc;
use fabro_types::run_event::{InterviewCompletedProps, InterviewOption, InterviewStartedProps};
use fabro_types::run_event::{
InterviewCompletedProps, InterviewOption, InterviewStartedProps, RunControlEffectProps,
};
use fabro_types::settings::SettingsLayer;
use fabro_types::{
Checkpoint, EventBody, InterviewQuestionType, RunBlobId, RunControlAction, RunEvent,
@ -616,6 +692,29 @@ mod tests {
}
}
fn test_raw_event(
seq: u32,
event: &str,
properties: &serde_json::Value,
node_id: Option<&str>,
) -> EventEnvelope {
EventEnvelope {
seq,
payload: EventPayload::new(
json!({
"id": format!("evt-{seq}"),
"ts": Utc::now().to_rfc3339(),
"run_id": fixtures::RUN_1,
"event": event,
"node_id": node_id,
"properties": properties,
}),
&fixtures::RUN_1,
)
.unwrap(),
}
}
#[test]
fn deserialize_projection_defaults_missing_nodes_and_checkpoints() {
let state: RunProjection = serde_json::from_value(serde_json::json!({
@ -780,6 +879,162 @@ mod tests {
);
}
#[test]
fn queued_and_blocked_events_drive_projection_and_summary_fields() {
let mut state = RunProjection::default();
state
.apply_event(&test_raw_event(1, "run.queued", &json!({}), None))
.unwrap();
assert_eq!(
state
.status
.as_ref()
.map(|status| status.status.to_string()),
Some("queued".to_string())
);
state
.apply_event(&test_event(
2,
EventBody::RunPaused(RunControlEffectProps::default()),
None,
))
.unwrap();
state
.apply_event(&test_raw_event(
3,
"run.blocked",
&json!({ "blocked_reason": "human_input_required" }),
None,
))
.unwrap();
let status_json = serde_json::to_value(state.status.as_ref().unwrap()).unwrap();
assert_eq!(status_json["status"], "paused");
assert_eq!(status_json["status_reason"], serde_json::Value::Null);
assert_eq!(status_json["blocked_reason"], "human_input_required");
let summary = state.build_summary(&fixtures::RUN_1);
let summary_json = serde_json::to_value(summary).unwrap();
assert_eq!(summary_json["status"], "paused");
assert_eq!(summary_json["status_reason"], serde_json::Value::Null);
assert_eq!(summary_json["blocked_reason"], "human_input_required");
}
#[test]
fn run_unblocked_clears_blocked_reason_and_restores_running() {
let mut state = RunProjection::default();
state
.apply_event(&test_raw_event(
1,
"run.blocked",
&json!({ "blocked_reason": "human_input_required" }),
None,
))
.unwrap();
state
.apply_event(&test_raw_event(2, "run.unblocked", &json!({}), None))
.unwrap();
let status_json = serde_json::to_value(state.status.as_ref().unwrap()).unwrap();
assert_eq!(status_json["status"], "running");
assert_eq!(status_json["blocked_reason"], serde_json::Value::Null);
}
#[test]
fn run_unblocked_while_paused_clears_blocked_reason_without_changing_paused_status() {
let mut state = RunProjection::default();
state
.apply_event(&test_raw_event(
1,
"run.blocked",
&json!({ "blocked_reason": "human_input_required" }),
None,
))
.unwrap();
state
.apply_event(&test_event(
2,
EventBody::RunPaused(RunControlEffectProps::default()),
None,
))
.unwrap();
state
.apply_event(&test_raw_event(3, "run.unblocked", &json!({}), None))
.unwrap();
let status_json = serde_json::to_value(state.status.as_ref().unwrap()).unwrap();
assert_eq!(status_json["status"], "paused");
assert_eq!(status_json["blocked_reason"], serde_json::Value::Null);
}
#[test]
fn unpause_to_still_blocked_yields_visible_blocked_after_event_sequence() {
let mut state = RunProjection::default();
state
.apply_event(&test_raw_event(
1,
"run.blocked",
&json!({ "blocked_reason": "human_input_required" }),
None,
))
.unwrap();
state
.apply_event(&test_event(
2,
EventBody::RunPaused(RunControlEffectProps::default()),
None,
))
.unwrap();
state
.apply_event(&test_event(
3,
EventBody::RunUnpaused(RunControlEffectProps::default()),
None,
))
.unwrap();
state
.apply_event(&test_raw_event(
4,
"run.blocked",
&json!({ "blocked_reason": "human_input_required" }),
None,
))
.unwrap();
let status_json = serde_json::to_value(state.status.as_ref().unwrap()).unwrap();
assert_eq!(status_json["status"], "blocked");
assert_eq!(status_json["blocked_reason"], "human_input_required");
}
#[test]
fn summary_synthesizes_submitted_when_run_exists_without_status() {
let state = RunProjection {
run: Some(fabro_types::RunRecord {
run_id: fixtures::RUN_1,
settings: SettingsLayer::default(),
graph: fabro_types::Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: std::path::PathBuf::from("/tmp/run"),
host_repo_path: Some("/tmp/repo".to_string()),
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
}),
..RunProjection::default()
};
let summary_json = serde_json::to_value(state.build_summary(&fixtures::RUN_1)).unwrap();
assert_eq!(summary_json["status"], "submitted");
}
#[test]
fn projection_serialization_includes_manifest_and_definition_blob_refs() {
let manifest_blob = RunBlobId::new(br#"{"version":1}"#).to_string();

View file

@ -402,7 +402,7 @@ mod tests {
assert_eq!(summary[1].run_id, test_run_id("run-1"));
assert_eq!(summary[1].workflow_name, Some("night-sky".to_string()));
assert_eq!(summary[1].goal, Some("map the constellations".to_string()));
assert_eq!(summary[1].status, Some(RunStatus::Succeeded));
assert_eq!(summary[1].status, RunStatus::Succeeded);
assert_eq!(summary[1].status_reason, Some(StatusReason::Completed));
let reopened = store.open_run(&test_run_id("run-1")).await.unwrap();
@ -471,7 +471,7 @@ mod tests {
let summary = store.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0].status, Some(RunStatus::Running));
assert_eq!(summary[0].status, RunStatus::Running);
assert_eq!(summary[0].pending_control, Some(RunControlAction::Pause));
}
@ -536,7 +536,7 @@ mod tests {
let summary = store.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0].status, Some(RunStatus::Failed));
assert_eq!(summary[0].status, RunStatus::Failed);
assert_eq!(summary[0].status_reason, Some(StatusReason::Cancelled));
assert_eq!(summary[0].pending_control, None);
}
@ -581,6 +581,6 @@ mod tests {
let summary = reopened.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(summary.len(), 1);
assert_eq!(summary[0].run_id, test_run_id("run-1"));
assert_eq!(summary[0].status, Some(RunStatus::Succeeded));
assert_eq!(summary[0].status, RunStatus::Succeeded);
}
}

View file

@ -1,7 +1,7 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use fabro_types::{RunControlAction, RunEvent, RunId, RunStatus, StatusReason};
use fabro_types::{BlockedReason, RunControlAction, RunEvent, RunId, RunStatus, StatusReason};
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
@ -15,8 +15,9 @@ pub struct RunSummary {
pub labels: HashMap<String, String>,
pub host_repo_path: Option<String>,
pub start_time: Option<DateTime<Utc>>,
pub status: Option<RunStatus>,
pub status: RunStatus,
pub status_reason: Option<StatusReason>,
pub blocked_reason: Option<BlockedReason>,
pub pending_control: Option<RunControlAction>,
pub duration_ms: Option<u64>,
pub total_usd_micros: Option<i64>,

View file

@ -53,6 +53,6 @@ pub use sandbox_record::SandboxRecord;
pub use stage_id::{ParallelBranchId, StageId};
pub use start::StartRecord;
pub use status::{
InvalidTransition, ParseRunStatusError, RunControlAction, RunStatus, RunStatusRecord,
StatusReason,
BlockedReason, InvalidTransition, ParseRunStatusError, RunControlAction, RunStatus,
RunStatusRecord, StatusReason,
};

View file

@ -90,10 +90,16 @@ pub enum EventBody {
RunStarted(RunStartedProps),
#[serde(rename = "run.submitted")]
RunSubmitted(RunSubmittedProps),
#[serde(rename = "run.queued")]
RunQueued(RunStatusEffectProps),
#[serde(rename = "run.starting")]
RunStarting(RunStatusTransitionProps),
#[serde(rename = "run.running")]
RunRunning(RunStatusTransitionProps),
#[serde(rename = "run.blocked")]
RunBlocked(RunBlockedProps),
#[serde(rename = "run.unblocked")]
RunUnblocked(RunStatusEffectProps),
#[serde(rename = "run.removing")]
RunRemoving(RunStatusTransitionProps),
#[serde(rename = "run.cancel.requested")]
@ -357,8 +363,11 @@ impl EventBody {
Self::RunCreated(_) => "run.created",
Self::RunStarted(_) => "run.started",
Self::RunSubmitted(_) => "run.submitted",
Self::RunQueued(_) => "run.queued",
Self::RunStarting(_) => "run.starting",
Self::RunRunning(_) => "run.running",
Self::RunBlocked(_) => "run.blocked",
Self::RunUnblocked(_) => "run.unblocked",
Self::RunRemoving(_) => "run.removing",
Self::RunCancelRequested(_) => "run.cancel.requested",
Self::RunPauseRequested(_) => "run.pause.requested",
@ -488,8 +497,11 @@ fn is_known_event_name(event: &str) -> bool {
"run.created"
| "run.started"
| "run.submitted"
| "run.queued"
| "run.starting"
| "run.running"
| "run.blocked"
| "run.unblocked"
| "run.removing"
| "run.rewound"
| "run.completed"
@ -1057,4 +1069,68 @@ mod tests {
assert!(!obj.contains_key("tool_call_id"));
assert!(!obj.contains_key("actor"));
}
#[test]
fn canonical_run_lifecycle_events_are_known() {
for event in ["run.queued", "run.blocked", "run.unblocked"] {
assert!(
is_known_event_name(event),
"{event} should be a known event"
);
}
}
#[test]
fn run_blocked_round_trips_as_typed_event() {
let value = json!({
"id": "evt_run_blocked",
"ts": "2026-04-19T12:00:00.000Z",
"run_id": fixtures::RUN_1,
"event": "run.blocked",
"properties": {
"blocked_reason": "human_input_required"
}
});
let parsed = RunEvent::from_value(value.clone()).unwrap();
assert!(
!matches!(parsed.body, EventBody::Unknown { .. }),
"run.blocked should deserialize into a typed event body"
);
let serialized = parsed.to_value().unwrap();
assert_eq!(serialized["event"], "run.blocked");
assert_eq!(
serialized["properties"]["blocked_reason"],
value["properties"]["blocked_reason"]
);
}
#[test]
fn run_queued_and_unblocked_round_trip_as_typed_events() {
for value in [
json!({
"id": "evt_run_queued",
"ts": "2026-04-19T12:00:00.000Z",
"run_id": fixtures::RUN_1,
"event": "run.queued",
"properties": {}
}),
json!({
"id": "evt_run_unblocked",
"ts": "2026-04-19T12:00:00.000Z",
"run_id": fixtures::RUN_1,
"event": "run.unblocked",
"properties": {}
}),
] {
let parsed = RunEvent::from_value(value.clone()).unwrap();
assert!(
!matches!(parsed.body, EventBody::Unknown { .. }),
"{} should deserialize into a typed event body",
value["event"].as_str().unwrap()
);
assert_eq!(parsed.to_value().unwrap()["event"], value["event"]);
}
}
}

View file

@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use super::{BilledTokenCounts, RunNoticeLevel};
use crate::settings::SettingsLayer;
use crate::status::BlockedReason;
use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, StatusReason};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -55,6 +56,10 @@ pub struct RunStatusTransitionProps {
pub reason: Option<StatusReason>,
}
#[allow(clippy::empty_structs_with_brackets)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunStatusEffectProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSubmittedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -68,6 +73,11 @@ pub struct RunControlRequestedProps {
pub action: RunControlAction,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunBlockedProps {
pub blocked_reason: BlockedReason,
}
#[allow(clippy::empty_structs_with_brackets)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunControlEffectProps {}

View file

@ -8,8 +8,10 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Submitted,
Queued,
Starting,
Running,
Blocked,
Paused,
Removing,
Succeeded,
@ -25,7 +27,13 @@ impl RunStatus {
pub fn is_active(self) -> bool {
matches!(
self,
Self::Submitted | Self::Starting | Self::Running | Self::Paused | Self::Removing
Self::Submitted
| Self::Queued
| Self::Starting
| Self::Running
| Self::Blocked
| Self::Paused
| Self::Removing
)
}
@ -38,16 +46,18 @@ impl RunStatus {
}
matches!(
(self, to),
(Self::Submitted, Self::Starting)
| (Self::Starting | Self::Paused, Self::Running)
(Self::Submitted, Self::Queued)
| (Self::Queued, Self::Starting)
| (Self::Starting | Self::Paused | Self::Blocked, Self::Running)
| (
Self::Starting | Self::Running | Self::Paused | Self::Removing,
Self::Starting | Self::Running | Self::Blocked | Self::Paused | Self::Removing,
Self::Failed
)
| (
Self::Running,
Self::Succeeded | Self::Paused | Self::Removing
Self::Succeeded | Self::Blocked | Self::Paused | Self::Removing
)
| (Self::Blocked, Self::Paused)
| (Self::Paused, Self::Removing)
)
}
@ -65,8 +75,10 @@ impl fmt::Display for RunStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Submitted => "submitted",
Self::Queued => "queued",
Self::Starting => "starting",
Self::Running => "running",
Self::Blocked => "blocked",
Self::Paused => "paused",
Self::Removing => "removing",
Self::Succeeded => "succeeded",
@ -83,8 +95,10 @@ impl FromStr for RunStatus {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"submitted" => Ok(Self::Submitted),
"queued" => Ok(Self::Queued),
"starting" => Ok(Self::Starting),
"running" => Ok(Self::Running),
"blocked" => Ok(Self::Blocked),
"paused" => Ok(Self::Paused),
"removing" => Ok(Self::Removing),
"succeeded" => Ok(Self::Succeeded),
@ -136,6 +150,12 @@ pub enum StatusReason {
SandboxInitializing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockedReason {
HumanInputRequired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunControlAction {
@ -146,18 +166,57 @@ pub enum RunControlAction {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStatusRecord {
pub status: RunStatus,
pub status: RunStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<StatusReason>,
pub updated_at: DateTime<Utc>,
pub status_reason: Option<StatusReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocked_reason: Option<BlockedReason>,
pub updated_at: DateTime<Utc>,
}
impl RunStatusRecord {
pub fn new(status: RunStatus, reason: Option<StatusReason>) -> Self {
pub fn new(status: RunStatus, status_reason: Option<StatusReason>) -> Self {
Self {
status,
reason,
status_reason,
blocked_reason: None,
updated_at: Utc::now(),
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::RunStatus;
#[test]
fn queued_and_blocked_parse_and_format() {
for status in ["queued", "blocked"] {
let parsed = RunStatus::from_str(status)
.unwrap_or_else(|_| panic!("expected {status} to parse"));
assert_eq!(parsed.to_string(), status);
assert!(parsed.is_active(), "{status} should be active");
assert!(!parsed.is_terminal(), "{status} should not be terminal");
}
}
#[test]
fn canonical_blocked_transitions_are_allowed() {
let submitted = RunStatus::from_str("submitted").unwrap();
let queued =
RunStatus::from_str("queued").unwrap_or_else(|_| panic!("expected queued to parse"));
let running = RunStatus::from_str("running").unwrap();
let blocked =
RunStatus::from_str("blocked").unwrap_or_else(|_| panic!("expected blocked to parse"));
let paused = RunStatus::from_str("paused").unwrap();
assert!(submitted.can_transition_to(queued));
assert!(queued.can_transition_to(RunStatus::from_str("starting").unwrap()));
assert!(running.can_transition_to(blocked));
assert!(blocked.can_transition_to(running));
assert!(blocked.can_transition_to(paused));
assert!(blocked.can_transition_to(RunStatus::from_str("failed").unwrap()));
}
}

View file

@ -5,8 +5,8 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use ::fabro_types::{
ActorRef, BilledTokenCounts, ParallelBranchId, RunBlobId, RunControlAction, RunEvent, RunId,
RunProvenance, StageId, StageStatus, StatusReason, run_event as fabro_types,
ActorRef, BilledTokenCounts, BlockedReason, ParallelBranchId, RunBlobId, RunControlAction,
RunEvent, RunId, RunProvenance, StageId, StageStatus, StatusReason, run_event as fabro_types,
};
use anyhow::{Context, Result};
use chrono::Utc;
@ -78,6 +78,7 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
definition_blob: Option<RunBlobId>,
},
RunQueued,
RunStarting {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
@ -86,6 +87,10 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
},
RunBlocked {
blocked_reason: BlockedReason,
},
RunUnblocked,
RunRemoving {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
@ -574,12 +579,21 @@ impl Event {
} => {
info!(?reason, ?definition_blob, "Run submitted");
}
Self::RunQueued => {
info!("Run queued");
}
Self::RunStarting { reason } => {
info!(?reason, "Run starting");
}
Self::RunRunning { reason } => {
info!(?reason, "Run running");
}
Self::RunBlocked { blocked_reason } => {
info!(?blocked_reason, "Run blocked");
}
Self::RunUnblocked => {
info!("Run unblocked");
}
Self::RunRemoving { reason } => {
info!(?reason, "Run removing");
}
@ -1146,8 +1160,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::RunStarting { .. } => "run.starting",
Event::RunRunning { .. } => "run.running",
Event::RunBlocked { .. } => "run.blocked",
Event::RunUnblocked => "run.unblocked",
Event::RunRemoving { .. } => "run.removing",
Event::RunCancelRequested { .. } => "run.cancel.requested",
Event::RunPauseRequested { .. } => "run.pause.requested",
@ -1519,12 +1536,21 @@ fn event_body_from_event(event: &Event) -> EventBody {
reason: *reason,
definition_blob: *definition_blob,
}),
Event::RunQueued => EventBody::RunQueued(fabro_types::RunStatusEffectProps::default()),
Event::RunStarting { reason } => {
EventBody::RunStarting(fabro_types::RunStatusTransitionProps { reason: *reason })
}
Event::RunRunning { reason } => {
EventBody::RunRunning(fabro_types::RunStatusTransitionProps { reason: *reason })
}
Event::RunBlocked { blocked_reason } => {
EventBody::RunBlocked(fabro_types::RunBlockedProps {
blocked_reason: *blocked_reason,
})
}
Event::RunUnblocked => {
EventBody::RunUnblocked(fabro_types::RunStatusEffectProps::default())
}
Event::RunRemoving { reason } => {
EventBody::RunRemoving(fabro_types::RunStatusTransitionProps { reason: *reason })
}

View file

@ -1,11 +1,12 @@
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use async_trait::async_trait;
use fabro_graphviz::graph::{Graph, Node};
use fabro_interview::{Answer, AnswerValue, Interviewer, Question, QuestionOption, QuestionType};
use fabro_types::BlockedReason;
use fabro_types::run_event::InterviewOption;
use ulid::Ulid;
@ -66,10 +67,61 @@ fn parse_accelerator_key(label: &str) -> String {
.unwrap_or_default()
}
/// Refcount of open interviews for this handler's run. Emits `run.blocked`
/// exactly once when the count transitions 0→1, and `run.unblocked` exactly
/// once when it transitions back to 0. Internal to `HumanHandler`; shared
/// across concurrent `execute` calls fanned out by `ParallelHandler`.
struct BlockedStateTracker {
unresolved_interviews: AtomicUsize,
}
impl BlockedStateTracker {
fn new() -> Self {
Self {
unresolved_interviews: AtomicUsize::new(0),
}
}
fn interview_started(&self, emitter: &Emitter) {
if self.unresolved_interviews.fetch_add(1, Ordering::AcqRel) == 0 {
emitter.emit(&Event::RunBlocked {
blocked_reason: BlockedReason::HumanInputRequired,
});
}
}
fn interview_resolved(&self, emitter: &Emitter) {
// Guard against unmatched resolves (e.g., tests that over-resolve) so
// the counter cannot underflow. `compare_exchange_weak` loops until we
// either observe zero (and bail) or successfully decrement.
let mut current = self.unresolved_interviews.load(Ordering::Acquire);
loop {
if current == 0 {
return;
}
match self.unresolved_interviews.compare_exchange_weak(
current,
current - 1,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
if current == 1 {
emitter.emit(&Event::RunUnblocked);
}
return;
}
Err(observed) => current = observed,
}
}
}
}
/// Blocks until a human selects an option derived from outgoing edges.
pub struct HumanHandler {
interviewer: Arc<dyn Interviewer>,
emitter: Option<Arc<Emitter>>,
tracker: BlockedStateTracker,
}
impl HumanHandler {
@ -77,6 +129,7 @@ impl HumanHandler {
Self {
interviewer,
emitter: None,
tracker: BlockedStateTracker::new(),
}
}
@ -229,6 +282,7 @@ impl Handler for HumanHandler {
},
&stage_scope,
);
self.tracker.interview_started(services.emitter.as_ref());
let interview_start = Instant::now();
let answer = self.interviewer.ask(question).await;
@ -244,6 +298,7 @@ impl Handler for HumanHandler {
},
&stage_scope,
);
self.tracker.interview_resolved(services.emitter.as_ref());
let default_choice = node
.attrs
.get("human.default_choice")
@ -282,6 +337,7 @@ impl Handler for HumanHandler {
},
&stage_scope,
);
self.tracker.interview_resolved(services.emitter.as_ref());
return Ok(unanswered_human_gate(
"human interaction interrupted before an answer was provided",
));
@ -297,6 +353,7 @@ impl Handler for HumanHandler {
},
&stage_scope,
);
self.tracker.interview_resolved(services.emitter.as_ref());
return Ok(unanswered_human_gate("human skipped interaction"));
}
@ -311,6 +368,7 @@ impl Handler for HumanHandler {
},
&stage_scope,
);
self.tracker.interview_resolved(services.emitter.as_ref());
// 6. Try fixed-choice match
if let Some(selected) = find_choice_match(&answer, &choices) {
@ -651,6 +709,47 @@ mod tests {
);
}
#[tokio::test]
async fn wait_human_emits_blocked_then_unblocked_around_interview() {
let interviewer = Arc::new(CallbackInterviewer::new(|_| {
Answer::selected("A", QuestionOption {
key: "A".to_string(),
label: "Approve".to_string(),
})
}));
let handler = HumanHandler::new(interviewer);
let graph = build_graph_with_human_gate();
let node = graph.nodes.get("gate").unwrap();
let context = Context::new();
let run_dir = Path::new("/tmp/test");
let events = Arc::new(Mutex::new(Vec::new()));
handler
.execute(
node,
&context,
&graph,
run_dir,
&make_services_with_events(Arc::clone(&events)),
)
.await
.unwrap();
let event_names = events
.lock()
.expect("event log lock poisoned")
.iter()
.map(|event| event.event_name().to_string())
.collect::<Vec<_>>();
assert_eq!(event_names, vec![
"interview.started",
"run.blocked",
"interview.completed",
"run.unblocked",
]);
}
#[tokio::test]
async fn wait_human_with_freeform_edge() {
let interviewer = Arc::new(fabro_interview::CallbackInterviewer::new(|_| {
@ -747,4 +846,48 @@ mod tests {
);
assert_eq!(outcome.suggested_next_ids, vec!["approve"]);
}
#[test]
fn blocked_state_tracker_emits_once_across_parallel_interview_races() {
let tracker = BlockedStateTracker::new();
let emitter = Arc::new(Emitter::new(fabro_types::fixtures::RUN_1));
let event_names = Arc::new(Mutex::new(Vec::new()));
emitter.on_event({
let event_names = Arc::clone(&event_names);
move |event| {
let name = match &event.body {
EventBody::RunBlocked(_) => Some("run.blocked"),
EventBody::RunUnblocked(_) => Some("run.unblocked"),
_ => None,
};
if let Some(name) = name {
event_names.lock().unwrap().push(name.to_string());
}
}
});
std::thread::scope(|scope| {
for _ in 0..8 {
let tracker = &tracker;
let emitter = Arc::clone(&emitter);
scope.spawn(move || tracker.interview_started(emitter.as_ref()));
}
});
std::thread::scope(|scope| {
for _ in 0..8 {
let tracker = &tracker;
let emitter = Arc::clone(&emitter);
scope.spawn(move || tracker.interview_resolved(emitter.as_ref()));
}
});
tracker.interview_resolved(emitter.as_ref());
assert_eq!(event_names.lock().unwrap().as_slice(), [
"run.blocked",
"run.unblocked"
],);
}
}

View file

@ -125,7 +125,10 @@ pub async fn start(run_dir: &Path, services: StartServices) -> Result<Started, E
}
if let Some(record) = state.status {
if !matches!(record.status, RunStatus::Submitted | RunStatus::Starting) {
if !matches!(
record.status,
RunStatus::Submitted | RunStatus::Queued | RunStatus::Starting
) {
return Err(Error::Precondition(format!(
"cannot start run: status is {:?}, expected submitted",
record.status
@ -975,8 +978,8 @@ async fn persist_detached_failure(
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::Utc;
@ -988,7 +991,7 @@ mod tests {
use super::*;
use crate::context::Context;
use crate::event::Emitter;
use crate::event::{Emitter, EventBody};
use crate::handler::HandlerRegistry;
use crate::handler::exit::ExitHandler;
use crate::handler::manager_loop::SubWorkflowHandler;

View file

@ -788,7 +788,7 @@ async fn execute_cancelled_mid_run_persists_cancelled_status() {
assert!(matches!(executed.outcome, Err(Error::Cancelled)));
let status = executed.run_store.state().await.unwrap().status.unwrap();
assert_eq!(status.status, RunStatus::Failed);
assert_eq!(status.reason, Some(StatusReason::Cancelled));
assert_eq!(status.status_reason, Some(StatusReason::Cancelled));
}
#[tokio::test]

View file

@ -71,8 +71,7 @@ impl RunInfo {
pub fn status(&self) -> RunStatus {
self.summary
.as_ref()
.and_then(|summary| summary.status)
.unwrap_or(RunStatus::Dead)
.map_or(RunStatus::Submitted, |summary| summary.status)
}
pub fn status_reason(&self) -> Option<StatusReason> {
@ -235,7 +234,7 @@ fn run_info_from_summary(summary: &RunSummary, scratch_base: &Path) -> Option<Ru
}
let dir_name = path.file_name()?.to_string_lossy().to_string();
let start_time_dt = summary.run_id.created_at();
let end_time = if summary.status.is_some_and(RunStatus::is_terminal) {
let end_time = if summary.status.is_terminal() {
summary.duration_ms.and_then(|duration_ms| {
Some(start_time_dt + chrono::Duration::milliseconds(i64::try_from(duration_ms).ok()?))
})

View file

@ -31,6 +31,7 @@ models/assistant-stage-turn.ts
models/billed-token-counts.ts
models/billing-by-model.ts
models/billing-stage-ref.ts
models/blocked-reason.ts
models/board-column-definition.ts
models/board-column.ts
models/check-run-status.ts
@ -66,7 +67,6 @@ models/file-diff.ts
models/health-response.ts
models/history-entry.ts
models/index.ts
models/internal-run-status.ts
models/internal-stage-status.ts
models/manifest-args.ts
models/manifest-config.ts
@ -97,6 +97,7 @@ models/paginated-run-stage-list.ts
models/paginated-saved-query-list.ts
models/paginated-stage-turn-list.ts
models/pagination-meta.ts
models/pending-interview-record.ts
models/preflight-check-detail.ts
models/preflight-check-report.ts
models/preflight-check-result.ts

View file

@ -15,21 +15,14 @@
/**
* Internal event-sourced run status.
* Specific reason a run is blocked on external intervention.
*/
export const InternalRunStatus = {
SUBMITTED: 'submitted',
STARTING: 'starting',
RUNNING: 'running',
PAUSED: 'paused',
REMOVING: 'removing',
SUCCEEDED: 'succeeded',
FAILED: 'failed',
DEAD: 'dead'
export const BlockedReason = {
HUMAN_INPUT_REQUIRED: 'human_input_required'
} as const;
export type InternalRunStatus = typeof InternalRunStatus[keyof typeof InternalRunStatus];
export type BlockedReason = typeof BlockedReason[keyof typeof BlockedReason];

View file

@ -21,7 +21,7 @@
export const BoardColumn = {
INITIALIZING: 'initializing',
RUNNING: 'running',
WAITING: 'waiting',
BLOCKED: 'blocked',
SUCCEEDED: 'succeeded',
FAILED: 'failed'
} as const;

View file

@ -13,6 +13,7 @@ export * from './assistant-stage-turn';
export * from './billed-token-counts';
export * from './billing-by-model';
export * from './billing-stage-ref';
export * from './blocked-reason';
export * from './board-column';
export * from './board-column-definition';
export * from './check-run';
@ -47,7 +48,6 @@ export * from './file-checkpoint';
export * from './file-diff';
export * from './health-response';
export * from './history-entry';
export * from './internal-run-status';
export * from './internal-stage-status';
export * from './manifest-args';
export * from './manifest-config';
@ -78,6 +78,7 @@ export * from './paginated-run-stage-list';
export * from './paginated-saved-query-list';
export * from './paginated-stage-turn-list';
export * from './pagination-meta';
export * from './pending-interview-record';
export * from './preflight-check-detail';
export * from './preflight-check-report';
export * from './preflight-check-result';

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.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ApiQuestion } from './api-question';
/**
* Pending interview question plus the time it entered the unresolved set.
*/
export interface PendingInterviewRecord {
'question'?: ApiQuestion;
'started_at'?: string | null;
}

View file

@ -18,6 +18,9 @@
import type { NodeState } from './node-state';
// May contain unused imports in some cases
// @ts-ignore
import type { PendingInterviewRecord } from './pending-interview-record';
// May contain unused imports in some cases
// @ts-ignore
import type { RunCheckpoint } from './run-checkpoint';
// May contain unused imports in some cases
// @ts-ignore
@ -46,6 +49,7 @@ export interface RunProjection {
'sandbox'?: { [key: string]: any; } | null;
'final_patch'?: string | null;
'pull_request'?: { [key: string]: any; } | null;
'pending_interviews'?: { [key: string]: PendingInterviewRecord; };
/**
* Map from StageId (`node_id@visit`) to NodeState.
*/

View file

@ -15,7 +15,10 @@
// May contain unused imports in some cases
// @ts-ignore
import type { InternalRunStatus } from './internal-run-status';
import type { BlockedReason } from './blocked-reason';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatus } from './run-status';
// May contain unused imports in some cases
// @ts-ignore
import type { StatusReason } from './status-reason';
@ -24,8 +27,9 @@ import type { StatusReason } from './status-reason';
* Internal run status record from the event projection.
*/
export interface RunStatusRecord {
'status': InternalRunStatus;
'reason'?: StatusReason | null;
'status': RunStatus;
'status_reason'?: StatusReason | null;
'blocked_reason'?: BlockedReason | null;
'updated_at': string;
}

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { BlockedReason } from './blocked-reason';
// May contain unused imports in some cases
// @ts-ignore
import type { RunControlAction } from './run-control-action';
@ -41,6 +44,7 @@ export interface RunStatusResponse {
*/
'queue_position'?: number;
'status_reason'?: StatusReason | null;
'blocked_reason'?: BlockedReason | null;
'pending_control'?: RunControlAction | null;
/**
* Timestamp when the run was created.

View file

@ -23,10 +23,12 @@ export const RunStatus = {
QUEUED: 'queued',
STARTING: 'starting',
RUNNING: 'running',
COMPLETED: 'completed',
BLOCKED: 'blocked',
PAUSED: 'paused',
REMOVING: 'removing',
SUCCEEDED: 'succeeded',
FAILED: 'failed',
CANCELLED: 'cancelled',
PAUSED: 'paused'
DEAD: 'dead'
} as const;
export type RunStatus = typeof RunStatus[keyof typeof RunStatus];

View file

@ -5,7 +5,7 @@
* 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
@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { BlockedReason } from './blocked-reason';
// May contain unused imports in some cases
// @ts-ignore
import type { RepositoryReference } from './repository-reference';
@ -21,6 +24,9 @@ import type { RepositoryReference } from './repository-reference';
import type { RunControlAction } from './run-control-action';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatus } from './run-status';
// May contain unused imports in some cases
// @ts-ignore
import type { StatusReason } from './status-reason';
/**
@ -37,13 +43,11 @@ export interface StoreRunSummary {
'repository': RepositoryReference;
'start_time'?: string | null;
'created_at': string;
'status'?: string | null;
'status': RunStatus;
'status_reason'?: StatusReason | null;
'blocked_reason'?: BlockedReason | null;
'pending_control'?: RunControlAction | null;
'duration_ms'?: number | null;
'elapsed_secs'?: number | null;
'total_usd_micros'?: number | null;
}