From cccb557281068c22475bc6de824703b7e4ff1a57 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 10 May 2026 20:48:55 -0400 Subject: [PATCH] feat(api): unify public run shape Return canonical Run payloads across run list, board, create, and lifecycle endpoints. Move archive state out of RunStatus and into lifecycle metadata, split sandbox runtime from planned sandbox data, and separate static pull request records from live pull request details. Regenerate the TypeScript API client and migrate web, CLI, server, store, workflow, and API tests to the new contract. --- apps/fabro-web/app/data/runs.test.ts | 154 ++-- apps/fabro-web/app/data/runs.ts | 74 +- apps/fabro-web/app/lib/mutations.ts | 9 +- apps/fabro-web/app/lib/queries.ts | 4 +- apps/fabro-web/app/lib/run-actions.test.ts | 86 ++- apps/fabro-web/app/lib/run-actions.ts | 15 +- apps/fabro-web/app/routes/automation-runs.tsx | 4 +- apps/fabro-web/app/routes/run-detail.test.ts | 76 +- apps/fabro-web/app/routes/run-detail.tsx | 6 +- apps/fabro-web/app/routes/run-events.tsx | 5 +- .../app/routes/run-files.render.test.tsx | 39 +- apps/fabro-web/app/routes/run-files.tsx | 2 +- apps/fabro-web/app/routes/run-logs.tsx | 2 +- apps/fabro-web/app/routes/run-overview.tsx | 14 +- .../fabro-web/app/routes/run-sandbox.test.tsx | 129 ++-- apps/fabro-web/app/routes/run-sandbox.tsx | 17 +- apps/fabro-web/app/routes/run-stages.tsx | 4 +- apps/fabro-web/app/routes/runs.test.tsx | 57 +- apps/fabro-web/app/routes/runs.tsx | 7 +- .../2026-05-10-unified-run-type-shape-plan.md | 258 +++++++ docs/public/api-reference/fabro-api.yaml | 547 +++++++-------- lib/crates/fabro-api/build.rs | 14 +- lib/crates/fabro-api/src/lib.rs | 67 +- .../tests/pull_request_round_trip.rs | 49 +- .../tests/run_projection_round_trip.rs | 12 +- .../fabro-api/tests/run_sandbox_round_trip.rs | 42 +- .../fabro-api/tests/run_summary_round_trip.rs | 163 +++-- .../tests/sandbox_details_round_trip.rs | 75 +- .../fabro-api/tests/status_round_trip.rs | 47 +- lib/crates/fabro-cli/src/commands/pr/view.rs | 36 +- lib/crates/fabro-cli/src/commands/run/wait.rs | 3 +- .../fabro-cli/src/commands/runs/list.rs | 2 +- lib/crates/fabro-cli/src/shared/utilities.rs | 1 - lib/crates/fabro-cli/tests/it/cmd/archive.rs | 5 +- lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 4 +- .../fabro-cli/tests/it/cmd/pr_create.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/pr_view.rs | 22 +- lib/crates/fabro-cli/tests/it/cmd/rewind.rs | 10 +- .../fabro-cli/tests/it/cmd/sandbox_cp.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/support.rs | 28 +- .../fabro-cli/tests/it/scenario/archive.rs | 5 +- .../fabro-cli/tests/it/scenario/lifecycle.rs | 7 +- lib/crates/fabro-client/src/client.rs | 2 +- lib/crates/fabro-dump/src/lib.rs | 17 +- lib/crates/fabro-sandbox/src/details.rs | 108 +-- lib/crates/fabro-sandbox/src/reconnect.rs | 26 +- lib/crates/fabro-sandbox/src/sandbox_spec.rs | 70 +- lib/crates/fabro-sandbox/src/terminal.rs | 29 +- lib/crates/fabro-server/src/demo/mod.rs | 163 +---- lib/crates/fabro-server/src/server.rs | 39 +- .../src/server/handler/lifecycle.rs | 213 +----- .../src/server/handler/pull_requests.rs | 18 +- .../fabro-server/src/server/handler/runs.rs | 169 +---- .../src/server/handler/sandbox.rs | 37 +- .../fabro-server/src/server/handler/steer.rs | 3 +- lib/crates/fabro-server/src/server/tests.rs | 238 ++++--- .../fabro-server/tests/it/api/sandbox_vnc.rs | 2 +- lib/crates/fabro-server/tests/it/helpers.rs | 4 +- .../fabro-server/tests/it/scenario/archive.rs | 9 +- .../tests/it/scenario/lifecycle.rs | 10 +- lib/crates/fabro-store/src/run_state.rs | 134 +++- .../tests/serializable_projection.rs | 31 +- lib/crates/fabro-types/src/lib.rs | 16 +- lib/crates/fabro-types/src/pull_request.rs | 49 +- lib/crates/fabro-types/src/repository.rs | 15 +- lib/crates/fabro-types/src/run_projection.rs | 10 + lib/crates/fabro-types/src/run_sandbox.rs | 16 +- lib/crates/fabro-types/src/run_summary.rs | 657 ++++++++++++------ lib/crates/fabro-types/src/sandbox_details.rs | 94 ++- lib/crates/fabro-types/src/status.rs | 109 +-- .../fabro-types/tests/sandbox_model_serde.rs | 69 +- .../fabro-workflow/src/operations/archive.rs | 56 +- .../fabro-workflow/src/operations/resume.rs | 2 +- .../fabro-workflow/src/operations/rewind.rs | 2 +- .../fabro-workflow/src/pipeline/initialize.rs | 14 +- .../src/pipeline/pull_request.rs | 1 + lib/crates/fabro-workflow/src/run_lookup.rs | 37 +- .../fabro-workflow/tests/it/cp_integration.rs | 36 +- .../tests/it/daytona_integration.rs | 17 +- .../src/.openapi-generator/FILES | 23 +- .../fabro-api-client/src/api/runs-api.ts | 58 +- .../src/models/automation-ref.ts | 21 + .../fabro-api-client/src/models/index.ts | 23 +- .../src/models/paginated-board-run-list.ts | 4 +- .../src/models/paginated-run-list.ts | 4 +- .../models/pull-request-details-timestamps.ts | 21 + ...uest-detail.ts => pull-request-details.ts} | 30 +- ...pull-request-record.ts => pull-request.ts} | 10 +- .../src/models/repository-ref.ts | 34 + .../src/models/run-billing-summary.ts | 20 + .../src/models/run-lifecycle.ts | 36 + .../fabro-api-client/src/models/run-links.ts | 20 + .../src/models/run-list-item.ts | 78 --- .../{repository-reference.ts => run-model.ts} | 9 +- .../fabro-api-client/src/models/run-origin.ts | 27 + .../src/models/run-projection.ts | 5 + .../src/models/run-pull-request.ts | 49 -- .../src/models/run-sandbox-runtime.ts | 24 + .../src/models/run-sandbox.ts | 28 +- .../src/models/run-status-archived.ts | 31 - .../src/models/run-status-response.ts | 56 -- .../fabro-api-client/src/models/run-status.ts | 10 +- .../src/models/run-summary.ts | 62 -- .../src/models/run-timestamps.ts | 25 + .../fabro-api-client/src/models/run.ts | 84 +++ .../src/models/sandbox-details.ts | 20 +- .../src/models/terminal-status.ts | 35 - .../src/models/workflow-ref.ts | 21 + 108 files changed, 2922 insertions(+), 2572 deletions(-) create mode 100644 docs/plans/2026-05-10-unified-run-type-shape-plan.md create mode 100644 lib/packages/fabro-api-client/src/models/automation-ref.ts create mode 100644 lib/packages/fabro-api-client/src/models/pull-request-details-timestamps.ts rename lib/packages/fabro-api-client/src/models/{pull-request-detail.ts => pull-request-details.ts} (56%) rename lib/packages/fabro-api-client/src/models/{pull-request-record.ts => pull-request.ts} (69%) create mode 100644 lib/packages/fabro-api-client/src/models/repository-ref.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-billing-summary.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-lifecycle.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-links.ts delete mode 100644 lib/packages/fabro-api-client/src/models/run-list-item.ts rename lib/packages/fabro-api-client/src/models/{repository-reference.ts => run-model.ts} (74%) create mode 100644 lib/packages/fabro-api-client/src/models/run-origin.ts delete mode 100644 lib/packages/fabro-api-client/src/models/run-pull-request.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-sandbox-runtime.ts delete mode 100644 lib/packages/fabro-api-client/src/models/run-status-archived.ts delete mode 100644 lib/packages/fabro-api-client/src/models/run-status-response.ts delete mode 100644 lib/packages/fabro-api-client/src/models/run-summary.ts create mode 100644 lib/packages/fabro-api-client/src/models/run-timestamps.ts create mode 100644 lib/packages/fabro-api-client/src/models/run.ts delete mode 100644 lib/packages/fabro-api-client/src/models/terminal-status.ts create mode 100644 lib/packages/fabro-api-client/src/models/workflow-ref.ts 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; +} +