mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
e8d0f75be9
commit
b8af65a9c6
47 changed files with 1848 additions and 463 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
6
.github/workflows/rust.yml
vendored
6
.github/workflows/rust.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
6
.github/workflows/typescript.yml
vendored
6
.github/workflows/typescript.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
|
|
|
|||
50
apps/fabro-web/app/routes/runs.test.tsx
Normal file
50
apps/fabro-web/app/routes/runs.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
}
|
||||
]
|
||||
"#);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
],);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()?))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
||||
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
export const BoardColumn = {
|
||||
INITIALIZING: 'initializing',
|
||||
RUNNING: 'running',
|
||||
WAITING: 'waiting',
|
||||
BLOCKED: 'blocked',
|
||||
SUCCEEDED: 'succeeded',
|
||||
FAILED: 'failed'
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue