diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 04aabfee2..dfe10d055 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -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 { + 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 { + 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"); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index 9ceaf2bf5..8c843de61 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -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; } diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index a5a2c2a4c..8702cced5 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -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, + action: (id: string) => Promise, ) { 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 => { + async (_key, { arg }: { arg: UpdateRunRequest }): Promise => { if (!id) throw new Error("id is required"); return apiData(() => runsApi.updateRun(id, arg)); }, diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index ae279fe04..90f8307bc 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -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( + return useSWR( id ? queryKeys.runs.detail(id) : null, () => apiNullableData(() => runsApi.retrieveRun(id!)), ); diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index 36be372ed..8a8fb9d9d 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -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); }); }); diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts index 17be46157..ae3126d3a 100644 --- a/apps/fabro-web/app/lib/run-actions.ts +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -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([ "dead", ]); -export async function cancelRun(id: string, request?: Request): Promise { +export async function cancelRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "cancel", request); } -export async function archiveRun(id: string, request?: Request): Promise { +export async function archiveRun(id: string, request?: Request): Promise { return runLifecycleAction(id, "archive", request); } -export async function unarchiveRun(id: string, request?: Request): Promise { +export async function unarchiveRun(id: string, request?: Request): Promise { 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 { +): Promise { try { switch (action) { case "cancel": diff --git a/apps/fabro-web/app/routes/automation-runs.tsx b/apps/fabro-web/app/routes/automation-runs.tsx index eadc8b55f..75cb6e3cd 100644 --- a/apps/fabro-web/app/routes/automation-runs.tsx +++ b/apps/fabro-web/app/routes/automation-runs.tsx @@ -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), diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index 8b6ab8058..028e9a844 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -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", diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 2c0e3ebd4..b8d694843 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -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) => diff --git a/apps/fabro-web/app/routes/run-events.tsx b/apps/fabro-web/app/routes/run-events.tsx index 16ced72c7..2d12128a4 100644 --- a/apps/fabro-web/app/routes/run-events.tsx +++ b/apps/fabro-web/app/routes/run-events.tsx @@ -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 + } /> ); diff --git a/apps/fabro-web/app/routes/run-files.render.test.tsx b/apps/fabro-web/app/routes/run-files.render.test.tsx index d31c20e2f..2963a0e7a 100644 --- a/apps/fabro-web/app/routes/run-files.render.test.tsx +++ b/apps/fabro-web/app/routes/run-files.render.test.tsx @@ -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: () => ({ diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx index 03f3b647f..9a378388f 100644 --- a/apps/fabro-web/app/routes/run-files.tsx +++ b/apps/fabro-web/app/routes/run-files.tsx @@ -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. diff --git a/apps/fabro-web/app/routes/run-logs.tsx b/apps/fabro-web/app/routes/run-logs.tsx index c00eb4383..09c42c30b 100644 --- a/apps/fabro-web/app/routes/run-logs.tsx +++ b/apps/fabro-web/app/routes/run-logs.tsx @@ -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), diff --git a/apps/fabro-web/app/routes/run-overview.tsx b/apps/fabro-web/app/routes/run-overview.tsx index 48066eae0..db6a4a78f 100644 --- a/apps/fabro-web/app/routes/run-overview.tsx +++ b/apps/fabro-web/app/routes/run-overview.tsx @@ -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(null); const innerRef = useRef(null); const svgRef = useRef(null); diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index 0621d1db5..cde374135 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -86,6 +86,39 @@ mock.restore(); const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; +function sandboxDetails( + overrides: Partial & { + sandbox?: Partial & { + runtime?: Partial>; + }; + } = {}, +): 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 diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index d4c990d69..f660bf65d 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -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 (
- {details.provider} + {provider} @@ -142,15 +143,17 @@ function StatusStrip({ details }: { details: SandboxDetails }) { } function OverviewPanel({ details }: { details: SandboxDetails }) { + const sandbox = details.sandbox; + const runtime = sandbox.runtime; return ( - - + + - + ); } @@ -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 ( ); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index c657a3fa0..91ebc3210 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -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( () => diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx index 9df090370..54722ae0d 100644 --- a/apps/fabro-web/app/routes/runs.test.tsx +++ b/apps/fabro-web/app/routes/runs.test.tsx @@ -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 }, }; } diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index 452796320..c1cc68598 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -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)); } } diff --git a/docs/plans/2026-05-10-unified-run-type-shape-plan.md b/docs/plans/2026-05-10-unified-run-type-shape-plan.md new file mode 100644 index 000000000..b6e32e08b --- /dev/null +++ b/docs/plans/2026-05-10-unified-run-type-shape-plan.md @@ -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 + 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 + 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>` + - 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`. diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 830822be0..1c766be53 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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: diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index a848f668b..abaf2dadd 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -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", &[]), ( diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 59fe99e45..1be42e068 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -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, + pub queue_position: Option, + pub pending_control: Option, + pub created_at: chrono::DateTime, + pub web_url: Option, + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct RunPullRequest { + pub number: i64, + pub html_url: Option, + pub additions: Option, + pub deletions: Option, + pub comments: Option, + pub checks: Vec, + } + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct RunListItem { + pub run_id: String, + pub workflow_name: Option, + pub workflow_slug: Option, + pub goal: String, + pub repository: fabro_types::RepositoryRef, + pub title: String, + pub status: fabro_types::RunStatus, + pub labels: HashMap, + pub source_directory: Option, + pub repo_origin_url: Option, + pub start_time: Option>, + pub pending_control: Option, + pub duration_ms: Option, + pub elapsed_secs: Option, + pub total_usd_micros: Option, + pub column: BoardColumn, + pub pull_request: Option, + pub sandbox: Option, + pub question: Option, + pub created_at: chrono::DateTime, + pub last_event_at: Option>, + } } pub use generated::Client as ApiClient; diff --git a/lib/crates/fabro-api/tests/pull_request_round_trip.rs b/lib/crates/fabro-api/tests/pull_request_round_trip.rs index 96d67ac6b..3ed430fb3 100644 --- a/lib/crates/fabro-api/tests/pull_request_round_trip.rs +++ b/lib/crates/fabro-api/tests/pull_request_round_trip.rs @@ -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 = diff --git a/lib/crates/fabro-api/tests/run_projection_round_trip.rs b/lib/crates/fabro-api/tests/run_projection_round_trip.rs index 7c2a651f7..00aeb7ae2 100644 --- a/lib/crates/fabro-api/tests/run_projection_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_projection_round_trip.rs @@ -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, diff --git a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs index 0ab016199..e13dafc17 100644 --- a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs @@ -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::(); assert_same_type::(); - assert_same_type::(); } #[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" } }) ); diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs index c05762bb5..9c25b2407 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -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::(); - assert_same_type::(); + assert_same_type::(); } #[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); } diff --git a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs index 3ea19d5da..59a3025d7 100644 --- a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs +++ b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs @@ -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()); diff --git a/lib/crates/fabro-api/tests/status_round_trip.rs b/lib/crates/fabro-api/tests/status_round_trip.rs index d355560f7..7bc5ba21a 100644 --- a/lib/crates/fabro-api/tests/status_round_trip.rs +++ b/lib/crates/fabro-api/tests/status_round_trip.rs @@ -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::(); assert_same_type::(); assert_same_type::(); - assert_same_type::(); assert_same_type::(); assert_same_type::(); } @@ -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() { assert_eq!( TypeId::of::(), diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index 61f212262..4838b62ff 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -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(()) } diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index 549cd269e..3c9c26feb 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -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); diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index ba718853d..92e2db313 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -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), }; diff --git a/lib/crates/fabro-cli/src/shared/utilities.rs b/lib/crates/fabro-cli/src/shared/utilities.rs index c554dcda4..433886932 100644 --- a/lib/crates/fabro-cli/src/shared/utilities.rs +++ b/lib/crates/fabro-cli/src/shared/utilities.rs @@ -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", } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/archive.rs b/lib/crates/fabro-cli/tests/it/cmd/archive.rs index c392677ce..9bba641a1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/archive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/archive.rs @@ -96,9 +96,8 @@ fn archive_succeeded_run_hides_it_from_default_ps() { assert!(output.status.success()); let runs: Vec = 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); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 95b7b700c..0633cdb59 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -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" + } } ] "#); diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs index e5773b31d..8c3cfc784 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs index f1e475fc8..ee88bf2df 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs @@ -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 ----- "); diff --git a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs index 15e2ed6ce..f4dc51b3f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs @@ -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()); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs index 174b01375..5a13fe29a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs @@ -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 "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 37a6eddc7..ec582164d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -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(); diff --git a/lib/crates/fabro-cli/tests/it/scenario/archive.rs b/lib/crates/fabro-cli/tests/it/scenario/archive.rs index 8c7b4d4f3..cb336c940 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/archive.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/archive.rs @@ -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() diff --git a/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs index 59a3cbe1a..0eadec381 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/lifecycle.rs @@ -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(), }), diff --git a/lib/crates/fabro-client/src/client.rs b/lib/crates/fabro-client/src/client.rs index 0ce392396..97627466b 100644 --- a/lib/crates/fabro-client/src/client.rs +++ b/lib/crates/fabro-client/src/client.rs @@ -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> { diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs index 6579ffe4f..06505dca1 100644 --- a/lib/crates/fabro-dump/src/lib.rs +++ b/lib/crates/fabro-dump/src/lib.rs @@ -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)); diff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/crates/fabro-sandbox/src/details.rs index ee350d5c5..be4d1cb28 100644 --- a/lib/crates/fabro-sandbox/src/details.rs +++ b/lib/crates/fabro-sandbox/src/details.rs @@ -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 { 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::) + .inspect_container(&runtime.id, None::) .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, ) -> Result { - 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()); diff --git a/lib/crates/fabro-sandbox/src/reconnect.rs b/lib/crates/fabro-sandbox/src/reconnect.rs index 2aaca705a..21c10a7d1 100644 --- a/lib/crates/fabro-sandbox/src/reconnect.rs +++ b/lib/crates/fabro-sandbox/src/reconnect.rs @@ -52,9 +52,13 @@ pub async fn reconnect_for_run_with_callback( run_id: Option, event_callback: Option, ) -> Result> { + 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"), } } diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs index 1b96dd343..ca321755e 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs @@ -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, + }), }, } } diff --git a/lib/crates/fabro-sandbox/src/terminal.rs b/lib/crates/fabro-sandbox/src/terminal.rs index 4ee9bbb80..2c4ffc2e0 100644 --- a/lib/crates/fabro-sandbox/src/terminal.rs +++ b/lib/crates/fabro-sandbox/src/terminal.rs @@ -41,6 +41,11 @@ pub async fn open_terminal_for_run( run_id: Option, size: TerminalSize, ) -> crate::Result> { + #[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", )), diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 4559f83ac..3b197d14a 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -62,7 +62,7 @@ pub(crate) async fn list_board_runs( State(_state): State>, Query(pagination): Query, ) -> 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, 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, - sandbox: Option, - question: Option, - ) -> 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) -> 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, - ) -> 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 { vec![ BoardColumnDefinition { @@ -1322,81 +1250,6 @@ mod runs { ] } - pub(super) fn board_items() -> Vec { - let mut summaries = summaries() - .into_iter() - .map(|summary| (summary.run_id, summary)) - .collect::>(); - - 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 { vec![ run_stage_from_stage_id( diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 214f76ad3..c9ab3868c 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -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 { 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), diff --git a/lib/crates/fabro-server/src/server/handler/lifecycle.rs b/lib/crates/fabro-server/src/server/handler/lifecycle.rs index cdc4022b7..469f74bc1 100644 --- a/lib/crates/fabro-server/src/server/handler/lifecycle.rs +++ b/lib/crates/fabro-server/src/server/handler/lifecycle.rs @@ -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> { .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>, @@ -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, 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 diff --git a/lib/crates/fabro-server/src/server/handler/pull_requests.rs b/lib/crates/fabro-server/src/server/handler/pull_requests.rs index 0f354965d..ec4e927db 100644 --- a/lib/crates/fabro-server/src/server/handler/pull_requests.rs +++ b/lib/crates/fabro-server/src/server/handler/pull_requests.rs @@ -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 { .. }) => { diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index 07ea2e082..2be84bf3c 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -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 { +fn board_column(status: RunStatus, archived: bool) -> Option { + 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 { 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 serde_json::Map { - 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(items: Vec, pagination: &PaginationParams) -> (Vec, 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, ¶ms.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::>(), "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::>(); let (data, has_more) = paginate_items(items, ¶ms.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 { diff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/crates/fabro-server/src/server/handler/sandbox.rs index b3c8ddd0d..9b9e2f9d2 100644 --- a/lib/crates/fabro-server/src/server/handler/sandbox.rs +++ b/lib/crates/fabro-server/src/server/handler/sandbox.rs @@ -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()); diff --git a/lib/crates/fabro-server/src/server/handler/steer.rs b/lib/crates/fabro-server/src/server/handler/steer.rs index 4ff468e45..6ee20497c 100644 --- a/lib/crates/fabro-server/src/server/handler/steer.rs +++ b/lib/crates/fabro-server/src/server/handler/steer.rs @@ -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 { diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 858fbc1a1..8317f1b56 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -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")); diff --git a/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs b/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs index 7ba45170e..49447361b 100644 --- a/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs +++ b/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs @@ -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; diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 32927c684..6735aa2f6 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -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(); diff --git a/lib/crates/fabro-server/tests/it/scenario/archive.rs b/lib/crates/fabro-server/tests/it/scenario/archive.rs index 462060aac..fb342650e 100644 --- a/lib/crates/fabro-server/tests/it/scenario/archive.rs +++ b/lib/crates/fabro-server/tests/it/scenario/archive.rs @@ -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), diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs index 488095263..d4c5c1583 100644 --- a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs @@ -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)] diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 128efa316..ba03d8ee5 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -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 { 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::() + .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 { + 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::>(); + 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) -> 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] diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index e11c0ccde..1b5f36657 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -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 diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index c978f3696..dcac895f2 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-types/src/pull_request.rs b/lib/crates/fabro-types/src/pull_request.rs index 5aa4d697e..54f8fb00c 100644 --- a/lib/crates/fabro-types/src/pull_request.rs +++ b/lib/crates/fabro-types/src/pull_request.rs @@ -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, + pub mergeable: Option, + pub additions: u64, + pub deletions: u64, + pub changed_files: u64, + pub comments: u64, + pub checks: Vec, + 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, + pub html_url: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CheckRunStatus { + Queued, + InProgress, + Completed, + Unknown, } diff --git a/lib/crates/fabro-types/src/repository.rs b/lib/crates/fabro-types/src/repository.rs index b14bbebd9..526a33d4e 100644 --- a/lib/crates/fabro-types/src/repository.rs +++ b/lib/crates/fabro-types/src/repository.rs @@ -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, + pub provider: RepositoryProvider, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryProvider { + Github, + Git, + Unknown, } diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 91d275bce..9cba67103 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -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, pub start: Option, pub status: RunStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived_at: Option>, pub status_updated_at: DateTime, pub last_event_at: DateTime, pub pending_control: Option, @@ -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) } diff --git a/lib/crates/fabro-types/src/run_sandbox.rs b/lib/crates/fabro-types/src/run_sandbox.rs index 79c8c8395..855a417af 100644 --- a/lib/crates/fabro-types/src/run_sandbox.rs +++ b/lib/crates/fabro-types/src/run_sandbox.rs @@ -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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime: Option, +} + +#[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, #[serde(default, skip_serializing_if = "Option::is_none")] pub clone_branch: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resources: Option, } diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index 1d76967b5..15b037eba 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -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, - #[serde(default)] - pub workflow_slug: Option, - 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, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub created_by: Option, + pub origin: RunOrigin, pub labels: HashMap, + pub lifecycle: RunLifecycle, + #[serde(default)] + pub sandbox: Option, + pub models: Vec, #[serde(default)] pub source_directory: Option, + pub timestamps: RunTimestamps, #[serde(default)] - pub repo_origin_url: Option, - pub repository: RepositoryReference, + pub billing: Option, #[serde(default)] - pub start_time: Option>, - pub created_at: DateTime, + pub diff: Option, #[serde(default)] - pub last_event_at: Option>, - pub status: RunStatus, + pub pull_request: Option, #[serde(default)] - pub pending_control: Option, - #[serde(default)] - pub duration_ms: Option, - #[serde(default)] - pub elapsed_secs: Option, - #[serde(default)] - pub total_usd_micros: Option, + pub current_question: Option, #[serde(default)] pub superseded_by: Option, - #[serde(default)] + pub links: RunLinks, + #[serde(skip, default = "RunId::new")] + pub run_id: RunId, + #[serde(skip)] + pub workflow_name: Option, + #[serde(skip)] + pub workflow_slug: Option, + #[serde(skip)] + pub repo_origin_url: Option, + #[serde(skip)] + pub start_time: Option>, + #[serde(skip, default = "Utc::now")] + pub created_at: DateTime, + #[serde(skip)] + pub last_event_at: Option>, + #[serde(skip, default = "default_run_status")] + pub status: RunStatus, + #[serde(skip)] + pub pending_control: Option, + #[serde(skip)] + pub duration_ms: Option, + #[serde(skip)] + pub elapsed_secs: Option, + #[serde(skip)] + pub total_usd_micros: Option, + #[serde(skip)] pub diff_summary: Option, - #[serde(default)] - pub pull_request: Option, } -impl RunSummary { +#[derive(Debug, Deserialize)] +struct RunWire { + #[serde(default)] + id: Option, + #[serde(default)] + run_id: Option, + #[serde(default)] + title: Option, + #[serde(default)] + goal: Option, + #[serde(default)] + workflow: Option, + #[serde(default)] + workflow_name: Option, + #[serde(default)] + workflow_slug: Option, + #[serde(default)] + automation: Option, + #[serde(default)] + repository: Option, + #[serde(default)] + repo_origin_url: Option, + #[serde(default)] + created_by: Option, + #[serde(default)] + origin: Option, + #[serde(default)] + labels: HashMap, + #[serde(default)] + lifecycle: Option, + #[serde(default)] + status: Option, + #[serde(default)] + pending_control: Option, + #[serde(default)] + archived_at: Option>, + #[serde(default)] + sandbox: Option, + #[serde(default)] + models: Vec, + #[serde(default)] + source_directory: Option, + #[serde(default)] + timestamps: Option, + #[serde(default)] + start_time: Option>, + #[serde(default)] + created_at: Option>, + #[serde(default)] + last_event_at: Option>, + #[serde(default)] + duration_ms: Option, + #[serde(default)] + elapsed_secs: Option, + #[serde(default)] + billing: Option, + #[serde(default)] + total_usd_micros: Option, + #[serde(default)] + diff: Option, + #[serde(default)] + diff_summary: Option, + #[serde(default)] + pull_request: Option, + #[serde(default)] + current_question: Option, + #[serde(default)] + superseded_by: Option, + #[serde(default)] + links: Option, +} + +#[derive(Debug, Deserialize)] +struct RepositoryRefWire { + name: String, + #[serde(default)] + origin_url: Option, + #[serde(default)] + provider: Option, +} + +impl From 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, +) -> Result<(Option, 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(deserializer: D) -> Result + 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, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AutomationRef { + pub id: String, + #[serde(default)] + pub name: Option, +} + +#[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, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunLifecycle { + pub status: RunStatus, + #[serde(default)] + pub pending_control: Option, + #[serde(default)] + pub queue_position: Option, + #[serde(default)] + pub error: Option, + pub archived: bool, + #[serde(default)] + pub archived_at: Option>, +} + +#[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, + #[serde(default)] + pub started_at: Option>, + #[serde(default)] + pub last_event_at: Option>, + #[serde(default)] + pub completed_at: Option>, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub elapsed_secs: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunBillingSummary { + #[serde(default)] + pub total_usd_micros: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunLinks { + #[serde(default)] + pub web: Option, +} + +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, source_directory: Option, repo_origin_url: Option, + created_by: Option, start_time: Option>, last_event_at: Option>, + completed_at: Option>, status: RunStatus, pending_control: Option, duration_ms: Option, total_usd_micros: Option, superseded_by: Option, diff_summary: Option, - pull_request: Option, + pull_request: Option, + archived_at: Option>, + sandbox: Option, + models: Vec, + current_question: Option, + web_url: Option, ) -> 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 { if let Some(path) = origin @@ -147,177 +544,3 @@ fn path_basename(path: &str) -> Option<&str> { fn elapsed_secs(duration_ms: Option) -> Option { 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"); - } -} diff --git a/lib/crates/fabro-types/src/sandbox_details.rs b/lib/crates/fabro-types/src/sandbox_details.rs index 9be1df3e5..64d916f8b 100644 --- a/lib/crates/fabro-types/src/sandbox_details.rs +++ b/lib/crates/fabro-types/src/sandbox_details.rs @@ -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, + pub native_state: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image: Option, - pub resources: SandboxResources, + pub region: Option, + pub resources: SandboxResources, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub labels: BTreeMap, - pub timestamps: SandboxTimestamps, + pub labels: BTreeMap, + 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()); diff --git a/lib/crates/fabro-types/src/status.rs b/lib/crates/fabro-types/src/status.rs index f6ee5f233..d59f89b65 100644 --- a/lib/crates/fabro-types/src/status.rs +++ b/lib/crates/fabro-types/src/status.rs @@ -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 }); } diff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/crates/fabro-types/tests/sandbox_model_serde.rs index acc3dc93f..4b0c87e83 100644 --- a/lib/crates/fabro-types/tests/sandbox_model_serde.rs +++ b/lib/crates/fabro-types/tests/sandbox_model_serde.rs @@ -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()); } diff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/crates/fabro-workflow/src/operations/archive.rs index 3ceb7cff0..56c3394b7 100644 --- a/lib/crates/fabro-workflow/src/operations/archive.rs +++ b/lib/crates/fabro-workflow/src/operations/archive.rs @@ -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, 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, 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); } } diff --git a/lib/crates/fabro-workflow/src/operations/resume.rs b/lib/crates/fabro-workflow/src/operations/resume.rs index ccae730b1..beaad87a9 100644 --- a/lib/crates/fabro-workflow/src/operations/resume.rs +++ b/lib/crates/fabro-workflow/src/operations/resume.rs @@ -15,7 +15,7 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result 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 { @@ -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 { self.summary .as_ref() - .and_then(|summary| summary.duration_ms) + .and_then(|summary| summary.timestamps.duration_ms) } pub fn total_cost(&self) -> Option { 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 { 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 { - 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 { diff --git a/lib/crates/fabro-workflow/tests/it/cp_integration.rs b/lib/crates/fabro-workflow/tests/it/cp_integration.rs index bb0000317..3aba03426 100644 --- a/lib/crates/fabro-workflow/tests/it/cp_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/cp_integration.rs @@ -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, + }), } } diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index c8d506c28..f02c2d0b6 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -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 diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 049f049f7..74a7a8594 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index 6580b7ad8..634b53a9d 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -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> { + async archiveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async cancelRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async getRunPullRequest(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async pauseRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async resolveRun(selector: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async retrieveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async unarchiveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async unpauseRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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> { + async updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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 { + archiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + cancelRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + createRun(runManifest: RunManifest, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + createRunPullRequest(id: string, createRunPullRequestRequest: CreateRunPullRequestRequest, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + getRunPullRequest(id: string, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + pauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + resolveRun(selector: string, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + retrieveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + unarchiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + unpauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise { 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 { + updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.updateRun(id, updateRunRequest, options).then((request) => request(axios, basePath)); }, /** diff --git a/lib/packages/fabro-api-client/src/models/automation-ref.ts b/lib/packages/fabro-api-client/src/models/automation-ref.ts new file mode 100644 index 000000000..f965a18d0 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/automation-ref.ts @@ -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; +} + diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 2e7a9081f..be6ada97e 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts b/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts index 42335c819..3fb53d459 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-board-run-list.ts @@ -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; - 'data': Array; + 'data': Array; 'meta': PaginationMeta; } diff --git a/lib/packages/fabro-api-client/src/models/paginated-run-list.ts b/lib/packages/fabro-api-client/src/models/paginated-run-list.ts index 98ed81653..d36007fa3 100644 --- a/lib/packages/fabro-api-client/src/models/paginated-run-list.ts +++ b/lib/packages/fabro-api-client/src/models/paginated-run-list.ts @@ -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; + 'data': Array; 'meta': PaginationMeta; } diff --git a/lib/packages/fabro-api-client/src/models/pull-request-details-timestamps.ts b/lib/packages/fabro-api-client/src/models/pull-request-details-timestamps.ts new file mode 100644 index 000000000..5ca3e0a71 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/pull-request-details-timestamps.ts @@ -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; +} + diff --git a/lib/packages/fabro-api-client/src/models/pull-request-detail.ts b/lib/packages/fabro-api-client/src/models/pull-request-details.ts similarity index 56% rename from lib/packages/fabro-api-client/src/models/pull-request-detail.ts rename to lib/packages/fabro-api-client/src/models/pull-request-details.ts index 1c2a24f97..104b9a94f 100644 --- a/lib/packages/fabro-api-client/src/models/pull-request-detail.ts +++ b/lib/packages/fabro-api-client/src/models/pull-request-details.ts @@ -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; + 'author': PullRequestUser; + 'timestamps': PullRequestDetailsTimestamps; } diff --git a/lib/packages/fabro-api-client/src/models/pull-request-record.ts b/lib/packages/fabro-api-client/src/models/pull-request.ts similarity index 69% rename from lib/packages/fabro-api-client/src/models/pull-request-record.ts rename to lib/packages/fabro-api-client/src/models/pull-request.ts index ee72547e3..109ac4aac 100644 --- a/lib/packages/fabro-api-client/src/models/pull-request-record.ts +++ b/lib/packages/fabro-api-client/src/models/pull-request.ts @@ -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]; + + diff --git a/lib/packages/fabro-api-client/src/models/repository-ref.ts b/lib/packages/fabro-api-client/src/models/repository-ref.ts new file mode 100644 index 000000000..492078034 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/repository-ref.ts @@ -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]; + + diff --git a/lib/packages/fabro-api-client/src/models/run-billing-summary.ts b/lib/packages/fabro-api-client/src/models/run-billing-summary.ts new file mode 100644 index 000000000..41221d873 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-billing-summary.ts @@ -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; +} + diff --git a/lib/packages/fabro-api-client/src/models/run-lifecycle.ts b/lib/packages/fabro-api-client/src/models/run-lifecycle.ts new file mode 100644 index 000000000..aafe4629b --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-lifecycle.ts @@ -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; +} + + + diff --git a/lib/packages/fabro-api-client/src/models/run-links.ts b/lib/packages/fabro-api-client/src/models/run-links.ts new file mode 100644 index 000000000..0789c6c03 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-links.ts @@ -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; +} + diff --git a/lib/packages/fabro-api-client/src/models/run-list-item.ts b/lib/packages/fabro-api-client/src/models/run-list-item.ts deleted file mode 100644 index cb7a3222a..000000000 --- a/lib/packages/fabro-api-client/src/models/run-list-item.ts +++ /dev/null @@ -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; -} - - - diff --git a/lib/packages/fabro-api-client/src/models/repository-reference.ts b/lib/packages/fabro-api-client/src/models/run-model.ts similarity index 74% rename from lib/packages/fabro-api-client/src/models/repository-reference.ts rename to lib/packages/fabro-api-client/src/models/run-model.ts index d25275a0b..d2f9b0c72 100644 --- a/lib/packages/fabro-api-client/src/models/repository-reference.ts +++ b/lib/packages/fabro-api-client/src/models/run-model.ts @@ -14,13 +14,8 @@ -/** - * Reference to a repository by name. - */ -export interface RepositoryReference { - /** - * Repository name. - */ +export interface RunModel { + 'provider': string | null; 'name': string; } diff --git a/lib/packages/fabro-api-client/src/models/run-origin.ts b/lib/packages/fabro-api-client/src/models/run-origin.ts new file mode 100644 index 000000000..b323ebe0b --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-origin.ts @@ -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]; + + diff --git a/lib/packages/fabro-api-client/src/models/run-projection.ts b/lib/packages/fabro-api-client/src/models/run-projection.ts index 2d0b42249..624251dfb 100644 --- a/lib/packages/fabro-api-client/src/models/run-projection.ts +++ b/lib/packages/fabro-api-client/src/models/run-projection.ts @@ -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; diff --git a/lib/packages/fabro-api-client/src/models/run-pull-request.ts b/lib/packages/fabro-api-client/src/models/run-pull-request.ts deleted file mode 100644 index 84aa0d7cb..000000000 --- a/lib/packages/fabro-api-client/src/models/run-pull-request.ts +++ /dev/null @@ -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; -} - diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-runtime.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-runtime.ts new file mode 100644 index 000000000..3d2e6fa1b --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-sandbox-runtime.ts @@ -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; +} + diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox.ts b/lib/packages/fabro-api-client/src/models/run-sandbox.ts index 0da84460d..e7a9e0115 100644 --- a/lib/packages/fabro-api-client/src/models/run-sandbox.ts +++ b/lib/packages/fabro-api-client/src/models/run-sandbox.ts @@ -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; } diff --git a/lib/packages/fabro-api-client/src/models/run-status-archived.ts b/lib/packages/fabro-api-client/src/models/run-status-archived.ts deleted file mode 100644 index 7b727cc10..000000000 --- a/lib/packages/fabro-api-client/src/models/run-status-archived.ts +++ /dev/null @@ -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]; - - diff --git a/lib/packages/fabro-api-client/src/models/run-status-response.ts b/lib/packages/fabro-api-client/src/models/run-status-response.ts deleted file mode 100644 index ee6ff3884..000000000 --- a/lib/packages/fabro-api-client/src/models/run-status-response.ts +++ /dev/null @@ -1,56 +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 { 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'; - -/** - * Current status of a run with optional error and queue position. - */ -export interface RunStatusResponse { - /** - * Unique run identifier (ULID). - */ - 'id': string; - /** - * Non-blank resolved run title. - */ - 'title': string; - 'status': RunStatus; - 'error'?: RunError; - /** - * Position in the queue (1-based). Only present when the status kind is `queued`. - */ - 'queue_position'?: number; - 'pending_control'?: RunControlAction | null; - /** - * Timestamp when the run was created. - */ - 'created_at': string; - /** - * 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. - */ - 'web_url'?: string; -} - - - diff --git a/lib/packages/fabro-api-client/src/models/run-status.ts b/lib/packages/fabro-api-client/src/models/run-status.ts index e56bae134..153f875c1 100644 --- a/lib/packages/fabro-api-client/src/models/run-status.ts +++ b/lib/packages/fabro-api-client/src/models/run-status.ts @@ -21,9 +21,6 @@ import type { BlockedReason } from './blocked-reason'; import type { FailureReason } from './failure-reason'; // May contain unused imports in some cases // @ts-ignore -import type { RunStatusArchived } from './run-status-archived'; -// May contain unused imports in some cases -// @ts-ignore import type { RunStatusBlocked } from './run-status-blocked'; // May contain unused imports in some cases // @ts-ignore @@ -52,14 +49,11 @@ import type { RunStatusSubmitted } from './run-status-submitted'; // May contain unused imports in some cases // @ts-ignore import type { RunStatusSucceeded } from './run-status-succeeded'; -// May contain unused imports in some cases -// @ts-ignore -import type { TerminalStatus } from './terminal-status'; /** * @type RunStatus - * 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. */ -export type RunStatus = { kind: 'archived' } & RunStatusArchived | { kind: 'blocked' } & RunStatusBlocked | { kind: 'dead' } & RunStatusDead | { kind: 'failed' } & RunStatusFailed | { kind: 'paused' } & RunStatusPaused | { kind: 'queued' } & RunStatusQueued | { kind: 'removing' } & RunStatusRemoving | { kind: 'running' } & RunStatusRunning | { kind: 'starting' } & RunStatusStarting | { kind: 'submitted' } & RunStatusSubmitted | { kind: 'succeeded' } & RunStatusSucceeded; +export type RunStatus = { kind: 'blocked' } & RunStatusBlocked | { kind: 'dead' } & RunStatusDead | { kind: 'failed' } & RunStatusFailed | { kind: 'paused' } & RunStatusPaused | { kind: 'queued' } & RunStatusQueued | { kind: 'removing' } & RunStatusRemoving | { kind: 'running' } & RunStatusRunning | { kind: 'starting' } & RunStatusStarting | { kind: 'submitted' } & RunStatusSubmitted | { kind: 'succeeded' } & RunStatusSucceeded; diff --git a/lib/packages/fabro-api-client/src/models/run-summary.ts b/lib/packages/fabro-api-client/src/models/run-summary.ts deleted file mode 100644 index 44b7bbd7f..000000000 --- a/lib/packages/fabro-api-client/src/models/run-summary.ts +++ /dev/null @@ -1,62 +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 { DiffSummary } from './diff-summary'; -// May contain unused imports in some cases -// @ts-ignore -import type { PullRequestRecord } from './pull-request-record'; -// 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 { RunStatus } from './run-status'; - -/** - * Durable run summary derived from the backing store. - */ -export interface RunSummary { - 'run_id': string; - 'workflow_name'?: string | null; - 'workflow_slug'?: string | null; - 'goal': string; - /** - * Non-blank resolved run title. - */ - 'title': string; - 'labels': { [key: string]: string; }; - 'source_directory'?: string | null; - 'repo_origin_url'?: string | null; - 'repository': RepositoryReference; - 'start_time'?: string | null; - 'created_at': string; - 'last_event_at'?: string | null; - 'status': RunStatus; - 'pending_control'?: RunControlAction | null; - 'duration_ms'?: number | null; - 'elapsed_secs'?: number | null; - 'total_usd_micros'?: number | null; - 'superseded_by'?: string | null; - 'diff_summary'?: DiffSummary | null; - 'pull_request'?: PullRequestRecord | null; -} - - - diff --git a/lib/packages/fabro-api-client/src/models/run-timestamps.ts b/lib/packages/fabro-api-client/src/models/run-timestamps.ts new file mode 100644 index 000000000..18ef61885 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-timestamps.ts @@ -0,0 +1,25 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface RunTimestamps { + 'created_at': string; + 'started_at': string | null; + 'last_event_at': string | null; + 'completed_at': string | null; + 'duration_ms'?: number | null; + 'elapsed_secs'?: number | null; +} + diff --git a/lib/packages/fabro-api-client/src/models/run.ts b/lib/packages/fabro-api-client/src/models/run.ts new file mode 100644 index 000000000..b93a79dc1 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run.ts @@ -0,0 +1,84 @@ +/* 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 { AutomationRef } from './automation-ref'; +// May contain unused imports in some cases +// @ts-ignore +import type { DiffSummary } from './diff-summary'; +// May contain unused imports in some cases +// @ts-ignore +import type { Principal } from './principal'; +// May contain unused imports in some cases +// @ts-ignore +import type { PullRequest } from './pull-request'; +// May contain unused imports in some cases +// @ts-ignore +import type { RepositoryRef } from './repository-ref'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunBillingSummary } from './run-billing-summary'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunLifecycle } from './run-lifecycle'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunLinks } from './run-links'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunModel } from './run-model'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunOrigin } from './run-origin'; +// 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 { RunTimestamps } from './run-timestamps'; +// May contain unused imports in some cases +// @ts-ignore +import type { WorkflowRef } from './workflow-ref'; + +/** + * Canonical public run shape. + */ +export interface Run { + 'id': string; + 'title': string; + 'goal': string; + 'workflow': WorkflowRef; + 'automation': AutomationRef | null; + 'repository': RepositoryRef | null; + 'created_by': Principal | null; + 'origin': RunOrigin; + 'labels': { [key: string]: string; }; + 'lifecycle': RunLifecycle; + 'sandbox': RunSandbox | null; + 'models': Array; + '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': RunLinks; +} + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-details.ts b/lib/packages/fabro-api-client/src/models/sandbox-details.ts index 646f4b588..c3a917272 100644 --- a/lib/packages/fabro-api-client/src/models/sandbox-details.ts +++ b/lib/packages/fabro-api-client/src/models/sandbox-details.ts @@ -15,7 +15,7 @@ // May contain unused imports in some cases // @ts-ignore -import type { SandboxProvider } from './sandbox-provider'; +import type { RunSandbox } from './run-sandbox'; // May contain unused imports in some cases // @ts-ignore import type { SandboxResources } from './sandbox-resources'; @@ -30,28 +30,16 @@ import type { SandboxTimestamps } from './sandbox-timestamps'; * Provider-neutral details about the sandbox owned by a run. */ export interface SandboxDetails { - '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; + 'sandbox': RunSandbox; 'state': SandboxState; /** * Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`. */ - 'native_state'?: string; + 'native_state'?: string | null; /** * Provider region or target. Null for local-style providers. */ - 'region'?: string; - /** - * Container image or sandbox snapshot identifier. - */ - 'image'?: string; + 'region'?: string | null; 'resources': SandboxResources; /** * Provider-reported labels. diff --git a/lib/packages/fabro-api-client/src/models/terminal-status.ts b/lib/packages/fabro-api-client/src/models/terminal-status.ts deleted file mode 100644 index 8eeabea62..000000000 --- a/lib/packages/fabro-api-client/src/models/terminal-status.ts +++ /dev/null @@ -1,35 +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 { FailureReason } from './failure-reason'; -// May contain unused imports in some cases -// @ts-ignore -import type { RunStatusDead } from './run-status-dead'; -// May contain unused imports in some cases -// @ts-ignore -import type { RunStatusFailed } from './run-status-failed'; -// May contain unused imports in some cases -// @ts-ignore -import type { RunStatusSucceeded } from './run-status-succeeded'; - -/** - * @type TerminalStatus - * Terminal run status captured inside an archived run. - */ -export type TerminalStatus = { kind: 'dead' } & RunStatusDead | { kind: 'failed' } & RunStatusFailed | { kind: 'succeeded' } & RunStatusSucceeded; - - diff --git a/lib/packages/fabro-api-client/src/models/workflow-ref.ts b/lib/packages/fabro-api-client/src/models/workflow-ref.ts new file mode 100644 index 000000000..5eccc32c3 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/workflow-ref.ts @@ -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 WorkflowRef { + 'slug': string | null; + 'name': string; +} +