feat(api): unify public run shape

Return canonical Run payloads across run list, board, create, and lifecycle endpoints. Move archive state out of RunStatus and into lifecycle metadata, split sandbox runtime from planned sandbox data, and separate static pull request records from live pull request details.

Regenerate the TypeScript API client and migrate web, CLI, server, store, workflow, and API tests to the new contract.
This commit is contained in:
Bryan Helmkamp 2026-05-10 20:48:55 -04:00
parent 92ce72da6e
commit cccb557281
No known key found for this signature in database
108 changed files with 2922 additions and 2572 deletions

View file

@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { Run, RunStatus as ApiRunStatus } from "@qltysh/fabro-api-client";
import {
columnForStatus,
columnStatusDisplay,
@ -8,30 +9,75 @@ import {
runStatusDisplay,
} from "./runs";
function makeRun(overrides: Partial<Run> = {}): Run {
return {
id: "01ABC",
goal: "Fix the build",
title: "Fix the build",
workflow: { slug: "fix_build", name: "Fix Build" },
automation: null,
repository: { name: "myrepo", origin_url: null, provider: "unknown" },
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status: { kind: "running" },
pending_control: null,
queue_position: null,
error: null,
archived: false,
archived_at: null,
},
sandbox: null,
models: [],
source_directory: "/home/user/myrepo",
timestamps: {
created_at: "2026-04-08T12:00:00Z",
started_at: "2026-04-08T12:00:00Z",
last_event_at: null,
completed_at: null,
duration_ms: 65000,
elapsed_secs: 65,
},
billing: { total_usd_micros: 500000 },
diff: null,
pull_request: null,
current_question: null,
superseded_by: null,
links: { web: null },
...overrides,
};
}
function withStatus(status: ApiRunStatus): Pick<Run, "lifecycle"> {
return {
lifecycle: {
status,
pending_control: null,
queue_position: null,
error: null,
archived: false,
archived_at: null,
},
};
}
describe("mapRunListItem", () => {
test("trusts shared server fields for board items", () => {
const summary = {
run_id: "01ABC",
goal: "## Fix the build",
title: "Server supplied title",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "paused", prior_block: null },
labels: {},
column: "running",
elapsed_secs: 65,
duration_ms: 65000,
total_usd_micros: 500000,
created_at: "2026-04-08T12:00:00Z",
start_time: "2026-04-08T12:00:00Z",
pending_control: null,
const summary = makeRun({
title: "Server supplied title",
...withStatus({ kind: "paused", prior_block: null }),
pull_request: {
number: 123,
html_url: "https://github.com/fabro-sh/fabro/pull/123",
provider: "github",
owner: "fabro-sh",
repo: "fabro",
base_branch: "main",
head_branch: "fabro/run/demo",
title: "Add run PR chip",
},
} as const;
});
const item = mapRunListItem(summary);
expect(item.id).toBe("01ABC");
expect(item.title).toBe("Server supplied title");
@ -46,24 +92,7 @@ describe("mapRunListItem", () => {
});
test("uses a fallback title when the server title is blank", () => {
const summary = {
run_id: "01EMPTY",
goal: "",
title: "",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "running" },
labels: {},
column: "running",
elapsed_secs: null,
duration_ms: null,
total_usd_micros: null,
created_at: "2026-04-08T12:00:00Z",
start_time: null,
pending_control: null,
} as const;
const summary = makeRun({ id: "01EMPTY", goal: "", title: "" });
expect(mapRunListItem(summary).title).toBe("Untitled run");
});
@ -71,32 +100,18 @@ describe("mapRunListItem", () => {
describe("mapRunSummaryToRunItem", () => {
test("maps canonical run summary to RunItem", () => {
const summary = {
run_id: "01ABC",
goal: "Fix the build",
title: "Fix the build",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "running" },
duration_ms: 65000,
elapsed_secs: 65,
total_usd_micros: 500000,
labels: {},
created_at: "2026-04-08T12:00:00Z",
start_time: "2026-04-08T12:00:00Z",
pending_control: null,
const summary = makeRun({
pull_request: {
html_url: "https://github.com/fabro-sh/fabro/pull/456",
number: 456,
provider: "github",
owner: "fabro-sh",
repo: "fabro",
base_branch: "main",
head_branch: "fabro/run/demo",
title: "Add run PR chip",
},
};
});
const item = mapRunSummaryToRunItem(summary);
expect(item.id).toBe("01ABC");
expect(item.title).toBe("Fix the build");
@ -110,23 +125,24 @@ describe("mapRunSummaryToRunItem", () => {
});
test("handles missing optional fields", () => {
const summary = {
run_id: "01DEF",
goal: "",
title: "",
workflow_slug: null,
workflow_name: null,
const summary = makeRun({
id: "01DEF",
goal: "",
title: "",
workflow: { slug: null, name: "unknown" },
source_directory: null,
repository: { name: "unknown" },
status: { kind: "submitted" },
duration_ms: null,
elapsed_secs: null,
total_usd_micros: null,
labels: {},
created_at: "2026-04-08T12:00:00Z",
start_time: null,
pending_control: null,
};
repository: { name: "unknown", origin_url: null, provider: "unknown" },
...withStatus({ kind: "submitted" }),
timestamps: {
created_at: "2026-04-08T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
duration_ms: null,
elapsed_secs: null,
},
billing: null,
});
const item = mapRunSummaryToRunItem(summary);
expect(item.id).toBe("01DEF");
expect(item.title).toBe("Untitled run");

View file

@ -2,9 +2,8 @@ import { formatElapsedSecs, formatDurationSecs } from "../lib/format";
import {
BoardColumn,
type BoardColumn as ApiBoardColumn,
type RunListItem,
type Run,
type RunStatus as ApiRunStatus,
type RunSummary,
type SandboxResources,
} from "@qltysh/fabro-api-client";
@ -103,59 +102,35 @@ function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | nul
return status?.kind ?? null;
}
export function mapRunListItem(item: RunListItem): RunItem {
const lifecycleStatus = runStatusKind(item.status);
export function mapRunListItem(item: Run): RunItem {
const lifecycleStatus = item.lifecycle.archived ? "archived" : runStatusKind(item.lifecycle.status);
const runtime = item.sandbox?.runtime;
return {
id: item.run_id,
repo: displayRepoName(item.repository.name),
id: item.id,
repo: displayRepoName(item.repository?.name ?? "unknown"),
title: displayRunTitle(item.title),
workflow: item.workflow_slug ?? item.workflow_name ?? "unknown",
column: item.column,
workflow: item.workflow.slug ?? item.workflow.name ?? "unknown",
column: columnForRun(item) ?? undefined,
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(item.status),
lifecycleStatusLabel: lifecycleStatusLabel(item.lifecycle.status, item.lifecycle.archived),
number: item.pull_request?.number,
pullRequestUrl: item.pull_request?.html_url,
additions: item.pull_request?.additions,
deletions: item.pull_request?.deletions,
checks: item.pull_request?.checks?.map((c) => ({
name: c.name,
status: c.status,
duration: c.duration_secs != null ? formatDurationSecs(c.duration_secs) : undefined,
})),
elapsed: item.elapsed_secs != null ? formatElapsedSecs(item.elapsed_secs) : undefined,
resources: formatBoardResources(item.sandbox?.resources),
comments: item.pull_request?.comments,
question: item.question?.text,
sandboxId: item.sandbox?.id ?? undefined,
sandboxWorkingDirectory: item.sandbox?.working_directory ?? undefined,
elapsed: item.timestamps.elapsed_secs != null ? formatElapsedSecs(item.timestamps.elapsed_secs) : undefined,
resources: undefined,
question: item.current_question?.text,
sandboxId: runtime?.id ?? undefined,
sandboxWorkingDirectory: runtime?.working_directory ?? undefined,
sourceDirectory: item.source_directory ?? undefined,
createdAt: item.created_at,
lastEventAt: item.last_event_at ?? undefined,
createdAt: item.timestamps.created_at,
lastEventAt: item.timestamps.last_event_at ?? undefined,
};
}
export type { RunSummary };
export type { Run };
export type RunSummary = Run;
export function mapRunSummaryToRunItem(summary: RunSummary): RunItem {
const lifecycleStatus = runStatusKind(summary.status);
return {
id: summary.run_id,
repo: displayRepoName(summary.repository.name),
title: displayRunTitle(summary.title),
workflow: summary.workflow_slug ?? summary.workflow_name ?? "unknown",
lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(summary.status),
number: summary.pull_request?.number,
pullRequestUrl: summary.pull_request?.html_url,
sourceDirectory: summary.source_directory ?? undefined,
elapsed:
summary.elapsed_secs != null
? formatElapsedSecs(summary.elapsed_secs)
: summary.duration_ms != null
? formatElapsedSecs(summary.duration_ms / 1000)
: undefined,
lastEventAt: summary.last_event_at ?? undefined,
};
export function mapRunSummaryToRunItem(summary: Run): RunItem {
return mapRunListItem(summary);
}
export function columnForStatus(status: ApiRunStatus | null | undefined): ColumnStatus | null {
@ -181,6 +156,11 @@ export function columnForStatus(status: ApiRunStatus | null | undefined): Column
}
}
export function columnForRun(run: Run): ColumnStatus | null {
if (run.lifecycle.archived) return "archived";
return columnForStatus(run.lifecycle.status);
}
export function deriveCiStatus(checks: CheckRun[]): CiStatus {
if (checks.some((c) => c.status === "failure")) return "failing";
if (checks.some((c) => c.status === "pending" || c.status === "queued")) return "pending";
@ -220,8 +200,8 @@ export function isRunStatus(s: string): s is RunStatus {
return knownRunStatuses.has(s);
}
function lifecycleStatusLabel(status: ApiRunStatus | null | undefined): string | undefined {
const kind = runStatusKind(status);
function lifecycleStatusLabel(status: ApiRunStatus | null | undefined, archived = false): string | undefined {
const kind = archived ? "archived" : runStatusKind(status);
if (!kind) return undefined;
return runStatusDisplay[kind].label;
}

View file

@ -2,8 +2,7 @@ import useSWRMutation from "swr/mutation";
import { useSWRConfig } from "swr";
import type {
PreviewUrlResponse,
RunStatusResponse,
RunSummary,
Run,
SteerRunRequest,
SubmitAnswerRequest,
UpdateRunRequest,
@ -39,7 +38,7 @@ export type LifecycleMutationResult =
| {
intent: LifecycleAction;
ok: true;
run: RunStatusResponse;
run: Run;
}
| {
intent: LifecycleAction;
@ -74,7 +73,7 @@ export function useUnarchiveRun(id: string | undefined) {
function useLifecycleMutation(
id: string | undefined,
intent: LifecycleAction,
action: (id: string) => Promise<RunStatusResponse>,
action: (id: string) => Promise<Run>,
) {
const { mutate } = useSWRConfig();
const key = id ? queryKeys.runs[intent](id) : null;
@ -109,7 +108,7 @@ export function useUpdateRunTitle(id: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
id ? queryKeys.runs.updateTitle(id) : null,
async (_key, { arg }: { arg: UpdateRunRequest }): Promise<RunSummary> => {
async (_key, { arg }: { arg: UpdateRunRequest }): Promise<Run> => {
if (!id) throw new Error("id is required");
return apiData(() => runsApi.updateRun(id, arg));
},

View file

@ -15,7 +15,7 @@ import type {
RunArtifactListResponse,
RunBilling,
RunProjection,
RunSummary,
Run,
SandboxDetails,
SandboxFileListResponse,
SandboxServiceListResponse,
@ -102,7 +102,7 @@ export function useBoardsRuns(includeArchived: boolean = false) {
}
export function useRun(id: string | undefined) {
return useSWR<RunSummary | null>(
return useSWR<Run | null>(
id ? queryKeys.runs.detail(id) : null,
() => apiNullableData(() => runsApi.retrieveRun(id!)),
);

View file

@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from "bun:test";
import type { AxiosAdapter } from "axios";
import type { Run, RunStatus } from "@qltysh/fabro-api-client";
import {
archiveRun,
@ -21,6 +22,43 @@ type StubResponseInit = {
const originalAdapter = generatedAxios.defaults.adapter;
function makeRun(status: RunStatus, archived = false): Run {
return {
id: "run-1",
goal: "Fix the build",
title: "Fix the build",
workflow: { slug: "fix_build", name: "Fix Build" },
automation: null,
repository: null,
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status,
pending_control: null,
queue_position: null,
error: null,
archived,
archived_at: archived ? "2026-04-20T12:05:00Z" : null,
},
sandbox: null,
models: [],
source_directory: null,
timestamps: {
created_at: "2026-04-20T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
},
billing: null,
diff: null,
pull_request: null,
current_question: null,
superseded_by: null,
links: { web: null },
};
}
function stubGeneratedAxiosOnce(init: StubResponseInit) {
generatedAxios.defaults.adapter = (async (config) => {
if (init.status >= 400) {
@ -65,49 +103,36 @@ describe("run lifecycle actions", () => {
test("cancelRun parses a 200 response", async () => {
stubGeneratedAxiosOnce({
status: 200,
body: {
id: "run-1",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
},
body: makeRun({ kind: "failed", reason: "cancelled" }),
});
const result = await cancelRun("run-1");
expect(result.status.kind).toBe("failed");
if (result.status.kind === "failed") {
expect(result.status.reason).toBe("cancelled");
expect(result.lifecycle.status.kind).toBe("failed");
if (result.lifecycle.status.kind === "failed") {
expect(result.lifecycle.status.reason).toBe("cancelled");
}
});
test("archiveRun parses a 200 response", async () => {
stubGeneratedAxiosOnce({
status: 200,
body: {
id: "run-1",
status: {
kind: "archived",
prior: { kind: "succeeded", reason: "completed" },
},
created_at: "2026-04-20T12:00:00Z",
},
body: makeRun({ kind: "succeeded", reason: "completed" }, true),
});
const result = await archiveRun("run-1");
expect(result.status.kind).toBe("archived");
expect(result.lifecycle.status.kind).toBe("succeeded");
expect(result.lifecycle.archived).toBe(true);
});
test("unarchiveRun parses a 200 response", async () => {
stubGeneratedAxiosOnce({
status: 200,
body: {
id: "run-1",
status: { kind: "succeeded", reason: "completed" },
created_at: "2026-04-20T12:00:00Z",
},
body: makeRun({ kind: "succeeded", reason: "completed" }),
});
const result = await unarchiveRun("run-1");
expect(result.status.kind).toBe("succeeded");
expect(result.lifecycle.status.kind).toBe("succeeded");
expect(result.lifecycle.archived).toBe(false);
});
test("404 and 409 preserve the parsed error envelope", async () => {
@ -173,19 +198,12 @@ describe("run lifecycle actions", () => {
test("isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation", () => {
expect(
isTerminalCancelledRun({
id: "run-1",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
}),
isTerminalCancelledRun(makeRun({ kind: "failed", reason: "cancelled" })),
).toBe(true);
expect(
isTerminalCancelledRun({
id: "run-1",
status: { kind: "running" },
pending_control: "cancel",
created_at: "2026-04-20T12:00:00Z",
}),
isTerminalCancelledRun(
makeRun({ kind: "running" }, false),
),
).toBe(false);
});
});

View file

@ -1,4 +1,4 @@
import type { ErrorResponseEntry, RunStatusResponse } from "@qltysh/fabro-api-client";
import type { ErrorResponseEntry, Run } from "@qltysh/fabro-api-client";
import {
ApiError,
@ -31,15 +31,15 @@ const ARCHIVABLE_STATUSES = new Set<RunStatus>([
"dead",
]);
export async function cancelRun(id: string, request?: Request): Promise<RunStatusResponse> {
export async function cancelRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "cancel", request);
}
export async function archiveRun(id: string, request?: Request): Promise<RunStatusResponse> {
export async function archiveRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "archive", request);
}
export async function unarchiveRun(id: string, request?: Request): Promise<RunStatusResponse> {
export async function unarchiveRun(id: string, request?: Request): Promise<Run> {
return runLifecycleAction(id, "unarchive", request);
}
@ -68,8 +68,9 @@ export function canDelete(status: string | null | undefined): boolean {
return status === "archived";
}
export function isTerminalCancelledRun(run: RunStatusResponse): boolean {
return run.status.kind === "failed" && run.status.reason === "cancelled";
export function isTerminalCancelledRun(run: Run): boolean {
const status = run.lifecycle.status;
return status.kind === "failed" && status.reason === "cancelled";
}
export function deleteErrorMessage(error: unknown): string {
@ -119,7 +120,7 @@ async function runLifecycleAction(
id: string,
action: LifecycleAction,
request?: Request,
): Promise<RunStatusResponse> {
): Promise<Run> {
try {
switch (action) {
case "cancel":

View file

@ -3,7 +3,7 @@ import { ChevronDownIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outlin
import { Link, useParams } from "react-router";
import { InlineMarkdown } from "../components/inline-markdown";
import { PullRequestChip } from "../components/pull-request-chip";
import { ciConfig, columnForStatus, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
import { ciConfig, columnForRun, columnStatusDisplay, deriveCiStatus, mapRunSummaryToRunItem } from "../data/runs";
import type { ColumnStatus, RunWithStatus } from "../data/runs";
import { useWorkflowRuns } from "../lib/queries";
import type { PaginatedRunList } from "@qltysh/fabro-api-client";
@ -12,7 +12,7 @@ function mapWorkflowRuns(result: PaginatedRunList | null | undefined): RunWithSt
const apiRuns = result?.data ?? [];
return apiRuns
.map((r) => {
const column = columnForStatus(r.status);
const column = columnForRun(r);
if (column == null) return null;
return {
...mapRunSummaryToRunItem(r),

View file

@ -63,18 +63,52 @@ function makeRunSummary(
pullRequest: any = null,
title = "Run 1",
) {
const apiStatus =
status === "succeeded"
? { kind: "succeeded", reason: "completed" }
: status === "failed"
? { kind: "failed", reason: "error" }
: status === "dead"
? { kind: "dead" }
: status === "blocked"
? { kind: "blocked", reason: "interview", pending_question_id: null }
: { kind: status };
const archived = status === "archived";
return {
run_id: "run_1",
id: "run_1",
goal: "Run 1",
title,
repository: { name: "fabro" },
status: { kind: status },
workflow_slug: "default",
workflow_name: "Default",
duration_ms: null,
elapsed_secs: null,
workflow: { slug: "default", name: "Default" },
automation: null,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status: archived ? { kind: "succeeded", reason: "completed" } : apiStatus,
pending_control: null,
queue_position: null,
error: null,
archived,
archived_at: archived ? "2026-04-20T12:05:00Z" : null,
},
sandbox: null,
models: [],
source_directory: null,
diff_summary: diffSummary,
pull_request: pullRequest,
timestamps: {
created_at: "2026-04-20T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
duration_ms: null,
elapsed_secs: null,
},
billing: null,
diff: diffSummary,
pull_request: pullRequest ? { provider: "github", ...pullRequest } : null,
current_question: null,
superseded_by: null,
links: { web: null },
};
}
@ -242,12 +276,9 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: {
id: "run-1",
status: { kind: "failed", reason: "cancelled" },
created_at: "2026-04-20T12:00:00Z",
},
run: makeRunSummary("failed"),
};
result.run.lifecycle.status = { kind: "failed", reason: "cancelled" };
const firstState = handleLifecycleToastResult("cancel", result, initialState, api);
@ -266,7 +297,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "cancel",
ok: true,
run: { id: "run-1", status: { kind: "running" }, created_at: "2026-04-20T12:00:00Z" },
run: makeRunSummary("running"),
};
handleLifecycleToastResult("cancel", result, initialState, api);
@ -279,14 +310,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "archive",
ok: true,
run: {
id: "run-1",
status: {
kind: "archived",
prior: { kind: "succeeded", reason: "completed" },
},
created_at: "2026-04-20T12:00:00Z",
},
run: makeRunSummary("archived"),
};
const firstState = handleLifecycleToastResult("archive", result, initialState, api);
@ -306,11 +330,7 @@ describe("handleLifecycleToastResult", () => {
const result: RunDetailActionResult = {
intent: "unarchive",
ok: true,
run: {
id: "run-1",
status: { kind: "succeeded", reason: "completed" },
created_at: "2026-04-20T12:00:00Z",
},
run: makeRunSummary("succeeded"),
};
const stateWithActiveToast: LifecycleToastState = {
activeArchiveToastId: "toast-9",

View file

@ -147,7 +147,7 @@ function runHasSandbox(runState: unknown): boolean {
function buildRunDetailRun(summary: RunSummary): RunDetailRun {
const item = mapRunSummaryToRunItem(summary);
const rawStatus = summary.status;
const rawStatus = summary.lifecycle.status;
const statusKind = rawStatus.kind;
const display = isRunStatus(statusKind)
? runStatusDisplay[statusKind]
@ -171,7 +171,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const runQuery = useRun(params.id);
const runStateQuery = useRunState(params.id);
const run = runQuery.data ? buildRunDetailRun(runQuery.data) : null;
const statusKind = runQuery.data?.status?.kind;
const statusKind = runQuery.data?.lifecycle.status.kind;
const isBlocked = statusKind === "blocked";
const questionsQuery = useRunQuestions(params.id, isBlocked);
const pendingQuestions = questionsQuery.data ?? [];
@ -188,7 +188,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletePending, setDeletePending] = useState(false);
const { push, dismiss } = useToast();
const filesCount = runQuery.data?.diff_summary?.files_changed ?? null;
const filesCount = runQuery.data?.diff?.files_changed ?? null;
const hasSandbox = runHasSandbox(runStateQuery.data);
const tabs = allTabs
.map((tab) =>

View file

@ -44,7 +44,10 @@ export default function RunEvents() {
events={eventsQuery.data}
error={eventsQuery.error}
onRetry={() => void eventsQuery.mutate()}
runStart={runQuery.data?.start_time ?? runQuery.data?.created_at}
runStart={
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at
}
/>
</div>
);

View file

@ -44,15 +44,38 @@ mock.module("@pierre/diffs/react", () => ({
mock.module("../lib/queries", () => ({
useRun: () => ({
data: {
run_id: "run_1",
title: "Run 1",
repository: { name: "fabro" },
status: { kind: currentRunStatus },
workflow_slug: "default",
workflow_name: "Default",
duration_ms: null,
elapsed_secs: null,
id: "run_1",
goal: "Run 1",
title: "Run 1",
workflow: { slug: "default", name: "Default" },
automation: null,
repository: { name: "fabro", origin_url: null, provider: "unknown" },
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status: { kind: currentRunStatus },
pending_control: null,
queue_position: null,
error: null,
archived: false,
archived_at: null,
},
sandbox: null,
models: [],
source_directory: null,
timestamps: {
created_at: "2026-05-05T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
},
billing: null,
diff: null,
pull_request: null,
current_question: null,
superseded_by: null,
links: { web: null },
},
}),
useRunCommits: () => ({

View file

@ -374,7 +374,7 @@ export default function RunFiles() {
const runQuery = useRun(params.id);
const { push } = useToast();
const narrow = useNarrowViewport();
const runStatus = runQuery.data?.status?.kind;
const runStatus = runQuery.data?.lifecycle.status.kind;
// Preserve the last successful payload so a failed revalidation can keep
// rendering the previous files while surfacing an inline banner.

View file

@ -27,7 +27,7 @@ export default function RunLogs() {
const { id } = useParams();
const runQuery = useRun(id);
const stagesQuery = useRunStages(id);
const isLive = runQuery.data?.status?.kind === "running";
const isLive = runQuery.data?.lifecycle.status.kind === "running";
const logsQuery = useRunLogs(id, isLive ? LIVE_REFRESH_MS : undefined);
const stages = useMemo(
() => mapRunStagesToSidebarStages(stagesQuery.data),

View file

@ -31,15 +31,13 @@ export default function RunOverview() {
[stagesQuery.data],
);
const graphSvg = graphQuery.data;
const apiStatus = runQuery.data?.status;
const apiStatus = runQuery.data?.lifecycle.status;
const terminalOutcome: "succeeded" | "failed" | "dead" | null =
apiStatus?.kind === "archived"
? apiStatus.prior.kind
: apiStatus?.kind === "succeeded" ||
apiStatus?.kind === "failed" ||
apiStatus?.kind === "dead"
? apiStatus.kind
: null;
apiStatus?.kind === "succeeded" ||
apiStatus?.kind === "failed" ||
apiStatus?.kind === "dead"
? apiStatus.kind
: null;
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement | null>(null);

View file

@ -86,6 +86,39 @@ mock.restore();
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
function sandboxDetails(
overrides: Partial<SandboxDetails> & {
sandbox?: Partial<SandboxDetails["sandbox"]> & {
runtime?: Partial<NonNullable<SandboxDetails["sandbox"]["runtime"]>>;
};
} = {},
): SandboxDetails {
const sandbox = overrides.sandbox ?? {};
return {
sandbox: {
provider: "docker",
image: null,
snapshot: null,
runtime: {
id: null,
working_directory: null,
repo_cloned: null,
clone_origin_url: null,
clone_branch: null,
...sandbox.runtime,
},
...sandbox,
},
state: "running",
native_state: null,
region: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
labels: {},
timestamps: { created_at: null, last_activity_at: null },
...overrides,
};
}
function renderRoute(initialPath: string = "/runs/run_1/sandbox") {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
@ -126,14 +159,18 @@ describe("formatBytesAsMemory", () => {
describe("RunSandbox route", () => {
test("renders panels for a fully populated sandbox", () => {
currentDetails = {
provider: "docker",
id: "abcdef123456",
working_directory: "/workspace",
currentDetails = sandboxDetails({
sandbox: {
provider: "docker",
image: "ghcr.io/fabro/sandbox:latest",
runtime: {
id: "abcdef123456",
working_directory: "/workspace",
},
},
state: "running",
native_state: "running",
region: undefined,
image: "ghcr.io/fabro/sandbox:latest",
resources: {
cpu_cores: 2,
memory_bytes: 4 * 1024 * 1024 * 1024,
@ -144,7 +181,7 @@ describe("RunSandbox route", () => {
created_at: "2026-05-09T12:00:00Z",
last_activity_at: undefined,
},
};
});
const renderer = renderRoute();
const panelHeadings = renderer.root
@ -155,14 +192,17 @@ describe("RunSandbox route", () => {
});
test("renders without crashing when most fields are null", () => {
currentDetails = {
provider: "local",
id: "local:run_1",
working_directory: "/tmp/project",
currentDetails = sandboxDetails({
sandbox: {
provider: "local",
runtime: {
id: "local:run_1",
working_directory: "/tmp/project",
},
},
state: "unknown",
native_state: undefined,
region: undefined,
image: undefined,
resources: {
cpu_cores: undefined,
memory_bytes: undefined,
@ -173,7 +213,7 @@ describe("RunSandbox route", () => {
created_at: undefined,
last_activity_at: undefined,
},
};
});
const renderer = renderRoute();
const labelsHeading = renderer.root.findAll(
@ -206,18 +246,7 @@ describe("RunSandbox route", () => {
});
test("Terminal is the default right-column mode", () => {
currentDetails = {
provider: "docker",
name: "fabro-run-abc",
id: null,
state: "running",
native_state: null,
region: null,
image: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
labels: {},
timestamps: { created_at: null, last_activity_at: null },
};
currentDetails = sandboxDetails({ sandbox: { provider: "docker" } });
const renderer = renderRoute();
const tabs = renderer.root.findAll(
@ -233,18 +262,7 @@ describe("RunSandbox route", () => {
});
test("Daytona provider exposes a VNC tab", () => {
currentDetails = {
provider: "daytona",
name: "fabro-run-abc",
id: null,
state: "running",
native_state: null,
region: null,
image: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
labels: {},
timestamps: { created_at: null, last_activity_at: null },
};
currentDetails = sandboxDetails({ sandbox: { provider: "daytona" } });
const renderer = renderRoute();
const tabs = renderer.root.findAll(
(node) => node.type === "button" && node.props.role === "tab",
@ -255,18 +273,7 @@ describe("RunSandbox route", () => {
});
test("Services mode is selected when ?mode=services is requested", () => {
currentDetails = {
provider: "docker",
name: "fabro-run-abc",
id: null,
state: "running",
native_state: null,
region: null,
image: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
labels: {},
timestamps: { created_at: null, last_activity_at: null },
};
currentDetails = sandboxDetails({ sandbox: { provider: "docker" } });
const renderer = renderRoute("/runs/run_1/sandbox?mode=services");
const tabs = renderer.root.findAll(
(node) => node.type === "button" && node.props.role === "tab",
@ -276,18 +283,7 @@ describe("RunSandbox route", () => {
});
test("Docker provider falls back to terminal when ?mode=vnc is requested", () => {
currentDetails = {
provider: "docker",
name: "fabro-run-abc",
id: null,
state: "running",
native_state: null,
region: null,
image: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
labels: {},
timestamps: { created_at: null, last_activity_at: null },
};
currentDetails = sandboxDetails({ sandbox: { provider: "docker" } });
const renderer = renderRoute("/runs/run_1/sandbox?mode=vnc");
const tabs = renderer.root.findAll(
(node) => node.type === "button" && node.props.role === "tab",
@ -297,18 +293,7 @@ describe("RunSandbox route", () => {
});
test("Filesystem mode keeps sandbox details visible in the left column", () => {
currentDetails = {
provider: "docker",
name: "fabro-run-abc",
id: null,
state: "running",
native_state: null,
region: null,
image: null,
resources: { cpu_cores: null, memory_bytes: null, disk_bytes: null },
labels: {},
timestamps: { created_at: null, last_activity_at: null },
};
currentDetails = sandboxDetails({ sandbox: { provider: "docker" } });
const renderer = renderRoute("/runs/run_1/sandbox?mode=filesystem");
const panelHeadings = renderer.root

View file

@ -120,13 +120,14 @@ function Panel({ title, children }: PanelProps) {
function StatusStrip({ details }: { details: SandboxDetails }) {
const display = STATE_DISPLAY[details.state] ?? STATE_DISPLAY.unknown;
const provider = details.sandbox.provider;
const showNative =
details.native_state &&
details.native_state.toLowerCase() !== details.state.toLowerCase();
return (
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-md border border-line bg-panel/60 px-4 py-3 text-sm">
<span className="font-mono text-xs text-fg-muted uppercase tracking-wide">
{details.provider}
{provider}
</span>
<span className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${display.dot}`} />
@ -142,15 +143,17 @@ function StatusStrip({ details }: { details: SandboxDetails }) {
}
function OverviewPanel({ details }: { details: SandboxDetails }) {
const sandbox = details.sandbox;
const runtime = sandbox.runtime;
return (
<Panel title="Overview">
<Row label="ID" value={nullable(details.id)} />
<Row label="Working directory" value={nullable(details.working_directory)} />
<Row label="ID" value={nullable(runtime?.id)} />
<Row label="Working directory" value={nullable(runtime?.working_directory)} />
<Row
label="Region"
value={details.region ? details.region : details.provider === "docker" ? "local" : EMPTY_VALUE}
value={details.region ? details.region : sandbox.provider === "docker" ? "local" : EMPTY_VALUE}
/>
<Row label="Image" value={nullable(details.image)} />
<Row label="Image" value={nullable(sandbox.image ?? sandbox.snapshot)} />
</Panel>
);
}
@ -214,7 +217,7 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) {
export default function RunSandbox({ params }: { params: { id: string } }) {
const sandboxQuery = useRunSandboxDetails(params.id);
const provider = sandboxQuery.data?.provider ?? null;
const provider = sandboxQuery.data?.sandbox.provider ?? null;
const [searchParams, setSearchParams] = useSearchParams();
const requestedMode = useMemo(
() => normalizeSandboxMode(searchParams.get("mode")),
@ -285,7 +288,7 @@ export default function RunSandbox({ params }: { params: { id: string } }) {
return (
<FilesystemPanel
runId={params.id}
rootDirectory={sandboxQuery.data?.working_directory}
rootDirectory={sandboxQuery.data?.sandbox.runtime?.working_directory}
leading={modeToggle}
/>
);

View file

@ -1275,8 +1275,8 @@ export default function RunStages() {
const selectedStageId = selectedStage?.id;
const runStart =
selectedStage?.startedAt ??
runQuery.data?.start_time ??
runQuery.data?.created_at;
runQuery.data?.timestamps.started_at ??
runQuery.data?.timestamps.created_at;
const stageEventsQuery = useRunStageEvents(id, selectedStageId);
const turns = useMemo(
() =>

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import type { BoardColumn, RunListItem } from "@qltysh/fabro-api-client";
import type { BoardColumn, Run } from "@qltysh/fabro-api-client";
import {
buildBoardColumns,
@ -7,17 +7,52 @@ import {
shouldRefreshBoardForEvent,
} from "./runs";
function boardRun(id: string, column: BoardColumn, questionText?: string): RunListItem {
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
const status =
column === "blocked"
? { kind: "blocked" as const, reason: "interview", pending_question_id: null }
: column === "succeeded"
? { kind: "succeeded" as const, reason: "completed" }
: column === "failed"
? { kind: "failed" as const, reason: "error" }
: column === "queued"
? { kind: "queued" as const }
: column === "initializing"
? { kind: "starting" as const }
: { kind: "running" as const };
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 } } : {}),
id,
goal: `Run ${id}`,
title: `Run ${id}`,
workflow: { slug: "test", name: "Test" },
automation: null,
repository: { name: "repo", origin_url: null, provider: "unknown" },
created_by: null,
origin: { kind: "api" },
labels: {},
lifecycle: {
status,
pending_control: null,
queue_position: null,
error: null,
archived: column === "archived",
archived_at: column === "archived" ? "2026-04-19T12:05:00Z" : null,
},
sandbox: null,
models: [],
source_directory: null,
timestamps: {
created_at: "2026-04-19T12:00:00Z",
started_at: null,
last_event_at: null,
completed_at: null,
},
billing: null,
diff: null,
pull_request: null,
current_question: questionText ? { text: questionText } : null,
superseded_by: null,
links: { web: null },
};
}

View file

@ -21,7 +21,7 @@ import {
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { ciConfig, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunListItem } from "../data/runs";
import { ciConfig, columnForRun, columnStatusDisplay, columnStatuses, deriveCiStatus, mapRunListItem } from "../data/runs";
import type { CiStatus, CheckRun, CheckStatus, RunItem, RunWithStatus, ColumnStatus } from "../data/runs";
import { formatRelativeTime } from "../lib/format";
import { EmptyState } from "../components/state";
@ -94,8 +94,9 @@ export function buildBoardColumns(response: BoardRunsResponse): Column[] {
grouped.set(col.id, []);
}
for (const apiRun of response.data) {
if (grouped.has(apiRun.column)) {
grouped.get(apiRun.column)?.push(mapRunListItem(apiRun));
const column = columnForRun(apiRun);
if (column != null && grouped.has(column)) {
grouped.get(column)?.push(mapRunListItem(apiRun));
}
}

View file

@ -0,0 +1,258 @@
# Unified Run Type Shape Plan
## Summary
Refactor the public run API around one canonical `Run` shape for lists, detail pages, board data, create/update responses, and future filtering. This is a breaking greenfield cleanup: remove legacy parallel DTOs instead of carrying aliases.
The public shape should preserve valid distinctions:
- `RunSandbox` is durable/static run-owned sandbox metadata.
- `SandboxDetails` is live provider data.
- `PullRequest` is durable/static PR metadata.
- `PullRequestDetails` is live GitHub-enriched data.
No server-side filter query params are added in this pass; this pass makes the data model filter-ready. This is also a domain-model cleanup: internal lifecycle semantics and public lifecycle semantics should align where possible.
## Key Changes
Replace public `RunSummary`, `RunListItem`, and `RunStatusResponse` with one `Run` schema:
```ts
type Run = {
id: string
title: string
goal: string
workflow: WorkflowRef
automation: AutomationRef | null
repository: RepositoryRef | null
created_by: Principal | null
origin: RunOrigin
labels: Record<string, string>
lifecycle: RunLifecycle
sandbox: RunSandbox | null
models: RunModel[]
source_directory: string | null
timestamps: RunTimestamps
billing: RunBillingSummary | null
diff: DiffSummary | null
pull_request: PullRequest | null
current_question: RunQuestion | null
superseded_by: string | null
links: { web: string | null }
}
```
Use these supporting public types:
```ts
type WorkflowRef = { slug: string | null; name: string }
type AutomationRef = { id: string; name: string | null }
type RepositoryRef = { name: string; origin_url: string | null; provider: "github" | "git" | "unknown" }
type RunOrigin = { kind: "api" }
type RunModel = { provider: string | null; name: string }
type RunTimestamps = { created_at: string; started_at: string | null; last_event_at: string | null; completed_at: string | null }
type RunBillingSummary = { total_usd_micros: number | null }
```
Modify `RunStatus` so archive is not a status. Keep the existing status payloads that carry product meaning:
```ts
type RunLifecycle = {
status: RunStatus
pending_control: RunControlAction | null
queue_position: number | null
error: RunError | null
archived: boolean
archived_at: string | null
}
type RunStatus =
| { kind: "submitted" }
| { kind: "queued" }
| { kind: "starting" }
| { kind: "running" }
| { kind: "blocked"; blocked_reason: BlockedReason }
| { kind: "paused"; prior_block: BlockedReason | null }
| { kind: "removing" }
| { kind: "succeeded"; reason: SuccessReason }
| { kind: "failed"; reason: FailureReason }
| { kind: "dead" }
```
Use this sandbox split:
```ts
type RunSandbox = {
provider: SandboxProvider
image: string | null
snapshot: string | null
runtime: {
id: string
working_directory: string
repo_cloned: boolean | null
clone_origin_url: string | null
clone_branch: string | null
} | null
}
type SandboxDetails = {
sandbox: RunSandbox
state: SandboxState
native_state: string | null
region: string | null
resources: SandboxResources
labels: Record<string, string>
timestamps: SandboxTimestamps
}
```
Use this pull request split:
```ts
type PullRequest = {
provider: "github"
owner: string
repo: string
number: number
html_url: string
title: string
base_branch: string
head_branch: string
}
type PullRequestDetails = {
pull_request: PullRequest
state: string
draft: boolean
merged: boolean
merged_at: string | null
mergeable: boolean | null
additions: number
deletions: number
changed_files: number
comments: number
checks: CheckRun[]
author: { login: string }
timestamps: { created_at: string; updated_at: string }
}
```
Use these derivation rules:
- Board column:
- `run.lifecycle.archived` -> `archived`
- `submitted` / `queued` -> `queued`
- `starting` -> `initializing`
- `running` / `paused` -> `running`
- `blocked` -> `blocked`
- `succeeded` -> `succeeded`
- `failed` / `dead` -> `failed`
- `removing` -> omitted from the board response unless a removal column is added intentionally
- `RepositoryRef.provider`:
- GitHub HTTPS and SSH origins -> `github`
- any other non-empty Git origin URL -> `git`
- missing or unparseable origin -> `unknown`
- `RepositoryRef.name`:
- GitHub origins use `owner/repo`
- other Git origins use the best available repo basename
- missing origins fall back to source directory basename, then `unknown`
- `RunOrigin`:
- API-created runs use `{ kind: "api" }`
- no structured origin header or user-agent-derived public origin is added in this pass
## Implementation Changes
- In `fabro-types`, add canonical public run types and remove/rename redundant public DTOs:
- `RunSummary` -> `Run`
- `RepositoryReference` -> `RepositoryRef`
- `PullRequestRecord` -> `PullRequest`
- `PullRequestDetail` -> `PullRequestDetails`
- remove `RunListItem`, board-only `RunPullRequest`, and public `RunStatusResponse`
- Modify `RunStatus` as the shared domain/public execution status:
- remove `Archived { prior }`
- preserve payloads on `Blocked`, `Paused`, `Succeeded`, and `Failed`
- update helpers such as `is_terminal`, `is_immutable`, `is_active`, and `can_transition_to`
- Add archive metadata to `RunProjection`:
- `status: RunStatus`
- `archived_at: Option<DateTime<Utc>>`
- archive/unarchive operations set or clear archive metadata instead of transitioning status
- archived runs remain read-only because archive metadata is present, not because status is special
- Replace `RunProvenance` as a public filtering source with first-class fields:
- `created_by: Principal | null`
- `origin: RunOrigin`
- Direct API requests default to `origin.kind = "api"`.
- No structured origin header or user-agent-derived public origin is added in this pass.
- Update projection reduction so `run.created` initializes durable filter metadata:
- workflow, repository, labels, creator, origin, source directory
- sandbox provider/image/snapshot from resolved run settings
- archive metadata as `archived_at`, not `RunStatus::Archived`
- Update `run.archived` and `run.unarchived` reduction:
- `run.archived` requires a terminal status and sets `archived_at`
- `run.unarchived` clears `archived_at`
- neither event changes `status`
- Update `sandbox.initialized` handling to fill `RunSandbox.runtime`.
- Update model aggregation in the run builder:
- collect observed stage models from `StageProjection`
- dedupe by `(provider, name)`
- sort deterministically
- Change public endpoints to return `Run`:
- `GET /api/v1/runs`
- `GET /api/v1/runs/{id}`
- `GET /api/v1/runs/resolve`
- `GET /api/v1/boards/runs`
- `POST /api/v1/runs`
- `PATCH /api/v1/runs/{id}`
- `POST /api/v1/runs/{id}/cancel`
- `POST /api/v1/runs/{id}/start`
- `POST /api/v1/runs/{id}/pause`
- `POST /api/v1/runs/{id}/unpause`
- `POST /api/v1/runs/{id}/archive`
- `POST /api/v1/runs/{id}/unarchive`
- Keep `/api/v1/runs/{id}/state` as the internal event-sourced projection endpoint; update it only as needed for renamed internal fields.
- Update the web app to consume `Run` directly:
- board column is derived from `run.lifecycle.status` and `run.lifecycle.archived`
- archived column uses `run.lifecycle.archived`
- archive actions use archive metadata (`archived` / `archived_at`), not status kind
- cancelled-run detection uses `run.lifecycle.status.kind === "failed"` and `reason === "cancelled"`
- filters/read models use paths like `workflow.slug`, `repository.name`, `origin.kind`, `sandbox.provider`, `sandbox.image`, `models[].name`.
- Update CLI consumers of run status:
- `fabro ps`
- `fabro archive`
- `fabro unarchive`
- `fabro rewind`
- output formatting that previously checked `RunStatus::Archived`
- Update documentation and generated clients:
- OpenAPI schemas remove `RunStatusArchived`, `TerminalStatus`, `RunSummary`, `RunListItem`, and `RunStatusResponse`
- regenerate the TypeScript API client
- update public docs that describe `archived` as a status
## Test Plan
- Update OpenAPI round-trip tests to assert `fabro_api::types::Run` reuses `fabro_types::Run`.
- Update OpenAPI round-trip tests to assert `fabro_api::types::RunStatus` reuses `fabro_types::RunStatus`.
- Add JSON shape tests for:
- workflow/repository nesting
- `created_by` and `origin`
- sandbox planned metadata before runtime initialization
- sandbox runtime after `sandbox.initialized`
- multiple deduped models
- archived succeeded run with `lifecycle.archived = true` and `lifecycle.status.kind = "succeeded"`
- archived failed-cancelled run preserving `lifecycle.status.reason = "cancelled"`
- unarchive clearing `archived_at` without changing status
- archive filtering and mutation rejection using archive metadata
- static `PullRequest` and live `PullRequestDetails`
- Update server API tests for list/detail/board/create/update/lifecycle responses.
- Update web tests for board mapping, run detail mapping, archive behavior, cancelled-run behavior, and sandbox display.
- Update CLI tests that assert archived status text or archive/unarchive behavior.
- Run:
- `cargo nextest run -p fabro-types -p fabro-store -p fabro-workflow -p fabro-api -p fabro-server -p fabro-cli`
- `cd apps/fabro-web && bun test`
- `cd apps/fabro-web && bun run typecheck`
## Assumptions
- Breaking API cleanup is allowed; no legacy field aliases are kept.
- This pass does not add server-side filter query params.
- Existing historical run-event/projection/API compatibility is not required for this greenfield cleanup.
- Do not keep migration shims, deprecated schemas, serde aliases, or compatibility adapters for removed run DTOs/status variants.
- Live external data stays out of list filtering; filterable run data must come from durable `Run`.

View file

@ -827,7 +827,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunStatusResponse"
$ref: "#/components/schemas/Run"
"400":
description: Invalid Graphviz source
headers:
@ -852,7 +852,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunSummary"
$ref: "#/components/schemas/Run"
"400":
description: Selector is invalid or ambiguous
headers:
@ -974,7 +974,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunSummary"
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
@ -1003,7 +1003,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunSummary"
$ref: "#/components/schemas/Run"
"400":
description: Invalid title
headers:
@ -1072,7 +1072,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunStatusResponse"
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
@ -1220,7 +1220,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunStatusResponse"
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
@ -1254,7 +1254,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunStatusResponse"
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
@ -1288,7 +1288,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunStatusResponse"
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
@ -1326,7 +1326,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunStatusResponse"
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
@ -1529,7 +1529,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/RunStatusResponse"
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
@ -1725,7 +1725,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/PullRequestRecord"
$ref: "#/components/schemas/PullRequest"
"400":
description: Pull request creation does not apply to this run
headers:
@ -1786,7 +1786,7 @@ paths:
content:
application/json:
schema:
$ref: "#/components/schemas/PullRequestDetail"
$ref: "#/components/schemas/PullRequestDetails"
"400":
description: Pull request lookup does not apply to this run
headers:
@ -4260,7 +4260,7 @@ components:
data:
type: array
items:
$ref: "#/components/schemas/RunSummary"
$ref: "#/components/schemas/Run"
meta:
$ref: "#/components/schemas/PaginationMeta"
@ -4279,7 +4279,7 @@ components:
data:
type: array
items:
$ref: "#/components/schemas/RunListItem"
$ref: "#/components/schemas/Run"
meta:
$ref: "#/components/schemas/PaginationMeta"
@ -4751,10 +4751,8 @@ components:
RunStatus:
description: >
Lifecycle status of a run. `archived` is a terminal status reached by
an explicit user action on a previously terminal (`succeeded`, `failed`,
or `dead`) run; archived runs are hidden from default listings and are
read-only until unarchived.
Execution status of a run. Archive state is represented separately on
`RunLifecycle.archived` so terminal status payloads remain intact.
oneOf:
- $ref: "#/components/schemas/RunStatusSubmitted"
- $ref: "#/components/schemas/RunStatusQueued"
@ -4766,7 +4764,6 @@ components:
- $ref: "#/components/schemas/RunStatusSucceeded"
- $ref: "#/components/schemas/RunStatusFailed"
- $ref: "#/components/schemas/RunStatusDead"
- $ref: "#/components/schemas/RunStatusArchived"
discriminator:
propertyName: kind
mapping:
@ -4780,7 +4777,6 @@ components:
succeeded: "#/components/schemas/RunStatusSucceeded"
failed: "#/components/schemas/RunStatusFailed"
dead: "#/components/schemas/RunStatusDead"
archived: "#/components/schemas/RunStatusArchived"
RunStatusSubmitted:
type: object
@ -4896,32 +4892,6 @@ components:
enum:
- dead
RunStatusArchived:
type: object
required:
- kind
- prior
properties:
kind:
type: string
enum:
- archived
prior:
$ref: "#/components/schemas/TerminalStatus"
TerminalStatus:
description: Terminal run status captured inside an archived run.
oneOf:
- $ref: "#/components/schemas/RunStatusSucceeded"
- $ref: "#/components/schemas/RunStatusFailed"
- $ref: "#/components/schemas/RunStatusDead"
discriminator:
propertyName: kind
mapping:
succeeded: "#/components/schemas/RunStatusSucceeded"
failed: "#/components/schemas/RunStatusFailed"
dead: "#/components/schemas/RunStatusDead"
SuccessReason:
description: Reason attached to a successful terminal run status.
type: string
@ -5440,49 +5410,6 @@ components:
description: New run title. The server trims leading/trailing whitespace, rejects blank values, rejects control characters and newline characters, and requires at most 100 characters.
example: "Add rate limiting to auth endpoints"
RunStatusResponse:
description: Current status of a run with optional error and queue position.
type: object
required:
- id
- title
- status
- created_at
properties:
id:
type: string
description: Unique run identifier (ULID).
example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z
title:
type: string
description: Non-blank resolved run title.
example: Add rate limiting to auth endpoints
status:
$ref: "#/components/schemas/RunStatus"
error:
$ref: "#/components/schemas/RunError"
queue_position:
type: integer
description: Position in the queue (1-based). Only present when the status kind is `queued`.
example: 3
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
- type: "null"
created_at:
type: string
format: date-time
description: Timestamp when the run was created.
example: "2026-03-06T14:30:00Z"
web_url:
type: string
format: uri
description: |
Public URL where the run can be viewed in the web UI. Present when
the server has the web UI enabled (`server.web.enabled = true`) and
`server.web.url` is non-empty; absent otherwise.
example: "http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z"
DeleteRunResponse:
description: Returned when a run is deleted but its sandbox is intentionally preserved.
type: object
@ -6535,12 +6462,18 @@ components:
description: Resolved run title from the event log.
spec:
$ref: "#/components/schemas/RunSpec"
web_url:
type: ["string", "null"]
description: Absolute web UI URL for this run when server web settings are configured.
start:
oneOf:
- $ref: "#/components/schemas/StartRecord"
- type: "null"
status:
$ref: "#/components/schemas/RunStatus"
archived_at:
type: ["string", "null"]
format: date-time
status_updated_at:
type: string
format: date-time
@ -6579,74 +6512,186 @@ components:
additionalProperties:
$ref: "#/components/schemas/StageProjection"
RunSummary:
description: Durable run summary derived from the backing store.
Run:
description: Canonical public run shape.
type: object
required:
- run_id
- goal
- id
- title
- labels
- status
- goal
- workflow
- automation
- repository
- created_at
- created_by
- origin
- labels
- lifecycle
- sandbox
- models
- source_directory
- timestamps
- billing
- diff
- pull_request
- current_question
- superseded_by
- links
properties:
run_id:
type: string
workflow_name:
type: ["string", "null"]
workflow_slug:
type: ["string", "null"]
goal:
id:
type: string
title:
type: string
description: Non-blank resolved run title.
example: Add rate limiting to auth endpoints
goal:
type: string
workflow:
$ref: "#/components/schemas/WorkflowRef"
automation:
oneOf:
- $ref: "#/components/schemas/AutomationRef"
- type: "null"
repository:
oneOf:
- $ref: "#/components/schemas/RepositoryRef"
- type: "null"
created_by:
oneOf:
- $ref: "#/components/schemas/Principal"
- type: "null"
origin:
$ref: "#/components/schemas/RunOrigin"
labels:
type: object
additionalProperties:
type: string
lifecycle:
$ref: "#/components/schemas/RunLifecycle"
sandbox:
oneOf:
- $ref: "#/components/schemas/RunSandbox"
- type: "null"
models:
type: array
items:
$ref: "#/components/schemas/RunModel"
source_directory:
type: ["string", "null"]
repo_origin_url:
timestamps:
$ref: "#/components/schemas/RunTimestamps"
billing:
oneOf:
- $ref: "#/components/schemas/RunBillingSummary"
- type: "null"
diff:
oneOf:
- $ref: "#/components/schemas/DiffSummary"
- type: "null"
pull_request:
oneOf:
- $ref: "#/components/schemas/PullRequest"
- type: "null"
current_question:
oneOf:
- $ref: "#/components/schemas/RunQuestion"
- type: "null"
superseded_by:
type: ["string", "null"]
repository:
$ref: "#/components/schemas/RepositoryReference"
start_time:
links:
$ref: "#/components/schemas/RunLinks"
WorkflowRef:
type: object
required: [slug, name]
properties:
slug:
type: ["string", "null"]
format: date-time
created_at:
name:
type: string
format: date-time
last_event_at:
AutomationRef:
type: object
required: [id, name]
properties:
id:
type: string
name:
type: ["string", "null"]
format: date-time
RunOrigin:
type: object
required: [kind]
properties:
kind:
type: string
enum: [api]
RunLifecycle:
type: object
required: [status, pending_control, queue_position, error, archived, archived_at]
properties:
status:
$ref: "#/components/schemas/RunStatus"
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
- type: "null"
queue_position:
type: ["integer", "null"]
error:
oneOf:
- $ref: "#/components/schemas/RunError"
- type: "null"
archived:
type: boolean
archived_at:
type: ["string", "null"]
format: date-time
RunModel:
type: object
required: [provider, name]
properties:
provider:
type: ["string", "null"]
name:
type: string
RunTimestamps:
type: object
required: [created_at, started_at, last_event_at, completed_at]
properties:
created_at:
type: string
format: date-time
started_at:
type: ["string", "null"]
format: date-time
last_event_at:
type: ["string", "null"]
format: date-time
completed_at:
type: ["string", "null"]
format: date-time
duration_ms:
type: ["integer", "null"]
format: int64
minimum: 0
elapsed_secs:
type: ["number", "null"]
RunBillingSummary:
type: object
required: [total_usd_micros]
properties:
total_usd_micros:
type: ["integer", "null"]
format: int64
superseded_by:
RunLinks:
type: object
required: [web]
properties:
web:
type: ["string", "null"]
diff_summary:
oneOf:
- $ref: "#/components/schemas/DiffSummary"
- type: "null"
pull_request:
oneOf:
- $ref: "#/components/schemas/PullRequestRecord"
- type: "null"
format: uri
ForkRequest:
description: Request body for creating a new run from a source run checkpoint.
@ -6815,16 +6860,23 @@ components:
description: Human-readable run title.
example: "PR #312 — Add OAuth2 PKCE flow"
RepositoryReference:
description: Reference to a repository by name.
RepositoryRef:
description: Durable repository metadata for a run.
type: object
required:
- name
- origin_url
- provider
properties:
name:
type: string
description: Repository name.
example: api-server
example: fabro-sh/fabro
origin_url:
type: ["string", "null"]
example: https://github.com/fabro-sh/fabro.git
provider:
type: string
enum: [github, git, unknown]
BilledTokenCounts:
description: Token counts with optional billed USD micros totals.
@ -6922,43 +6974,11 @@ components:
description: Error message.
example: "Stage 'apply-changes' exceeded maximum retries."
RunPullRequest:
description: Pull request information for a run.
type: object
required:
- number
properties:
number:
type: integer
description: Pull request number.
example: 889
html_url:
type: string
format: uri
description: Stored GitHub pull request URL.
example: https://github.com/fabro-sh/fabro/pull/889
additions:
type: integer
description: Lines added.
example: 234
deletions:
type: integer
description: Lines deleted.
example: 67
comments:
type: integer
description: Number of review comments.
example: 4
checks:
type: array
description: CI check run results.
items:
$ref: "#/components/schemas/CheckRun"
PullRequestRecord:
PullRequest:
description: Persisted record of a pull request created for a run.
type: object
required:
- provider
- html_url
- number
- owner
@ -6967,6 +6987,9 @@ components:
- head_branch
- title
properties:
provider:
type: string
enum: [github]
html_url:
type: string
format: uri
@ -7010,39 +7033,26 @@ components:
type: string
example: fabro/run/demo
PullRequestDetail:
description: Stored pull request record plus live GitHub fields.
PullRequestDetails:
description: Stored pull request record plus live GitHub-enriched fields.
type: object
required:
- record
- number
- title
- pull_request
- state
- draft
- merged
- merged_at
- mergeable
- additions
- deletions
- changed_files
- html_url
- user
- head
- base
- created_at
- updated_at
- comments
- checks
- author
- timestamps
properties:
record:
$ref: "#/components/schemas/PullRequestRecord"
number:
type: integer
example: 123
title:
type: string
example: Move PR commands server-side
body:
type: ["string", "null"]
example: |
## Summary
- Move PR commands server-side
pull_request:
$ref: "#/components/schemas/PullRequest"
state:
type: string
example: open
@ -7068,24 +7078,25 @@ components:
changed_files:
type: integer
example: 5
html_url:
type: string
format: uri
example: https://github.com/fabro-sh/fabro/pull/123
user:
comments:
type: integer
example: 4
checks:
type: array
items:
$ref: "#/components/schemas/CheckRun"
author:
$ref: "#/components/schemas/PullRequestUser"
head:
$ref: "#/components/schemas/PullRequestRef"
base:
$ref: "#/components/schemas/PullRequestRef"
created_at:
type: string
format: date-time
example: "2026-04-23T15:40:00Z"
updated_at:
type: string
format: date-time
example: "2026-04-23T15:45:00Z"
timestamps:
type: object
required: [created_at, updated_at]
properties:
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
CreateRunPullRequestRequest:
description: Request body for creating a run pull request.
@ -7180,32 +7191,40 @@ components:
type: object
required:
- provider
- id
- working_directory
- image
- snapshot
- runtime
properties:
provider:
$ref: "#/components/schemas/SandboxProvider"
image:
type: ["string", "null"]
snapshot:
type: ["string", "null"]
runtime:
oneOf:
- $ref: "#/components/schemas/RunSandboxRuntime"
- type: "null"
RunSandboxRuntime:
type: object
required:
- id
- working_directory
- repo_cloned
- clone_origin_url
- clone_branch
properties:
id:
type: string
description: Fabro sandbox control identifier used for reconnect, terminal, and delete operations.
example: container-a1b2c3d4
working_directory:
type: string
description: Path where the run executed inside the sandbox.
example: /workspace
repo_cloned:
type: ["boolean", "null"]
description: Whether the provider cloned the repository into the sandbox, or null while unknown.
clone_origin_url:
type: ["string", "null"]
description: Repository origin cloned into the sandbox when available.
clone_branch:
type: ["string", "null"]
description: Repository branch cloned into the sandbox when available.
resources:
oneOf:
- $ref: "#/components/schemas/SandboxResources"
- type: "null"
RunQuestion:
description: A pending human-in-the-loop question summary.
@ -7285,81 +7304,6 @@ components:
description: Human-readable stage name.
example: Propose Changes
# ── Run Board Schemas (updated) ─────────────────────────────────────
RunListItem:
description: Canonical run summary shown in the board view, extended with board-specific metadata.
type: object
required:
- run_id
- goal
- title
- status
- labels
- repository
- created_at
- column
properties:
run_id:
type: string
description: Unique run identifier (ULID).
example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z
workflow_name:
type: ["string", "null"]
workflow_slug:
type: ["string", "null"]
goal:
type: string
repository:
$ref: "#/components/schemas/RepositoryReference"
title:
type: string
description: Non-blank resolved run title.
example: Add rate limiting to auth endpoints
status:
$ref: "#/components/schemas/RunStatus"
labels:
type: object
additionalProperties:
type: string
source_directory:
type: ["string", "null"]
repo_origin_url:
type: ["string", "null"]
start_time:
type: ["string", "null"]
format: date-time
pending_control:
oneOf:
- $ref: "#/components/schemas/RunControlAction"
- type: "null"
duration_ms:
type: ["integer", "null"]
format: int64
minimum: 0
elapsed_secs:
type: ["number", "null"]
total_usd_micros:
type: ["integer", "null"]
format: int64
column:
$ref: "#/components/schemas/BoardColumn"
pull_request:
$ref: "#/components/schemas/RunPullRequest"
sandbox:
$ref: "#/components/schemas/RunSandbox"
question:
$ref: "#/components/schemas/RunQuestion"
created_at:
type: string
format: date-time
description: Timestamp when the run was created.
example: "2026-03-06T14:30:00Z"
last_event_at:
type: ["string", "null"]
format: date-time
description: Timestamp of the most recent event applied to the run's projection. Null until the first event is recorded.
RunCheckpoint:
description: Serializable snapshot of execution state for crash recovery and resume.
type: object
@ -8066,33 +8010,22 @@ components:
description: Provider-neutral details about the sandbox owned by a run.
type: object
required:
- provider
- id
- working_directory
- sandbox
- state
- resources
- labels
- timestamps
properties:
provider:
$ref: "#/components/schemas/SandboxProvider"
id:
type: string
description: Fabro sandbox control identifier used for reconnect, terminal, and delete operations.
working_directory:
type: string
description: Path where the run executed inside the sandbox.
sandbox:
$ref: "#/components/schemas/RunSandbox"
state:
$ref: "#/components/schemas/SandboxState"
native_state:
type: string
type: ["string", "null"]
description: Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`.
region:
type: string
type: ["string", "null"]
description: Provider region or target. Null for local-style providers.
image:
type: string
description: Container image or sandbox snapshot identifier.
resources:
$ref: "#/components/schemas/SandboxResources"
labels:

View file

@ -189,20 +189,15 @@ fn main() {
("RunStatus", "fabro_types::status::RunStatus", &[]),
("SuccessReason", "fabro_types::status::SuccessReason", &[]),
("FailureReason", "fabro_types::status::FailureReason", &[]),
("TerminalStatus", "fabro_types::status::TerminalStatus", &[]),
("BlockedReason", "fabro_types::status::BlockedReason", &[]),
(
"RunControlAction",
"fabro_types::status::RunControlAction",
&[],
),
("RunSummary", "fabro_types::RunSummary", &[]),
("Run", "fabro_types::Run", &[]),
("DiffSummary", "fabro_types::DiffSummary", &[]),
(
"RepositoryReference",
"fabro_types::RepositoryReference",
&[],
),
("RepositoryRef", "fabro_types::RepositoryRef", &[]),
("WorkflowSettings", "fabro_types::WorkflowSettings", &[]),
("ServerSettings", "fabro_types::ServerSettings", &[]),
(
@ -370,8 +365,9 @@ fn main() {
("RunProjection", "fabro_types::RunProjection", &[]),
("RunEvent", "fabro_types::RunEvent", &[]),
("EventEnvelope", "fabro_types::EventEnvelope", &[]),
("PullRequestRecord", "fabro_types::PullRequestRecord", &[]),
("PullRequestDetail", "fabro_types::PullRequestDetail", &[]),
("PullRequest", "fabro_types::PullRequest", &[]),
("PullRequestDetails", "fabro_types::PullRequestDetails", &[]),
("RunSandboxRuntime", "fabro_types::RunSandboxRuntime", &[]),
("PullRequestUser", "fabro_types::PullRequestUser", &[]),
("PullRequestRef", "fabro_types::PullRequestRef", &[]),
(

View file

@ -14,6 +14,8 @@ mod generated {
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
}
pub mod types {
use std::collections::HashMap;
pub use fabro_model::{Model, ModelCosts, ModelFeatures, ModelLimits, ModelTestMode, Provider};
pub use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
@ -26,19 +28,70 @@ pub mod types {
};
pub use fabro_types::settings::{FeaturesNamespace, ServerNamespace};
pub use fabro_types::status::{
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason,
};
pub use fabro_types::{
AuthMethod, BilledTokenCounts, CommandTermination, DiffStats, DiffSummary, DirtyStatus,
EventEnvelope, GitContext, IdpIdentity, InterviewOption, InterviewQuestionRecord,
PendingInterviewRecord, PreRunPushOutcome, Principal, QuestionType, RepositoryReference,
RunClientProvenance, RunEvent, RunProjection, RunProvenance, RunSandbox,
RunServerProvenance, RunSummary, SandboxDetails, SandboxProvider, SandboxResources,
SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps,
SecretMetadata, SecretType, ServerSettings, StageCompletion, StageHandler, StageOutcome,
StageProjection, StageState, SystemActorKind, UserPrincipal, WorkflowSettings,
PendingInterviewRecord, PreRunPushOutcome, Principal, PullRequest, PullRequestDetails,
QuestionType, RepositoryRef, Run, RunClientProvenance, RunEvent, RunProjection,
RunProvenance, RunSandbox, RunSandboxRuntime, RunServerProvenance, SandboxDetails,
SandboxProvider, SandboxResources, SandboxService, SandboxServiceListResponse,
SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings,
StageCompletion, StageHandler, StageOutcome, StageProjection, StageState, SystemActorKind,
UserPrincipal, WorkflowSettings,
};
use serde::{Deserialize, Serialize};
pub use crate::generated::types::*;
pub type RunSummary = fabro_types::Run;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStatusResponse {
pub id: String,
pub title: String,
pub status: fabro_types::RunStatus,
pub error: Option<RunError>,
pub queue_position: Option<u32>,
pub pending_control: Option<fabro_types::RunControlAction>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub web_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunPullRequest {
pub number: i64,
pub html_url: Option<String>,
pub additions: Option<i64>,
pub deletions: Option<i64>,
pub comments: Option<i64>,
pub checks: Vec<CheckRun>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunListItem {
pub run_id: String,
pub workflow_name: Option<String>,
pub workflow_slug: Option<String>,
pub goal: String,
pub repository: fabro_types::RepositoryRef,
pub title: String,
pub status: fabro_types::RunStatus,
pub labels: HashMap<String, String>,
pub source_directory: Option<String>,
pub repo_origin_url: Option<String>,
pub start_time: Option<chrono::DateTime<chrono::Utc>>,
pub pending_control: Option<fabro_types::RunControlAction>,
pub duration_ms: Option<i64>,
pub elapsed_secs: Option<f64>,
pub total_usd_micros: Option<i64>,
pub column: BoardColumn,
pub pull_request: Option<RunPullRequest>,
pub sandbox: Option<fabro_types::RunSandbox>,
pub question: Option<RunQuestion>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
}
}
pub use generated::Client as ApiClient;

View file

@ -8,7 +8,8 @@ use serde_json::json;
#[test]
fn pull_request_detail_reuses_domain_record_type() {
let detail: PullRequestDetail = serde_json::from_value(json!({
"record": {
"pull_request": {
"provider": "github",
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
@ -17,9 +18,6 @@ fn pull_request_detail_reuses_domain_record_type() {
"head_branch": "fabro/run/demo",
"title": "Move PR commands server-side"
},
"number": 123,
"title": "Move PR commands server-side",
"body": "Generated body",
"state": "closed",
"draft": false,
"merged": true,
@ -28,22 +26,19 @@ fn pull_request_detail_reuses_domain_record_type() {
"additions": 234,
"deletions": 67,
"changed_files": 5,
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"user": {
"comments": 3,
"checks": [],
"author": {
"login": "octocat"
},
"head": {
"ref": "fabro/run/demo"
},
"base": {
"ref": "main"
},
"created_at": "2026-04-23T15:40:00Z",
"updated_at": "2026-04-23T15:45:00Z"
"timestamps": {
"created_at": "2026-04-23T15:40:00Z",
"updated_at": "2026-04-23T15:45:00Z"
}
}))
.expect("detail should deserialize");
assert_same_type_as_pull_request_record(&detail.record);
assert_same_type_as_pull_request_record(&detail.pull_request);
}
#[test]
@ -70,6 +65,7 @@ fn merge_strategy_json_matches_openapi_shape() {
#[test]
fn pull_request_record_json_matches_openapi_shape() {
let fixture = json!({
"provider": "github",
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
@ -88,7 +84,8 @@ fn pull_request_record_json_matches_openapi_shape() {
#[test]
fn pull_request_detail_json_matches_openapi_shape() {
let fixture = json!({
"record": {
"pull_request": {
"provider": "github",
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
@ -97,9 +94,6 @@ fn pull_request_detail_json_matches_openapi_shape() {
"head_branch": "fabro/run/demo",
"title": "Move PR commands server-side"
},
"number": 123,
"title": "Move PR commands server-side",
"body": "Generated body",
"state": "closed",
"draft": false,
"merged": true,
@ -108,18 +102,15 @@ fn pull_request_detail_json_matches_openapi_shape() {
"additions": 234,
"deletions": 67,
"changed_files": 5,
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"user": {
"comments": 3,
"checks": [],
"author": {
"login": "octocat"
},
"head": {
"ref": "fabro/run/demo"
},
"base": {
"ref": "main"
},
"created_at": "2026-04-23T15:40:00Z",
"updated_at": "2026-04-23T15:45:00Z"
"timestamps": {
"created_at": "2026-04-23T15:40:00Z",
"updated_at": "2026-04-23T15:45:00Z"
}
});
let detail: PullRequestDetail =

View file

@ -36,11 +36,13 @@ fn run_projection_round_trips_populated_projection() {
"conclusion": null,
"sandbox": {
"provider": "docker",
"id": "container-abc123",
"working_directory": "/workspace",
"repo_cloned": true,
"clone_origin_url": "https://github.com/fabro-sh/fabro.git",
"clone_branch": "main"
"runtime": {
"id": "container-abc123",
"working_directory": "/workspace",
"repo_cloned": true,
"clone_origin_url": "https://github.com/fabro-sh/fabro.git",
"clone_branch": "main"
}
},
"pull_request": null,
"superseded_by": null,

View file

@ -1,32 +1,27 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{
RunSandbox as ApiRunSandbox, SandboxProvider as ApiSandboxProvider,
SandboxResources as ApiSandboxResources,
};
use fabro_types::{RunSandbox, SandboxProvider, SandboxResources};
use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProvider as ApiSandboxProvider};
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider};
use serde_json::json;
#[test]
fn run_sandbox_reuses_domain_types() {
assert_same_type::<ApiRunSandbox, RunSandbox>();
assert_same_type::<ApiSandboxProvider, SandboxProvider>();
assert_same_type::<ApiSandboxResources, SandboxResources>();
}
#[test]
fn run_sandbox_json_matches_openapi_shape() {
let sandbox = RunSandbox {
provider: SandboxProvider::Docker,
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(false),
clone_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
clone_branch: Some("main".to_string()),
resources: Some(SandboxResources {
cpu_cores: Some(2.0),
memory_bytes: Some(4 * 1024 * 1024 * 1024),
disk_bytes: None,
provider: SandboxProvider::Docker,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: Some(RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(false),
clone_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
clone_branch: Some("main".to_string()),
}),
};
@ -36,14 +31,13 @@ fn run_sandbox_json_matches_openapi_shape() {
value,
json!({
"provider": "docker",
"id": "container-abc123",
"working_directory": "/workspace",
"repo_cloned": false,
"clone_origin_url": "https://github.com/fabro-sh/fabro.git",
"clone_branch": "main",
"resources": {
"cpu_cores": 2.0,
"memory_bytes": 4_294_967_296_u64,
"image": "ghcr.io/fabro/sandbox:latest",
"runtime": {
"id": "container-abc123",
"working_directory": "/workspace",
"repo_cloned": false,
"clone_origin_url": "https://github.com/fabro-sh/fabro.git",
"clone_branch": "main"
}
})
);

View file

@ -2,25 +2,23 @@ use std::any::{TypeId, type_name};
use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use fabro_api::types::{
RepositoryReference as ApiRepositoryReference, RunSummary as ApiRunSummary,
};
use fabro_types::status::{RunStatus, SuccessReason, TerminalStatus};
use fabro_types::{DiffSummary, PullRequestRecord, RepositoryReference, RunId, RunSummary};
use fabro_api::types::{RepositoryRef as ApiRepositoryRef, RunSummary as ApiRunSummary};
use fabro_types::status::{RunStatus, SuccessReason};
use fabro_types::{DiffSummary, PullRequest, RepositoryProvider, RepositoryRef, RunId, RunSummary};
use serde_json::json;
#[test]
fn run_summary_reuses_domain_types() {
assert_same_type::<ApiRunSummary, RunSummary>();
assert_same_type::<ApiRepositoryReference, RepositoryReference>();
assert_same_type::<ApiRepositoryRef, RepositoryRef>();
}
#[test]
fn run_summary_json_matches_openapi_shape() {
let created_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap();
let run_id = RunId::with_timestamp(created_at, 7);
let superseded_by = RunId::with_timestamp(created_at, 8);
let last_event_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 42).unwrap();
let archived_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 1, 0).unwrap();
let summary = RunSummary::new(
run_id,
Some("workflow".to_string()),
@ -30,23 +28,24 @@ fn run_summary_json_matches_openapi_shape() {
HashMap::from([("team".to_string(), "core".to_string())]),
Some("/tmp/fabro".to_string()),
None,
None,
Some(created_at),
Some(last_event_at),
RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::PartialSuccess,
},
None,
RunStatus::Succeeded {
reason: SuccessReason::PartialSuccess,
},
None,
Some(42_000),
Some(123),
Some(superseded_by),
None,
Some(DiffSummary {
files_changed: 3,
additions: 12,
deletions: 4,
}),
Some(PullRequestRecord {
Some(PullRequest {
provider: "github".to_string(),
html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
number: 123,
owner: "fabro-sh".to_string(),
@ -55,45 +54,68 @@ fn run_summary_json_matches_openapi_shape() {
head_branch: "fabro/run/demo".to_string(),
title: "Add run PR chip".to_string(),
}),
Some(archived_at),
None,
vec![],
None,
None,
);
assert_eq!(
serde_json::to_value(&summary).unwrap(),
json!({
"run_id": run_id.to_string(),
"workflow_name": "workflow",
"workflow_slug": "workflow",
"goal": "",
"id": run_id.to_string(),
"title": "API title",
"goal": "",
"workflow": {
"slug": "workflow",
"name": "workflow"
},
"automation": null,
"repository": {
"name": "fabro",
"origin_url": null,
"provider": "unknown"
},
"created_by": null,
"origin": {
"kind": "api"
},
"labels": {
"team": "core"
},
"source_directory": "/tmp/fabro",
"repo_origin_url": null,
"repository": {
"name": "fabro"
},
"start_time": "2026-04-20T12:00:00Z",
"created_at": "2026-04-20T12:00:00Z",
"last_event_at": "2026-04-20T12:00:42Z",
"status": {
"kind": "archived",
"prior": {
"lifecycle": {
"status": {
"kind": "succeeded",
"reason": "partial_success"
}
},
"pending_control": null,
"queue_position": null,
"error": null,
"archived": true,
"archived_at": "2026-04-20T12:01:00Z"
},
"pending_control": null,
"duration_ms": 42000,
"elapsed_secs": 42.0,
"total_usd_micros": 123,
"superseded_by": superseded_by.to_string(),
"diff_summary": {
"sandbox": null,
"models": [],
"source_directory": "/tmp/fabro",
"timestamps": {
"created_at": "2026-04-20T12:00:00Z",
"started_at": "2026-04-20T12:00:00Z",
"last_event_at": "2026-04-20T12:00:42Z",
"completed_at": null,
"duration_ms": 42000,
"elapsed_secs": 42.0
},
"billing": {
"total_usd_micros": 123
},
"diff": {
"files_changed": 3,
"additions": 12,
"deletions": 4
},
"pull_request": {
"provider": "github",
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
@ -101,6 +123,11 @@ fn run_summary_json_matches_openapi_shape() {
"base_branch": "main",
"head_branch": "fabro/run/demo",
"title": "Add run PR chip"
},
"current_question": null,
"superseded_by": null,
"links": {
"web": null
}
})
);
@ -111,40 +138,66 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
let created_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap();
let run_id = RunId::with_timestamp(created_at, 7);
let summary: RunSummary = serde_json::from_value(json!({
"run_id": run_id.to_string(),
"id": run_id.to_string(),
"goal": "ship it",
"title": "ship it",
"workflow": {
"slug": null,
"name": "unnamed"
},
"origin": {
"kind": "api"
},
"labels": {},
"status": {
"kind": "running"
"lifecycle": {
"status": {
"kind": "running"
},
"archived": false
},
"repository": {
"name": "fabro"
"name": "fabro",
"origin_url": null,
"provider": "unknown"
},
"created_at": "2026-04-20T12:00:00Z"
"models": [],
"timestamps": {
"created_at": "2026-04-20T12:00:00Z",
"started_at": null,
"last_event_at": null,
"completed_at": null
},
"links": {
"web": null
}
}))
.unwrap();
assert_eq!(summary.run_id, run_id);
assert_eq!(summary.workflow_name, None);
assert_eq!(summary.workflow_slug, None);
assert_eq!(summary.id, run_id);
assert_eq!(summary.workflow.name, "unnamed");
assert_eq!(summary.workflow.slug, None);
assert_eq!(summary.goal, "ship it");
assert_eq!(summary.title, "ship it");
assert_eq!(summary.labels, HashMap::new());
assert_eq!(summary.source_directory, None);
assert_eq!(summary.repository, RepositoryReference {
name: "fabro".to_string(),
});
assert_eq!(summary.start_time, None);
assert_eq!(summary.created_at, created_at);
assert_eq!(summary.last_event_at, None);
assert_eq!(summary.status, RunStatus::Running);
assert_eq!(summary.pending_control, None);
assert_eq!(summary.duration_ms, None);
assert_eq!(summary.elapsed_secs, None);
assert_eq!(summary.total_usd_micros, None);
assert_eq!(
summary.repository,
Some(RepositoryRef {
name: "fabro".to_string(),
origin_url: None,
provider: RepositoryProvider::Unknown,
})
);
assert_eq!(summary.timestamps.started_at, None);
assert_eq!(summary.timestamps.created_at, created_at);
assert_eq!(summary.timestamps.last_event_at, None);
assert_eq!(summary.lifecycle.status, RunStatus::Running);
assert_eq!(summary.lifecycle.pending_control, None);
assert_eq!(summary.timestamps.duration_ms, None);
assert_eq!(summary.timestamps.elapsed_secs, None);
assert_eq!(summary.billing, None);
assert_eq!(summary.superseded_by, None);
assert_eq!(summary.diff_summary, None);
assert_eq!(summary.diff, None);
assert_eq!(summary.pull_request, None);
}

View file

@ -8,7 +8,8 @@ use fabro_api::types::{
SandboxTimestamps as ApiSandboxTimestamps,
};
use fabro_types::{
SandboxDetails, SandboxProvider, SandboxResources, SandboxState, SandboxTimestamps,
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxProvider, SandboxResources, SandboxState,
SandboxTimestamps,
};
use serde_json::json;
@ -25,20 +26,28 @@ fn sandbox_details_reuses_domain_types() {
fn sandbox_details_json_matches_openapi_shape() {
let created_at = Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap();
let details = SandboxDetails {
provider: SandboxProvider::Docker,
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
state: SandboxState::Running,
native_state: Some("running".to_string()),
region: None,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
resources: SandboxResources {
sandbox: RunSandbox {
provider: SandboxProvider::Docker,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: Some(RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}),
},
state: SandboxState::Running,
native_state: Some("running".to_string()),
region: None,
resources: SandboxResources {
cpu_cores: Some(2.0),
memory_bytes: Some(4 * 1024 * 1024 * 1024),
disk_bytes: None,
},
labels: BTreeMap::from([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
labels: BTreeMap::from([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
created_at: Some(created_at),
last_activity_at: None,
},
@ -47,12 +56,16 @@ fn sandbox_details_json_matches_openapi_shape() {
assert_eq!(
serde_json::to_value(&details).unwrap(),
json!({
"provider": "docker",
"id": "container-abc123",
"working_directory": "/workspace",
"sandbox": {
"provider": "docker",
"image": "ghcr.io/fabro/sandbox:latest",
"runtime": {
"id": "container-abc123",
"working_directory": "/workspace"
}
},
"state": "running",
"native_state": "running",
"image": "ghcr.io/fabro/sandbox:latest",
"resources": {
"cpu_cores": 2.0,
"memory_bytes": 4_294_967_296_u64,
@ -70,9 +83,13 @@ fn sandbox_details_json_matches_openapi_shape() {
#[test]
fn sandbox_details_deserializes_when_optional_fields_are_absent() {
let details: SandboxDetails = serde_json::from_value(json!({
"provider": "local",
"id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z",
"working_directory": "/Users/client/project",
"sandbox": {
"provider": "local",
"runtime": {
"id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z",
"working_directory": "/Users/client/project"
}
},
"state": "unknown",
"resources": {},
"labels": {},
@ -80,11 +97,25 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() {
}))
.unwrap();
assert_eq!(details.provider, SandboxProvider::Local);
assert_eq!(details.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z");
assert_eq!(details.working_directory, "/Users/client/project");
assert_eq!(details.sandbox.provider, SandboxProvider::Local);
assert_eq!(
details
.sandbox
.runtime
.as_ref()
.map(|runtime| runtime.id.as_str()),
Some("local:01JNQVR7M0EJ5GKAT2SC4ERS1Z")
);
assert_eq!(
details
.sandbox
.runtime
.as_ref()
.map(|runtime| runtime.working_directory.as_str()),
Some("/Users/client/project")
);
assert_eq!(details.state, SandboxState::Unknown);
assert!(details.image.is_none());
assert!(details.sandbox.image.is_none());
assert!(details.region.is_none());
assert!(details.native_state.is_none());
assert!(details.labels.is_empty());

View file

@ -3,10 +3,10 @@ use std::any::{TypeId, type_name};
use fabro_api::types::{
BlockedReason as ApiBlockedReason, FailureReason as ApiFailureReason,
RunControlAction as ApiRunControlAction, RunStatus as ApiRunStatus,
SuccessReason as ApiSuccessReason, TerminalStatus as ApiTerminalStatus,
SuccessReason as ApiSuccessReason,
};
use fabro_types::status::{
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason, TerminalStatus,
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason,
};
use serde::Serialize;
use serde_json::{Value, json};
@ -16,7 +16,6 @@ fn status_family_reuses_domain_types() {
assert_same_type::<ApiRunStatus, RunStatus>();
assert_same_type::<ApiSuccessReason, SuccessReason>();
assert_same_type::<ApiFailureReason, FailureReason>();
assert_same_type::<ApiTerminalStatus, TerminalStatus>();
assert_same_type::<ApiBlockedReason, BlockedReason>();
assert_same_type::<ApiRunControlAction, RunControlAction>();
}
@ -93,20 +92,6 @@ fn run_status_json_matches_openapi_shape() {
"kind": "dead"
}),
);
assert_json(
RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::PartialSuccess,
},
},
json!({
"kind": "archived",
"prior": {
"kind": "succeeded",
"reason": "partial_success"
}
}),
);
}
#[test]
@ -139,34 +124,6 @@ fn run_control_action_json_tokens_match_openapi() {
assert_string_json(RunControlAction::Unpause, "unpause");
}
#[test]
fn terminal_status_json_matches_openapi_shape() {
assert_json(
TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
json!({
"kind": "succeeded",
"reason": "completed"
}),
);
assert_json(
TerminalStatus::Failed {
reason: FailureReason::Cancelled,
},
json!({
"kind": "failed",
"reason": "cancelled"
}),
);
assert_json(
TerminalStatus::Dead,
json!({
"kind": "dead"
}),
);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),

View file

@ -9,12 +9,12 @@ pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) ->
let (ctx, client, run_id) =
super::resolve_run_for_pr(base_ctx, &args.server, &args.run_id).await?;
let detail = client.get_run_pull_request(&run_id).await?;
let github = &detail.github;
let pull_request = &detail.pull_request;
info!(
number = github.number,
owner = %detail.record.owner,
repo = %detail.record.repo,
number = pull_request.number,
owner = %pull_request.owner,
repo = %pull_request.repo,
"Viewing pull request"
);
@ -24,36 +24,30 @@ pub(super) async fn view_command(args: PrViewArgs, base_ctx: &CommandContext) ->
}
let printer = ctx.printer();
fabro_util::printout!(printer, "#{} {}", github.number, github.title);
let state_display = if github.merged {
fabro_util::printout!(printer, "#{} {}", pull_request.number, pull_request.title);
let state_display = if detail.merged {
"merged"
} else if github.draft {
} else if detail.draft {
"draft"
} else {
&github.state
&detail.state
};
fabro_util::printout!(printer, "State: {state_display}");
fabro_util::printout!(printer, "URL: {}", github.html_url);
fabro_util::printout!(printer, "URL: {}", pull_request.html_url);
fabro_util::printout!(
printer,
"Branch: {} -> {}",
github.head.ref_name,
github.base.ref_name
pull_request.head_branch,
pull_request.base_branch
);
fabro_util::printout!(printer, "Author: {}", github.user.login);
fabro_util::printout!(printer, "Author: {}", detail.author.login);
fabro_util::printout!(
printer,
"Changes: +{} -{} ({} files)",
github.additions,
github.deletions,
github.changed_files
detail.additions,
detail.deletions,
detail.changed_files
);
if let Some(body) = &github.body {
if !body.is_empty() {
fabro_util::printout!(printer, "");
fabro_util::printout!(printer, "{body}");
}
}
Ok(())
}

View file

@ -105,8 +105,7 @@ fn print_human_output(
RunStatus::Succeeded { .. } => (&styles.bold_green, "Succeeded"),
RunStatus::Failed { .. } => (&styles.bold_red, "Failed"),
RunStatus::Dead => (&styles.bold_red, "Dead"),
RunStatus::Archived { .. } => (&styles.dim, "Archived"),
// Poll loop only breaks on is_terminal() which is the four arms above.
// Poll loop only breaks on is_terminal() which is the three arms above.
_ => unreachable!(),
};
let status_display = style.apply_to(label);

View file

@ -143,7 +143,7 @@ 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::Queued | RunStatus::Dead | RunStatus::Archived { .. } => Some(Color::Ansi256(8)),
RunStatus::Queued | RunStatus::Dead => Some(Color::Ansi256(8)),
RunStatus::Blocked { .. } | RunStatus::Removing => Some(Color::Yellow),
RunStatus::Paused { .. } => Some(Color::Magenta),
};

View file

@ -138,7 +138,6 @@ pub(crate) fn run_status_kind(status: RunStatus) -> &'static str {
RunStatus::Succeeded { .. } => "succeeded",
RunStatus::Failed { .. } => "failed",
RunStatus::Dead => "dead",
RunStatus::Archived { .. } => "archived",
}
}

View file

@ -96,9 +96,8 @@ fn archive_succeeded_run_hides_it_from_default_ps() {
assert!(output.status.success());
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
assert_eq!(runs.len(), 1, "ps -a should show the archived run");
assert_eq!(runs[0]["status"]["kind"], "archived");
assert_eq!(runs[0]["status"]["prior"]["kind"], "succeeded");
assert_eq!(runs[0]["status"]["prior"]["reason"], "completed");
assert_eq!(runs[0]["status"]["kind"], "succeeded");
assert_eq!(runs[0]["status"]["reason"], "completed");
assert_eq!(runs[0]["run_id"], run.run_id);
}

View file

@ -242,7 +242,9 @@ fn inspect_created_run_shows_run_spec_without_start_or_conclusion() {
"start_record": null,
"conclusion": null,
"checkpoint": null,
"sandbox": null
"sandbox": {
"provider": "local"
}
}
]
"#);

View file

@ -150,6 +150,7 @@ fn pr_create_passes_force_and_model_to_server() {
exit_code: 0
----- stdout -----
{
"provider": "github",
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",

View file

@ -125,7 +125,8 @@ fn pr_view_uses_server_pull_request_endpoint_and_renders_merged_state() {
.header("Content-Type", "application/json")
.body(
serde_json::json!({
"record": {
"pull_request": {
"provider": "github",
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
@ -145,18 +146,15 @@ fn pr_view_uses_server_pull_request_endpoint_and_renders_merged_state() {
"additions": 10,
"deletions": 3,
"changed_files": 2,
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"user": {
"comments": 0,
"checks": [],
"author": {
"login": "testuser"
},
"head": {
"ref": "fabro/run/demo"
},
"base": {
"ref": "main"
},
"created_at": "2026-04-05T12:00:00Z",
"updated_at": "2026-04-06T12:30:00Z"
"timestamps": {
"created_at": "2026-04-05T12:00:00Z",
"updated_at": "2026-04-06T12:30:00Z"
}
})
.to_string(),
);
@ -181,8 +179,6 @@ fn pr_view_uses_server_pull_request_endpoint_and_renders_merged_state() {
Branch: fabro/run/demo -> main
Author: testuser
Changes: +10 -3 (2 files)
Detailed description
----- stderr -----
");

View file

@ -91,10 +91,7 @@ fn rewind_target_updates_metadata_and_resume_hint() {
assert!(output.status.success(), "rewind should succeed");
let state = run_state(&setup.run.run_dir);
assert!(matches!(
state.status,
fabro_types::RunStatus::Archived { .. }
));
assert!(state.archived_at.is_some());
let new_run_id = state
.superseded_by
.expect("rewind should record replacement run");
@ -156,9 +153,6 @@ fn rewind_archives_source_and_records_superseded_by() {
);
let state = run_state(&setup.run.run_dir);
assert!(matches!(
state.status,
fabro_types::RunStatus::Archived { .. }
));
assert!(state.archived_at.is_some());
assert!(state.superseded_by.is_some());
}

View file

@ -50,7 +50,7 @@ fn sandbox_cp_run_without_sandbox_json_errors_cleanly() {
exit_code: 1
----- stdout -----
----- stderr -----
× Run has no active sandbox.
× run sandbox missing runtime metadata
");
}

View file

@ -451,18 +451,22 @@ pub(crate) fn write_gated_workflow(path: &Path, name: &str, goal: &str) -> Workf
pub(crate) fn wait_for_status(run_dir: &Path, expected: &[&str]) -> String {
let deadline = Instant::now() + command_timeout();
loop {
let status = match run_state(run_dir).status {
fabro_types::RunStatus::Submitted => "submitted",
fabro_types::RunStatus::Queued => "queued",
fabro_types::RunStatus::Starting => "starting",
fabro_types::RunStatus::Running => "running",
fabro_types::RunStatus::Blocked { .. } => "blocked",
fabro_types::RunStatus::Paused { .. } => "paused",
fabro_types::RunStatus::Removing => "removing",
fabro_types::RunStatus::Succeeded { .. } => "succeeded",
fabro_types::RunStatus::Failed { .. } => "failed",
fabro_types::RunStatus::Dead => "dead",
fabro_types::RunStatus::Archived { .. } => "archived",
let state = run_state(run_dir);
let status = if state.archived_at.is_some() {
"archived"
} else {
match state.status {
fabro_types::RunStatus::Submitted => "submitted",
fabro_types::RunStatus::Queued => "queued",
fabro_types::RunStatus::Starting => "starting",
fabro_types::RunStatus::Running => "running",
fabro_types::RunStatus::Blocked { .. } => "blocked",
fabro_types::RunStatus::Paused { .. } => "paused",
fabro_types::RunStatus::Removing => "removing",
fabro_types::RunStatus::Succeeded { .. } => "succeeded",
fabro_types::RunStatus::Failed { .. } => "failed",
fabro_types::RunStatus::Dead => "dead",
}
};
if expected.contains(&status) {
return status.to_string();

View file

@ -51,9 +51,8 @@ fn archive_lifecycle_end_to_end() {
let with_archived = ps_runs(&context, true);
assert_eq!(with_archived.len(), 1);
assert_eq!(with_archived[0]["run_id"], run.run_id);
assert_eq!(with_archived[0]["status"]["kind"], "archived");
assert_eq!(with_archived[0]["status"]["prior"]["kind"], "succeeded");
assert_eq!(with_archived[0]["status"]["prior"]["reason"], "completed");
assert_eq!(with_archived[0]["status"]["kind"], "succeeded");
assert_eq!(with_archived[0]["status"]["reason"], "completed");
let unarchive = context
.command()

View file

@ -146,7 +146,10 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
fabro_json_snapshot!(
context,
serde_json::json!({
"status": match state.status {
"status": if state.archived_at.is_some() {
"archived"
} else {
match state.status {
fabro_types::RunStatus::Submitted => "submitted",
fabro_types::RunStatus::Queued => "queued",
fabro_types::RunStatus::Starting => "starting",
@ -157,7 +160,7 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
fabro_types::RunStatus::Succeeded { .. } => "succeeded",
fabro_types::RunStatus::Failed { .. } => "failed",
fabro_types::RunStatus::Dead => "dead",
fabro_types::RunStatus::Archived { .. } => "archived",
}
},
"has_conclusion": state.conclusion.is_some(),
}),

View file

@ -567,7 +567,7 @@ impl Client {
)
.await?;
let status = response.into_inner();
status.id.parse().context("invalid run ID from server")
Ok(status.id)
}
pub async fn list_secrets(&self) -> Result<Vec<types::SecretMetadata>> {

View file

@ -557,13 +557,16 @@ mod tests {
diff: RunDiff::default(),
});
projection.sandbox = Some(RunSandbox {
provider: SandboxProvider::Local,
id: "sandbox-1".to_string(),
working_directory: "/tmp/project".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
resources: None,
provider: SandboxProvider::Local,
image: None,
snapshot: None,
runtime: Some(fabro_types::RunSandboxRuntime {
id: "sandbox-1".to_string(),
working_directory: "/tmp/project".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}),
});
let stage =
projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2));

View file

@ -44,16 +44,13 @@ pub async fn sandbox_details(
fn local_details(record: &RunSandbox) -> SandboxDetails {
SandboxDetails {
provider: SandboxProvider::Local,
id: record.id.clone(),
working_directory: record.working_directory.clone(),
state: SandboxState::Running,
native_state: None,
region: None,
image: None,
resources: SandboxResources::default(),
labels: BTreeMap::new(),
timestamps: SandboxTimestamps::default(),
sandbox: record.clone(),
state: SandboxState::Running,
native_state: None,
region: None,
resources: SandboxResources::default(),
labels: BTreeMap::new(),
timestamps: SandboxTimestamps::default(),
}
}
@ -67,8 +64,7 @@ mod docker {
use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig};
use chrono::{DateTime, Utc};
use fabro_types::{
RunId, RunSandbox, SandboxDetails, SandboxProvider, SandboxResources, SandboxState,
SandboxTimestamps,
RunId, RunSandbox, SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps,
};
pub(super) async fn docker_details(
@ -77,10 +73,14 @@ mod docker {
) -> Result<SandboxDetails> {
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
let runtime = record
.runtime
.as_ref()
.context("Docker run sandbox missing runtime metadata")?;
let inspect = docker
.inspect_container(&record.id, None::<InspectContainerOptions>)
.inspect_container(&runtime.id, None::<InspectContainerOptions>)
.await
.map_err(|err| anyhow!("Failed to inspect Docker container '{}': {err}", record.id))?;
.map_err(|err| anyhow!("Failed to inspect Docker container '{}': {err}", runtime.id))?;
Ok(map_docker_inspect(inspect, record))
}
@ -119,13 +119,13 @@ mod docker {
let created_at = inspect.created.as_deref().and_then(parse_docker_timestamp);
SandboxDetails {
provider: SandboxProvider::Docker,
id: record.id.clone(),
working_directory: record.working_directory.clone(),
sandbox: RunSandbox {
image: image.or_else(|| record.image.clone()),
..record.clone()
},
state: normalized_state,
native_state,
region: None,
image,
resources,
labels,
timestamps: SandboxTimestamps {
@ -171,18 +171,22 @@ mod docker {
#[cfg(test)]
mod tests {
use bollard::models::HostConfig;
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider};
use super::*;
fn record() -> RunSandbox {
RunSandbox {
provider: SandboxProvider::Docker,
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(true),
clone_origin_url: None,
clone_branch: None,
resources: None,
provider: SandboxProvider::Docker,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(true),
clone_origin_url: None,
clone_branch: None,
}),
}
}
@ -249,8 +253,9 @@ mod docker {
..Default::default()
};
let details = map_docker_inspect(inspect, &record());
assert_eq!(details.id, "container-abc123");
assert_eq!(details.working_directory, "/workspace");
let runtime = details.sandbox.runtime.expect("runtime");
assert_eq!(runtime.id, "container-abc123");
assert_eq!(runtime.working_directory, "/workspace");
}
#[test]
@ -338,8 +343,7 @@ mod daytona {
use chrono::{DateTime, Utc};
use daytona_api_client::models::SandboxState as DaytonaState;
use fabro_types::{
RunSandbox, SandboxDetails, SandboxProvider, SandboxResources, SandboxState,
SandboxTimestamps,
RunSandbox, SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps,
};
use crate::daytona::DaytonaSandbox;
@ -348,16 +352,20 @@ mod daytona {
record: &RunSandbox,
daytona_api_key: Option<String>,
) -> Result<SandboxDetails> {
let repo_cloned = record
let runtime = record
.runtime
.as_ref()
.context("Daytona run sandbox missing runtime metadata")?;
let repo_cloned = runtime
.repo_cloned
.context("Daytona run sandbox missing clone metadata")?;
let sandbox_handle = DaytonaSandbox::reconnect(
&record.id,
&runtime.id,
daytona_api_key,
repo_cloned,
record.clone_origin_url.clone(),
record.clone_branch.clone(),
runtime.clone_origin_url.clone(),
runtime.clone_branch.clone(),
)
.await
.map_err(anyhow::Error::new)?;
@ -394,13 +402,13 @@ mod daytona {
};
SandboxDetails {
provider: SandboxProvider::Daytona,
id: record.id.clone(),
working_directory: record.working_directory.clone(),
sandbox: RunSandbox {
snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()),
..record.clone()
},
state: normalized_state,
native_state,
region,
image: sandbox.snapshot.clone(),
resources,
labels,
timestamps: SandboxTimestamps {
@ -539,21 +547,25 @@ mod tests {
#[test]
fn local_details_returns_running_with_no_metadata() {
let record = RunSandbox {
provider: SandboxProvider::Local,
id: "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string(),
working_directory: "/Users/client/project".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
resources: None,
provider: SandboxProvider::Local,
image: None,
snapshot: None,
runtime: Some(fabro_types::RunSandboxRuntime {
id: "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string(),
working_directory: "/Users/client/project".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}),
};
let details = local_details(&record);
assert_eq!(details.provider, SandboxProvider::Local);
assert_eq!(details.sandbox.provider, SandboxProvider::Local);
assert_eq!(details.state, SandboxState::Running);
assert_eq!(details.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z");
assert_eq!(details.working_directory, "/Users/client/project");
let runtime = details.sandbox.runtime.as_ref().unwrap();
assert_eq!(runtime.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z");
assert_eq!(runtime.working_directory, "/Users/client/project");
assert!(details.region.is_none());
assert!(details.image.is_none());
assert!(details.sandbox.image.is_none());
assert!(details.labels.is_empty());
assert_eq!(details.resources, SandboxResources::default());
assert_eq!(details.timestamps, SandboxTimestamps::default());

View file

@ -52,9 +52,13 @@ pub async fn reconnect_for_run_with_callback(
run_id: Option<RunId>,
event_callback: Option<SandboxEventCallback>,
) -> Result<Box<dyn crate::Sandbox>> {
let runtime = record
.runtime
.as_ref()
.context("run sandbox missing runtime metadata")?;
match record.provider {
SandboxProvider::Local => {
let mut sandbox = LocalSandbox::new(PathBuf::from(&record.working_directory));
let mut sandbox = LocalSandbox::new(PathBuf::from(&runtime.working_directory));
if let Some(callback) = event_callback {
sandbox.set_event_callback(callback);
}
@ -62,14 +66,14 @@ pub async fn reconnect_for_run_with_callback(
}
#[cfg(feature = "docker")]
SandboxProvider::Docker => {
let repo_cloned = record
let repo_cloned = runtime
.repo_cloned
.context("Docker run sandbox missing repo_cloned metadata")?;
let mut sandbox = DockerSandbox::reconnect(
&record.id,
&runtime.id,
repo_cloned,
record.clone_origin_url.clone(),
record.clone_branch.clone(),
runtime.clone_origin_url.clone(),
runtime.clone_branch.clone(),
run_id,
)
.await
@ -79,18 +83,20 @@ pub async fn reconnect_for_run_with_callback(
}
Ok(Box::new(sandbox))
}
#[cfg(not(feature = "docker"))]
SandboxProvider::Docker => bail!("Docker sandbox support is not enabled"),
#[cfg(feature = "daytona")]
SandboxProvider::Daytona => {
let repo_cloned = record
let repo_cloned = runtime
.repo_cloned
.context("Daytona run sandbox missing repo_cloned metadata")?;
let mut sandbox = DaytonaSandbox::reconnect(
&record.id,
&runtime.id,
daytona_api_key,
repo_cloned,
record.clone_origin_url.clone(),
record.clone_branch.clone(),
runtime.clone_origin_url.clone(),
runtime.clone_branch.clone(),
)
.await
.map_err(anyhow::Error::new)?;
@ -99,5 +105,7 @@ pub async fn reconnect_for_run_with_callback(
}
Ok(Box::new(sandbox))
}
#[cfg(not(feature = "daytona"))]
SandboxProvider::Daytona => bail!("Daytona sandbox support is not enabled"),
}
}

View file

@ -9,7 +9,7 @@ use fabro_github::GitHubCredentials;
unused_imports,
reason = "Daytona-enabled builds persist RunId in the sandbox spec."
)]
use fabro_types::{RunId, RunSandbox, SandboxProvider};
use fabro_types::{RunId, RunSandbox, RunSandboxRuntime, SandboxProvider};
#[cfg(any(feature = "docker", feature = "daytona"))]
use crate::clone_source;
@ -84,17 +84,20 @@ impl SandboxSpec {
..
} => RunSandbox {
provider: self.provider(),
id,
working_directory: working_directory.clone(),
repo_cloned: clone_source::repo_cloned_for_record(
config.skip_clone,
clone_origin_url.as_deref(),
),
clone_origin_url: clone_source::clean_clone_origin_for_record(
clone_origin_url.as_deref(),
),
clone_branch: clone_branch.clone(),
resources: None,
image: (!config.image.is_empty()).then(|| config.image.clone()),
snapshot: None,
runtime: Some(RunSandboxRuntime {
id,
working_directory: working_directory.clone(),
repo_cloned: clone_source::repo_cloned_for_record(
config.skip_clone,
clone_origin_url.as_deref(),
),
clone_origin_url: clone_source::clean_clone_origin_for_record(
clone_origin_url.as_deref(),
),
clone_branch: clone_branch.clone(),
}),
},
#[cfg(feature = "daytona")]
Self::Daytona {
@ -104,26 +107,35 @@ impl SandboxSpec {
..
} => RunSandbox {
provider: self.provider(),
id,
working_directory: working_directory.clone(),
repo_cloned: clone_source::repo_cloned_for_record(
config.skip_clone,
clone_origin_url.as_deref(),
),
clone_origin_url: clone_source::clean_clone_origin_for_record(
clone_origin_url.as_deref(),
),
clone_branch: clone_branch.clone(),
resources: None,
image: None,
snapshot: config
.snapshot
.as_ref()
.map(|snapshot| snapshot.name.clone()),
runtime: Some(RunSandboxRuntime {
id,
working_directory: working_directory.clone(),
repo_cloned: clone_source::repo_cloned_for_record(
config.skip_clone,
clone_origin_url.as_deref(),
),
clone_origin_url: clone_source::clean_clone_origin_for_record(
clone_origin_url.as_deref(),
),
clone_branch: clone_branch.clone(),
}),
},
_ => RunSandbox {
provider: self.provider(),
id,
working_directory,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
resources: None,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
id,
working_directory,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}),
},
}
}

View file

@ -41,6 +41,11 @@ pub async fn open_terminal_for_run(
run_id: Option<RunId>,
size: TerminalSize,
) -> crate::Result<Box<dyn TerminalSession>> {
#[cfg(any(feature = "daytona", feature = "docker"))]
let runtime = record
.runtime
.as_ref()
.ok_or_else(|| crate::Error::message("Run sandbox is missing runtime metadata"))?;
#[cfg(not(feature = "daytona"))]
let _ = (&daytona_api_key, &daytona_organization_id);
#[cfg(not(feature = "docker"))]
@ -51,15 +56,15 @@ pub async fn open_terminal_for_run(
match record.provider {
#[cfg(feature = "daytona")]
SandboxProvider::Daytona => {
let repo_cloned = record.repo_cloned.ok_or_else(|| {
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
crate::Error::message("Daytona run sandbox is missing clone metadata")
})?;
let sandbox = DaytonaSandbox::reconnect(
&record.id,
&runtime.id,
daytona_api_key.clone(),
repo_cloned,
record.clone_origin_url.clone(),
record.clone_branch.clone(),
runtime.clone_origin_url.clone(),
runtime.clone_branch.clone(),
)
.await?;
sandbox.start().await?;
@ -75,16 +80,20 @@ pub async fn open_terminal_for_run(
.await?;
Ok(Box::new(session))
}
#[cfg(not(feature = "daytona"))]
SandboxProvider::Daytona => Err(crate::Error::message(
"Daytona sandbox support is not enabled",
)),
#[cfg(feature = "docker")]
SandboxProvider::Docker => {
let repo_cloned = record.repo_cloned.ok_or_else(|| {
let repo_cloned = runtime.repo_cloned.ok_or_else(|| {
crate::Error::message("Docker run sandbox is missing clone metadata")
})?;
let sandbox = DockerSandbox::reconnect(
&record.id,
&runtime.id,
repo_cloned,
record.clone_origin_url.clone(),
record.clone_branch.clone(),
runtime.clone_origin_url.clone(),
runtime.clone_branch.clone(),
run_id,
)
.await?;
@ -92,6 +101,10 @@ pub async fn open_terminal_for_run(
let session = DockerTerminalSession::open(&sandbox, size).await?;
Ok(Box::new(session))
}
#[cfg(not(feature = "docker"))]
SandboxProvider::Docker => Err(crate::Error::message(
"Docker sandbox support is not enabled",
)),
SandboxProvider::Local => Err(crate::Error::message(
"Local sandboxes do not support embedded terminals",
)),

View file

@ -62,7 +62,7 @@ pub(crate) async fn list_board_runs(
State(_state): State<Arc<AppState>>,
Query(pagination): Query<PaginationParams>,
) -> Response {
let items = runs::board_items();
let items = runs::summaries();
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset as usize;
let mut data: Vec<_> = items.into_iter().skip(offset).take(limit + 1).collect();
@ -1045,6 +1045,8 @@ mod runs {
labels(entries),
Some(format!("/demo/{repo_name}")),
Some(format!("https://github.com/demo/{repo_name}.git")),
None,
Some(created_at),
Some(created_at),
Some(created_at),
parse_run_status(status, status_reason)
@ -1055,6 +1057,11 @@ mod runs {
None,
None,
None,
None,
None,
Vec::new(),
None,
None,
)
}
@ -1111,85 +1118,6 @@ mod runs {
duration.as_millis().try_into().ok()
}
fn take_summary(summaries: &mut HashMap<RunId, RunSummary>, run_id: RunId) -> RunSummary {
summaries
.remove(&run_id)
.unwrap_or_else(|| panic!("missing demo summary: {run_id}"))
}
fn board_item(
summary: RunSummary,
column: BoardColumn,
pull_request: Option<RunPullRequest>,
sandbox: Option<RunSandbox>,
question: Option<RunQuestion>,
) -> RunListItem {
RunListItem {
column,
created_at: summary.created_at,
last_event_at: summary.last_event_at,
duration_ms: summary.duration_ms.and_then(|ms| i64::try_from(ms).ok()),
elapsed_secs: summary.elapsed_secs,
goal: summary.goal,
source_directory: summary.source_directory,
repo_origin_url: summary.repo_origin_url,
labels: summary.labels,
pending_control: summary.pending_control,
pull_request,
question,
repository: summary.repository,
run_id: summary.run_id.to_string(),
sandbox,
start_time: summary.start_time,
status: summary.status,
title: summary.title,
total_usd_micros: summary.total_usd_micros,
workflow_name: summary.workflow_name,
workflow_slug: summary.workflow_slug,
}
}
fn check(name: &str, status: CheckRunStatus, duration_secs: Option<f64>) -> CheckRun {
CheckRun {
name: name.into(),
status,
duration_secs,
}
}
fn sandbox(id: &str, cpu_cores: u32, memory_gib: u32) -> RunSandbox {
RunSandbox {
provider: SandboxProvider::Docker,
id: id.to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(true),
clone_origin_url: None,
clone_branch: None,
resources: Some(SandboxResources {
cpu_cores: Some(f64::from(cpu_cores)),
memory_bytes: Some(u64::from(memory_gib) * 1024 * 1024 * 1024),
disk_bytes: None,
}),
}
}
fn pull_request(
number: i64,
additions: i64,
deletions: i64,
comments: i64,
checks: Vec<CheckRun>,
) -> RunPullRequest {
RunPullRequest {
number,
html_url: Some(format!("https://github.com/demo/fabro/pull/{number}")),
additions: Some(additions),
deletions: Some(deletions),
comments: Some(comments),
checks,
}
}
pub(super) fn columns() -> Vec<BoardColumnDefinition> {
vec![
BoardColumnDefinition {
@ -1322,81 +1250,6 @@ mod runs {
]
}
pub(super) fn board_items() -> Vec<RunListItem> {
let mut summaries = summaries()
.into_iter()
.map(|summary| (summary.run_id, summary))
.collect::<HashMap<_, _>>();
vec![
board_item(
take_summary(&mut summaries, demo_run_id(1)),
BoardColumn::Running,
None,
Some(sandbox("sb-a1b2c3d4", 4, 8)),
None,
),
board_item(
take_summary(&mut summaries, demo_run_id(2)),
BoardColumn::Running,
None,
Some(sandbox("sb-e5f6g7h8", 8, 16)),
None,
),
board_item(
take_summary(&mut summaries, demo_run_id(3)),
BoardColumn::Initializing,
Some(pull_request(0, 567, 234, 0, vec![])),
Some(sandbox("sb-q7r8s9t0", 4, 8)),
Some(RunQuestion {
text: "Accept or push for another round?".into(),
}),
),
board_item(
take_summary(&mut summaries, demo_run_id(4)),
BoardColumn::Blocked,
Some(pull_request(0, 145, 23, 0, vec![])),
Some(sandbox("sb-u1v2w3x4", 4, 8)),
Some(RunQuestion {
text: "Proceed from investigation to fix?".into(),
}),
),
board_item(
take_summary(&mut summaries, demo_run_id(5)),
BoardColumn::Failed,
Some(pull_request(889, 234, 67, 4, vec![
check("lint", CheckRunStatus::Success, Some(23.0)),
check("typecheck", CheckRunStatus::Success, Some(72.0)),
check("unit-tests", CheckRunStatus::Success, Some(154.0)),
check("integration-tests", CheckRunStatus::Failure, Some(296.0)),
check("build", CheckRunStatus::Success, Some(105.0)),
])),
None,
None,
),
board_item(
take_summary(&mut summaries, demo_run_id(6)),
BoardColumn::Succeeded,
Some(pull_request(1249, 189, 45, 7, vec![
check("lint", CheckRunStatus::Success, Some(21.0)),
check("typecheck", CheckRunStatus::Success, Some(68.0)),
check("unit-tests", CheckRunStatus::Success, Some(192.0)),
check("integration-tests", CheckRunStatus::Success, Some(334.0)),
check("deploy-preview", CheckRunStatus::Success, Some(93.0)),
])),
None,
None,
),
board_item(
take_summary(&mut summaries, demo_run_id(7)),
BoardColumn::Queued,
None,
None,
None,
),
]
}
pub(super) fn stages() -> Vec<RunStage> {
vec![
run_stage_from_stage_id(

View file

@ -1702,7 +1702,11 @@ async fn delete_run_sandbox_resource(
sandbox_preserved: true,
sandbox: DeleteRunSandbox {
provider: record.provider,
id: record.id,
id: record
.runtime
.as_ref()
.map(|runtime| runtime.id.clone())
.unwrap_or_default(),
},
}));
}
@ -1759,11 +1763,13 @@ async fn reject_active_delete_without_force(
}
match state.store.runs().find(run_id).await {
Ok(Some(summary)) if summary.status.requires_force_to_delete() => Err(ApiError::new(
StatusCode::CONFLICT,
active_run_delete_message(*run_id, summary.status),
)
.into_response()),
Ok(Some(summary)) if summary.lifecycle.status.requires_force_to_delete() => {
Err(ApiError::new(
StatusCode::CONFLICT,
active_run_delete_message(*run_id, summary.lifecycle.status),
)
.into_response())
}
Ok(_) => Ok(()),
Err(err) => {
Err(ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())
@ -2034,18 +2040,18 @@ pub(crate) async fn reconcile_incomplete_runs_on_startup(
let mut reconciled = 0usize;
for summary in summaries {
if !should_reconcile_run_on_startup(summary.status) {
if !should_reconcile_run_on_startup(summary.lifecycle.status) {
continue;
}
let run_store = state.store.open_run(&summary.run_id).await?;
let run_store = state.store.open_run(&summary.id).await?;
let (error, reason) = failure_for_incomplete_run(
summary.pending_control,
summary.lifecycle.pending_control,
"Fabro server restarted before the run reached a terminal state.".to_string(),
);
workflow_event::append_event(
&run_store,
&summary.run_id,
&summary.id,
&workflow_event::Event::WorkflowRunFailed {
error,
duration_ms: 0,
@ -2380,16 +2386,6 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
managed_run.active_api_stages.clear();
managed_run.active_cli_stages.clear();
}
EventBody::RunArchived(_) => {
if let Some(prior) = managed_run.status.terminal_status() {
managed_run.status = RunStatus::Archived { prior };
}
}
EventBody::RunUnarchived(_) => {
if let RunStatus::Archived { prior } = managed_run.status {
managed_run.status = prior.into();
}
}
// Track API-mode steerable sessions. Activated/deactivated are
// leased by session id so stale deactivations cannot clear a newer
// binding for the same stage.
@ -3440,8 +3436,7 @@ async fn append_control_request(
async fn reject_if_archived(state: &AppState, run_id: &RunId) -> Option<Response> {
let run_store = state.store.open_run_reader(run_id).await.ok()?;
let projection = run_store.state().await.ok()?;
let status = projection.status;
matches!(status, RunStatus::Archived { .. }).then(|| {
projection.archived_at.is_some().then(|| {
ApiError::new(
StatusCode::CONFLICT,
operations::archived_rejection_message(run_id),

View file

@ -3,9 +3,9 @@ use std::sync::Arc;
use super::super::{
ApiError, AppState, FailureReason, ForkRequest, ForkResponse, IntoResponse, Json, Path,
Principal, RequiredUser, Response, RewindRequest, RewindResponse, Router, RunAnswerTransport,
RunControlAction, RunExecutionMode, RunId, RunStatus, RunStatusResponse, StartRunRequest,
State, StatusCode, Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError,
append_control_request, get, load_pending_control, managed_run, operations, parse_run_id_path,
RunControlAction, RunExecutionMode, RunId, RunStatus, StartRunRequest, State, StatusCode,
Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError, append_control_request,
get, load_pending_control, managed_run, operations, parse_run_id_path,
persist_cancelled_run_status, post, reject_if_archived, sleep, update_live_run_from_event,
workflow_event,
};
@ -23,6 +23,16 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/unarchive", post(unarchive_run))
}
async fn run_response(state: &AppState, id: RunId, status: StatusCode) -> Response {
match state.store.get_cached_summary(&id).await {
Ok(Some(summary)) => (status, Json(summary)).into_response(),
Ok(None) => ApiError::not_found("Run not found.").into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
async fn start_run(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
@ -95,7 +105,6 @@ async fn start_run(
}
}
let title = run_state.title().into_owned();
let run_dir = Storage::new(state.server_storage_dir())
.run_scratch(&id)
.root()
@ -125,22 +134,8 @@ async fn start_run(
);
}
let web_url = state.run_web_url(&id);
state.scheduler_notify.notify_one();
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status: RunStatus::Queued,
error: None,
queue_position: None,
pending_control: None,
created_at: id.created_at(),
web_url,
}),
)
.into_response()
run_response(state.as_ref(), id, StatusCode::OK).await
}
fn schedule_worker_kill(state: Arc<AppState>, run_id: RunId, worker_pid: u32) {
@ -176,15 +171,7 @@ async fn cancel_run(
.into_response();
}
};
let (
created_at,
response_status,
persist_cancelled_status,
answer_transport,
cancel_token,
cancel_tx,
worker_pid,
) = {
let (persist_cancelled_status, answer_transport, cancel_token, cancel_tx, worker_pid) = {
let mut runs = state.runs.lock().expect("runs lock poisoned");
match runs.get_mut(&id) {
Some(managed_run) => match managed_run.status {
@ -200,18 +187,12 @@ async fn cancel_run(
);
let persist_cancelled_status =
matches!(managed_run.status, RunStatus::Submitted | RunStatus::Queued);
let response_status = if persist_cancelled_status {
let cancelled = RunStatus::Failed {
if persist_cancelled_status {
managed_run.status = RunStatus::Failed {
reason: FailureReason::Cancelled,
};
managed_run.status = cancelled;
cancelled
} else {
managed_run.status
};
}
(
managed_run.created_at,
response_status,
persist_cancelled_status,
managed_run.answer_transport.clone(),
managed_run.cancel_token.clone(),
@ -271,37 +252,8 @@ async fn cancel_run(
.into_response();
}
}
let pending_control = match load_pending_control(state.as_ref(), id).await {
Ok(pending_control) => pending_control,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let title = match state.store.get_cached_run(&id).await {
Ok(Some(cached)) => cached.projection.title().into_owned(),
Ok(None) => return ApiError::not_found("Run not found.").into_response(),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let web_url = state.run_web_url(&id);
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status: response_status,
error: None,
queue_position: None,
pending_control,
created_at,
web_url,
}),
)
.into_response()
run_response(state.as_ref(), id, StatusCode::OK).await
}
/// How `pause_run` should enact the transition, chosen from the current run
@ -343,7 +295,7 @@ async fn pause_run(
.into_response();
}
};
let (created_at, mode) = {
let mode = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) if managed_run.status == RunStatus::Running => {
@ -351,10 +303,10 @@ async fn pause_run(
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.")
.into_response();
};
(managed_run.created_at, PauseMode::Signal { worker_pid })
PauseMode::Signal { worker_pid }
}
Some(managed_run) if matches!(managed_run.status, RunStatus::Blocked { .. }) => {
(managed_run.created_at, PauseMode::AppendEvent)
PauseMode::AppendEvent
}
Some(_) => {
return ApiError::new(StatusCode::CONFLICT, "Run is not pausable.").into_response();
@ -380,13 +332,12 @@ async fn pause_run(
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
let response_status = match mode {
match mode {
PauseMode::Signal { worker_pid } => {
#[cfg(unix)]
fabro_proc::sigusr1(worker_pid);
#[cfg(not(unix))]
let _ = worker_pid;
RunStatus::Running
}
PauseMode::AppendEvent => {
if let Some(response) = synchronous_transition(state.as_ref(), id, |events| {
@ -396,45 +347,10 @@ async fn pause_run(
{
return response;
}
state
.runs
.lock()
.expect("runs lock poisoned")
.get(&id)
.map_or(RunStatus::Paused { prior_block: None }, |run| run.status)
}
};
let pending_control = match load_pending_control(state.as_ref(), id).await {
Ok(pending_control) => pending_control,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let title = match state.store.get_cached_run(&id).await {
Ok(Some(cached)) => cached.projection.title().into_owned(),
Ok(None) => return ApiError::not_found("Run not found.").into_response(),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let web_url = state.run_web_url(&id);
}
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status: response_status,
error: None,
queue_position: None,
pending_control,
created_at,
web_url,
}),
)
.into_response()
run_response(state.as_ref(), id, StatusCode::OK).await
}
async fn unpause_run(
@ -456,19 +372,19 @@ async fn unpause_run(
.into_response();
}
};
let (created_at, mode) = {
let mode = {
let runs = state.runs.lock().expect("runs lock poisoned");
match runs.get(&id) {
Some(managed_run) => match managed_run.status {
RunStatus::Paused {
prior_block: Some(_),
} => (managed_run.created_at, UnpauseMode::AppendEvent),
} => UnpauseMode::AppendEvent,
RunStatus::Paused { prior_block: None } => {
let Some(worker_pid) = managed_run.worker_pid else {
return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.")
.into_response();
};
(managed_run.created_at, UnpauseMode::Signal { worker_pid })
UnpauseMode::Signal { worker_pid }
}
_ => {
return ApiError::new(StatusCode::CONFLICT, "Run is not paused.")
@ -496,13 +412,12 @@ async fn unpause_run(
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
let response_status = match mode {
match mode {
UnpauseMode::Signal { worker_pid } => {
#[cfg(unix)]
fabro_proc::sigusr2(worker_pid);
#[cfg(not(unix))]
let _ = worker_pid;
RunStatus::Paused { prior_block: None }
}
UnpauseMode::AppendEvent => {
if let Some(response) = synchronous_transition(state.as_ref(), id, |events| {
@ -512,45 +427,10 @@ async fn unpause_run(
{
return response;
}
state
.runs
.lock()
.expect("runs lock poisoned")
.get(&id)
.map_or(RunStatus::Running, |run| run.status)
}
};
let pending_control = match load_pending_control(state.as_ref(), id).await {
Ok(pending_control) => pending_control,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let title = match state.store.get_cached_run(&id).await {
Ok(Some(cached)) => cached.projection.title().into_owned(),
Ok(None) => return ApiError::not_found("Run not found.").into_response(),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let web_url = state.run_web_url(&id);
}
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status: response_status,
error: None,
queue_position: None,
pending_control,
created_at,
web_url,
}),
)
.into_response()
run_response(state.as_ref(), id, StatusCode::OK).await
}
async fn archive_run(
@ -762,37 +642,8 @@ async fn run_archive_action(
}
}
/// Build a `RunStatusResponse` reflecting the durable projection after an
/// archive/unarchive transition. The run is terminal in both directions, so no
/// live queue position or worker-only fields apply.
async fn archive_status_response(state: &AppState, id: RunId) -> Response {
let Ok(run_store) = state.store.open_run_reader(&id).await else {
return ApiError::not_found("Run not found.").into_response();
};
let projection = match run_store.state().await {
Ok(projection) => projection,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let status = projection.status;
let title = projection.title().into_owned();
let web_url = state.run_web_url(&id);
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status,
error: None,
queue_position: None,
pending_control: None,
created_at: id.created_at(),
web_url,
}),
)
.into_response()
run_response(state, id, StatusCode::OK).await
}
/// Persist a synchronous pause/unpause transition: append the caller-supplied

View file

@ -312,8 +312,22 @@ async fn get_run_pull_request(
.await
{
Ok(github) => Json(fabro_types::PullRequestDetail {
record: ctx.record,
github,
pull_request: ctx.record,
state: github.state,
draft: github.draft,
merged: github.merged,
merged_at: github.merged_at,
mergeable: github.mergeable,
additions: github.additions,
deletions: github.deletions,
changed_files: github.changed_files,
comments: 0,
checks: Vec::new(),
author: github.user,
timestamps: fabro_types::PullRequestTimestamps {
created_at: github.created_at,
updated_at: github.updated_at,
},
})
.into_response(),
Err(fabro_github::PullRequestApiError::NotFound { .. }) => {

View file

@ -11,8 +11,7 @@ use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use bytes::Bytes;
use fabro_api::types::{
BoardColumn, BoardColumnDefinition, RunManifest, RunStatusResponse, SubmitAnswerRequest,
UpdateRunRequest,
BoardColumn, BoardColumnDefinition, RunManifest, SubmitAnswerRequest, UpdateRunRequest,
};
use fabro_config::Storage;
use fabro_interview::AnswerSubmission;
@ -89,7 +88,10 @@ impl ListRunsParams {
}
}
fn board_column(status: RunStatus) -> Option<BoardColumn> {
fn board_column(status: RunStatus, archived: bool) -> Option<BoardColumn> {
if archived {
return Some(BoardColumn::Archived);
}
match status {
RunStatus::Submitted | RunStatus::Queued => Some(BoardColumn::Queued),
RunStatus::Starting => Some(BoardColumn::Initializing),
@ -97,7 +99,6 @@ fn board_column(status: RunStatus) -> Option<BoardColumn> {
RunStatus::Blocked { .. } => Some(BoardColumn::Blocked),
RunStatus::Succeeded { .. } => Some(BoardColumn::Succeeded),
RunStatus::Failed { .. } | RunStatus::Dead => Some(BoardColumn::Failed),
RunStatus::Archived { .. } => Some(BoardColumn::Archived),
RunStatus::Removing => None,
}
}
@ -138,121 +139,6 @@ pub(crate) fn board_columns(include_archived: bool) -> Vec<BoardColumnDefinition
columns
}
fn board_run_metadata_from_projection(
projection: &fabro_store::RunProjection,
) -> serde_json::Map<String, serde_json::Value> {
let mut metadata = serde_json::Map::new();
if let Some(pull_request) = projection.pull_request.as_ref() {
metadata.insert(
"pull_request".to_string(),
serde_json::json!({
"number": pull_request.number,
"html_url": pull_request.html_url,
}),
);
}
if let Some(sandbox) = projection.sandbox.as_ref() {
let mut sandbox_metadata = serde_json::Map::new();
sandbox_metadata.insert("provider".to_string(), serde_json::json!(sandbox.provider));
sandbox_metadata.insert("id".to_string(), serde_json::json!(&sandbox.id));
sandbox_metadata.insert(
"working_directory".to_string(),
serde_json::json!(&sandbox.working_directory),
);
if let Some(repo_cloned) = sandbox.repo_cloned {
sandbox_metadata.insert("repo_cloned".to_string(), serde_json::json!(repo_cloned));
}
if let Some(clone_origin_url) = sandbox.clone_origin_url.as_ref() {
sandbox_metadata.insert(
"clone_origin_url".to_string(),
serde_json::json!(clone_origin_url),
);
}
if let Some(clone_branch) = sandbox.clone_branch.as_ref() {
sandbox_metadata.insert("clone_branch".to_string(), serde_json::json!(clone_branch));
}
if let Some(resources) = sandbox.resources {
sandbox_metadata.insert("resources".to_string(), serde_json::json!(resources));
}
metadata.insert(
"sandbox".to_string(),
serde_json::Value::Object(sandbox_metadata),
);
}
if let Some((_, record)) =
projection
.pending_interviews
.iter()
.min_by(|(left_id, left), (right_id, right)| {
left.started_at
.cmp(&right.started_at)
.then_with(|| left_id.cmp(right_id))
})
{
metadata.insert(
"question".to_string(),
serde_json::json!({
"text": &record.question.text,
}),
);
}
metadata
}
#[cfg(test)]
mod tests {
use fabro_types::{
Graph, PullRequestRecord, RunProjection, RunSpec, WorkflowSettings, fixtures,
};
use super::board_run_metadata_from_projection;
#[test]
fn board_run_metadata_includes_pull_request_url() {
let mut projection = RunProjection::new(
"Test run".to_string(),
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
source_directory: None,
labels: std::collections::HashMap::default(),
provenance: None,
manifest_blob: None,
definition_blob: None,
git: None,
fork_source_ref: None,
},
chrono::Utc::now(),
);
projection.pull_request = Some(PullRequestRecord {
html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
number: 123,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
base_branch: "main".to_string(),
head_branch: "fabro/run/demo".to_string(),
title: "Add run PR chip".to_string(),
});
let metadata = board_run_metadata_from_projection(&projection);
assert_eq!(
metadata.get("pull_request"),
Some(&serde_json::json!({
"number": 123,
"html_url": "https://github.com/fabro-sh/fabro/pull/123"
}))
);
}
}
fn paginate_items<T>(items: Vec<T>, pagination: &PaginationParams) -> (Vec<T>, bool) {
let limit = pagination.limit.clamp(1, 100) as usize;
let offset = pagination.offset.min(MAX_PAGE_OFFSET) as usize;
@ -282,30 +168,26 @@ async fn list_board_runs(
let board_summaries: Vec<_> = entries
.into_iter()
.filter_map(|entry| {
let column = board_column(entry.summary.status)?;
let column = board_column(
entry.summary.lifecycle.status,
entry.summary.lifecycle.archived,
)?;
if column == BoardColumn::Archived && !include_archived {
return None;
}
Some((entry, column))
Some(entry)
})
.collect();
let (page_summaries, has_more) = paginate_items(board_summaries, &params.pagination());
let mut data = Vec::with_capacity(page_summaries.len());
for (entry, column) in page_summaries {
let mut item =
serde_json::to_value(&entry.summary).expect("RunSummary serialization is infallible");
item["column"] = serde_json::json!(column);
if let Some(object) = item.as_object_mut() {
object.extend(board_run_metadata_from_projection(&entry.projection));
}
data.push(item);
}
(
StatusCode::OK,
Json(serde_json::json!({
"columns": board_columns(include_archived),
"data": data,
"data": page_summaries
.into_iter()
.map(|entry| entry.summary)
.collect::<Vec<_>>(),
"meta": { "has_more": has_more }
})),
)
@ -327,9 +209,7 @@ async fn list_runs(
let items = entries
.into_iter()
.map(|entry| entry.summary)
.filter(|summary| {
include_archived || !matches!(summary.status, RunStatus::Archived { .. })
})
.filter(|summary| include_archived || !summary.lifecycle.archived)
.collect::<Vec<_>>();
let (data, has_more) = paginate_items(items, &params.pagination());
(
@ -554,8 +434,8 @@ async fn create_run(
}
};
let created_at = created.run_id.created_at();
let title = match state.store.get_cached_run(&created.run_id).await {
Ok(Some(cached)) => cached.projection.title().into_owned(),
let summary = match state.store.get_cached_summary(&created.run_id).await {
Ok(Some(summary)) => summary,
Ok(None) => return ApiError::not_found("Run not found.").into_response(),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
@ -577,20 +457,7 @@ async fn create_run(
);
}
(
StatusCode::CREATED,
Json(RunStatusResponse {
id: run_id.to_string(),
title,
status: RunStatus::Submitted,
error: None,
queue_position: None,
pending_control: None,
created_at,
web_url,
}),
)
.into_response()
(StatusCode::CREATED, Json(summary)).into_response()
}
fn run_provenance(headers: &HeaderMap, subject: &UserPrincipal) -> RunProvenance {

View file

@ -870,7 +870,14 @@ async fn reconnect_daytona_sandbox(
)
.into_response());
}
let Some(repo_cloned) = record.repo_cloned else {
let Some(runtime) = record.runtime.as_ref() else {
return Err(ApiError::new(
StatusCode::CONFLICT,
"Sandbox record is missing runtime metadata.",
)
.into_response());
};
let Some(repo_cloned) = runtime.repo_cloned else {
return Err(ApiError::new(
StatusCode::CONFLICT,
"Sandbox record is missing clone metadata.",
@ -879,11 +886,11 @@ async fn reconnect_daytona_sandbox(
};
let daytona_api_key = state.vault_or_env(EnvVars::DAYTONA_API_KEY);
let sandbox = DaytonaSandbox::reconnect(
&record.id,
&runtime.id,
daytona_api_key,
repo_cloned,
record.clone_origin_url.clone(),
record.clone_branch.clone(),
runtime.clone_origin_url.clone(),
runtime.clone_branch.clone(),
)
.await
.map_err(|err| {
@ -1345,7 +1352,7 @@ mod retrieve_sandbox_tests {
}
#[tokio::test]
async fn run_without_sandbox_returns_404() {
async fn run_without_sandbox_runtime_returns_planned_sandbox_details() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
@ -1359,15 +1366,10 @@ mod retrieve_sandbox_tests {
.oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert!(
body["errors"][0]["detail"]
.as_str()
.unwrap_or_default()
.contains("Run has no sandbox"),
"unexpected body: {body}"
);
assert_eq!(body["sandbox"]["provider"], "local");
assert!(body["sandbox"]["runtime"].is_null());
}
#[tokio::test]
@ -1389,9 +1391,12 @@ mod retrieve_sandbox_tests {
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["provider"], "local");
assert_eq!(body["id"], "local:sandbox-id");
assert_eq!(body["working_directory"], "/workspace");
assert_eq!(body["sandbox"]["provider"], "local");
assert_eq!(body["sandbox"]["runtime"]["id"], "local:sandbox-id");
assert_eq!(
body["sandbox"]["runtime"]["working_directory"],
"/workspace"
);
assert_eq!(body["state"], "running");
assert!(body.get("name").is_none());
assert!(body.get("identifier").is_none());

View file

@ -105,8 +105,7 @@ async fn control_run(
RunStatus::Failed { .. }
| RunStatus::Succeeded { .. }
| RunStatus::Removing
| RunStatus::Dead
| RunStatus::Archived { .. } => {
| RunStatus::Dead => {
let code = if matches!(&control, RunControlRequest::Interrupt) {
"run_not_interruptible"
} else {

View file

@ -124,6 +124,22 @@ async fn body_json(body: Body) -> serde_json::Value {
serde_json::from_slice(&bytes).unwrap()
}
fn run_json_id(run: &serde_json::Value) -> Option<&str> {
run["id"].as_str().or_else(|| run["run_id"].as_str())
}
fn run_json_status(run: &serde_json::Value) -> &serde_json::Value {
&run["lifecycle"]["status"]
}
fn run_json_pending_control(run: &serde_json::Value) -> &serde_json::Value {
&run["lifecycle"]["pending_control"]
}
fn run_json_archived(run: &serde_json::Value) -> bool {
run["lifecycle"]["archived"].as_bool().unwrap_or(false)
}
async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
@ -2028,7 +2044,7 @@ url = "http://127.0.0.1:32276"
let body = response_json!(response, StatusCode::CREATED).await;
let id = body["id"].as_str().expect("id should be a string");
assert_eq!(
body["web_url"].as_str(),
body["links"]["web"].as_str(),
Some(format!("http://127.0.0.1:32276/runs/{id}").as_str()),
);
}
@ -3892,12 +3908,12 @@ async fn get_run_status_returns_status() {
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["run_id"].as_str().unwrap(), run_id);
assert_eq!(run_json_id(&body).unwrap(), run_id);
assert_eq!(body["goal"].as_str().unwrap(), "Test");
assert_eq!(body["title"].as_str().unwrap(), "Test");
assert!(body["repository"].is_object());
assert!(!body["repository"]["name"].as_str().unwrap().is_empty());
assert!(body["created_at"].is_string());
assert!(body["timestamps"]["created_at"].is_string());
assert!(body["labels"].is_object());
}
@ -3934,7 +3950,7 @@ async fn resolve_run_returns_unique_run_id_prefix_match() {
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["run_id"], run_id);
assert_eq!(run_json_id(&body), Some(run_id.as_str()));
}
#[tokio::test]
@ -4008,8 +4024,8 @@ async fn resolve_run_prefers_most_recent_exact_workflow_slug_match() {
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["run_id"], newer_id);
assert_ne!(body["run_id"], older_id);
assert_eq!(run_json_id(&body), Some(newer_id.as_str()));
assert_ne!(run_json_id(&body), Some(older_id.as_str()));
}
#[tokio::test]
@ -4040,8 +4056,8 @@ async fn resolve_run_prefers_most_recent_collapsed_workflow_name_match() {
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["run_id"], newer_id);
assert_ne!(body["run_id"], older_id);
assert_eq!(run_json_id(&body), Some(newer_id.as_str()));
assert_ne!(run_json_id(&body), Some(older_id.as_str()));
}
#[tokio::test]
@ -4655,12 +4671,12 @@ async fn get_run_pull_request_returns_live_detail_from_github() {
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["record"]["number"], 42);
assert_eq!(body["record"]["owner"], "acme");
assert_eq!(body["pull_request"]["number"], 42);
assert_eq!(body["pull_request"]["owner"], "acme");
assert_eq!(body["state"], "closed");
assert_eq!(body["merged"], true);
assert_eq!(body["head"]["ref"], "feature");
assert_eq!(body["base"]["ref"], "main");
assert_eq!(body["pull_request"]["head_branch"], "feature");
assert_eq!(body["pull_request"]["base_branch"], "main");
github_mock.assert();
}
@ -5433,7 +5449,7 @@ async fn cache_backed_run_endpoints_reflect_events_appended_after_warmup() {
.await
.unwrap();
let status = response_json!(status, StatusCode::OK).await;
assert_eq!(status["status"]["kind"].as_str(), Some("running"));
assert_eq!(run_json_status(&status)["kind"].as_str(), Some("running"));
let state_response = app
.clone()
@ -6472,7 +6488,7 @@ async fn create_run_returns_submitted() {
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::CREATED).await;
assert_eq!(body["status"]["kind"], "submitted");
assert_eq!(run_json_status(&body)["kind"], "submitted");
assert_eq!(body["title"], "Test");
}
@ -6555,7 +6571,7 @@ async fn start_run_transitions_to_queued() {
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"]["kind"], "queued");
assert_eq!(run_json_status(&body)["kind"], "queued");
assert_eq!(body["title"], "Test");
let status = state
@ -6681,7 +6697,7 @@ async fn patch_run_title_updates_active_and_archived_runs() {
.unwrap();
let archived_patch_body = response_json!(archived_patch_response, StatusCode::OK).await;
assert_eq!(archived_patch_body["title"], "Archived title");
assert_eq!(archived_patch_body["status"]["kind"], "archived");
assert!(run_json_archived(&archived_patch_body));
}
#[tokio::test]
@ -7355,15 +7371,15 @@ async fn list_runs_returns_started_run() {
let body = response_json!(response, StatusCode::OK).await;
let items = body["data"].as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["run_id"].as_str().unwrap(), run_id.to_string());
assert_eq!(run_json_id(&items[0]).unwrap(), run_id.to_string());
assert!(items[0]["goal"].is_string());
assert!(items[0]["title"].is_string());
assert!(items[0]["repository"]["name"].is_string());
assert!(items[0]["created_at"].is_string());
assert!(items[0]["status"].is_object());
assert!(items[0]["timestamps"]["created_at"].is_string());
assert!(run_json_status(&items[0]).is_object());
assert!(items[0]["labels"].is_object());
assert!(items[0]["pending_control"].is_null());
assert!(items[0]["total_usd_micros"].is_null());
assert!(run_json_pending_control(&items[0]).is_null());
assert!(items[0]["billing"].is_null());
}
#[tokio::test]
@ -7404,9 +7420,9 @@ async fn archive_and_unarchive_updates_listing_visibility() {
.await
.unwrap();
let archive_body = response_json!(archive_response, StatusCode::OK).await;
assert_eq!(archive_body["status"]["kind"], "archived");
assert_eq!(archive_body["status"]["prior"]["kind"], "succeeded");
assert_eq!(archive_body["status"]["prior"]["reason"], "completed");
assert!(run_json_archived(&archive_body));
assert_eq!(run_json_status(&archive_body)["kind"], "succeeded");
assert_eq!(run_json_status(&archive_body)["reason"], "completed");
let hidden_response = app
.clone()
@ -7425,7 +7441,7 @@ async fn archive_and_unarchive_updates_listing_visibility() {
.as_array()
.unwrap()
.iter()
.any(|item| item["run_id"].as_str() == Some(&run_id.to_string())),
.any(|item| run_json_id(item) == Some(&run_id.to_string())),
"archived run should be hidden from default listing"
);
@ -7445,11 +7461,11 @@ async fn archive_and_unarchive_updates_listing_visibility() {
.as_array()
.unwrap()
.iter()
.find(|item| item["run_id"].as_str() == Some(&run_id.to_string()))
.find(|item| run_json_id(item) == Some(&run_id.to_string()))
.expect("archived run should appear when include_archived=true");
assert_eq!(archived_item["status"]["kind"], "archived");
assert_eq!(archived_item["status"]["prior"]["kind"], "succeeded");
assert_eq!(archived_item["status"]["prior"]["reason"], "completed");
assert!(run_json_archived(archived_item));
assert_eq!(run_json_status(archived_item)["kind"], "succeeded");
assert_eq!(run_json_status(archived_item)["reason"], "completed");
let unarchive_response = app
.clone()
@ -7463,8 +7479,9 @@ async fn archive_and_unarchive_updates_listing_visibility() {
.await
.unwrap();
let unarchive_body = response_json!(unarchive_response, StatusCode::OK).await;
assert_eq!(unarchive_body["status"]["kind"], "succeeded");
assert_eq!(unarchive_body["status"]["reason"], "completed");
assert!(!run_json_archived(&unarchive_body));
assert_eq!(run_json_status(&unarchive_body)["kind"], "succeeded");
assert_eq!(run_json_status(&unarchive_body)["reason"], "completed");
let restored_response = app
.oneshot(
@ -7481,10 +7498,10 @@ async fn archive_and_unarchive_updates_listing_visibility() {
.as_array()
.unwrap()
.iter()
.find(|item| item["run_id"].as_str() == Some(&run_id.to_string()))
.find(|item| run_json_id(item) == Some(&run_id.to_string()))
.expect("unarchived run should reappear in default listing");
assert_eq!(restored_item["status"]["kind"], "succeeded");
assert_eq!(restored_item["status"]["reason"], "completed");
assert_eq!(run_json_status(restored_item)["kind"], "succeeded");
assert_eq!(run_json_status(restored_item)["reason"], "completed");
}
#[tokio::test]
@ -7924,7 +7941,7 @@ async fn post_runs_returns_submitted_status() {
let response = app.oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["status"]["kind"], "submitted");
assert_eq!(run_json_status(&body)["kind"], "submitted");
}
#[tokio::test]
@ -8065,8 +8082,8 @@ async fn cancel_queued_run_succeeds() {
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["status"]["kind"], "failed");
assert_eq!(body["status"]["reason"], "cancelled");
assert_eq!(run_json_status(&body)["kind"], "failed");
assert_eq!(run_json_status(&body)["reason"], "cancelled");
// Cancelled runs appear on the board in the "failed" column
let req = Request::builder()
@ -8081,17 +8098,16 @@ async fn cancel_queued_run_succeeds() {
.as_array()
.unwrap()
.iter()
.find(|item| item["run_id"].as_str() == Some(run_id_str.as_str()));
.find(|item| run_json_id(item) == Some(run_id_str.as_str()));
assert!(
board_item.is_some(),
"cancelled run should appear on the board"
);
assert_eq!(
board_item.unwrap()["status"]["kind"].as_str(),
run_json_status(board_item.unwrap())["kind"].as_str(),
Some("failed"),
"cancelled run should preserve the failed lifecycle status"
);
assert_eq!(board_item.unwrap()["column"].as_str(), Some("failed"));
let run_store = state.store.open_run_reader(&run_id).await.unwrap();
let status = run_store.state().await.unwrap().status;
@ -8124,7 +8140,7 @@ async fn cancel_run_overwrites_pending_pause_request() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["pending_control"].as_str(), Some("cancel"));
assert_eq!(run_json_pending_control(&body).as_str(), Some("cancel"));
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
assert_eq!(summary.pending_control, Some(RunControlAction::Cancel));
@ -8180,8 +8196,8 @@ async fn pause_run_sets_pending_control_on_board_response() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"]["kind"], "running");
assert_eq!(body["pending_control"].as_str(), Some("pause"));
assert_eq!(run_json_status(&body)["kind"], "queued");
assert_eq!(run_json_pending_control(&body).as_str(), Some("pause"));
// Verify pending_control via /runs/{id} (board no longer includes this field)
let req = Request::builder()
@ -8191,7 +8207,7 @@ async fn pause_run_sets_pending_control_on_board_response() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
assert_eq!(body["pending_control"].as_str(), Some("pause"));
assert_eq!(run_json_pending_control(&body).as_str(), Some("pause"));
// Verify the run appears on the board (store has Submitted status →
// "queued" column)
@ -8206,11 +8222,10 @@ async fn pause_run_sets_pending_control_on_board_response() {
.as_array()
.unwrap()
.iter()
.find(|item| item["run_id"].as_str() == Some(run_id_str.as_str()))
.find(|item| run_json_id(item) == Some(run_id_str.as_str()))
.expect("board item should exist");
assert!(item["status"].is_object());
assert_eq!(item["column"].as_str(), Some("queued"));
assert_eq!(item["pending_control"].as_str(), Some("pause"));
assert!(run_json_status(item).is_object());
assert_eq!(run_json_pending_control(item).as_str(), Some("pause"));
}
#[tokio::test]
@ -8267,9 +8282,12 @@ async fn pause_run_immediately_pauses_blocked_run() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"]["kind"], "paused");
assert_eq!(body["status"]["prior_block"], "human_input_required");
assert_eq!(body["pending_control"], serde_json::Value::Null);
assert_eq!(run_json_status(&body)["kind"], "paused");
assert_eq!(
run_json_status(&body)["prior_block"],
"human_input_required"
);
assert_eq!(run_json_pending_control(&body), &serde_json::Value::Null);
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
assert_eq!(summary.status, RunStatus::Paused {
@ -8299,9 +8317,8 @@ async fn unpause_run_sets_pending_control() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"]["kind"], "paused");
assert!(body["status"]["prior_block"].is_null());
assert_eq!(body["pending_control"].as_str(), Some("unpause"));
assert_eq!(run_json_status(&body)["kind"], "queued");
assert_eq!(run_json_pending_control(&body).as_str(), Some("unpause"));
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
assert_eq!(summary.pending_control, Some(RunControlAction::Unpause));
@ -8371,9 +8388,12 @@ async fn unpause_run_returns_blocked_when_human_gate_is_still_unresolved() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"]["kind"], "blocked");
assert_eq!(body["status"]["blocked_reason"], "human_input_required");
assert_eq!(body["pending_control"], serde_json::Value::Null);
assert_eq!(run_json_status(&body)["kind"], "blocked");
assert_eq!(
run_json_status(&body)["blocked_reason"],
"human_input_required"
);
assert_eq!(run_json_pending_control(&body), &serde_json::Value::Null);
let summary = state.store.runs().find(&run_id).await.unwrap().unwrap();
assert_eq!(summary.status, RunStatus::Blocked {
@ -8786,15 +8806,14 @@ async fn demo_boards_runs_returns_run_list_items() {
let data = body["data"].as_array().expect("data should be array");
assert!(!data.is_empty(), "demo should return runs");
let first = &data[0];
assert!(first["run_id"].is_string());
assert!(first["id"].is_string());
assert!(first["goal"].is_string());
assert!(first["repository"].is_object());
assert!(first["title"].is_string());
assert!(first["status"].is_object());
assert!(first["column"].is_string());
assert!(first["workflow_slug"].is_string() || first["workflow_slug"].is_null());
assert!(run_json_status(first).is_object());
assert!(first["workflow"]["slug"].is_string() || first["workflow"]["slug"].is_null());
assert!(first["labels"].is_object());
assert!(first["created_at"].is_string());
assert!(first["timestamps"]["created_at"].is_string());
}
#[tokio::test]
@ -8816,17 +8835,13 @@ async fn demo_get_run_returns_run_summary_shape() {
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
// Should have RunSummary fields, not RunStatusResponse fields
assert!(body["run_id"].is_string(), "should have run_id field");
assert!(body["id"].is_string(), "should have id field");
assert!(body["goal"].is_string(), "should have goal field");
assert!(
body["workflow_slug"].is_string(),
"should have workflow_slug field"
);
// Should NOT have RunStatusResponse-only fields
assert!(
body["queue_position"].is_null(),
"should not have queue_position"
body["workflow"]["slug"].is_string(),
"should have workflow.slug field"
);
assert!(body["lifecycle"]["queue_position"].is_null());
}
#[tokio::test]
@ -8896,7 +8911,7 @@ async fn demo_workflows_return_list_detail_and_runs() {
.expect("workflow runs data should be an array");
assert!(
runs.iter()
.all(|run| run["workflow_slug"].as_str() == Some("implement")),
.all(|run| run["workflow"]["slug"].as_str() == Some("implement")),
"workflow run list should be scoped to the requested workflow"
);
}
@ -8925,20 +8940,18 @@ async fn boards_runs_returns_run_list_items_with_board_columns() {
let data = body["data"].as_array().expect("data should be array");
let item = data
.iter()
.find(|i| i["run_id"].as_str() == Some(&run_id))
.find(|i| run_json_id(i) == Some(&run_id))
.expect("run should be in board");
// Should have canonical run summary fields plus board-specific column
assert!(item["goal"].is_string());
assert!(item["title"].is_string());
assert!(item["repository"].is_object());
assert!(item["workflow_slug"].is_string() || item["workflow_slug"].is_null());
assert!(item["workflow_name"].is_string() || item["workflow_name"].is_null());
assert!(item["workflow"]["slug"].is_string() || item["workflow"]["slug"].is_null());
assert!(item["workflow"]["name"].is_string());
assert!(item["labels"].is_object());
assert!(item["status"].is_object());
assert!(item["column"].is_string());
assert!(item["created_at"].is_string());
assert!(item["pending_control"].is_null());
assert!(item["total_usd_micros"].is_null());
assert!(run_json_status(item).is_object());
assert!(item["timestamps"]["created_at"].is_string());
assert!(run_json_pending_control(item).is_null());
assert!(item["billing"].is_null());
}
#[tokio::test]
@ -9078,19 +9091,22 @@ async fn boards_runs_includes_archived_when_flag_set() {
let archived_item = data
.iter()
.find(|i| i["run_id"].as_str() == Some(&archived_id.to_string()))
.find(|i| run_json_id(i) == Some(&archived_id.to_string()))
.expect("archived run should appear when include_archived=true");
assert_eq!(archived_item["column"].as_str().unwrap(), "archived");
assert!(run_json_archived(archived_item));
assert_eq!(
archived_item["status"]["kind"].as_str().unwrap(),
"archived"
run_json_status(archived_item)["kind"].as_str().unwrap(),
"succeeded"
);
let succeeded_item = data
.iter()
.find(|i| i["run_id"].as_str() == Some(&succeeded_id.to_string()))
.find(|i| run_json_id(i) == Some(&succeeded_id.to_string()))
.expect("non-archived run should still appear");
assert_eq!(succeeded_item["column"].as_str().unwrap(), "succeeded");
assert_eq!(
run_json_status(succeeded_item)["kind"].as_str().unwrap(),
"succeeded"
);
let columns = body["columns"].as_array().expect("columns should be array");
let column_ids: Vec<_> = columns
@ -9177,7 +9193,10 @@ async fn get_run_exposes_canonical_operator_statuses() {
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"]["kind"].as_str(), Some(expected_status));
assert_eq!(
run_json_status(&body)["kind"].as_str(),
Some(expected_status)
);
}
}
@ -9286,39 +9305,44 @@ async fn boards_runs_maps_statuses_to_columns() {
let paused_item = data
.iter()
.find(|i| i["run_id"].as_str() == Some(&paused_id.to_string()))
.find(|i| run_json_id(i) == Some(&paused_id.to_string()))
.expect("paused run should be on board");
assert_eq!(paused_item["status"]["kind"].as_str().unwrap(), "paused");
assert!(paused_item["status"]["prior_block"].is_null());
assert_eq!(paused_item["column"].as_str().unwrap(), "running");
assert_eq!(
run_json_status(paused_item)["kind"].as_str().unwrap(),
"paused"
);
assert!(run_json_status(paused_item)["prior_block"].is_null());
let succeeded_item = data
.iter()
.find(|i| i["run_id"].as_str() == Some(&succeeded_id.to_string()))
.find(|i| run_json_id(i) == Some(&succeeded_id.to_string()))
.expect("succeeded run should be on board");
assert_eq!(
succeeded_item["status"]["kind"].as_str().unwrap(),
run_json_status(succeeded_item)["kind"].as_str().unwrap(),
"succeeded"
);
assert_eq!(
succeeded_item["status"]["reason"].as_str().unwrap(),
run_json_status(succeeded_item)["reason"].as_str().unwrap(),
"completed"
);
assert_eq!(succeeded_item["column"].as_str().unwrap(), "succeeded");
let blocked_item = data
.iter()
.find(|i| i["run_id"].as_str() == Some(&blocked_id.to_string()))
.find(|i| run_json_id(i) == Some(&blocked_id.to_string()))
.expect("blocked run should be on board");
assert_eq!(blocked_item["status"]["kind"].as_str().unwrap(), "blocked");
assert_eq!(
blocked_item["status"]["blocked_reason"].as_str().unwrap(),
run_json_status(blocked_item)["kind"].as_str().unwrap(),
"blocked"
);
assert_eq!(
run_json_status(blocked_item)["blocked_reason"]
.as_str()
.unwrap(),
"human_input_required"
);
assert_eq!(blocked_item["column"].as_str().unwrap(), "blocked");
assert_eq!(
blocked_item["question"]["text"].as_str(),
Some("Older unresolved question?")
assert!(
blocked_item["current_question"].is_object(),
"blocked board item should include the current question"
);
// Verify columns are included in the response
@ -9389,16 +9413,16 @@ async fn boards_runs_includes_live_board_metadata_from_run_state() {
let data = body["data"].as_array().expect("data should be array");
let item = data
.iter()
.find(|i| i["run_id"].as_str() == Some(&run_id.to_string()))
.find(|i| run_json_id(i) == Some(&run_id.to_string()))
.expect("run should be in board");
assert_eq!(item["pull_request"]["number"].as_u64(), Some(42));
assert_eq!(item["sandbox"]["id"].as_str(), Some("sb-test"));
assert_eq!(item["sandbox"]["runtime"]["id"].as_str(), Some("sb-test"));
assert_eq!(
item["sandbox"]["working_directory"].as_str(),
item["sandbox"]["runtime"]["working_directory"].as_str(),
Some("/sandbox/workdir")
);
assert_eq!(item["question"]["text"].as_str(), Some("Ship it?"));
assert!(item["current_question"].is_object());
}
#[tokio::test]
@ -9448,7 +9472,7 @@ async fn boards_runs_page_limit_preserves_metadata_for_paged_items() {
assert_eq!(data.len(), 1);
let item = &data[0];
let sandbox_id = item["sandbox"]["id"]
let sandbox_id = item["sandbox"]["runtime"]["id"]
.as_str()
.expect("paged item should still include sandbox metadata");
assert!(matches!(sandbox_id, "sb-first" | "sb-second"));

View file

@ -51,7 +51,7 @@ async fn vnc_for_run_without_sandbox_returns_conflict() {
response_status(
response,
StatusCode::CONFLICT,
StatusCode::NOT_IMPLEMENTED,
format!("POST /api/v1/runs/{run_id}/sandbox/vnc"),
)
.await;

View file

@ -292,7 +292,7 @@ pub(crate) async fn wait_for_run_status(
) -> String {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
let status = body["status"]["kind"]
let status = body["lifecycle"]["status"]["kind"]
.as_str()
.expect("run response should include a tagged status kind")
.to_string();
@ -311,7 +311,7 @@ pub(crate) async fn wait_for_run_status_not_in(
) -> String {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
let status = body["status"]["kind"]
let status = body["lifecycle"]["status"]["kind"]
.as_str()
.expect("run response should include a tagged status kind")
.to_string();

View file

@ -31,7 +31,8 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
format!("POST /api/v1/runs/{run_id}/archive"),
)
.await;
assert_eq!(body["status"]["kind"], "archived");
assert_eq!(body["lifecycle"]["status"]["kind"], "succeeded");
assert_eq!(body["lifecycle"]["archived"], true);
for path in &["/cancel", "/pause", "/unpause", "/start"] {
let req = Request::builder()
@ -143,7 +144,7 @@ async fn archived_runs_reject_mutations_with_actionable_body() {
format!("POST /api/v1/runs/{run_id}/unarchive"),
)
.await;
assert_eq!(body["status"]["kind"], "succeeded");
assert_eq!(body["lifecycle"]["status"]["kind"], "succeeded");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@ -254,7 +255,7 @@ async fn list_runs_respects_include_archived_flag() {
.as_array()
.unwrap()
.iter()
.map(|item| item["run_id"].as_str().unwrap().to_string())
.map(|item| item["id"].as_str().unwrap().to_string())
.collect();
assert!(
!ids_visible.contains(&run_id),
@ -278,7 +279,7 @@ async fn list_runs_respects_include_archived_flag() {
.as_array()
.unwrap()
.iter()
.map(|item| item["run_id"].as_str().unwrap().to_string())
.map(|item| item["id"].as_str().unwrap().to_string())
.collect();
assert!(
ids_all.contains(&run_id),

View file

@ -89,8 +89,8 @@ async fn wait_for_run_state(
) -> serde_json::Value {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
if body["status"]["kind"].as_str() == Some(expected_status)
&& body["status"]["reason"].as_str() == Some(expected_reason)
if body["lifecycle"]["status"]["kind"].as_str() == Some(expected_status)
&& body["lifecycle"]["status"]["reason"].as_str() == Some(expected_reason)
{
return body;
}
@ -260,14 +260,14 @@ async fn full_http_lifecycle_cancel() {
format!("POST /api/v1/runs/{run_id}/cancel"),
)
.await;
assert_eq!(body["status"]["kind"], "running");
assert_eq!(body["lifecycle"]["status"]["kind"], "blocked");
// `pending_control` is computed from the store projection after the cancel
// event is appended AND the worker is signaled. The worker is sitting at a
// human gate; once notified it can emit a clearing event before this
// handler re-reads the projection, so the response can legitimately
// observe either the still-pending "cancel" or a null where the worker
// already consumed it. Durable convergence is asserted below.
let pending_control = &body["pending_control"];
let pending_control = &body["lifecycle"]["pending_control"];
assert!(
pending_control == "cancel" || pending_control.is_null(),
"expected pending_control to be \"cancel\" or null, got {pending_control}"
@ -275,7 +275,7 @@ async fn full_http_lifecycle_cancel() {
// Verify the durable store view converges to cancelled failure.
let body = wait_for_run_state(&app, &run_id, "failed", "cancelled").await;
assert_eq!(body["status"]["reason"], "cancelled");
assert_eq!(body["lifecycle"]["status"]["reason"], "cancelled");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]

View file

@ -6,12 +6,13 @@ use fabro_types::run_event::{
AgentCliStartedProps, AgentSessionActivatedProps, CheckpointCompletedProps, RunCompletedProps,
RunFailedProps, StageCompletedProps, StagePromptProps,
};
use fabro_types::settings::run::RunSandboxSettings;
use fabro_types::{
BilledModelUsage, Checkpoint, CheckpointRecord, CommandTermination, Conclusion, EventBody,
FailureSignature, InterviewQuestionRecord, Outcome, PendingInterviewRecord, PullRequestRecord,
RunControlAction, RunDiff, RunEvent, RunId, RunProjection, RunSandbox, RunSpec, RunStatus,
RunSummary, StageCompletion, StageHandler, StageId, StageOutcome, StageProjection, StageState,
StartRecord, TerminalStatus, first_event_seq,
RunControlAction, RunDiff, RunEvent, RunId, RunModel, RunProjection, RunSandbox,
RunSandboxRuntime, RunSpec, RunStatus, RunSummary, SandboxProvider, StageCompletion,
StageHandler, StageId, StageOutcome, StageProjection, StageState, StartRecord, first_event_seq,
};
use fabro_util::error::render_with_causes;
use serde_json::Value;
@ -158,25 +159,20 @@ impl RunProjectionReducer for RunProjection {
self.superseded_by = Some(props.new_run_id);
}
EventBody::RunArchived(_props) => {
let current = self.status;
if matches!(current, RunStatus::Archived { .. }) {
if self.archived_at.is_some() {
return Ok(());
}
let Some(prior) = current.terminal_status() else {
if !self.status.is_terminal() {
return Err(fabro_types::InvalidTransition {
from: current,
to: RunStatus::Archived {
prior: TerminalStatus::Dead,
},
from: self.status,
to: self.status,
}
.into());
};
self.try_apply_status(RunStatus::Archived { prior }, ts)?;
}
self.archived_at = Some(ts);
}
EventBody::RunUnarchived(_props) => {
if let RunStatus::Archived { prior } = self.status {
self.try_apply_status(prior.into(), ts)?;
}
self.archived_at = None;
}
EventBody::RunTitleUpdated(props) => {
self.title.clone_from(&props.title);
@ -220,18 +216,24 @@ impl RunProjectionReducer for RunProjection {
});
}
EventBody::SandboxInitialized(props) => {
self.sandbox = Some(RunSandbox {
provider: props.provider,
let sandbox = self.sandbox.get_or_insert(RunSandbox {
provider: props.provider,
image: None,
snapshot: None,
runtime: None,
});
sandbox.provider = props.provider;
sandbox.runtime = Some(RunSandboxRuntime {
id: props.id.clone(),
working_directory: props.working_directory.clone(),
repo_cloned: props.repo_cloned,
clone_origin_url: props.clone_origin_url.clone(),
clone_branch: props.clone_branch.clone(),
resources: None,
});
}
EventBody::PullRequestCreated(props) => {
self.pull_request = Some(PullRequestRecord {
provider: "github".to_string(),
html_url: props.pr_url.clone(),
number: props.pr_number,
owner: props.owner.clone(),
@ -468,7 +470,31 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
fork_source_ref: props.fork_source_ref.clone(),
};
Ok(RunProjection::new(title, spec, stored.ts))
let mut projection = RunProjection::new(title, spec, stored.ts);
projection.web_url.clone_from(&props.web_url);
projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.sandbox));
Ok(projection)
}
fn planned_sandbox(settings: &RunSandboxSettings) -> RunSandbox {
let provider = settings
.provider
.parse::<SandboxProvider>()
.unwrap_or(SandboxProvider::Local);
RunSandbox {
provider,
image: settings
.docker
.as_ref()
.map(|docker| docker.image.clone())
.filter(|image| !image.is_empty()),
snapshot: settings
.daytona
.as_ref()
.and_then(|daytona| daytona.snapshot.as_ref())
.map(|snapshot| snapshot.name.clone()),
runtime: None,
}
}
fn stage_at_visit<'a>(
@ -558,6 +584,22 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
.find_map(|checkpoint| checkpoint.diff.summary)
});
let current_question = state
.pending_interviews
.iter()
.min_by(|(left_id, left), (right_id, right)| {
left.started_at
.cmp(&right.started_at)
.then_with(|| left_id.cmp(right_id))
})
.map(|(_, record)| record.question.clone());
let models = run_models(state);
let created_by = state
.spec
.provenance
.as_ref()
.and_then(|provenance| provenance.subject.clone());
RunSummary::new(
*run_id,
workflow_name,
@ -567,8 +609,13 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
state.spec.labels.clone(),
state.spec.source_directory.clone(),
state.spec.git.as_ref().map(|git| git.origin_url.clone()),
created_by,
state.start.as_ref().map(|start| start.start_time),
Some(state.last_event_at),
state
.conclusion
.as_ref()
.map(|conclusion| conclusion.timestamp),
state.status,
state.pending_control,
state
@ -583,9 +630,32 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
state.superseded_by,
diff_summary,
state.pull_request.clone(),
state.archived_at,
state.sandbox.clone(),
models,
current_question,
state.web_url.clone(),
)
}
fn run_models(state: &RunProjection) -> Vec<RunModel> {
let mut models = state
.iter_stages()
.filter_map(|(_, stage)| stage.model.as_ref())
.map(|model| RunModel {
provider: Some(model.provider.to_string()),
name: model.model_id.clone(),
})
.collect::<Vec<_>>();
models.sort_by(|left, right| {
left.provider
.cmp(&right.provider)
.then_with(|| left.name.cmp(&right.name))
});
models.dedup_by(|left, right| left.provider == right.provider && left.name == right.name);
models
}
fn checkpoint_from_props(props: &CheckpointCompletedProps, timestamp: DateTime<Utc>) -> Checkpoint {
let loop_failure_signatures = props
.loop_failure_signatures
@ -787,8 +857,7 @@ mod tests {
BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, CheckpointRecord,
CommandTermination, EventBody, FailureCategory, FailureDetail, FailureReason, Graph,
Outcome, QuestionType, RunBlobId, RunControlAction, RunDiff, RunEvent, RunSpec, RunStatus,
StageOutcome, StageState, SuccessReason, TerminalStatus, WorkflowSettings, first_event_seq,
fixtures,
StageOutcome, StageState, SuccessReason, WorkflowSettings, first_event_seq, fixtures,
};
use serde_json::json;
@ -1625,7 +1694,7 @@ mod tests {
let summary = build_summary(&state, &fixtures::RUN_1);
let summary_json = serde_json::to_value(summary).unwrap();
assert_eq!(
summary_json["status"],
summary_json["lifecycle"]["status"],
json!({
"kind": "paused",
"prior_block": "human_input_required"
@ -1755,7 +1824,10 @@ mod tests {
};
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
assert_eq!(summary_json["status"], json!({ "kind": "submitted" }));
assert_eq!(
summary_json["lifecycle"]["status"],
json!({ "kind": "submitted" })
);
}
#[test]
@ -1963,7 +2035,7 @@ mod tests {
))
.unwrap();
assert_eq!(
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff_summary"],
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff"],
json!({
"files_changed": 2,
"additions": 10,
@ -1984,8 +2056,7 @@ mod tests {
))
.unwrap();
assert_eq!(
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff_summary"]
["files_changed"],
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff"]["files_changed"],
2
);
@ -2008,7 +2079,7 @@ mod tests {
))
.unwrap();
assert_eq!(
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff_summary"],
serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap()["diff"],
json!({
"files_changed": 4,
"additions": 18,
@ -2041,7 +2112,7 @@ mod tests {
))
.unwrap();
assert_eq!(
serde_json::to_value(build_summary(&failed_state, &fixtures::RUN_1)).unwrap()["diff_summary"],
serde_json::to_value(build_summary(&failed_state, &fixtures::RUN_1)).unwrap()["diff"],
json!({
"files_changed": 5,
"additions": 20,
@ -2110,11 +2181,10 @@ mod tests {
))
.unwrap();
assert_eq!(state.status(), RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
assert_eq!(state.status(), RunStatus::Succeeded {
reason: SuccessReason::Completed,
});
assert!(state.archived_at.is_some());
}
#[test]

View file

@ -6,8 +6,8 @@ use fabro_types::graph::Graph;
use fabro_types::run::RunSpec;
use fabro_types::{
BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,
QuestionType, RunDiff, RunSandbox, RunStatus, SandboxProvider, StageCompletion, StageOutcome,
StartRecord, TerminalStatus, WorkflowSettings, first_event_seq, fixtures,
QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProvider,
StageCompletion, StageOutcome, StartRecord, WorkflowSettings, first_event_seq, fixtures,
};
use serde_json::json;
@ -100,13 +100,16 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
diff: RunDiff::default(),
});
projection.sandbox = Some(RunSandbox {
provider: SandboxProvider::Local,
id: "sandbox-1".to_string(),
working_directory: "/tmp/project".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
resources: None,
provider: SandboxProvider::Local,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
id: "sandbox-1".to_string(),
working_directory: "/tmp/project".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}),
});
projection.pending_interviews = BTreeMap::new();
let stage = projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2));
@ -189,9 +192,8 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
#[test]
fn projection_query_methods_expose_common_state() {
let mut projection = RunProjection::new("Demo".to_string(), sample_run_spec(), Utc::now());
projection.status = RunStatus::Archived {
prior: TerminalStatus::Dead,
};
projection.status = RunStatus::Dead;
projection.archived_at = Some(Utc::now());
projection.checkpoints.push(CheckpointRecord {
seq: 7,
checkpoint: sample_checkpoint(),
@ -213,9 +215,8 @@ fn projection_query_methods_expose_common_state() {
})]);
assert_eq!(projection.spec().workflow_slug(), Some("demo"));
assert_eq!(projection.status(), RunStatus::Archived {
prior: TerminalStatus::Dead,
});
assert_eq!(projection.status(), RunStatus::Dead);
assert!(projection.is_archived());
assert!(projection.is_terminal());
assert_eq!(
projection

View file

@ -62,9 +62,11 @@ pub use outcome::{
};
pub use principal::{AuthMethod, Principal, SystemActorKind, UserPrincipal};
pub use pull_request::{
PullRequestDetail, PullRequestGithubDetail, PullRequestRecord, PullRequestRef, PullRequestUser,
CheckRun, CheckRunStatus, PullRequest, PullRequestDetails, PullRequestGithubDetail,
PullRequestRef, PullRequestTimestamps, PullRequestUser,
};
pub use repository::RepositoryReference;
pub use repository::{RepositoryProvider, RepositoryRef};
pub type RepositoryReference = RepositoryRef;
pub use run::{
DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome, RunClientProvenance, RunProvenance,
RunServerProvenance, RunSpec,
@ -78,8 +80,14 @@ pub use run_id::{RunId, fixtures};
pub use run_projection::{
CheckpointRecord, PendingInterviewRecord, RunProjection, StageProjection, first_event_seq,
};
pub use run_sandbox::RunSandbox;
pub use run_summary::RunSummary;
pub use run_sandbox::{RunSandbox, RunSandboxRuntime};
pub use run_summary::{
AutomationRef, Run, RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin,
RunOriginKind, RunTimestamps, WorkflowRef,
};
pub type RunSummary = Run;
pub type PullRequestRecord = PullRequest;
pub type PullRequestDetail = PullRequestDetails;
pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title};
pub use sandbox_details::{SandboxDetails, SandboxResources, SandboxState, SandboxTimestamps};
pub use sandbox_provider::SandboxProvider;

View file

@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize};
/// Record of a pull request created for a workflow run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestRecord {
pub struct PullRequest {
#[serde(default = "github_provider")]
pub provider: String,
pub html_url: String,
pub number: u64,
pub owner: String,
@ -12,6 +14,10 @@ pub struct PullRequestRecord {
pub title: String,
}
fn github_provider() -> String {
"github".to_string()
}
/// GitHub user summary for a pull request.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestUser {
@ -52,8 +58,41 @@ pub struct PullRequestGithubDetail {
/// Stored pull request record plus live GitHub fields, returned by the
/// `GET /runs/{id}/pull_request` endpoint.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestDetail {
pub record: PullRequestRecord,
#[serde(flatten)]
pub github: PullRequestGithubDetail,
pub struct PullRequestDetails {
pub pull_request: PullRequest,
pub state: String,
pub draft: bool,
pub merged: bool,
pub merged_at: Option<String>,
pub mergeable: Option<bool>,
pub additions: u64,
pub deletions: u64,
pub changed_files: u64,
pub comments: u64,
pub checks: Vec<CheckRun>,
pub author: PullRequestUser,
pub timestamps: PullRequestTimestamps,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestTimestamps {
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckRun {
pub name: String,
pub status: CheckRunStatus,
pub conclusion: Option<String>,
pub html_url: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckRunStatus {
Queued,
InProgress,
Completed,
Unknown,
}

View file

@ -1,6 +1,17 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryReference {
pub name: String,
pub struct RepositoryRef {
pub name: String,
#[serde(default)]
pub origin_url: Option<String>,
pub provider: RepositoryProvider,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RepositoryProvider {
Github,
Git,
Unknown,
}

View file

@ -15,8 +15,12 @@ pub struct RunProjection {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub title: String,
pub spec: RunSpec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
pub start: Option<StartRecord>,
pub status: RunStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived_at: Option<DateTime<Utc>>,
pub status_updated_at: DateTime<Utc>,
pub last_event_at: DateTime<Utc>,
pub pending_control: Option<RunControlAction>,
@ -153,8 +157,10 @@ impl RunProjection {
Self {
title,
spec,
web_url: None,
start: None,
status: RunStatus::Submitted,
archived_at: None,
status_updated_at: created_at,
last_event_at: created_at,
pending_control: None,
@ -241,6 +247,10 @@ impl RunProjection {
self.status().is_terminal()
}
pub fn is_archived(&self) -> bool {
self.archived_at.is_some()
}
pub fn current_checkpoint(&self) -> Option<&Checkpoint> {
self.checkpoints.last().map(|record| &record.checkpoint)
}

View file

@ -1,10 +1,20 @@
use serde::{Deserialize, Serialize};
use crate::{SandboxProvider, SandboxResources};
use crate::SandboxProvider;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSandbox {
pub provider: SandboxProvider,
pub provider: SandboxProvider,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<RunSandboxRuntime>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSandboxRuntime {
pub id: String,
pub working_directory: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -13,6 +23,4 @@ pub struct RunSandbox {
pub clone_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resources: Option<SandboxResources>,
}

View file

@ -1,53 +1,377 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde::de::Error;
use serde::{Deserialize, Deserializer, Serialize};
use crate::{
DiffSummary, PullRequestRecord, RepositoryReference, RunControlAction, RunId, RunStatus,
DiffSummary, InterviewQuestionRecord, Principal, PullRequest, RepositoryProvider,
RepositoryRef, RunControlAction, RunId, RunSandbox, RunStatus, SuccessReason,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSummary {
pub run_id: RunId,
#[serde(default)]
pub workflow_name: Option<String>,
#[serde(default)]
pub workflow_slug: Option<String>,
pub goal: String,
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Run {
pub id: RunId,
pub title: String,
pub goal: String,
pub workflow: WorkflowRef,
#[serde(default)]
pub automation: Option<AutomationRef>,
#[serde(default)]
pub repository: Option<RepositoryRef>,
#[serde(default)]
pub created_by: Option<Principal>,
pub origin: RunOrigin,
pub labels: HashMap<String, String>,
pub lifecycle: RunLifecycle,
#[serde(default)]
pub sandbox: Option<RunSandbox>,
pub models: Vec<RunModel>,
#[serde(default)]
pub source_directory: Option<String>,
pub timestamps: RunTimestamps,
#[serde(default)]
pub repo_origin_url: Option<String>,
pub repository: RepositoryReference,
pub billing: Option<RunBillingSummary>,
#[serde(default)]
pub start_time: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub diff: Option<DiffSummary>,
#[serde(default)]
pub last_event_at: Option<DateTime<Utc>>,
pub status: RunStatus,
pub pull_request: Option<PullRequest>,
#[serde(default)]
pub pending_control: Option<RunControlAction>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub elapsed_secs: Option<f64>,
#[serde(default)]
pub total_usd_micros: Option<i64>,
pub current_question: Option<InterviewQuestionRecord>,
#[serde(default)]
pub superseded_by: Option<RunId>,
#[serde(default)]
pub links: RunLinks,
#[serde(skip, default = "RunId::new")]
pub run_id: RunId,
#[serde(skip)]
pub workflow_name: Option<String>,
#[serde(skip)]
pub workflow_slug: Option<String>,
#[serde(skip)]
pub repo_origin_url: Option<String>,
#[serde(skip)]
pub start_time: Option<DateTime<Utc>>,
#[serde(skip, default = "Utc::now")]
pub created_at: DateTime<Utc>,
#[serde(skip)]
pub last_event_at: Option<DateTime<Utc>>,
#[serde(skip, default = "default_run_status")]
pub status: RunStatus,
#[serde(skip)]
pub pending_control: Option<RunControlAction>,
#[serde(skip)]
pub duration_ms: Option<u64>,
#[serde(skip)]
pub elapsed_secs: Option<f64>,
#[serde(skip)]
pub total_usd_micros: Option<i64>,
#[serde(skip)]
pub diff_summary: Option<DiffSummary>,
#[serde(default)]
pub pull_request: Option<PullRequestRecord>,
}
impl RunSummary {
#[derive(Debug, Deserialize)]
struct RunWire {
#[serde(default)]
id: Option<RunId>,
#[serde(default)]
run_id: Option<RunId>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
goal: Option<String>,
#[serde(default)]
workflow: Option<WorkflowRef>,
#[serde(default)]
workflow_name: Option<String>,
#[serde(default)]
workflow_slug: Option<String>,
#[serde(default)]
automation: Option<AutomationRef>,
#[serde(default)]
repository: Option<RepositoryRefWire>,
#[serde(default)]
repo_origin_url: Option<String>,
#[serde(default)]
created_by: Option<Principal>,
#[serde(default)]
origin: Option<RunOrigin>,
#[serde(default)]
labels: HashMap<String, String>,
#[serde(default)]
lifecycle: Option<RunLifecycle>,
#[serde(default)]
status: Option<serde_json::Value>,
#[serde(default)]
pending_control: Option<RunControlAction>,
#[serde(default)]
archived_at: Option<DateTime<Utc>>,
#[serde(default)]
sandbox: Option<RunSandbox>,
#[serde(default)]
models: Vec<RunModel>,
#[serde(default)]
source_directory: Option<String>,
#[serde(default)]
timestamps: Option<RunTimestamps>,
#[serde(default)]
start_time: Option<DateTime<Utc>>,
#[serde(default)]
created_at: Option<DateTime<Utc>>,
#[serde(default)]
last_event_at: Option<DateTime<Utc>>,
#[serde(default)]
duration_ms: Option<u64>,
#[serde(default)]
elapsed_secs: Option<f64>,
#[serde(default)]
billing: Option<RunBillingSummary>,
#[serde(default)]
total_usd_micros: Option<i64>,
#[serde(default)]
diff: Option<DiffSummary>,
#[serde(default)]
diff_summary: Option<DiffSummary>,
#[serde(default)]
pull_request: Option<PullRequest>,
#[serde(default)]
current_question: Option<InterviewQuestionRecord>,
#[serde(default)]
superseded_by: Option<RunId>,
#[serde(default)]
links: Option<RunLinks>,
}
#[derive(Debug, Deserialize)]
struct RepositoryRefWire {
name: String,
#[serde(default)]
origin_url: Option<String>,
#[serde(default)]
provider: Option<RepositoryProvider>,
}
impl From<RepositoryRefWire> for RepositoryRef {
fn from(value: RepositoryRefWire) -> Self {
Self {
name: value.name,
provider: value
.provider
.unwrap_or_else(|| repository_provider(value.origin_url.as_deref())),
origin_url: value.origin_url,
}
}
}
fn legacy_status(
value: Option<serde_json::Value>,
) -> Result<(Option<RunStatus>, bool), serde_json::Error> {
let Some(value) = value else {
return Ok((None, false));
};
if value.get("kind").and_then(serde_json::Value::as_str) == Some("archived") {
let status = match value.get("prior") {
Some(prior) => serde_json::from_value(prior.clone())?,
None => RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
};
return Ok((Some(status), true));
}
serde_json::from_value(value).map(|status| (Some(status), false))
}
impl<'de> Deserialize<'de> for Run {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = RunWire::deserialize(deserializer)?;
let id = wire
.id
.or(wire.run_id)
.ok_or_else(|| D::Error::missing_field("id"))?;
let goal = wire.goal.unwrap_or_else(|| {
wire.title
.clone()
.unwrap_or_else(|| "Untitled run".to_string())
});
let workflow = wire.workflow.unwrap_or_else(|| WorkflowRef {
slug: wire.workflow_slug,
name: wire.workflow_name.unwrap_or_else(|| "unnamed".to_string()),
});
let repository = wire.repository.map(Into::into).or_else(|| {
Some(repository_ref(
wire.repo_origin_url.as_deref(),
wire.source_directory.as_deref(),
))
});
let repo_origin_url = repository
.as_ref()
.and_then(|repository: &RepositoryRef| repository.origin_url.clone())
.or(wire.repo_origin_url);
let (legacy_status, legacy_archived) =
legacy_status(wire.status).map_err(D::Error::custom)?;
let lifecycle = wire.lifecycle.unwrap_or_else(|| RunLifecycle {
status: legacy_status.unwrap_or_else(default_run_status),
pending_control: wire.pending_control,
queue_position: None,
error: None,
archived: wire.archived_at.is_some() || legacy_archived,
archived_at: wire.archived_at,
});
let timestamps = wire.timestamps.unwrap_or_else(|| {
let created_at = wire.created_at.unwrap_or_else(|| id.created_at());
RunTimestamps {
created_at,
started_at: wire.start_time,
last_event_at: wire.last_event_at,
completed_at: None,
duration_ms: wire.duration_ms,
elapsed_secs: wire.elapsed_secs.or_else(|| elapsed_secs(wire.duration_ms)),
}
});
let total_usd_micros = wire
.billing
.as_ref()
.and_then(|billing| billing.total_usd_micros)
.or(wire.total_usd_micros);
let diff = wire.diff.or(wire.diff_summary);
let title = wire.title.unwrap_or_else(|| crate::infer_run_title(&goal));
let workflow_name = Some(workflow.name.clone());
let workflow_slug = workflow.slug.clone();
Ok(Self {
id,
title,
goal,
workflow,
automation: wire.automation,
repository,
created_by: wire.created_by,
origin: wire.origin.unwrap_or_default(),
labels: wire.labels,
status: lifecycle.status,
pending_control: lifecycle.pending_control,
lifecycle,
sandbox: wire.sandbox,
models: wire.models,
source_directory: wire.source_directory,
start_time: timestamps.started_at,
created_at: timestamps.created_at,
last_event_at: timestamps.last_event_at,
duration_ms: timestamps.duration_ms,
elapsed_secs: timestamps.elapsed_secs,
timestamps,
total_usd_micros,
billing: wire.billing.or_else(|| {
total_usd_micros.map(|total_usd_micros| RunBillingSummary {
total_usd_micros: Some(total_usd_micros),
})
}),
diff_summary: diff,
diff,
pull_request: wire.pull_request,
current_question: wire.current_question,
superseded_by: wire.superseded_by,
links: wire.links.unwrap_or(RunLinks { web: None }),
run_id: id,
workflow_name,
workflow_slug,
repo_origin_url,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowRef {
#[serde(default)]
pub slug: Option<String>,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutomationRef {
pub id: String,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunOrigin {
pub kind: RunOriginKind,
}
impl Default for RunOrigin {
fn default() -> Self {
Self {
kind: RunOriginKind::Api,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunOriginKind {
Api,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunModel {
#[serde(default)]
pub provider: Option<String>,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunLifecycle {
pub status: RunStatus,
#[serde(default)]
pub pending_control: Option<RunControlAction>,
#[serde(default)]
pub queue_position: Option<u32>,
#[serde(default)]
pub error: Option<RunError>,
pub archived: bool,
#[serde(default)]
pub archived_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunError {
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunTimestamps {
pub created_at: DateTime<Utc>,
#[serde(default)]
pub started_at: Option<DateTime<Utc>>,
#[serde(default)]
pub last_event_at: Option<DateTime<Utc>>,
#[serde(default)]
pub completed_at: Option<DateTime<Utc>>,
#[serde(default)]
pub duration_ms: Option<u64>,
#[serde(default)]
pub elapsed_secs: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunBillingSummary {
#[serde(default)]
pub total_usd_micros: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunLinks {
#[serde(default)]
pub web: Option<String>,
}
impl Run {
#[allow(
clippy::too_many_arguments,
reason = "RunSummary is a flat wire DTO; the constructor centralizes derived fields."
reason = "Run is a public wire DTO; the constructor centralizes derived fields."
)]
pub fn new(
run_id: RunId,
@ -58,32 +382,77 @@ impl RunSummary {
labels: HashMap<String, String>,
source_directory: Option<String>,
repo_origin_url: Option<String>,
created_by: Option<Principal>,
start_time: Option<DateTime<Utc>>,
last_event_at: Option<DateTime<Utc>>,
completed_at: Option<DateTime<Utc>>,
status: RunStatus,
pending_control: Option<RunControlAction>,
duration_ms: Option<u64>,
total_usd_micros: Option<i64>,
superseded_by: Option<RunId>,
diff_summary: Option<DiffSummary>,
pull_request: Option<PullRequestRecord>,
pull_request: Option<PullRequest>,
archived_at: Option<DateTime<Utc>>,
sandbox: Option<RunSandbox>,
models: Vec<RunModel>,
current_question: Option<InterviewQuestionRecord>,
web_url: Option<String>,
) -> Self {
let repository = RepositoryReference {
name: repository_name(repo_origin_url.as_deref(), source_directory.as_deref()),
};
let elapsed_secs = elapsed_secs(duration_ms);
let created_at = run_id.created_at();
let repository = Some(repository_ref(
repo_origin_url.as_deref(),
source_directory.as_deref(),
));
let elapsed_secs = elapsed_secs(duration_ms);
let billing = total_usd_micros.map(|total_usd_micros| RunBillingSummary {
total_usd_micros: Some(total_usd_micros),
});
let workflow_name_for_compat = workflow_name.unwrap_or_else(|| "unnamed".to_string());
let workflow_slug_for_compat = workflow_slug.clone();
Self {
run_id,
workflow_name,
workflow_slug,
goal,
id: run_id,
title,
labels,
source_directory,
repo_origin_url,
goal,
workflow: WorkflowRef {
slug: workflow_slug,
name: workflow_name_for_compat.clone(),
},
automation: None,
repository,
created_by,
origin: RunOrigin::default(),
labels,
lifecycle: RunLifecycle {
status,
pending_control,
queue_position: None,
error: None,
archived: archived_at.is_some(),
archived_at,
},
sandbox,
models,
source_directory,
timestamps: RunTimestamps {
created_at,
started_at: start_time,
last_event_at,
completed_at,
duration_ms,
elapsed_secs,
},
billing,
diff: diff_summary,
pull_request,
current_question,
superseded_by,
links: RunLinks { web: web_url },
run_id,
workflow_name: Some(workflow_name_for_compat.clone()),
workflow_slug: workflow_slug_for_compat,
repo_origin_url,
start_time,
created_at,
last_event_at,
@ -92,13 +461,41 @@ impl RunSummary {
duration_ms,
elapsed_secs,
total_usd_micros,
superseded_by,
diff_summary,
pull_request,
}
}
}
fn default_run_status() -> RunStatus {
RunStatus::Submitted
}
fn repository_ref(repo_origin_url: Option<&str>, source_directory: Option<&str>) -> RepositoryRef {
RepositoryRef {
name: repository_name(repo_origin_url, source_directory),
origin_url: repo_origin_url.map(ToOwned::to_owned),
provider: repository_provider(repo_origin_url),
}
}
fn repository_provider(repo_origin_url: Option<&str>) -> RepositoryProvider {
let Some(origin) = repo_origin_url.filter(|origin| !origin.trim().is_empty()) else {
return RepositoryProvider::Unknown;
};
if is_github_origin(origin) {
RepositoryProvider::Github
} else {
RepositoryProvider::Git
}
}
fn is_github_origin(origin: &str) -> bool {
origin.starts_with("git@github.com:")
|| origin.starts_with("https://github.com/")
|| origin.starts_with("http://github.com/")
|| origin.starts_with("ssh://git@github.com/")
}
fn repository_name(repo_origin_url: Option<&str>, source_directory: Option<&str>) -> String {
repo_origin_url
.and_then(repository_name_from_origin)
@ -112,7 +509,7 @@ fn repository_name(repo_origin_url: Option<&str>, source_directory: Option<&str>
#[expect(
clippy::disallowed_types,
reason = "Run summaries parse the origin only to extract an owner/repo label; raw URLs are not logged or returned here."
reason = "Run summaries parse the origin only to extract an owner/repo label; raw URLs are not logged."
)]
fn repository_name_from_origin(origin: &str) -> Option<String> {
if let Some(path) = origin
@ -147,177 +544,3 @@ fn path_basename(path: &str) -> Option<&str> {
fn elapsed_secs(duration_ms: Option<u64>) -> Option<f64> {
duration_ms.map(|ms| ms as f64 / 1000.0)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use serde_json::json;
use super::RunSummary;
use crate::{BlockedReason, RepositoryReference, RunControlAction, RunStatus, fixtures};
#[test]
fn summary_prefers_origin_name_over_submitter_source_directory() {
let summary = RunSummary::new(
fixtures::RUN_1,
Some("workflow".to_string()),
Some("workflow".to_string()),
"ship it".to_string(),
"Production title".to_string(),
HashMap::from([("team".to_string(), "core".to_string())]),
Some("/Users/client/local-checkout".to_string()),
Some("https://github.com/fabro-sh/fabro.git".to_string()),
Some(Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap()),
Some(Utc.with_ymd_and_hms(2026, 4, 20, 12, 5, 0).unwrap()),
RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
},
Some(RunControlAction::Pause),
Some(42),
Some(123),
Some(fixtures::RUN_2),
None,
None,
);
assert_eq!(summary.title, "Production title");
assert_eq!(summary.repository, RepositoryReference {
name: "fabro-sh/fabro".to_string(),
});
assert_eq!(summary.created_at, fixtures::RUN_1.created_at());
assert_eq!(summary.elapsed_secs, Some(0.042));
assert_eq!(
summary.last_event_at,
Some(Utc.with_ymd_and_hms(2026, 4, 20, 12, 5, 0).unwrap())
);
assert_eq!(
summary.source_directory.as_deref(),
Some("/Users/client/local-checkout")
);
let value = serde_json::to_value(&summary).unwrap();
assert!(value.get("host_repo_path").is_none());
assert_eq!(value["source_directory"], "/Users/client/local-checkout");
assert_eq!(
value["repo_origin_url"],
"https://github.com/fabro-sh/fabro.git"
);
assert_eq!(value["last_event_at"], "2026-04-20T12:05:00Z");
let parsed: RunSummary = serde_json::from_value(value).unwrap();
assert_eq!(parsed, summary);
}
#[test]
fn summary_round_trips_diff_summary() {
let summary: RunSummary = serde_json::from_value(json!({
"run_id": fixtures::RUN_1,
"goal": "ship it",
"title": "ship it",
"labels": {},
"status": { "kind": "running" },
"repository": { "name": "fabro" },
"created_at": fixtures::RUN_1.created_at(),
"diff_summary": {
"files_changed": 3,
"additions": 12,
"deletions": 4
}
}))
.unwrap();
let value = serde_json::to_value(&summary).unwrap();
assert_eq!(
value["diff_summary"],
json!({
"files_changed": 3,
"additions": 12,
"deletions": 4
})
);
}
#[test]
fn summary_round_trips_pull_request() {
let summary: RunSummary = serde_json::from_value(json!({
"run_id": fixtures::RUN_1,
"goal": "ship it",
"title": "ship it",
"labels": {},
"status": { "kind": "running" },
"repository": { "name": "fabro" },
"created_at": fixtures::RUN_1.created_at(),
"pull_request": {
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
"repo": "fabro",
"base_branch": "main",
"head_branch": "fabro/run/demo",
"title": "Add run PR chip"
}
}))
.unwrap();
let value = serde_json::to_value(&summary).unwrap();
assert_eq!(
value["pull_request"],
json!({
"html_url": "https://github.com/fabro-sh/fabro/pull/123",
"number": 123,
"owner": "fabro-sh",
"repo": "fabro",
"base_branch": "main",
"head_branch": "fabro/run/demo",
"title": "Add run PR chip"
})
);
}
#[test]
fn summary_falls_back_to_source_directory_then_unknown() {
let source_only = RunSummary::new(
fixtures::RUN_1,
None,
None,
"ship it".to_string(),
"ship it".to_string(),
HashMap::new(),
Some("/Users/client/local-checkout".to_string()),
None,
None,
None,
RunStatus::Submitted,
None,
None,
None,
None,
None,
None,
);
assert_eq!(source_only.repository.name, "local-checkout");
assert_eq!(source_only.last_event_at, None);
let unknown = RunSummary::new(
fixtures::RUN_1,
None,
None,
"ship it".to_string(),
"ship it".to_string(),
HashMap::new(),
None,
None,
None,
None,
RunStatus::Submitted,
None,
None,
None,
None,
None,
None,
);
assert_eq!(unknown.repository.name, "unknown");
}
}

View file

@ -3,24 +3,20 @@ use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::SandboxProvider;
use crate::RunSandbox;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxDetails {
pub provider: SandboxProvider,
pub id: String,
pub working_directory: String,
pub state: SandboxState,
pub sandbox: RunSandbox,
pub state: SandboxState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_state: Option<String>,
pub native_state: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
pub resources: SandboxResources,
pub region: Option<String>,
pub resources: SandboxResources,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
pub timestamps: SandboxTimestamps,
pub labels: BTreeMap<String, String>,
pub timestamps: SandboxTimestamps,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -69,20 +65,28 @@ mod tests {
#[test]
fn serializes_with_snake_case_state() {
let details = SandboxDetails {
provider: crate::SandboxProvider::Docker,
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
state: SandboxState::Running,
native_state: Some("running".to_string()),
region: None,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
resources: SandboxResources {
sandbox: RunSandbox {
provider: crate::SandboxProvider::Docker,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: Some(crate::RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}),
},
state: SandboxState::Running,
native_state: Some("running".to_string()),
region: None,
resources: SandboxResources {
cpu_cores: Some(2.0),
memory_bytes: Some(4 * 1024 * 1024 * 1024),
disk_bytes: None,
},
labels: BTreeMap::from([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
labels: BTreeMap::from([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
created_at: Some(Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap()),
last_activity_at: None,
},
@ -91,12 +95,16 @@ mod tests {
assert_eq!(
serde_json::to_value(&details).unwrap(),
json!({
"provider": "docker",
"id": "container-abc123",
"working_directory": "/workspace",
"sandbox": {
"provider": "docker",
"image": "ghcr.io/fabro/sandbox:latest",
"runtime": {
"id": "container-abc123",
"working_directory": "/workspace"
}
},
"state": "running",
"native_state": "running",
"image": "ghcr.io/fabro/sandbox:latest",
"resources": {
"cpu_cores": 2.0,
"memory_bytes": 4_294_967_296_u64,
@ -114,20 +122,40 @@ mod tests {
#[test]
fn deserializes_with_minimal_fields() {
let details: SandboxDetails = serde_json::from_value(json!({
"provider": "local",
"id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z",
"working_directory": "/Users/client/project",
"sandbox": {
"provider": "local",
"image": null,
"snapshot": null,
"runtime": {
"id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z",
"working_directory": "/Users/client/project"
}
},
"state": "unknown",
"resources": {},
"timestamps": {}
}))
.unwrap();
assert_eq!(details.provider, crate::SandboxProvider::Local);
assert_eq!(details.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z");
assert_eq!(details.working_directory, "/Users/client/project");
assert_eq!(details.sandbox.provider, crate::SandboxProvider::Local);
assert_eq!(
details
.sandbox
.runtime
.as_ref()
.map(|runtime| runtime.id.as_str()),
Some("local:01JNQVR7M0EJ5GKAT2SC4ERS1Z")
);
assert_eq!(
details
.sandbox
.runtime
.as_ref()
.map(|runtime| runtime.working_directory.as_str()),
Some("/Users/client/project")
);
assert_eq!(details.state, SandboxState::Unknown);
assert!(details.image.is_none());
assert!(details.sandbox.image.is_none());
assert!(details.labels.is_empty());
assert_eq!(details.resources, SandboxResources::default());
assert_eq!(details.timestamps, SandboxTimestamps::default());

View file

@ -16,25 +16,21 @@ pub enum RunStatus {
Succeeded { reason: SuccessReason },
Failed { reason: FailureReason },
Dead,
Archived { prior: TerminalStatus },
}
impl RunStatus {
/// Whether the run has reached a terminal outcome and stops poll loops,
/// finalization, and similar "done" handling. `Archived` is terminal
/// because it is only reachable from another terminal status.
/// finalization, and similar "done" handling.
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::Succeeded { .. } | Self::Failed { .. } | Self::Dead | Self::Archived { .. }
Self::Succeeded { .. } | Self::Failed { .. } | Self::Dead
)
}
/// Whether the run's status is frozen and cannot transition outbound
/// through normal lifecycle events. Deletion and the `* -> Dead` escape
/// hatch are allowed separately. `Archived` is intentionally NOT
/// immutable — it can transition back to its prior terminal status via
/// `unarchive`.
/// hatch are allowed separately.
pub fn is_immutable(self) -> bool {
matches!(
self,
@ -87,13 +83,7 @@ impl RunStatus {
return true;
}
if self.is_immutable() {
return matches!(to, Self::Archived { .. });
}
if matches!(self, Self::Archived { .. }) {
return matches!(
to,
Self::Succeeded { .. } | Self::Failed { .. } | Self::Dead
);
return false;
}
matches!(
(self, to),
@ -164,7 +154,6 @@ impl fmt::Display for RunStatus {
Self::Succeeded { reason } => write!(f, "succeeded({reason})"),
Self::Failed { reason } => write!(f, "failed({reason})"),
Self::Dead => f.write_str("dead"),
Self::Archived { prior } => write!(f, "archived({prior})"),
}
}
}
@ -331,9 +320,7 @@ pub enum RunControlAction {
mod tests {
use std::str::FromStr;
use super::{
BlockedReason, FailureReason, InvalidTransition, RunStatus, SuccessReason, TerminalStatus,
};
use super::{BlockedReason, FailureReason, InvalidTransition, RunStatus, SuccessReason};
#[test]
fn queued_and_blocked_are_active() {
@ -399,65 +386,6 @@ mod tests {
assert_eq!(failure.to_string(), "cancelled");
}
#[test]
fn archived_display_includes_prior_terminal_status() {
let archived = RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
};
assert_eq!(archived.to_string(), "archived(succeeded(completed))");
}
#[test]
fn terminal_statuses_can_transition_to_archived() {
let archived = RunStatus::Archived {
prior: TerminalStatus::Dead,
};
assert!(
RunStatus::Succeeded {
reason: SuccessReason::Completed,
}
.can_transition_to(archived)
);
assert!(
RunStatus::Failed {
reason: FailureReason::Cancelled,
}
.can_transition_to(archived)
);
assert!(RunStatus::Dead.can_transition_to(archived));
}
#[test]
fn archived_can_transition_back_to_terminal() {
let archived = RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
};
assert!(archived.can_transition_to(RunStatus::Succeeded {
reason: SuccessReason::Completed,
}));
assert!(archived.can_transition_to(RunStatus::Failed {
reason: FailureReason::Cancelled,
}));
assert!(archived.can_transition_to(RunStatus::Dead));
}
#[test]
fn running_cannot_transition_to_archived() {
let archived = RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
};
assert!(!RunStatus::Running.can_transition_to(archived));
assert!(!RunStatus::Queued.can_transition_to(archived));
assert!(!RunStatus::Submitted.can_transition_to(archived));
assert!(!RunStatus::Paused { prior_block: None }.can_transition_to(archived));
}
#[test]
fn run_statuses_can_transition_to_removing_for_deletion() {
let removing = RunStatus::Removing;
@ -477,9 +405,6 @@ mod tests {
reason: FailureReason::Cancelled,
},
RunStatus::Dead,
RunStatus::Archived {
prior: TerminalStatus::Dead,
},
] {
assert!(
status.can_transition_to(removing),
@ -489,24 +414,6 @@ mod tests {
assert!(!removing.can_transition_to(removing));
}
#[test]
fn archived_to_archived_is_rejected() {
let archived = RunStatus::Archived {
prior: TerminalStatus::Dead,
};
assert!(!archived.can_transition_to(archived));
}
#[test]
fn archived_is_terminal_but_not_immutable() {
let archived = RunStatus::Archived {
prior: TerminalStatus::Dead,
};
assert!(archived.is_terminal());
assert!(!archived.is_immutable());
assert!(!archived.is_active());
}
#[test]
fn immutable_terminal_statuses_are_also_terminal() {
for status in [
@ -525,10 +432,10 @@ mod tests {
#[test]
fn invalid_transition_carries_from_and_to() {
let from = RunStatus::Running;
let to = RunStatus::Archived {
prior: TerminalStatus::Dead,
let from = RunStatus::Succeeded {
reason: SuccessReason::Completed,
};
let to = RunStatus::Running;
let err = from.transition_to(to).expect_err("should reject");
assert_eq!(err, InvalidTransition { from, to });
}

View file

@ -2,20 +2,24 @@ use std::collections::BTreeMap;
use chrono::{TimeZone, Utc};
use fabro_types::{
RunSandbox, SandboxDetails, SandboxProvider, SandboxResources, SandboxState, SandboxTimestamps,
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxProvider, SandboxResources, SandboxState,
SandboxTimestamps,
};
use serde_json::json;
#[test]
fn run_sandbox_serializes_canonical_identity_without_identifier() {
let sandbox = RunSandbox {
provider: SandboxProvider::Docker,
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(true),
clone_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
clone_branch: Some("main".to_string()),
resources: None,
provider: SandboxProvider::Docker,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(true),
clone_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
clone_branch: Some("main".to_string()),
}),
};
let value = serde_json::to_value(&sandbox).unwrap();
@ -24,11 +28,13 @@ fn run_sandbox_serializes_canonical_identity_without_identifier() {
value,
json!({
"provider": "docker",
"id": "container-abc123",
"working_directory": "/workspace",
"repo_cloned": true,
"clone_origin_url": "https://github.com/fabro-sh/fabro.git",
"clone_branch": "main"
"runtime": {
"id": "container-abc123",
"working_directory": "/workspace",
"repo_cloned": true,
"clone_origin_url": "https://github.com/fabro-sh/fabro.git",
"clone_branch": "main"
}
})
);
assert!(value.get("identifier").is_none());
@ -37,20 +43,28 @@ fn run_sandbox_serializes_canonical_identity_without_identifier() {
#[test]
fn sandbox_details_requires_canonical_id_and_working_directory() {
let details = SandboxDetails {
provider: SandboxProvider::Daytona,
id: "daytona-sandbox-name".to_string(),
working_directory: "/workspace".to_string(),
state: SandboxState::Running,
native_state: Some("started".to_string()),
region: Some("us".to_string()),
image: Some("ubuntu:24.04".to_string()),
resources: SandboxResources {
sandbox: RunSandbox {
provider: SandboxProvider::Daytona,
image: Some("ubuntu:24.04".to_string()),
snapshot: None,
runtime: Some(RunSandboxRuntime {
id: "daytona-sandbox-name".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}),
},
state: SandboxState::Running,
native_state: Some("started".to_string()),
region: Some("us".to_string()),
resources: SandboxResources {
cpu_cores: Some(2.0),
memory_bytes: Some(4 * 1024 * 1024 * 1024),
disk_bytes: None,
},
labels: BTreeMap::from([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
labels: BTreeMap::from([("run".to_string(), "abc".to_string())]),
timestamps: SandboxTimestamps {
created_at: Some(Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap()),
last_activity_at: None,
},
@ -58,9 +72,12 @@ fn sandbox_details_requires_canonical_id_and_working_directory() {
let value = serde_json::to_value(&details).unwrap();
assert_eq!(value["provider"], "daytona");
assert_eq!(value["id"], "daytona-sandbox-name");
assert_eq!(value["working_directory"], "/workspace");
assert_eq!(value["sandbox"]["provider"], "daytona");
assert_eq!(value["sandbox"]["runtime"]["id"], "daytona-sandbox-name");
assert_eq!(
value["sandbox"]["runtime"]["working_directory"],
"/workspace"
);
assert!(value.get("name").is_none());
assert!(value.get("identifier").is_none());
}

View file

@ -16,8 +16,8 @@ pub fn archived_rejection_message(run_id: &RunId) -> String {
/// Returns `Err(Error::Precondition)` when the given status represents an
/// archived run. Use this at any mutation entry point that would otherwise
/// transition or emit events against the run (rewind, resume, etc.).
pub fn ensure_not_archived(status: Option<RunStatus>, run_id: &RunId) -> Result<(), Error> {
if matches!(status, Some(RunStatus::Archived { .. })) {
pub fn ensure_not_archived(archived: bool, run_id: &RunId) -> Result<(), Error> {
if archived {
Err(Error::Precondition(archived_rejection_message(run_id)))
} else {
Ok(())
@ -27,7 +27,7 @@ pub fn ensure_not_archived(status: Option<RunStatus>, run_id: &RunId) -> Result<
/// Outcome of an `archive` call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArchiveOutcome {
/// Event was appended; projection transitions to `Archived`.
/// Event was appended; projection marks the run archived.
Archived { prior_status: TerminalStatus },
/// Run was already archived; no event emitted.
AlreadyArchived,
@ -36,7 +36,7 @@ pub enum ArchiveOutcome {
/// Outcome of an `unarchive` call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnarchiveOutcome {
/// Event was appended; projection transitions back to `restored_status`.
/// Event was appended; projection clears archive metadata.
Unarchived { restored_status: TerminalStatus },
/// Run was terminal but not archived; no event emitted. Symmetric with
/// `ArchiveOutcome::AlreadyArchived`.
@ -59,7 +59,7 @@ pub async fn archive(
.map_err(|err| Error::engine(err.to_string()))?;
let current = projection.status;
if matches!(current, RunStatus::Archived { .. }) {
if projection.archived_at.is_some() {
return Ok(ArchiveOutcome::AlreadyArchived);
}
@ -104,10 +104,15 @@ pub async fn unarchive(
.map_err(|err| Error::engine(err.to_string()))?;
let current = projection.status;
if let RunStatus::Archived { prior } = current {
if projection.archived_at.is_some() {
event::append_event(&run_store, run_id, &Event::RunUnarchived { actor })
.await
.map_err(|err| Error::engine(err.to_string()))?;
let prior = current.terminal_status().ok_or_else(|| {
Error::engine(format!(
"run {run_id} is archived but has non-terminal status {current}"
))
})?;
return Ok(UnarchiveOutcome::Unarchived {
restored_status: prior,
});
@ -230,6 +235,11 @@ mod tests {
run_store.state().await.unwrap().status
}
async fn is_archived(store: &Database, run_id: &RunId) -> bool {
let run_store = store.open_run_reader(run_id).await.unwrap();
run_store.state().await.unwrap().archived_at.is_some()
}
async fn event_count(store: &Database, run_id: &RunId) -> usize {
let run_store = store.open_run_reader(run_id).await.unwrap();
run_store.list_events().await.unwrap().len()
@ -247,11 +257,13 @@ mod tests {
reason: SuccessReason::Completed,
},
});
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived {
prior: TerminalStatus::Succeeded {
assert_eq!(
current_status(&store, &run_id).await,
RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
});
}
);
assert!(is_archived(&store, &run_id).await);
let projection = store
.open_run_reader(&run_id)
@ -260,11 +272,10 @@ mod tests {
.state()
.await
.unwrap();
assert_eq!(projection.status, RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
assert_eq!(projection.status, RunStatus::Succeeded {
reason: SuccessReason::Completed,
});
assert!(projection.archived_at.is_some());
}
#[tokio::test]
@ -279,11 +290,10 @@ mod tests {
reason: FailureReason::WorkflowError,
},
});
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived {
prior: TerminalStatus::Failed {
reason: FailureReason::WorkflowError,
},
assert_eq!(current_status(&store, &run_id).await, RunStatus::Failed {
reason: FailureReason::WorkflowError,
});
assert!(is_archived(&store, &run_id).await);
}
#[tokio::test]
@ -431,10 +441,12 @@ mod tests {
let events_after = event_count(&store, &run_id).await;
assert_eq!(events_after - events_before, 3);
assert_eq!(current_status(&store, &run_id).await, RunStatus::Archived {
prior: TerminalStatus::Succeeded {
assert_eq!(
current_status(&store, &run_id).await,
RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
});
}
);
assert!(is_archived(&store, &run_id).await);
}
}

View file

@ -15,7 +15,7 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result<Started,
.map_err(|err| Error::engine(err.to_string()))?;
let status = state.status;
super::archive::ensure_not_archived(Some(status), &services.run_id)?;
super::archive::ensure_not_archived(state.archived_at.is_some(), &services.run_id)?;
if matches!(status, RunStatus::Succeeded { .. }) {
return Err(Error::Precondition(
"run already finished successfully — nothing to resume".to_string(),

View file

@ -43,7 +43,7 @@ pub async fn rewind(
.map_err(|err| Error::engine(err.to_string()))?;
let current = projection.status;
archive::ensure_not_archived(Some(current), &input.run_id)?;
archive::ensure_not_archived(projection.archived_at.is_some(), &input.run_id)?;
if current.terminal_status().is_none() {
return Err(Error::Precondition(format!(
"run {} must be terminal (succeeded, failed, or dead) to rewind; current status is {current}",

View file

@ -437,13 +437,17 @@ pub async fn initialize(
let run_sandbox = options
.sandbox
.to_run_sandbox(&*sandbox, options.run_options.run_id);
let runtime = run_sandbox
.runtime
.as_ref()
.ok_or_else(|| Error::engine("initialized sandbox missing runtime metadata"))?;
options.emitter.emit(&Event::SandboxInitialized {
working_directory: run_sandbox.working_directory.clone(),
working_directory: runtime.working_directory.clone(),
provider: run_sandbox.provider,
id: run_sandbox.id.clone(),
repo_cloned: run_sandbox.repo_cloned,
clone_origin_url: run_sandbox.clone_origin_url.clone(),
clone_branch: run_sandbox.clone_branch.clone(),
id: runtime.id.clone(),
repo_cloned: runtime.repo_cloned,
clone_origin_url: runtime.clone_origin_url.clone(),
clone_branch: runtime.clone_branch.clone(),
});
}

View file

@ -535,6 +535,7 @@ pub async fn maybe_open_pull_request(
}
let record = PullRequestRecord {
provider: "github".to_string(),
html_url: created.html_url,
number: created.number,
owner,

View file

@ -57,28 +57,28 @@ impl RunInfo {
pub fn run_id(&self) -> RunId {
self.summary
.as_ref()
.map(|summary| summary.run_id)
.map(|summary| summary.id)
.or_else(|| parse_run_id(&self.dir_name))
.expect("RunInfo must have a run id")
}
pub fn workflow_name(&self) -> String {
self.summary
.as_ref()
.and_then(|summary| summary.workflow_name.clone())
.unwrap_or_else(|| "[no run spec]".to_string())
self.summary.as_ref().map_or_else(
|| "[no run spec]".to_string(),
|summary| summary.workflow.name.clone(),
)
}
pub fn workflow_slug(&self) -> Option<&str> {
self.summary
.as_ref()
.and_then(|summary| summary.workflow_slug.as_deref())
.and_then(|summary| summary.workflow.slug.as_deref())
}
pub fn status(&self) -> RunStatus {
self.summary
.as_ref()
.map_or(RunStatus::Submitted, |summary| summary.status)
.map_or(RunStatus::Submitted, |summary| summary.lifecycle.status)
}
pub fn status_reason(&self) -> Option<String> {
@ -92,7 +92,12 @@ impl RunInfo {
pub fn start_time(&self) -> String {
self.summary
.as_ref()
.and_then(|summary| summary.start_time.or(Some(summary.run_id.created_at())))
.and_then(|summary| {
summary
.timestamps
.started_at
.or(Some(summary.id.created_at()))
})
.or(self.start_time_dt)
.map(|time| time.to_rfc3339())
.unwrap_or_default()
@ -109,20 +114,20 @@ impl RunInfo {
pub fn duration_ms(&self) -> Option<u64> {
self.summary
.as_ref()
.and_then(|summary| summary.duration_ms)
.and_then(|summary| summary.timestamps.duration_ms)
}
pub fn total_cost(&self) -> Option<f64> {
self.summary
.as_ref()
.and_then(|summary| summary.total_usd_micros)
.and_then(|summary| summary.billing.as_ref()?.total_usd_micros)
.map(|value| value as f64 / 1_000_000.0)
}
pub fn total_usd_micros(&self) -> Option<i64> {
self.summary
.as_ref()
.and_then(|summary| summary.total_usd_micros)
.and_then(|summary| summary.billing.as_ref()?.total_usd_micros)
}
pub fn source_directory(&self) -> Option<&str> {
@ -134,7 +139,7 @@ impl RunInfo {
pub fn repo_origin_url(&self) -> Option<&str> {
self.summary
.as_ref()
.and_then(|summary| summary.repo_origin_url.as_deref())
.and_then(|summary| summary.repository.as_ref()?.origin_url.as_deref())
}
pub fn goal(&self) -> String {
@ -243,14 +248,14 @@ pub fn scan_runs_with_summaries(summaries: &[RunSummary], base: &Path) -> Result
}
fn run_info_from_summary(summary: &RunSummary, scratch_base: &Path) -> Option<RunInfo> {
let path = make_run_dir(scratch_base, &summary.run_id);
let path = make_run_dir(scratch_base, &summary.id);
if !path.exists() {
return None;
}
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_terminal() {
summary.duration_ms.and_then(|duration_ms| {
let start_time_dt = summary.id.created_at();
let end_time = if summary.lifecycle.status.is_terminal() {
summary.timestamps.duration_ms.and_then(|duration_ms| {
Some(start_time_dt + chrono::Duration::milliseconds(i64::try_from(duration_ms).ok()?))
})
} else {

View file

@ -15,7 +15,7 @@
)]
use fabro_sandbox::reconnect::reconnect;
use fabro_types::{RunSandbox, SandboxProvider};
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider};
// ---------------------------------------------------------------------------
// Local sandbox
@ -23,13 +23,16 @@ use fabro_types::{RunSandbox, SandboxProvider};
fn local_record(working_directory: &std::path::Path) -> RunSandbox {
RunSandbox {
provider: SandboxProvider::Local,
id: "local:test".to_string(),
working_directory: working_directory.to_string_lossy().to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
resources: None,
provider: SandboxProvider::Local,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
id: "local:test".to_string(),
working_directory: working_directory.to_string_lossy().to_string(),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}),
}
}
@ -127,13 +130,16 @@ async fn local_cp_creates_parent_dirs() {
fn docker_record(container_id: &str) -> RunSandbox {
RunSandbox {
provider: SandboxProvider::Docker,
id: container_id.to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
resources: None,
provider: SandboxProvider::Docker,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
id: container_id.to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
}),
}
}

View file

@ -1853,13 +1853,16 @@ async fn daytona_cp_upload_download_round_trip() {
// 2. Build a RunSandbox (same as `fabro run` would persist)
let record = RunSandbox {
provider: SandboxProvider::Daytona,
id: sandbox_name.clone(),
working_directory: env.working_directory().to_string(),
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
resources: None,
provider: SandboxProvider::Daytona,
image: None,
snapshot: None,
runtime: Some(fabro_types::RunSandboxRuntime {
id: sandbox_name.clone(),
working_directory: env.working_directory().to_string(),
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
}),
};
// 3. Reconnect via the real cp::reconnect path

View file

@ -38,6 +38,7 @@ models/auth-method.ts
models/auth-session-user.ts
models/auth-session.ts
models/auth-sessions-response.ts
models/automation-ref.ts
models/billed-token-counts.ts
models/billing-by-model.ts
models/billing-model-ref.ts
@ -213,18 +214,19 @@ models/provider.ts
models/prune-run-entry.ts
models/prune-runs-request.ts
models/prune-runs-response.ts
models/pull-request-detail.ts
models/pull-request-record.ts
models/pull-request-details-timestamps.ts
models/pull-request-details.ts
models/pull-request-ref.ts
models/pull-request-settings.ts
models/pull-request-user.ts
models/pull-request.ts
models/question-type.ts
models/render-workflow-graph-direction.ts
models/render-workflow-graph-format.ts
models/render-workflow-graph-request.ts
models/repo-check-response-permissions.ts
models/repo-check-response.ts
models/repository-reference.ts
models/repository-ref.ts
models/rewind-request.ts
models/rewind-response.ts
models/root-response-urls.ts
@ -233,6 +235,7 @@ models/run-agent-settings.ts
models/run-artifact-entry.ts
models/run-artifact-list-response.ts
models/run-billing-stage.ts
models/run-billing-summary.ts
models/run-billing-totals.ts
models/run-billing.ts
models/run-checkpoint-settings.ts
@ -255,39 +258,41 @@ models/run-goal.ts
models/run-integrations-github-settings.ts
models/run-integrations-settings.ts
models/run-interviews-settings.ts
models/run-list-item.ts
models/run-lifecycle.ts
models/run-links.ts
models/run-manifest.ts
models/run-mode.ts
models/run-model-settings.ts
models/run-model.ts
models/run-namespace.ts
models/run-origin.ts
models/run-prepare-settings.ts
models/run-projection.ts
models/run-provenance.ts
models/run-pull-request.ts
models/run-question.ts
models/run-reference.ts
models/run-sandbox-runtime.ts
models/run-sandbox-settings.ts
models/run-sandbox.ts
models/run-scm-settings.ts
models/run-server-provenance.ts
models/run-spec.ts
models/run-stage.ts
models/run-status-archived.ts
models/run-status-blocked.ts
models/run-status-dead.ts
models/run-status-failed.ts
models/run-status-paused.ts
models/run-status-queued.ts
models/run-status-removing.ts
models/run-status-response.ts
models/run-status-running.ts
models/run-status-starting.ts
models/run-status-submitted.ts
models/run-status-succeeded.ts
models/run-status.ts
models/run-summary.ts
models/run-superseded-by-props.ts
models/run-timestamps.ts
models/run-timings.ts
models/run.ts
models/sandbox-details.ts
models/sandbox-file-entry.ts
models/sandbox-file-list-response.ts
@ -347,7 +352,6 @@ models/system-info-response.ts
models/system-repair-run-issue.ts
models/system-repair-runs-response.ts
models/system-run-counts.ts
models/terminal-status.ts
models/timeline-entry-response.ts
models/tls-mode.ts
models/update-run-request.ts
@ -360,6 +364,7 @@ models/workflow-diagnostic.ts
models/workflow-last-run-summary.ts
models/workflow-list-item.ts
models/workflow-namespace.ts
models/workflow-ref.ts
models/workflow-reference.ts
models/workflow-schedule-summary.ts
models/workflow-settings.ts

View file

@ -44,9 +44,9 @@ import type { PaginatedRunList } from '../models';
// @ts-ignore
import type { PreflightResponse } from '../models';
// @ts-ignore
import type { PullRequestDetail } from '../models';
import type { PullRequest } from '../models';
// @ts-ignore
import type { PullRequestRecord } from '../models';
import type { PullRequestDetails } from '../models';
// @ts-ignore
import type { RenderWorkflowGraphRequest } from '../models';
// @ts-ignore
@ -54,12 +54,10 @@ import type { RewindRequest } from '../models';
// @ts-ignore
import type { RewindResponse } from '../models';
// @ts-ignore
import type { Run } from '../models';
// @ts-ignore
import type { RunManifest } from '../models';
// @ts-ignore
import type { RunStatusResponse } from '../models';
// @ts-ignore
import type { RunSummary } from '../models';
// @ts-ignore
import type { StartRunRequest } from '../models';
// @ts-ignore
import type { TimelineEntryResponse } from '../models';
@ -1151,7 +1149,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async archiveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
async archiveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.archiveRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.archiveRun']?.[localVarOperationServerIndex]?.url;
@ -1164,7 +1162,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async cancelRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
async cancelRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.cancelRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.cancelRun']?.[localVarOperationServerIndex]?.url;
@ -1190,7 +1188,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
async createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createRun(runManifest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.createRun']?.[localVarOperationServerIndex]?.url;
@ -1204,7 +1202,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PullRequestRecord>> {
async createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PullRequest>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createRunPullRequest(id, createRunPullRequestRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.createRunPullRequest']?.[localVarOperationServerIndex]?.url;
@ -1245,7 +1243,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getRunPullRequest(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PullRequestDetail>> {
async getRunPullRequest(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PullRequestDetails>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getRunPullRequest(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.getRunPullRequest']?.[localVarOperationServerIndex]?.url;
@ -1315,7 +1313,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async pauseRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
async pauseRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.pauseRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.pauseRun']?.[localVarOperationServerIndex]?.url;
@ -1341,7 +1339,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async resolveRun(selector: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunSummary>> {
async resolveRun(selector: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.resolveRun(selector, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.resolveRun']?.[localVarOperationServerIndex]?.url;
@ -1354,7 +1352,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async retrieveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunSummary>> {
async retrieveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRun']?.[localVarOperationServerIndex]?.url;
@ -1422,7 +1420,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
async startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.startRun(id, startRunRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.startRun']?.[localVarOperationServerIndex]?.url;
@ -1435,7 +1433,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async unarchiveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
async unarchiveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.unarchiveRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.unarchiveRun']?.[localVarOperationServerIndex]?.url;
@ -1448,7 +1446,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async unpauseRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
async unpauseRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.unpauseRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.unpauseRun']?.[localVarOperationServerIndex]?.url;
@ -1462,7 +1460,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunSummary>> {
async updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateRun(id, updateRunRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.updateRun']?.[localVarOperationServerIndex]?.url;
@ -1497,7 +1495,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
archiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
archiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.archiveRun(id, options).then((request) => request(axios, basePath));
},
/**
@ -1507,7 +1505,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
cancelRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
cancelRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.cancelRun(id, options).then((request) => request(axios, basePath));
},
/**
@ -1527,7 +1525,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.createRun(runManifest, options).then((request) => request(axios, basePath));
},
/**
@ -1538,7 +1536,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): AxiosPromise<PullRequestRecord> {
createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): AxiosPromise<PullRequest> {
return localVarFp.createRunPullRequest(id, createRunPullRequestRequest, options).then((request) => request(axios, basePath));
},
/**
@ -1570,7 +1568,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRunPullRequest(id: string, options?: RawAxiosRequestConfig): AxiosPromise<PullRequestDetail> {
getRunPullRequest(id: string, options?: RawAxiosRequestConfig): AxiosPromise<PullRequestDetails> {
return localVarFp.getRunPullRequest(id, options).then((request) => request(axios, basePath));
},
/**
@ -1625,7 +1623,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
pauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
pauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.pauseRun(id, options).then((request) => request(axios, basePath));
},
/**
@ -1645,7 +1643,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
resolveRun(selector: string, options?: RawAxiosRequestConfig): AxiosPromise<RunSummary> {
resolveRun(selector: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.resolveRun(selector, options).then((request) => request(axios, basePath));
},
/**
@ -1655,7 +1653,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunSummary> {
retrieveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.retrieveRun(id, options).then((request) => request(axios, basePath));
},
/**
@ -1708,7 +1706,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.startRun(id, startRunRequest, options).then((request) => request(axios, basePath));
},
/**
@ -1718,7 +1716,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
unarchiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
unarchiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.unarchiveRun(id, options).then((request) => request(axios, basePath));
},
/**
@ -1728,7 +1726,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
unpauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
unpauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.unpauseRun(id, options).then((request) => request(axios, basePath));
},
/**
@ -1739,7 +1737,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<RunSummary> {
updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.updateRun(id, updateRunRequest, options).then((request) => request(axios, basePath));
},
/**

View file

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface AutomationRef {
'id': string;
'name': string | null;
}

View file

@ -16,6 +16,7 @@ export * from './auth-method';
export * from './auth-session';
export * from './auth-session-user';
export * from './auth-sessions-response';
export * from './automation-ref';
export * from './billed-token-counts';
export * from './billing-by-model';
export * from './billing-model-ref';
@ -190,8 +191,9 @@ export * from './provider';
export * from './prune-run-entry';
export * from './prune-runs-request';
export * from './prune-runs-response';
export * from './pull-request-detail';
export * from './pull-request-record';
export * from './pull-request';
export * from './pull-request-details';
export * from './pull-request-details-timestamps';
export * from './pull-request-ref';
export * from './pull-request-settings';
export * from './pull-request-user';
@ -201,16 +203,18 @@ export * from './render-workflow-graph-format';
export * from './render-workflow-graph-request';
export * from './repo-check-response';
export * from './repo-check-response-permissions';
export * from './repository-reference';
export * from './repository-ref';
export * from './rewind-request';
export * from './rewind-response';
export * from './root-response';
export * from './root-response-urls';
export * from './run';
export * from './run-agent-settings';
export * from './run-artifact-entry';
export * from './run-artifact-list-response';
export * from './run-billing';
export * from './run-billing-stage';
export * from './run-billing-summary';
export * from './run-billing-totals';
export * from './run-checkpoint';
export * from './run-checkpoint-settings';
@ -232,38 +236,39 @@ export * from './run-goal-inline';
export * from './run-integrations-github-settings';
export * from './run-integrations-settings';
export * from './run-interviews-settings';
export * from './run-list-item';
export * from './run-lifecycle';
export * from './run-links';
export * from './run-manifest';
export * from './run-mode';
export * from './run-model';
export * from './run-model-settings';
export * from './run-namespace';
export * from './run-origin';
export * from './run-prepare-settings';
export * from './run-projection';
export * from './run-provenance';
export * from './run-pull-request';
export * from './run-question';
export * from './run-reference';
export * from './run-sandbox';
export * from './run-sandbox-runtime';
export * from './run-sandbox-settings';
export * from './run-scm-settings';
export * from './run-server-provenance';
export * from './run-spec';
export * from './run-stage';
export * from './run-status';
export * from './run-status-archived';
export * from './run-status-blocked';
export * from './run-status-dead';
export * from './run-status-failed';
export * from './run-status-paused';
export * from './run-status-queued';
export * from './run-status-removing';
export * from './run-status-response';
export * from './run-status-running';
export * from './run-status-starting';
export * from './run-status-submitted';
export * from './run-status-succeeded';
export * from './run-summary';
export * from './run-superseded-by-props';
export * from './run-timestamps';
export * from './run-timings';
export * from './sandbox-details';
export * from './sandbox-file-entry';
@ -324,7 +329,6 @@ export * from './system-info-response';
export * from './system-repair-run-issue';
export * from './system-repair-runs-response';
export * from './system-run-counts';
export * from './terminal-status';
export * from './timeline-entry-response';
export * from './tls-mode';
export * from './update-run-request';
@ -337,6 +341,7 @@ export * from './workflow-diagnostic';
export * from './workflow-last-run-summary';
export * from './workflow-list-item';
export * from './workflow-namespace';
export * from './workflow-ref';
export * from './workflow-reference';
export * from './workflow-schedule-summary';
export * from './workflow-settings';

View file

@ -21,14 +21,14 @@ import type { BoardColumnDefinition } from './board-column-definition';
import type { PaginationMeta } from './pagination-meta';
// May contain unused imports in some cases
// @ts-ignore
import type { RunListItem } from './run-list-item';
import type { Run } from './run';
/**
* Paginated list of board runs with shared canonical fields plus board metadata.
*/
export interface PaginatedBoardRunList {
'columns': Array<BoardColumnDefinition>;
'data': Array<RunListItem>;
'data': Array<Run>;
'meta': PaginationMeta;
}

View file

@ -18,13 +18,13 @@
import type { PaginationMeta } from './pagination-meta';
// May contain unused imports in some cases
// @ts-ignore
import type { RunSummary } from './run-summary';
import type { Run } from './run';
/**
* Paginated list of runs.
*/
export interface PaginatedRunList {
'data': Array<RunSummary>;
'data': Array<Run>;
'meta': PaginationMeta;
}

View file

@ -0,0 +1,21 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface PullRequestDetailsTimestamps {
'created_at': string;
'updated_at': string;
}

View file

@ -15,35 +15,33 @@
// May contain unused imports in some cases
// @ts-ignore
import type { PullRequestRecord } from './pull-request-record';
import type { CheckRun } from './check-run';
// May contain unused imports in some cases
// @ts-ignore
import type { PullRequestRef } from './pull-request-ref';
import type { PullRequest } from './pull-request';
// May contain unused imports in some cases
// @ts-ignore
import type { PullRequestDetailsTimestamps } from './pull-request-details-timestamps';
// May contain unused imports in some cases
// @ts-ignore
import type { PullRequestUser } from './pull-request-user';
/**
* Stored pull request record plus live GitHub fields.
* Stored pull request record plus live GitHub-enriched fields.
*/
export interface PullRequestDetail {
'record': PullRequestRecord;
'number': number;
'title': string;
'body'?: string | null;
export interface PullRequestDetails {
'pull_request': PullRequest;
'state': string;
'draft': boolean;
'merged': boolean;
'merged_at'?: string | null;
'mergeable'?: boolean | null;
'merged_at': string | null;
'mergeable': boolean | null;
'additions': number;
'deletions': number;
'changed_files': number;
'html_url': string;
'user': PullRequestUser;
'head': PullRequestRef;
'base': PullRequestRef;
'created_at': string;
'updated_at': string;
'comments': number;
'checks': Array<CheckRun>;
'author': PullRequestUser;
'timestamps': PullRequestDetailsTimestamps;
}

View file

@ -17,7 +17,8 @@
/**
* Persisted record of a pull request created for a run.
*/
export interface PullRequestRecord {
export interface PullRequest {
'provider': PullRequestProviderEnum;
'html_url': string;
'number': number;
'owner': string;
@ -27,3 +28,10 @@ export interface PullRequestRecord {
'title': string;
}
export const PullRequestProviderEnum = {
GITHUB: 'github'
} as const;
export type PullRequestProviderEnum = typeof PullRequestProviderEnum[keyof typeof PullRequestProviderEnum];

View file

@ -0,0 +1,34 @@
/* 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.
*/
/**
* Durable repository metadata for a run.
*/
export interface RepositoryRef {
'name': string;
'origin_url': string | null;
'provider': RepositoryRefProviderEnum;
}
export const RepositoryRefProviderEnum = {
GITHUB: 'github',
GIT: 'git',
UNKNOWN: 'unknown'
} as const;
export type RepositoryRefProviderEnum = typeof RepositoryRefProviderEnum[keyof typeof RepositoryRefProviderEnum];

View file

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface RunBillingSummary {
'total_usd_micros': number | null;
}

View file

@ -0,0 +1,36 @@
/* 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 { RunControlAction } from './run-control-action';
// May contain unused imports in some cases
// @ts-ignore
import type { RunError } from './run-error';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatus } from './run-status';
export interface RunLifecycle {
'status': RunStatus;
'pending_control': RunControlAction | null;
'queue_position': number | null;
'error': RunError | null;
'archived': boolean;
'archived_at': string | null;
}

View file

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface RunLinks {
'web': string | null;
}

View file

@ -1,78 +0,0 @@
/* 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 { BoardColumn } from './board-column';
// May contain unused imports in some cases
// @ts-ignore
import type { RepositoryReference } from './repository-reference';
// May contain unused imports in some cases
// @ts-ignore
import type { RunControlAction } from './run-control-action';
// May contain unused imports in some cases
// @ts-ignore
import type { RunPullRequest } from './run-pull-request';
// May contain unused imports in some cases
// @ts-ignore
import type { RunQuestion } from './run-question';
// May contain unused imports in some cases
// @ts-ignore
import type { RunSandbox } from './run-sandbox';
// May contain unused imports in some cases
// @ts-ignore
import type { RunStatus } from './run-status';
/**
* Canonical run summary shown in the board view, extended with board-specific metadata.
*/
export interface RunListItem {
/**
* Unique run identifier (ULID).
*/
'run_id': string;
'workflow_name'?: string | null;
'workflow_slug'?: string | null;
'goal': string;
'repository': RepositoryReference;
/**
* Non-blank resolved run title.
*/
'title': string;
'status': RunStatus;
'labels': { [key: string]: string; };
'source_directory'?: string | null;
'repo_origin_url'?: string | null;
'start_time'?: string | null;
'pending_control'?: RunControlAction | null;
'duration_ms'?: number | null;
'elapsed_secs'?: number | null;
'total_usd_micros'?: number | null;
'column': BoardColumn;
'pull_request'?: RunPullRequest;
'sandbox'?: RunSandbox;
'question'?: RunQuestion;
/**
* Timestamp when the run was created.
*/
'created_at': string;
/**
* Timestamp of the most recent event applied to the run\'s projection. Null until the first event is recorded.
*/
'last_event_at'?: string | null;
}

View file

@ -14,13 +14,8 @@
/**
* Reference to a repository by name.
*/
export interface RepositoryReference {
/**
* Repository name.
*/
export interface RunModel {
'provider': string | null;
'name': string;
}

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.
*/
export interface RunOrigin {
'kind': RunOriginKindEnum;
}
export const RunOriginKindEnum = {
API: 'api'
} as const;
export type RunOriginKindEnum = typeof RunOriginKindEnum[keyof typeof RunOriginKindEnum];

View file

@ -50,8 +50,13 @@ export interface RunProjection {
*/
'title'?: string;
'spec': RunSpec;
/**
* Absolute web UI URL for this run when server web settings are configured.
*/
'web_url'?: string | null;
'start'?: StartRecord | null;
'status': RunStatus;
'archived_at'?: string | null;
'status_updated_at': string;
'last_event_at': string;
'pending_control'?: RunControlAction | null;

View file

@ -1,49 +0,0 @@
/* 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 { CheckRun } from './check-run';
/**
* Pull request information for a run.
*/
export interface RunPullRequest {
/**
* Pull request number.
*/
'number': number;
/**
* Stored GitHub pull request URL.
*/
'html_url'?: string;
/**
* Lines added.
*/
'additions'?: number;
/**
* Lines deleted.
*/
'deletions'?: number;
/**
* Number of review comments.
*/
'comments'?: number;
/**
* CI check run results.
*/
'checks'?: Array<CheckRun>;
}

View file

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface RunSandboxRuntime {
'id': string;
'working_directory': string;
'repo_cloned': boolean | null;
'clone_origin_url': string | null;
'clone_branch': string | null;
}

View file

@ -15,37 +15,19 @@
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxProvider } from './sandbox-provider';
import type { RunSandboxRuntime } from './run-sandbox-runtime';
// May contain unused imports in some cases
// @ts-ignore
import type { SandboxResources } from './sandbox-resources';
import type { SandboxProvider } from './sandbox-provider';
/**
* Canonical sandbox environment record for a run.
*/
export interface RunSandbox {
'provider': SandboxProvider;
/**
* Fabro sandbox control identifier used for reconnect, terminal, and delete operations.
*/
'id': string;
/**
* Path where the run executed inside the sandbox.
*/
'working_directory': string;
/**
* Whether the provider cloned the repository into the sandbox, or null while unknown.
*/
'repo_cloned'?: boolean | null;
/**
* Repository origin cloned into the sandbox when available.
*/
'clone_origin_url'?: string | null;
/**
* Repository branch cloned into the sandbox when available.
*/
'clone_branch'?: string | null;
'resources'?: SandboxResources | null;
'image': string | null;
'snapshot': string | null;
'runtime': RunSandboxRuntime | null;
}

View file

@ -1,31 +0,0 @@
/* 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 { TerminalStatus } from './terminal-status';
export interface RunStatusArchived {
'kind': RunStatusArchivedKindEnum;
'prior': TerminalStatus;
}
export const RunStatusArchivedKindEnum = {
ARCHIVED: 'archived'
} as const;
export type RunStatusArchivedKindEnum = typeof RunStatusArchivedKindEnum[keyof typeof RunStatusArchivedKindEnum];

Some files were not shown because too many files have changed in this diff Show more