diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx index f93a3fd8a..52e79cf0f 100644 --- a/apps/fabro-web/app/components/run-summary-panel.test.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx @@ -83,6 +83,58 @@ describe("RunSummaryPanelView", () => { expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe(EMPTY_VALUE); }); + test("renders planned sandbox on a failed run as not created", () => { + const tree = render({ + run: makeRun({ + lifecycle: { status: { kind: "failed", reason: "sandbox_init_failed" } }, + sandbox: { + kind: "planned", + plan: { provider: "docker", image: null, snapshot: null }, + }, + }), + sandboxState: null, + sandboxResources: null, + }); + + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Not created"); + }); + + test("renders sandbox lifecycle state before details are available", () => { + const tree = render({ + run: makeRun({ + sandbox: { + kind: "initializing", + plan: { provider: "docker", image: null, snapshot: null }, + }, + }), + sandboxState: null, + sandboxResources: null, + }); + + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Initializing"); + }); + + test("renders failed sandbox lifecycle error before details are available", () => { + const tree = render({ + run: makeRun({ + sandbox: { + kind: "failed", + plan: { provider: "docker", image: null, snapshot: null }, + failure: { + provider: "docker", + error: "Docker daemon unavailable", + causes: [], + duration_ms: 42, + }, + }, + }), + sandboxState: null, + sandboxResources: null, + }); + + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("Failed"); + }); + test("shows unavailable copy when artifacts count is zero", () => { const tree = render({ run: makeRun(), artifactsCount: 0 }); expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe(EMPTY_VALUE); diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx index 5d9c78ce4..20a08af5b 100644 --- a/apps/fabro-web/app/components/run-summary-panel.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.tsx @@ -13,6 +13,11 @@ import { } from "../lib/format"; import { principalDisplay } from "../lib/principal-display"; import { useRun, useRunArtifacts, useRunSandboxDetails } from "../lib/queries"; +import { + SANDBOX_LIFECYCLE_DISPLAY, + sandboxIsReady, + sandboxLifecycleKind, +} from "../lib/run-sandbox-lifecycle"; import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state"; import { Tooltip } from "./ui"; @@ -83,6 +88,25 @@ function SandboxValue({ ); } +function SandboxLifecycleValue({ + kind, +}: { + kind: keyof typeof SANDBOX_LIFECYCLE_DISPLAY; +}) { + const display = SANDBOX_LIFECYCLE_DISPLAY[kind]; + return ( +
+ + + {display.label} +
+ ); +} + export function RunSummaryPanelView({ run, runLoading, @@ -95,6 +119,7 @@ export function RunSummaryPanelView({ const created = run?.created_by ? principalDisplay(run.created_by) : null; const diff = run?.diff ?? null; const cost = formatUsdMicros(run?.billing?.total_usd_micros); + const sandboxKind = sandboxLifecycleKind(run?.sandbox); return (
@@ -135,6 +160,8 @@ export function RunSummaryPanelView({ ) : sandboxState ? ( + ) : sandboxKind ? ( + ) : ( )} @@ -177,8 +204,11 @@ export function RunSummaryPanelView({ export function RunSummaryPanel({ runId }: { runId: string }) { const runQuery = useRun(runId); - const sandboxQuery = useRunSandboxDetails(runId); + const sandboxQuery = useRunSandboxDetails( + sandboxIsReady(runQuery.data?.sandbox) ? runId : undefined, + ); const artifactsQuery = useRunArtifacts(runId); + const sandboxReady = sandboxIsReady(runQuery.data?.sandbox); return ( diff --git a/apps/fabro-web/app/components/terminal-view-helpers.ts b/apps/fabro-web/app/components/terminal-view-helpers.ts index ce49f8117..7c3023736 100644 --- a/apps/fabro-web/app/components/terminal-view-helpers.ts +++ b/apps/fabro-web/app/components/terminal-view-helpers.ts @@ -1,4 +1,5 @@ import type { RunSandbox } from "@qltysh/fabro-api-client"; +import { sandboxInstance, sandboxRuntime } from "../lib/run-sandbox-lifecycle"; export const TERMINAL_DOCK_CLEARANCE_CLASS = "pb-[calc(0.125rem+var(--fabro-interview-dock-clearance,0px))]"; @@ -40,5 +41,6 @@ export function terminalAccessCommandLabel(provider: string | null): string | nu } export function sandboxStatusDetail(sandbox: RunSandbox | null | undefined): string | null { - return sandbox?.runtime?.id ?? sandbox?.provider ?? null; + const instance = sandboxInstance(sandbox); + return sandboxRuntime(sandbox)?.id ?? instance?.provider ?? null; } diff --git a/apps/fabro-web/app/components/terminal-view.test.ts b/apps/fabro-web/app/components/terminal-view.test.ts index ceff00617..6e154b698 100644 --- a/apps/fabro-web/app/components/terminal-view.test.ts +++ b/apps/fabro-web/app/components/terminal-view.test.ts @@ -78,7 +78,11 @@ describe("terminal view helpers", () => { image: null, snapshot: null, runtime: null, - })).toBe("docker"); + })).toBeNull(); + expect(sandboxStatusDetail({ + kind: "planned", + plan: { provider: "docker" }, + })).toBeNull(); expect(sandboxStatusDetail(null)).toBeNull(); }); }); diff --git a/apps/fabro-web/app/components/terminal-view.tsx b/apps/fabro-web/app/components/terminal-view.tsx index 89fb91352..f4beb4142 100644 --- a/apps/fabro-web/app/components/terminal-view.tsx +++ b/apps/fabro-web/app/components/terminal-view.tsx @@ -15,6 +15,7 @@ import { ErrorState } from "./state"; import { useToast } from "./toast"; import { apiData, humanInTheLoopApi } from "../lib/api-client"; import { useRunState } from "../lib/queries"; +import { sandboxInstance } from "../lib/run-sandbox-lifecycle"; import { buildFullScreenTerminalUrl, sandboxStatusDetail, @@ -107,7 +108,7 @@ export default function TerminalView({ const { push } = useToast(); const stateQuery = useRunState(runId); const sandbox = stateQuery.data?.sandbox ?? null; - const provider = sandbox?.provider ?? null; + const provider = sandboxInstance(sandbox)?.provider ?? null; const sandboxDetail = sandboxStatusDetail(sandbox); const accessCommandLabel = terminalAccessCommandLabel(provider); const [connectionKey, reconnectTerminal] = useReducer((key: number) => key + 1, 0); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index f820b24c1..2b277b6d0 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -6,6 +6,7 @@ import { type RunSize, type RunStatus as ApiRunStatus, } from "@qltysh/fabro-api-client"; +import { sandboxRuntime } from "../lib/run-sandbox-lifecycle"; export type CiStatus = "passing" | "failing" | "pending"; @@ -89,7 +90,7 @@ function runStatusKind(status: ApiRunStatus | null | undefined): RunStatus | nul export function mapRunListItem(item: Run): RunItem { const lifecycleStatus = item.lifecycle.archived ? "archived" : runStatusKind(item.lifecycle.status); - const runtime = item.sandbox?.runtime; + const runtime = sandboxRuntime(item.sandbox); return { id: item.id, repo: displayRepoName(item.repository?.name ?? "unknown"), diff --git a/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts b/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts new file mode 100644 index 000000000..462e39672 --- /dev/null +++ b/apps/fabro-web/app/lib/run-sandbox-lifecycle.ts @@ -0,0 +1,91 @@ +import type { + Run, + RunProjection, + RunSandbox, + RunSandboxInstance, + RunSandboxKind, + RunSandboxRuntime, +} from "@qltysh/fabro-api-client"; + +export type SandboxLifecycleKind = RunSandboxKind; + +export type MaybeSandbox = Run["sandbox"] | RunProjection["sandbox"] | null | undefined; + +export const SANDBOX_LIFECYCLE_DISPLAY: Record< + SandboxLifecycleKind, + { label: string; description: string; dot: string; text: string } +> = { + planned: { + label: "Not created", + description: "The sandbox instance was not created.", + dot: "bg-fg-muted", + text: "text-fg-muted", + }, + initializing: { + label: "Initializing", + description: "The sandbox is being created.", + dot: "bg-amber", + text: "text-amber", + }, + ready: { + label: "Ready", + description: "The sandbox instance is available.", + dot: "bg-teal-500", + text: "text-teal-500", + }, + failed: { + label: "Failed", + description: "Sandbox creation failed.", + dot: "bg-coral", + text: "text-coral", + }, +}; + +export function sandboxLifecycleKind( + sandbox: MaybeSandbox, +): SandboxLifecycleKind | null { + if (!sandbox) return null; + const value = sandbox as RunSandbox & { + provider?: unknown; + runtime?: unknown; + }; + if (value.kind) return value.kind as SandboxLifecycleKind; + return value.runtime ? "ready" : "planned"; +} + +export function sandboxInstance( + sandbox: MaybeSandbox, +): RunSandboxInstance | null { + if (!sandbox) return null; + const value = sandbox as RunSandbox & { + provider?: RunSandboxInstance["provider"]; + image?: string | null; + snapshot?: string | null; + runtime?: RunSandboxRuntime | null; + }; + if (value.instance) return value.instance; + if (value.runtime && value.provider) { + return { + provider: value.provider, + image: value.image ?? null, + snapshot: value.snapshot ?? null, + runtime: value.runtime, + }; + } + return null; +} + +export function sandboxRuntime( + sandbox: MaybeSandbox, +): RunSandboxRuntime | null { + return sandboxInstance(sandbox)?.runtime ?? null; +} + +export function sandboxTabVisible(sandbox: MaybeSandbox): boolean { + const kind = sandboxLifecycleKind(sandbox); + return kind === "initializing" || kind === "ready" || kind === "failed"; +} + +export function sandboxIsReady(sandbox: MaybeSandbox): boolean { + return sandboxLifecycleKind(sandbox) === "ready" && sandboxInstance(sandbox) != null; +} diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index e30d38da8..844321220 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -664,8 +664,79 @@ describe("RunDetail full-height child routes", () => { expect(navigated).toEqual(["/runs/run_retry"]); }); - test("shows the Sandbox tab when the run has a sandbox", async () => { - currentRunState = { sandbox: { provider: "docker", id: "container-1" } }; + test("hides the Sandbox tab for a planned sandbox without an instance", async () => { + currentRunState = { + sandbox: { + kind: "planned", + plan: { provider: "docker", image: null, snapshot: null }, + }, + }; + const renderer = await renderRunDetail({ + initialEntry: "/runs/run_1", + }); + + const sandboxLinks = renderer.root.findAll( + (node) => + node.type === "a" && + node.props.href === "/runs/run_1/sandbox", + ); + expect(sandboxLinks).toHaveLength(0); + }); + + for (const kind of ["initializing", "ready", "failed"] as const) { + test(`shows the Sandbox tab for ${kind} sandbox state`, async () => { + currentRunState = { + sandbox: { + kind, + plan: { provider: "docker", image: null, snapshot: null }, + instance: kind === "ready" + ? { + provider: "docker", + image: null, + snapshot: null, + runtime: { + id: "container-1", + working_directory: "/workspace", + repo_cloned: null, + clone_origin_url: null, + clone_branch: null, + }, + } + : undefined, + failure: kind === "failed" + ? { + provider: "docker", + error: "Docker daemon unavailable", + causes: [], + duration_ms: 42, + } + : undefined, + }, + }; + const renderer = await renderRunDetail({ + initialEntry: "/runs/run_1", + }); + + const sandboxLinks = renderer.root.findAll( + (node) => + node.type === "a" && + node.props.href === "/runs/run_1/sandbox" && + node.children.includes("Sandbox"), + ); + expect(sandboxLinks).toHaveLength(1); + }); + } + + test("shows the Sandbox tab for legacy sandbox state with runtime metadata", async () => { + currentRunState = { + sandbox: { + provider: "docker", + runtime: { + id: "container-1", + working_directory: "/workspace", + }, + }, + }; const renderer = await renderRunDetail({ initialEntry: "/runs/run_1", }); diff --git a/apps/fabro-web/app/routes/run-detail/header.tsx b/apps/fabro-web/app/routes/run-detail/header.tsx index a8293585d..efc68f09b 100644 --- a/apps/fabro-web/app/routes/run-detail/header.tsx +++ b/apps/fabro-web/app/routes/run-detail/header.tsx @@ -36,6 +36,7 @@ import { formatRelativeTime, } from "../../lib/format"; import { useRunPullRequest } from "../../lib/queries"; +import { sandboxRuntime } from "../../lib/run-sandbox-lifecycle"; import { ActionsMenu, type ActionsMenuProps } from "./actions"; import { classNames, type RunDetailRun } from "./model"; @@ -135,7 +136,7 @@ export function RunDetailHeader({ content={ } > diff --git a/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx b/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx index fc047e4b1..cca7630bd 100644 --- a/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx +++ b/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx @@ -1,5 +1,6 @@ import { Link, Outlet, type UIMatch } from "react-router"; +import { sandboxTabVisible, type MaybeSandbox } from "../../lib/run-sandbox-lifecycle"; import { classNames } from "./model"; interface RunDetailTabDefinition { @@ -21,12 +22,10 @@ const allTabs: RunDetailTabDefinition[] = [ export type RunDetailTab = RunDetailTabDefinition; export function runHasSandbox(runState: unknown): boolean { - return !!( - runState && - typeof runState === "object" && - "sandbox" in runState && - (runState as { sandbox?: unknown }).sandbox - ); + if (!runState || typeof runState !== "object" || !("sandbox" in runState)) { + return false; + } + return sandboxTabVisible((runState as { sandbox?: MaybeSandbox }).sandbox); } export function buildRunDetailTabs({ diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index 915330d29..958d0a217 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -6,10 +6,25 @@ import { MemoryRouter, Route, Routes } from "react-router"; import type { SandboxDetails } from "@qltysh/fabro-api-client"; let currentDetails: SandboxDetails | null = null; +let currentRunState: any = null; let currentLoading = false; let currentError: Error | null = null; mock.module("../lib/queries", () => ({ + useRun: () => ({ + data: null, + error: null, + isLoading: false, + isValidating: false, + mutate: mock(() => Promise.resolve(null)), + }), + useRunState: () => ({ + data: currentRunState, + error: null, + isLoading: false, + isValidating: false, + mutate: mock(() => Promise.resolve(currentRunState)), + }), useRunSandboxDetails: () => ({ data: currentDetails, error: currentError, @@ -95,14 +110,15 @@ function sandboxDetails( } = {}, ): SandboxDetails { const sandbox = overrides.sandbox ?? {}; + const { sandbox: _sandboxOverride, ...detailOverrides } = overrides; return { sandbox: { provider: "docker", image: null, snapshot: null, runtime: { - id: null, - working_directory: null, + id: "", + working_directory: "", repo_cloned: null, clone_origin_url: null, clone_branch: null, @@ -117,7 +133,7 @@ function sandboxDetails( network: networkDetails(), labels: {}, timestamps: { created_at: null, last_activity_at: null }, - ...overrides, + ...detailOverrides, }; } @@ -166,6 +182,7 @@ afterEach(() => { act(() => renderer.unmount()); } currentDetails = null; + currentRunState = null; currentLoading = false; currentError = null; }); @@ -352,7 +369,52 @@ describe("RunSandbox route", () => { Array.isArray(node.children) && node.children.includes("No sandbox"), ); - expect(titles).toHaveLength(1); + expect(titles).toHaveLength(2); + }); + + test("renders a planned sandbox as not created without controls", () => { + currentRunState = { + sandbox: { + kind: "planned", + plan: { provider: "docker", image: null, snapshot: null }, + }, + }; + currentDetails = null; + currentError = new Error("Run sandbox was not created."); + const renderer = renderRoute(); + + expect(textContent(renderer)).toContain("Not created"); + const tabs = renderer.root.findAll( + (node) => node.type === "button" && node.props.role === "tab", + ); + expect(tabs).toHaveLength(0); + }); + + test("renders a failed sandbox lifecycle without service or file controls", () => { + currentRunState = { + sandbox: { + kind: "failed", + plan: { provider: "docker", image: null, snapshot: null }, + failure: { + provider: "docker", + error: "Docker daemon unavailable", + causes: ["connection refused"], + duration_ms: 42, + }, + }, + }; + currentDetails = null; + currentError = new Error("Run sandbox was not created."); + const renderer = renderRoute("/runs/run_1/sandbox?mode=services"); + + const copy = textContent(renderer); + expect(copy).toContain("Failed"); + expect(copy).toContain("Docker daemon unavailable"); + expect(copy).toContain("connection refused"); + const tabs = renderer.root.findAll( + (node) => node.type === "button" && node.props.role === "tab", + ); + expect(tabs).toHaveLength(0); }); test("Terminal is the default right-column mode", () => { diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index 09ce2d2c4..d190bd5e0 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -10,9 +10,17 @@ import { formatBytesAsMemory, formatCpuCores, } from "../lib/format"; -import { useRunSandboxDetails } from "../lib/queries"; +import { useRun, useRunSandboxDetails, useRunState } from "../lib/queries"; +import { + SANDBOX_LIFECYCLE_DISPLAY, + sandboxInstance, + sandboxIsReady, + sandboxLifecycleKind, + sandboxRuntime, +} from "../lib/run-sandbox-lifecycle"; import { SANDBOX_STATE_DISPLAY } from "../lib/sandbox-state"; import type { + RunSandbox, SandboxDetails, SandboxNetwork, SandboxResources, @@ -259,9 +267,51 @@ function DetailsColumn({ details }: { details: SandboxDetails | null }) { ); } +function SandboxLifecycleStateView({ + sandbox, + compact = false, +}: { + sandbox: RunSandbox | null | undefined; + compact?: boolean; +}) { + const kind = sandboxLifecycleKind(sandbox); + const failure = sandbox?.failure ?? null; + const display = kind ? SANDBOX_LIFECYCLE_DISPLAY[kind] : null; + const title = display?.label ?? "No sandbox"; + const description = + kind === "planned" + ? "Run sandbox was not created." + : kind === "failed" + ? failure?.error ?? display?.description + : display?.description + ?? "This run has no sandbox or its provider does not expose details."; + + const action = failure?.causes?.length ? ( +
+ {failure.causes.map((cause) => ( +

{cause}

+ ))} +
+ ) : null; + + return ; +} + export default function RunSandbox({ params }: { params: { id: string } }) { - const sandboxQuery = useRunSandboxDetails(params.id); - const provider = sandboxQuery.data?.sandbox.provider ?? null; + const runStateQuery = useRunState(params.id); + const runQuery = useRun(params.id); + const lifecycleSandbox = runStateQuery.data?.sandbox ?? runQuery.data?.sandbox ?? null; + const lifecycleReady = sandboxIsReady(lifecycleSandbox); + const lifecycleSourcesLoading = runStateQuery.isLoading || runQuery.isLoading; + const shouldLoadDetails = + lifecycleReady || (!lifecycleSandbox && !lifecycleSourcesLoading); + const sandboxQuery = useRunSandboxDetails(shouldLoadDetails ? params.id : undefined); + const details = sandboxQuery.data ?? null; + const provider = + details?.sandbox.provider + ?? sandboxInstance(lifecycleSandbox)?.provider + ?? null; + const ready = lifecycleReady || !!details; const [searchParams, setSearchParams] = useSearchParams(); const requestedMode = useMemo( () => normalizeSandboxMode(searchParams.get("mode")), @@ -288,14 +338,14 @@ export default function RunSandbox({ params }: { params: { id: string } }) { }, [setSearchParams]); const modeToggle = useMemo( - () => ( + () => ready ? ( - ), - [mode, provider, setMode], + ) : null, + [mode, provider, ready, setMode], ); // The outer flex spans from the tab bar's bottom border down to the @@ -307,7 +357,9 @@ export default function RunSandbox({ params }: { params: { id: string } }) {
- {(() => { + {!ready ? ( +
+ +
+ ) : (() => { if (mode === "terminal") { return ; } @@ -332,7 +388,10 @@ export default function RunSandbox({ params }: { params: { id: string } }) { return ; } if (mode === "filesystem") { - const rootDirectory = sandboxQuery.data?.sandbox.runtime?.working_directory ?? null; + const rootDirectory = + details?.sandbox?.runtime?.working_directory + ?? sandboxRuntime(lifecycleSandbox)?.working_directory + ?? null; return ( (); + assert_same_type::(); + assert_same_type::(); assert_same_type::(); } #[test] fn run_sandbox_json_matches_openapi_shape() { - let sandbox = RunSandbox { - provider: SandboxProviderKind::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()), - workspace_root: Some("/workspace".to_string()), - repos_root: Some("/repos".to_string()), - primary_repo_path: None, - primary_repo_link: None, - }), - }; + let sandbox = RunSandbox::ready( + RunSandboxPlan { + provider: SandboxProviderKind::Docker, + image: Some("ghcr.io/fabro/sandbox:latest".to_string()), + snapshot: None, + }, + RunSandboxInstance { + provider: SandboxProviderKind::Docker, + image: None, + snapshot: None, + runtime: 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()), + workspace_root: Some("/workspace".to_string()), + repos_root: Some("/repos".to_string()), + primary_repo_path: None, + primary_repo_link: None, + }, + }, + ); let value = serde_json::to_value(&sandbox).unwrap(); assert_eq!( value, json!({ - "provider": "docker", - "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", - "workspace_root": "/workspace", - "repos_root": "/repos" + "kind": "ready", + "plan": { + "provider": "docker", + "image": "ghcr.io/fabro/sandbox:latest" + }, + "instance": { + "provider": "docker", + "runtime": { + "id": "container-abc123", + "working_directory": "/workspace", + "repo_cloned": false, + "clone_origin_url": "https://github.com/fabro-sh/fabro.git", + "clone_branch": "main", + "workspace_root": "/workspace", + "repos_root": "/repos" + } } }) ); 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 a485ac4eb..712f22a7a 100644 --- a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs +++ b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs @@ -10,7 +10,7 @@ use fabro_api::types::{ SandboxState as ApiSandboxState, SandboxTimestamps as ApiSandboxTimestamps, }; use fabro_types::{ - RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, + RunSandboxInstance, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, }; @@ -32,11 +32,11 @@ 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 { - sandbox: RunSandbox { + sandbox: RunSandboxInstance { provider: SandboxProviderKind::Docker, image: Some("ghcr.io/fabro/sandbox:latest".to_string()), snapshot: None, - runtime: Some(RunSandboxRuntime { + runtime: RunSandboxRuntime { id: "container-abc123".to_string(), working_directory: "/workspace".to_string(), repo_cloned: None, @@ -46,7 +46,7 @@ fn sandbox_details_json_matches_openapi_shape() { repos_root: Some("/repos".to_string()), primary_repo_path: Some("/repos/fabro-sh/fabro".to_string()), primary_repo_link: Some("/workspace/fabro".to_string()), - }), + }, }, state: SandboxState::Running, native_state: Some("running".to_string()), @@ -132,20 +132,12 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() { assert_eq!(details.sandbox.provider, SandboxProviderKind::Local); assert_eq!( - details - .sandbox - .runtime - .as_ref() - .map(|runtime| runtime.id.as_str()), - Some("local:01JNQVR7M0EJ5GKAT2SC4ERS1Z") + details.sandbox.runtime.id.as_str(), + "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z" ); assert_eq!( - details - .sandbox - .runtime - .as_ref() - .map(|runtime| runtime.working_directory.as_str()), - Some("/Users/client/project") + details.sandbox.runtime.working_directory.as_str(), + "/Users/client/project" ); assert_eq!(details.state, SandboxState::Unknown); assert!(details.sandbox.image.is_none()); 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 5a13fe29a..e79bffbd9 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 sandbox missing runtime metadata + × Run sandbox was not created. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 9ac2e7673..2cc3e7c1f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -1816,7 +1816,7 @@ pub(crate) fn compact_inspect(output: &Output) -> Value { }), "sandbox": sandbox.as_object().map(|_| { serde_json::json!({ - "provider": sandbox["provider"], + "provider": compact_sandbox_provider(&sandbox), }) }), }) @@ -1879,7 +1879,7 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value { }), "sandbox": sandbox.as_object().map(|_| { serde_json::json!({ - "provider": sandbox["provider"], + "provider": compact_sandbox_provider(&sandbox), "working_directory": "[WORKTREE]", }) }), @@ -1889,6 +1889,16 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value { ) } +fn compact_sandbox_provider(sandbox: &Value) -> Value { + sandbox + .pointer("/instance/provider") + .or_else(|| sandbox.pointer("/plan/provider")) + .or_else(|| sandbox.pointer("/failure/provider")) + .or_else(|| sandbox.get("provider")) + .cloned() + .unwrap_or(Value::Null) +} + fn write_text_file(path: &Path, content: &str) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs index f042cef01..397d55a3f 100644 --- a/lib/crates/fabro-dump/src/lib.rs +++ b/lib/crates/fabro-dump/src/lib.rs @@ -473,9 +473,9 @@ mod tests { use fabro_types::graph::Graph; use fabro_types::run::RunSpec; use fabro_types::{ - Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus, - SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord, - SuccessReason, WorkflowSettings, first_event_seq, fixtures, + Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunSandboxInstance, + RunSandboxPlan, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, + StageOutcome, StartRecord, SuccessReason, WorkflowSettings, first_event_seq, fixtures, }; use futures::executor; @@ -558,22 +558,29 @@ mod tests { total_retries: 0, diff: RunDiff::default(), }); - projection.sandbox = Some(RunSandbox { - provider: SandboxProviderKind::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, - workspace_root: None, - repos_root: None, - primary_repo_path: None, - primary_repo_link: None, - }), - }); + projection.sandbox = Some(RunSandbox::ready( + RunSandboxPlan { + provider: SandboxProviderKind::Local, + image: None, + snapshot: None, + }, + RunSandboxInstance { + provider: SandboxProviderKind::Local, + image: None, + snapshot: None, + runtime: fabro_types::RunSandboxRuntime { + id: "sandbox-1".to_string(), + working_directory: "/tmp/project".to_string(), + repo_cloned: None, + clone_origin_url: None, + clone_branch: None, + workspace_root: None, + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, + }, + }, + )); let stage = projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2)); stage.prompt = Some("plan".to_string()); diff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/crates/fabro-sandbox/src/details.rs index e6b65bd9b..2ada66652 100644 --- a/lib/crates/fabro-sandbox/src/details.rs +++ b/lib/crates/fabro-sandbox/src/details.rs @@ -4,8 +4,8 @@ use anyhow::Result; #[cfg(any(feature = "docker", feature = "daytona"))] use chrono::{DateTime, Utc}; use fabro_types::{ - RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProviderKind, SandboxResources, - SandboxState, SandboxTimestamps, + RunId, RunSandboxInstance, SandboxDetails, SandboxNetwork, SandboxProviderKind, + SandboxResources, SandboxState, SandboxTimestamps, }; /// Inspect the sandbox identified by `record` and return provider-neutral @@ -20,7 +20,7 @@ use fabro_types::{ reason = "Feature-gated providers consume some parameters only when enabled." )] pub async fn sandbox_details( - record: &RunSandbox, + record: &RunSandboxInstance, daytona_api_key: Option, daytona_organization_id: Option, run_id: Option, @@ -44,7 +44,7 @@ pub async fn sandbox_details( } } -fn local_details(record: &RunSandbox) -> SandboxDetails { +fn local_details(record: &RunSandboxInstance) -> SandboxDetails { SandboxDetails { sandbox: record.clone(), state: SandboxState::Running, @@ -74,23 +74,21 @@ pub(crate) mod docker { use bollard::container::InspectContainerOptions; use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig}; use fabro_types::{ - RunId, RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, - SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, + RunId, RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork, + SandboxNetworkPolicy, SandboxProviderKind, SandboxResources, SandboxState, + SandboxTimestamps, }; use super::parse_rfc3339_utc; use crate::docker::WORKING_DIRECTORY; pub(super) async fn docker_details( - record: &RunSandbox, + record: &RunSandboxInstance, _run_id: Option, ) -> 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 runtime = &record.runtime; let inspect = docker .inspect_container(&runtime.id, None::) .await @@ -120,13 +118,13 @@ pub(crate) mod docker { pub(super) fn map_docker_inspect( inspect: &ContainerInspectResponse, - record: &RunSandbox, + record: &RunSandboxInstance, ) -> SandboxDetails { let fields = docker_fields_from_inspect(inspect); let image = fields.image.clone().or_else(|| record.image.clone()); SandboxDetails { - sandbox: RunSandbox { + sandbox: RunSandboxInstance { image, ..record.clone() }, @@ -274,18 +272,18 @@ pub(crate) mod docker { mod tests { use bollard::models::HostConfig; use fabro_types::{ - RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, + RunSandboxInstance, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, SandboxProviderKind, }; use super::*; - fn record() -> RunSandbox { - RunSandbox { + fn record() -> RunSandboxInstance { + RunSandboxInstance { provider: SandboxProviderKind::Docker, image: None, snapshot: None, - runtime: Some(RunSandboxRuntime { + runtime: RunSandboxRuntime { id: "container-abc123".to_string(), working_directory: "/workspace".to_string(), repo_cloned: Some(true), @@ -295,7 +293,7 @@ pub(crate) mod docker { repos_root: None, primary_repo_path: None, primary_repo_link: None, - }), + }, } } @@ -389,7 +387,7 @@ pub(crate) mod docker { ..Default::default() }; let details = map_docker_inspect(&inspect, &record()); - let runtime = details.sandbox.runtime.expect("runtime"); + let runtime = details.sandbox.runtime; assert_eq!(runtime.id, "container-abc123"); assert_eq!(runtime.working_directory, "/workspace"); } @@ -497,7 +495,7 @@ pub(crate) mod daytona { use anyhow::{Context, Result, anyhow}; use daytona_api_client::models::SandboxState as DaytonaState; use fabro_types::{ - RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, + RunSandboxInstance, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, }; @@ -505,13 +503,10 @@ pub(crate) mod daytona { use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox, WORKING_DIRECTORY}; pub(super) async fn daytona_details( - record: &RunSandbox, + record: &RunSandboxInstance, daytona_api_key: Option, ) -> Result { - let runtime = record - .runtime - .as_ref() - .context("Daytona run sandbox missing runtime metadata")?; + let runtime = &record.runtime; let repo_cloned = runtime .repo_cloned .context("Daytona run sandbox missing clone metadata")?; @@ -555,11 +550,11 @@ pub(crate) mod daytona { pub(super) fn map_daytona_sandbox( sandbox: &daytona_sdk::Sandbox, - record: &RunSandbox, + record: &RunSandboxInstance, ) -> SandboxDetails { let fields = daytona_fields_from_sdk_sandbox(sandbox); SandboxDetails { - sandbox: RunSandbox { + sandbox: RunSandboxInstance { snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()), ..record.clone() }, @@ -817,11 +812,11 @@ mod tests { #[test] fn local_details_returns_running_with_no_metadata() { - let record = RunSandbox { + let record = RunSandboxInstance { provider: SandboxProviderKind::Local, image: None, snapshot: None, - runtime: Some(fabro_types::RunSandboxRuntime { + runtime: fabro_types::RunSandboxRuntime { id: "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string(), working_directory: "/Users/client/project".to_string(), repo_cloned: None, @@ -831,12 +826,12 @@ mod tests { repos_root: None, primary_repo_path: None, primary_repo_link: None, - }), + }, }; let details = local_details(&record); assert_eq!(details.sandbox.provider, SandboxProviderKind::Local); assert_eq!(details.state, SandboxState::Running); - let runtime = details.sandbox.runtime.as_ref().unwrap(); + let runtime = &details.sandbox.runtime; assert_eq!(runtime.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z"); assert_eq!(runtime.working_directory, "/Users/client/project"); assert!(details.region.is_none()); diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index 2fb0f0b4b..e53b25756 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -40,7 +40,7 @@ pub use details::sandbox_details; #[cfg(feature = "docker")] pub use docker::{DockerSandbox, DockerSandboxOptions}; pub use error::{Error, Result, default_redacted_output_tail, display_for_log}; -pub use fabro_types::{RunSandbox, SandboxProviderKind}; +pub use fabro_types::{RunSandboxInstance, SandboxProviderKind}; pub use local::LocalSandbox; #[cfg(feature = "daytona")] pub use provider::daytona::DaytonaSandboxProvider; diff --git a/lib/crates/fabro-sandbox/src/reconnect.rs b/lib/crates/fabro-sandbox/src/reconnect.rs index c71b6dc4e..b0b950477 100644 --- a/lib/crates/fabro-sandbox/src/reconnect.rs +++ b/lib/crates/fabro-sandbox/src/reconnect.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; reason = "Feature-gated branches consume these imports when optional backends are enabled." )] use anyhow::{Context, Result, bail}; -use fabro_types::{RunId, RunSandbox, SandboxProviderKind}; +use fabro_types::{RunId, RunSandboxInstance, SandboxProviderKind}; use crate::SandboxEventCallback; #[cfg(feature = "daytona")] @@ -24,7 +24,7 @@ use crate::local::LocalSandbox; reason = "Feature-gated sandbox backends leave some parameters unused on partial builds." )] pub async fn reconnect( - record: &RunSandbox, + record: &RunSandboxInstance, daytona_api_key: Option, ) -> Result> { reconnect_for_run(record, daytona_api_key, None).await @@ -35,7 +35,7 @@ pub async fn reconnect( reason = "Feature-gated sandbox backends leave parameters unused on partial builds." )] pub async fn reconnect_for_run( - record: &RunSandbox, + record: &RunSandboxInstance, daytona_api_key: Option, run_id: Option, ) -> Result> { @@ -47,15 +47,12 @@ pub async fn reconnect_for_run( reason = "Feature-gated sandbox backends leave parameters unused on partial builds." )] pub async fn reconnect_for_run_with_callback( - record: &RunSandbox, + record: &RunSandboxInstance, daytona_api_key: Option, run_id: Option, event_callback: Option, ) -> Result> { - let runtime = record - .runtime - .as_ref() - .context("run sandbox missing runtime metadata")?; + let runtime = &record.runtime; match record.provider { SandboxProviderKind::Local => { let mut sandbox = LocalSandbox::new(PathBuf::from(&runtime.working_directory)); diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs index 975517edc..b6a56bd40 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, RunSandboxRuntime, SandboxProviderKind}; +use fabro_types::{RunId, RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind}; #[cfg(any(feature = "docker", feature = "daytona"))] use crate::clone_source; @@ -63,8 +63,12 @@ impl SandboxSpec { } } - /// Build a RunSandbox for persistence. - pub fn to_run_sandbox(&self, sandbox: &dyn Sandbox, run_id: RunId) -> RunSandbox { + /// Build initialized sandbox metadata for persistence. + pub fn to_run_sandbox_instance( + &self, + sandbox: &dyn Sandbox, + run_id: RunId, + ) -> RunSandboxInstance { let working_directory = sandbox.working_directory().to_string(); let id = { let info = sandbox.sandbox_info(); @@ -93,11 +97,11 @@ impl SandboxSpec { docker::WORKING_DIRECTORY, docker::REPOS_ROOT, ); - RunSandbox { + RunSandboxInstance { provider: self.provider(), image: (!config.image.is_empty()).then(|| config.image.clone()), snapshot: None, - runtime: Some(RunSandboxRuntime { + runtime: RunSandboxRuntime { id, working_directory: working_directory.clone(), repo_cloned, @@ -113,7 +117,7 @@ impl SandboxSpec { primary_repo_link: layout .as_ref() .map(|layout| layout.primary_repo_link.clone()), - }), + }, } } #[cfg(feature = "daytona")] @@ -133,11 +137,11 @@ impl SandboxSpec { daytona::WORKING_DIRECTORY, daytona::REPOS_ROOT, ); - RunSandbox { + RunSandboxInstance { provider: self.provider(), image: None, snapshot: sandbox.snapshot_info(), - runtime: Some(RunSandboxRuntime { + runtime: RunSandboxRuntime { id, working_directory: working_directory.clone(), repo_cloned, @@ -153,14 +157,14 @@ impl SandboxSpec { primary_repo_link: layout .as_ref() .map(|layout| layout.primary_repo_link.clone()), - }), + }, } } - _ => RunSandbox { + _ => RunSandboxInstance { provider: self.provider(), image: None, snapshot: None, - runtime: Some(RunSandboxRuntime { + runtime: RunSandboxRuntime { id, working_directory, repo_cloned: None, @@ -170,7 +174,7 @@ impl SandboxSpec { repos_root: None, primary_repo_path: None, primary_repo_link: None, - }), + }, }, } } @@ -277,8 +281,8 @@ mod tests { sandbox.working_dir = "/workspace/rack-test"; let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let record = spec.to_run_sandbox(&sandbox, run_id); - let runtime = record.runtime.expect("runtime"); + let record = spec.to_run_sandbox_instance(&sandbox, run_id); + let runtime = record.runtime; assert_eq!(runtime.working_directory, "/workspace/rack-test"); assert_eq!(runtime.repo_cloned, Some(true)); @@ -315,8 +319,8 @@ mod tests { sandbox.working_dir = "/workspace"; let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); - let record = spec.to_run_sandbox(&sandbox, run_id); - let runtime = record.runtime.expect("runtime"); + let record = spec.to_run_sandbox_instance(&sandbox, run_id); + let runtime = record.runtime; assert_eq!(runtime.working_directory, "/workspace"); assert_eq!(runtime.repo_cloned, Some(false)); diff --git a/lib/crates/fabro-sandbox/src/terminal.rs b/lib/crates/fabro-sandbox/src/terminal.rs index 67dfa29b6..44081f9bb 100644 --- a/lib/crates/fabro-sandbox/src/terminal.rs +++ b/lib/crates/fabro-sandbox/src/terminal.rs @@ -1,9 +1,8 @@ use async_trait::async_trait; #[cfg(feature = "daytona")] use fabro_static::EnvVars; -use fabro_types::{RunId, SandboxProviderKind}; +use fabro_types::{RunId, RunSandboxInstance, SandboxProviderKind}; -use crate::RunSandbox; #[cfg(any(feature = "daytona", feature = "docker"))] use crate::Sandbox; #[cfg(feature = "daytona")] @@ -35,17 +34,14 @@ pub trait TerminalSession: Send + Sync { } pub async fn open_terminal_for_run( - record: &RunSandbox, + record: &RunSandboxInstance, daytona_api_key: Option, daytona_organization_id: Option, 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"))?; + let runtime = &record.runtime; #[cfg(not(feature = "daytona"))] let _ = (&daytona_api_key, &daytona_organization_id); #[cfg(not(feature = "docker"))] diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs index 645c53424..1f358821b 100644 --- a/lib/crates/fabro-server/src/run_files.rs +++ b/lib/crates/fabro-server/src/run_files.rs @@ -1213,8 +1213,10 @@ async fn reconnect_run_sandbox( ) -> std::result::Result, ApiError> { let record = projection .sandbox - .clone() - .ok_or_else(|| ApiError::new(StatusCode::CONFLICT, "Run has no active sandbox."))?; + .as_ref() + .and_then(fabro_types::RunSandbox::instance) + .cloned() + .ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Run sandbox was not created."))?; let daytona_api_key = state.vault_secret(EnvVars::DAYTONA_API_KEY); let sandbox = reconnect_for_run(&record, daytona_api_key, Some(*run_id)) .await diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 1c83dd57b..43721bec3 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1059,7 +1059,7 @@ impl AskFabroReadiness { } else if run .sandbox .as_ref() - .and_then(|sandbox| sandbox.runtime.as_ref()) + .and_then(fabro_types::RunSandbox::instance) .is_none() { Some(AskFabroUnavailableReason::SandboxNotReady) @@ -2439,12 +2439,15 @@ async fn delete_run_sandbox_resource( .environment .lifecycle .preserve; - let Some(record) = projection.sandbox else { - return Ok(SandboxDeleteOutcome::Cleaned); - }; - let Some(runtime) = record.runtime.as_ref() else { + let Some(record) = projection + .sandbox + .as_ref() + .and_then(fabro_types::RunSandbox::instance) + .cloned() + else { return Ok(SandboxDeleteOutcome::Cleaned); }; + let runtime = &record.runtime; if preserve { return Ok(SandboxDeleteOutcome::Preserved(DeleteRunResponse { deleted: true, diff --git a/lib/crates/fabro-server/src/server/handler/sandbox.rs b/lib/crates/fabro-server/src/server/handler/sandbox.rs index bfba9c242..61d8b7ee9 100644 --- a/lib/crates/fabro-server/src/server/handler/sandbox.rs +++ b/lib/crates/fabro-server/src/server/handler/sandbox.rs @@ -5,7 +5,9 @@ use std::sync::Arc; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use fabro_sandbox::{TerminalSize, open_terminal_for_run}; -use fabro_types::{SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta}; +use fabro_types::{ + RunSandboxInstance, SandboxProviderKind, SandboxServiceDiscoverySource, SandboxServiceListMeta, +}; use futures_util::FutureExt; use futures_util::future::BoxFuture; @@ -103,7 +105,7 @@ async fn retrieve_run_sandbox( Ok(id) => id, Err(response) => return response, }; - let record = match load_run_sandbox_or_not_found(&state, &id).await { + let record = match load_run_sandbox_instance(&state, &id).await { Ok(record) => record, Err(response) => return response, }; @@ -216,7 +218,7 @@ async fn run_terminal( } async fn terminal_websocket(mut socket: WebSocket, state: Arc, id: RunId) { - let record = match load_run_sandbox(&state, &id).await { + let record = match load_run_sandbox_instance(&state, &id).await { Ok(record) => record, Err(response) => { let message = terminal_error_from_status(response.status()); @@ -395,14 +397,14 @@ async fn create_ssh_access( Ok(id) => id, Err(response) => return response, }; - let record = match load_run_sandbox(&state, &id).await { + let record = match load_run_sandbox_instance(&state, &id).await { Ok(record) => record, Err(response) => return response, }; match record.provider { SandboxProviderKind::Daytona => { - let sandbox = match reconnect_daytona_sandbox(&state, &id).await { + let sandbox = match reconnect_daytona_sandbox_instance(&state, &record).await { Ok(sandbox) => sandbox, Err(response) => return response, }; @@ -416,7 +418,7 @@ async fn create_ssh_access( } } SandboxProviderKind::Docker => { - let sandbox = match reconnect_run_sandbox(&state, &id).await { + let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await { Ok(sandbox) => sandbox, Err(response) => return response, }; @@ -451,7 +453,7 @@ async fn create_sandbox_vnc_preview( Ok(id) => id, Err(response) => return response, }; - let record = match load_run_sandbox(&state, &id).await { + let record = match load_run_sandbox_instance(&state, &id).await { Ok(record) => record, Err(response) => return response, }; @@ -462,7 +464,7 @@ async fn create_sandbox_vnc_preview( ) .into_response(); } - let sandbox = match reconnect_daytona_sandbox(&state, &id).await { + let sandbox = match reconnect_daytona_sandbox_instance(&state, &record).await { Ok(sandbox) => sandbox, Err(response) => return response, }; @@ -555,12 +557,12 @@ async fn list_sandbox_services( Ok(id) => id, Err(response) => return response, }; - let record = match load_run_sandbox(&state, &id).await { + let record = match load_run_sandbox_instance(&state, &id).await { Ok(record) => record, Err(response) => return response, }; let provider = record.provider; - let sandbox = match reconnect_run_sandbox(&state, &id).await { + let sandbox = match reconnect_run_sandbox_instance(&state, &id, &record).await { Ok(sandbox) => sandbox, Err(response) => return response, }; @@ -848,9 +850,17 @@ async fn reconnect_run_sandbox( state: &Arc, run_id: &RunId, ) -> Result, Response> { - let record = load_run_sandbox(state, run_id).await?; + let record = load_run_sandbox_instance(state, run_id).await?; + reconnect_run_sandbox_instance(state, run_id, &record).await +} + +async fn reconnect_run_sandbox_instance( + state: &Arc, + run_id: &RunId, + record: &RunSandboxInstance, +) -> Result, Response> { let daytona_api_key = state.vault_secret(EnvVars::DAYTONA_API_KEY); - let sandbox = reconnect_for_run(&record, daytona_api_key, Some(*run_id)) + let sandbox = reconnect_for_run(record, daytona_api_key, Some(*run_id)) .await .map_err(|err| { let detail = render_with_causes(&err.to_string(), &collect_causes(err.as_ref())); @@ -866,7 +876,14 @@ async fn reconnect_daytona_sandbox( state: &Arc, run_id: &RunId, ) -> Result { - let record = load_run_sandbox(state, run_id).await?; + let record = load_run_sandbox_instance(state, run_id).await?; + reconnect_daytona_sandbox_instance(state, &record).await +} + +async fn reconnect_daytona_sandbox_instance( + state: &Arc, + record: &RunSandboxInstance, +) -> Result { if record.provider != SandboxProviderKind::Daytona { return Err(ApiError::new( StatusCode::CONFLICT, @@ -874,13 +891,7 @@ async fn reconnect_daytona_sandbox( ) .into_response()); } - let Some(runtime) = record.runtime.as_ref() else { - return Err(ApiError::new( - StatusCode::CONFLICT, - "Sandbox record is missing runtime metadata.", - ) - .into_response()); - }; + let runtime = &record.runtime; let Some(repo_cloned) = runtime.repo_cloned else { return Err(ApiError::new( StatusCode::CONFLICT, @@ -907,35 +918,16 @@ async fn reconnect_daytona_sandbox( Ok(sandbox) } -async fn load_run_sandbox( +async fn load_run_sandbox_instance( state: &Arc, run_id: &RunId, -) -> Result { - match state.store.open_run_reader(run_id).await { - Ok(run_store) => match run_store.state().await { - Ok(run_state) => run_state.sandbox.ok_or_else(|| { - ApiError::new(StatusCode::CONFLICT, "Run has no active sandbox.").into_response() - }), - Err(err) => Err( - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), - ), - }, - Err(_) => Err(ApiError::not_found("Run not found.").into_response()), - } -} - -/// Same as `load_run_sandbox`, but treats a missing sandbox as -/// `404 Not Found` instead of `409 Conflict`. Used by the inspection endpoint -/// where there is no resource to act on if the run never had a sandbox. -async fn load_run_sandbox_or_not_found( - state: &Arc, - run_id: &RunId, -) -> Result { +) -> Result { match state.store.open_run_reader(run_id).await { Ok(run_store) => match run_store.state().await { Ok(run_state) => run_state .sandbox - .ok_or_else(|| ApiError::not_found("Run has no sandbox.").into_response()), + .and_then(fabro_types::RunSandbox::into_instance) + .ok_or_else(|| ApiError::not_found("Run sandbox was not created.").into_response()), Err(err) => Err( ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), ), @@ -1378,6 +1370,38 @@ mod retrieve_sandbox_tests { run_store.append_event(&payload).await.unwrap(); } + async fn append_sandbox_failed(run_store: &fabro_store::RunDatabase, run_id: &RunId) { + let payload = fabro_store::EventPayload::new( + json!({ + "id": "evt-sandbox-failed", + "ts": "2026-05-09T12:00:00Z", + "run_id": run_id, + "event": "sandbox.failed", + "properties": { + "provider": "docker", + "error": "Docker daemon unavailable", + "causes": ["connection refused"], + "duration_ms": 42, + }, + }), + run_id, + ) + .expect("sandbox.failed payload should validate"); + run_store.append_event(&payload).await.unwrap(); + } + + async fn assert_sandbox_not_created_response(response: axum::response::Response) { + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = body_json(response).await; + assert!( + body["errors"][0]["detail"] + .as_str() + .unwrap_or_default() + .contains("Run sandbox was not created."), + "unexpected body: {body}" + ); + } + #[tokio::test] async fn missing_run_returns_404() { let app = build_test_router(test_app_state()); @@ -1398,7 +1422,7 @@ mod retrieve_sandbox_tests { } #[tokio::test] - async fn run_without_sandbox_runtime_returns_planned_sandbox_details() { + async fn planned_sandbox_returns_404_from_details_endpoint() { let state = test_app_state(); let app = build_test_router(state.clone()); let run_id = RunId::new(); @@ -1412,10 +1436,52 @@ mod retrieve_sandbox_tests { .oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox"))) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response).await; - assert_eq!(body["sandbox"]["provider"], "local"); - assert!(body["sandbox"]["runtime"].is_null()); + assert_sandbox_not_created_response(response).await; + } + + #[tokio::test] + async fn planned_sandbox_rejects_live_operations() { + let state = test_app_state(); + let app = build_test_router(state.clone()); + let run_id = RunId::new(); + let run_store = state + .store_ref() + .create_run(&run_id) + .await + .expect("test run should be creatable"); + append_run_created(&run_store, &run_id).await; + + for uri in [ + format!("/api/v1/runs/{run_id}/sandbox/services"), + format!("/api/v1/runs/{run_id}/sandbox/files?path=/workspace"), + format!("/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md"), + ] { + let response = app.clone().oneshot(req_get(&uri)).await.unwrap(); + assert_sandbox_not_created_response(response).await; + } + } + + #[tokio::test] + async fn failed_sandbox_rejects_live_operations() { + let state = test_app_state(); + let app = build_test_router(state.clone()); + let run_id = RunId::new(); + let run_store = state + .store_ref() + .create_run(&run_id) + .await + .expect("test run should be creatable"); + append_run_created(&run_store, &run_id).await; + append_sandbox_failed(&run_store, &run_id).await; + + for uri in [ + format!("/api/v1/runs/{run_id}/sandbox/services"), + format!("/api/v1/runs/{run_id}/sandbox/files?path=/workspace"), + format!("/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md"), + ] { + let response = app.clone().oneshot(req_get(&uri)).await.unwrap(); + assert_sandbox_not_created_response(response).await; + } } #[tokio::test] diff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs index aaaaf368f..bad48c6d4 100644 --- a/lib/crates/fabro-server/src/server/handler/sessions.rs +++ b/lib/crates/fabro-server/src/server/handler/sessions.rs @@ -702,13 +702,11 @@ async fn build_agent_session( .sandbox .as_ref() .ok_or(AskFabroBuildError::NoSandbox)?; - if sandbox_record.runtime.is_none() { - return Err(AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!( - "run sandbox runtime is not ready" - ))); - } + let sandbox_instance = sandbox_record.instance().ok_or_else(|| { + AskFabroBuildError::SandboxUnavailable(anyhow::anyhow!("run sandbox was not created")) + })?; let sandbox = reconnect_for_run( - sandbox_record, + sandbox_instance, state.vault_secret(EnvVars::DAYTONA_API_KEY), Some(run_id), ) diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index b0105392c..78432c1b2 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -14330,9 +14330,13 @@ async fn list_runs_includes_live_metadata_from_run_state() { .expect("run should be in board"); assert_eq!(item["pull_request"]["number"].as_u64(), Some(42)); - assert_eq!(item["sandbox"]["runtime"]["id"].as_str(), Some("sb-test")); + assert_eq!(item["sandbox"]["kind"].as_str(), Some("ready")); assert_eq!( - item["sandbox"]["runtime"]["working_directory"].as_str(), + item["sandbox"]["instance"]["runtime"]["id"].as_str(), + Some("sb-test") + ); + assert_eq!( + item["sandbox"]["instance"]["runtime"]["working_directory"].as_str(), Some("/sandbox/workdir") ); assert!(item["current_question"].is_object()); @@ -14391,7 +14395,7 @@ async fn list_runs_page_limit_preserves_metadata_for_paged_items() { assert_eq!(data.len(), 1); let item = &data[0]; - let sandbox_id = item["sandbox"]["runtime"]["id"] + let sandbox_id = item["sandbox"]["instance"]["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/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs index 783100d5c..a3bf7ad76 100644 --- a/lib/crates/fabro-server/tests/it/api/run_files.rs +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -14,7 +14,7 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; use fabro_server::test_support::test_app_state_with_store; use fabro_store::{ArtifactStore, Database}; -use fabro_types::{Graph, RunId, WorkflowSettings}; +use fabro_types::{Graph, RunId, SandboxProviderKind, WorkflowSettings}; use fabro_workflow::event as workflow_event; use fabro_workflow::run_status::SuccessReason; use object_store::memory::InMemory as MemoryObjectStore; @@ -125,6 +125,33 @@ async fn append_completed_run_with_final_patch( .expect("append WorkflowRunCompleted"); } +async fn append_local_sandbox_initialized(store: &Database, run_id: &RunId) { + let run_store = store.open_run(run_id).await.expect("open run store"); + workflow_event::append_event( + &run_store, + run_id, + &workflow_event::Event::SandboxInitialized { + working_directory: std::env::current_dir() + .expect("test should run inside a source checkout") + .display() + .to_string(), + provider: SandboxProviderKind::Local, + id: "local:test-sandbox".to_string(), + image: None, + snapshot: None, + repo_cloned: None, + clone_origin_url: None, + clone_branch: None, + workspace_root: None, + repos_root: None, + primary_repo_path: None, + primary_repo_link: None, + }, + ) + .await + .expect("append SandboxInitialized"); +} + #[tokio::test] async fn invalid_run_id_returns_400() { let app = fabro_server::test_support::build_test_router(test_app_state()); @@ -311,6 +338,7 @@ diff --git a/.env.production b/.env.production +SECRET=new "; append_completed_run_with_final_patch(&store, &run_id, patch).await; + append_local_sandbox_initialized(&store, &run_id).await; let req = Request::builder() .method("GET") @@ -345,7 +373,7 @@ diff --git a/.env.production b/.env.production } #[tokio::test] -async fn unavailable_sandbox_falls_back_to_final_patch_for_every_scope() { +async fn planned_sandbox_rejects_files_for_every_scope() { let settings = test_settings(); let (store, artifact_store) = store_bundle(); let state = test_app_state_with_store( @@ -376,15 +404,15 @@ diff --git a/src/lib.rs b/src/lib.rs let resp = app.clone().oneshot(req).await.unwrap(); let body = response_json( resp, - StatusCode::OK, + StatusCode::NOT_FOUND, format!("GET /api/v1/runs/{run_id}/files?scope={scope}"), ) .await; - assert_eq!(body["meta"]["source"].as_str(), Some("final_patch")); - assert_eq!(body["meta"]["scope"].as_str(), Some("committed")); - assert_eq!(body["meta"]["degraded"].as_bool(), Some(true)); - assert_eq!(body["data"].as_array().map(Vec::len), Some(1)); + assert_eq!( + body["errors"][0]["detail"].as_str(), + Some("Run sandbox was not created.") + ); } } 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 49447361b..2ba6a716d 100644 --- a/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs +++ b/lib/crates/fabro-server/tests/it/api/sandbox_vnc.rs @@ -27,7 +27,7 @@ async fn vnc_for_missing_run_returns_not_found() { } #[tokio::test] -async fn vnc_for_run_without_sandbox_returns_conflict() { +async fn vnc_for_run_without_sandbox_returns_not_found() { let app = fabro_server::test_support::build_test_router(test_app_state()); let create_req = Request::builder() .method("POST") @@ -51,7 +51,7 @@ async fn vnc_for_run_without_sandbox_returns_conflict() { response_status( response, - StatusCode::NOT_IMPLEMENTED, + StatusCode::NOT_FOUND, format!("POST /api/v1/runs/{run_id}/sandbox/vnc"), ) .await; diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 2955cbbed..cd6fa0640 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -13,10 +13,11 @@ use fabro_types::{ McpServerProjection, McpServerStatus, Outcome, PendingInterviewRecord, PendingReason, PullRequestLink, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary, RunControlAction, RunDiff, RunEvent, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin, - RunProjection, RunSandbox, RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, - SandboxProviderKind, StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, - StageProjection, StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind, - TodoListProjection, TodoProjection, WorkflowRef, first_event_seq, + RunProjection, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxPlan, + RunSandboxRuntime, RunSize, RunSpec, RunStatus, RunTimestamps, SandboxProviderKind, + StageCompletion, StageHandler, StageId, StageModelUsage, StageOutcome, StageProjection, + StageState, StartRecord, SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, + TodoProjection, WorkflowRef, first_event_seq, }; use fabro_util::error::render_compact_with_causes; @@ -259,27 +260,37 @@ impl RunProjectionReducer for RunProjection { diff: diff_from_checkpoint_props(props), }); } + EventBody::SandboxInitializing(_) => { + let plan = sandbox_plan_from_projection_or_settings(self); + self.sandbox = Some(RunSandbox::initializing(plan)); + } + EventBody::SandboxFailed(props) => { + let plan = sandbox_plan_from_projection_or_settings(self); + self.sandbox = Some(RunSandbox::failed(plan, RunSandboxFailure { + provider: props.provider.clone(), + error: props.error.clone(), + causes: props.causes.clone(), + duration_ms: props.duration_ms, + })); + } EventBody::SandboxInitialized(props) => { - let sandbox = self.sandbox.get_or_insert(RunSandbox { + let plan = sandbox_plan_from_projection_or_settings(self); + self.sandbox = Some(RunSandbox::ready(plan, RunSandboxInstance { provider: props.provider, - image: None, - snapshot: None, - runtime: None, - }); - sandbox.provider = props.provider; - sandbox.image.clone_from(&props.image); - sandbox.snapshot.clone_from(&props.snapshot); - 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(), - workspace_root: props.workspace_root.clone(), - repos_root: props.repos_root.clone(), - primary_repo_path: props.primary_repo_path.clone(), - primary_repo_link: props.primary_repo_link.clone(), - }); + image: props.image.clone(), + snapshot: props.snapshot.clone(), + runtime: 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(), + workspace_root: props.workspace_root.clone(), + repos_root: props.repos_root.clone(), + primary_repo_path: props.primary_repo_path.clone(), + primary_repo_link: props.primary_repo_link.clone(), + }, + })); } EventBody::PullRequestCreated(props) => { self.pull_request = Some(PullRequestLink { @@ -794,20 +805,28 @@ fn projection_from_created(event: &EventEnvelope) -> Result { projection.parent_id = props.parent_id; projection.retried_from = props.retried_from; projection.web_url.clone_from(&props.web_url); - projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.environment)); + projection.sandbox = Some(RunSandbox::planned(sandbox_plan( + &projection.spec.settings.run.environment, + ))); Ok(projection) } -fn planned_sandbox(settings: &RunEnvironmentSettings) -> RunSandbox { +fn sandbox_plan_from_projection_or_settings(state: &RunProjection) -> RunSandboxPlan { + state.sandbox.as_ref().map_or_else( + || sandbox_plan(&state.spec.settings.run.environment), + |sandbox| sandbox.plan().clone(), + ) +} + +fn sandbox_plan(settings: &RunEnvironmentSettings) -> RunSandboxPlan { let provider = SandboxProviderKind::from(settings.provider); - RunSandbox { + RunSandboxPlan { provider, image: (settings.provider == EnvironmentProvider::Docker) .then(|| settings.image.docker.clone()) .flatten() .filter(|image| !image.is_empty()), snapshot: None, - runtime: None, } } @@ -1380,7 +1399,7 @@ mod tests { docker.provider = EnvironmentProvider::Docker; docker.image.docker = Some("ubuntu:24.04".to_string()); - let planned_docker = super::planned_sandbox(&docker); + let planned_docker = super::sandbox_plan(&docker); assert_eq!(planned_docker.image.as_deref(), Some("ubuntu:24.04")); assert_eq!(planned_docker.snapshot, None); @@ -1388,7 +1407,7 @@ mod tests { daytona.provider = EnvironmentProvider::Daytona; daytona.image.dockerfile = Some(DockerfileSource::Inline("FROM ubuntu:24.04".to_string())); - let planned_daytona = super::planned_sandbox(&daytona); + let planned_daytona = super::sandbox_plan(&daytona); assert_eq!(planned_daytona.image, None); assert_eq!(planned_daytona.snapshot, None); } @@ -1411,9 +1430,10 @@ mod tests { .unwrap(); let sandbox = state.sandbox.expect("sandbox should be projected"); - assert_eq!(sandbox.image, None); + let instance = sandbox.instance().expect("sandbox should be ready"); + assert_eq!(instance.image, None); assert_eq!( - sandbox.snapshot.as_deref(), + instance.snapshot.as_deref(), Some("fabro-11111111-2222-8333-8444-555555555555") ); } @@ -1469,6 +1489,155 @@ mod tests { ); } + #[test] + fn run_created_projects_planned_sandbox_lifecycle() { + let state = RunProjection::apply_events(&[test_raw_event( + 1, + "run.created", + &json!({ + "settings": WorkflowSettings::default(), + "graph": Graph::new("test"), + "labels": {}, + "run_dir": "/tmp/run" + }), + None, + )]) + .unwrap(); + + let sandbox = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); + assert_eq!(sandbox["kind"], "planned"); + assert_eq!(sandbox["plan"]["provider"], "local"); + assert!(sandbox.get("instance").is_none()); + assert!(sandbox.get("failure").is_none()); + } + + #[test] + fn sandbox_lifecycle_events_update_projected_sandbox_state() { + let mut state = RunProjection::apply_events(&[test_raw_event( + 1, + "run.created", + &json!({ + "settings": WorkflowSettings::default(), + "graph": Graph::new("test"), + "labels": {}, + "run_dir": "/tmp/run" + }), + None, + )]) + .unwrap(); + + state + .apply_event(&test_raw_event( + 2, + "sandbox.initializing", + &json!({ "provider": "docker" }), + None, + )) + .unwrap(); + let initializing = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); + assert_eq!(initializing["kind"], "initializing"); + assert!(initializing.get("instance").is_none()); + + state + .apply_event(&test_raw_event( + 3, + "sandbox.initialized", + &json!({ + "provider": "docker", + "id": "container-abc123", + "working_directory": "/workspace", + "image": "ubuntu:24.04", + "repo_cloned": true, + "clone_origin_url": "https://github.com/fabro-sh/fabro.git", + "clone_branch": "main" + }), + None, + )) + .unwrap(); + let ready = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); + assert_eq!(ready["kind"], "ready"); + assert_eq!(ready["plan"]["provider"], "local"); + assert_eq!(ready["instance"]["provider"], "docker"); + assert_eq!(ready["instance"]["image"], "ubuntu:24.04"); + assert_eq!(ready["instance"]["runtime"]["id"], "container-abc123"); + assert_eq!( + ready["instance"]["runtime"]["working_directory"], + "/workspace" + ); + + state + .apply_event(&test_raw_event( + 4, + "sandbox.failed", + &json!({ + "provider": "docker", + "error": "Docker daemon unavailable", + "causes": ["connection refused"], + "duration_ms": 42 + }), + None, + )) + .unwrap(); + let failed = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); + assert_eq!(failed["kind"], "failed"); + assert_eq!(failed["failure"]["provider"], "docker"); + assert_eq!(failed["failure"]["error"], "Docker daemon unavailable"); + assert_eq!(failed["failure"]["causes"], json!(["connection refused"])); + assert_eq!(failed["failure"]["duration_ms"], 42); + assert!(failed.get("instance").is_none()); + } + + #[test] + fn run_failed_before_sandbox_events_leaves_sandbox_planned() { + let state = RunProjection::apply_events(&[ + test_raw_event( + 1, + "run.created", + &json!({ + "settings": WorkflowSettings::default(), + "graph": Graph::new("test"), + "labels": {}, + "run_dir": "/tmp/run" + }), + None, + ), + test_raw_event( + 2, + "run.runnable", + &json!({ "source": "start_requested" }), + None, + ), + test_raw_event(3, "run.starting", &json!({}), None), + test_raw_event(4, "run.running", &json!({}), None), + test_raw_event( + 5, + "run.failed", + &json!({ + "failure": { + "reason": "sandbox_init_failed", + "detail": { + "message": "Failed before sandbox initialized", + "category": "transient_infra" + } + }, + "timing": { + "wall_time_ms": 1, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + } + }), + None, + ), + ]) + .unwrap(); + + let sandbox = serde_json::to_value(state.sandbox.as_ref().unwrap()).unwrap(); + assert_eq!(sandbox["kind"], "planned"); + assert!(sandbox.get("instance").is_none()); + assert!(sandbox.get("failure").is_none()); + } + fn test_raw_event( seq: u32, event: &str, diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index db34fa664..6584a36cd 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -6,9 +6,9 @@ use fabro_types::graph::Graph; use fabro_types::run::RunSpec; use fabro_types::{ BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord, - QuestionType, RunDiff, RunSandbox, RunSandboxRuntime, RunStatus, SandboxProviderKind, - StageCompletion, StageModelUsage, StageOutcome, StartRecord, WorkflowSettings, first_event_seq, - fixtures, + QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, + RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord, + WorkflowSettings, first_event_seq, fixtures, }; use serde_json::json; @@ -99,11 +99,16 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { checkpoint: sample_checkpoint(), diff: RunDiff::default(), }); - projection.sandbox = Some(RunSandbox { + let sandbox_plan = RunSandboxPlan { provider: SandboxProviderKind::Local, image: None, snapshot: None, - runtime: Some(RunSandboxRuntime { + }; + projection.sandbox = Some(RunSandbox::ready(sandbox_plan, RunSandboxInstance { + provider: SandboxProviderKind::Local, + image: None, + snapshot: None, + runtime: RunSandboxRuntime { id: "sandbox-1".to_string(), working_directory: "/tmp/project".to_string(), repo_cloned: None, @@ -113,8 +118,8 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { repos_root: None, primary_repo_path: None, primary_repo_link: None, - }), - }); + }, + })); projection.pending_interviews = BTreeMap::new(); let stage = projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2)); stage.prompt = Some("plan the work".to_string()); diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 31103c13a..f1a8c8b39 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -113,7 +113,10 @@ pub use run_projection::{ StageContextWindowStaleness, StageContextWindowUnavailableReason, StageContextWindowWarning, StageModelUsage, StageProjection, SubAgentProjection, SubAgentStatus, first_event_seq, }; -pub use run_sandbox::{RunSandbox, RunSandboxRuntime}; +pub use run_sandbox::{ + RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind, RunSandboxPlan, + RunSandboxRuntime, +}; pub use run_summary::{ AskFabro, AskFabroUnavailableReason, AutomationRef, Run, RunApproval, RunApprovalState, RunBillingSummary, RunError, RunLifecycle, RunLinks, RunModel, RunOrigin, RunOriginKind, diff --git a/lib/crates/fabro-types/src/run_event/infra.rs b/lib/crates/fabro-types/src/run_event/infra.rs index 64dcb8064..3abd80c12 100644 --- a/lib/crates/fabro-types/src/run_event/infra.rs +++ b/lib/crates/fabro-types/src/run_event/infra.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::SandboxProviderKind; +use crate::{RunSandboxFailure, SandboxProviderKind}; #[derive( Debug, @@ -201,14 +201,7 @@ pub struct SandboxReadyProps { pub url: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SandboxFailedProps { - pub provider: String, - pub error: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub causes: Vec, - pub duration_ms: u64, -} +pub type SandboxFailedProps = RunSandboxFailure; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SandboxCleanupStartedProps { diff --git a/lib/crates/fabro-types/src/run_sandbox.rs b/lib/crates/fabro-types/src/run_sandbox.rs index b2aedd829..91e3ec5f0 100644 --- a/lib/crates/fabro-types/src/run_sandbox.rs +++ b/lib/crates/fabro-types/src/run_sandbox.rs @@ -1,16 +1,196 @@ -use serde::{Deserialize, Serialize}; +use serde::de::Error as _; +use serde::ser::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::SandboxProviderKind; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunSandboxKind { + Planned, + Initializing, + Ready, + Failed, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RunSandbox { +pub struct RunSandboxPlan { pub provider: SandboxProviderKind, #[serde(default, skip_serializing_if = "Option::is_none")] pub image: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub snapshot: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSandboxInstance { + pub provider: SandboxProviderKind, #[serde(default, skip_serializing_if = "Option::is_none")] - pub runtime: Option, + pub image: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub snapshot: Option, + pub runtime: RunSandboxRuntime, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunSandboxFailure { + pub provider: String, + pub error: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub causes: Vec, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RunSandbox { + kind: RunSandboxKind, + plan: RunSandboxPlan, + instance: Option, + failure: Option, +} + +impl RunSandbox { + pub fn planned(plan: RunSandboxPlan) -> Self { + Self { + kind: RunSandboxKind::Planned, + plan, + instance: None, + failure: None, + } + } + + pub fn initializing(plan: RunSandboxPlan) -> Self { + Self { + kind: RunSandboxKind::Initializing, + plan, + instance: None, + failure: None, + } + } + + pub fn ready(plan: RunSandboxPlan, instance: RunSandboxInstance) -> Self { + Self { + kind: RunSandboxKind::Ready, + plan, + instance: Some(instance), + failure: None, + } + } + + pub fn failed(plan: RunSandboxPlan, failure: RunSandboxFailure) -> Self { + Self { + kind: RunSandboxKind::Failed, + plan, + instance: None, + failure: Some(failure), + } + } + + pub fn instance(&self) -> Option<&RunSandboxInstance> { + self.instance.as_ref() + } + + pub fn into_instance(self) -> Option { + self.instance + } + + pub fn kind(&self) -> RunSandboxKind { + self.kind + } + + pub fn plan(&self) -> &RunSandboxPlan { + &self.plan + } + + pub fn failure(&self) -> Option<&RunSandboxFailure> { + self.failure.as_ref() + } + + fn validate(&self) -> Result<(), String> { + match self.kind { + RunSandboxKind::Planned | RunSandboxKind::Initializing => { + if self.instance.is_some() { + return Err(format!( + "{:?} sandbox must not carry an instance", + self.kind + )); + } + if self.failure.is_some() { + return Err(format!("{:?} sandbox must not carry a failure", self.kind)); + } + } + RunSandboxKind::Ready => { + if self.instance.is_none() { + return Err("ready sandbox requires an instance".to_string()); + } + if self.failure.is_some() { + return Err("ready sandbox must not carry a failure".to_string()); + } + } + RunSandboxKind::Failed => { + if self.instance.is_some() { + return Err("failed sandbox must not carry an instance".to_string()); + } + if self.failure.is_none() { + return Err("failed sandbox requires failure details".to_string()); + } + } + } + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +struct RunSandboxWire { + kind: RunSandboxKind, + plan: RunSandboxPlan, + #[serde(default, skip_serializing_if = "Option::is_none")] + instance: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + failure: Option, +} + +#[derive(Serialize)] +struct RunSandboxWireRef<'a> { + kind: RunSandboxKind, + plan: &'a RunSandboxPlan, + #[serde(default, skip_serializing_if = "Option::is_none")] + instance: Option<&'a RunSandboxInstance>, + #[serde(default, skip_serializing_if = "Option::is_none")] + failure: Option<&'a RunSandboxFailure>, +} + +impl Serialize for RunSandbox { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.validate().map_err(S::Error::custom)?; + RunSandboxWireRef { + kind: self.kind, + plan: &self.plan, + instance: self.instance.as_ref(), + failure: self.failure.as_ref(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RunSandbox { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = RunSandboxWire::deserialize(deserializer)?; + let sandbox = Self { + kind: wire.kind, + plan: wire.plan, + instance: wire.instance, + failure: wire.failure, + }; + sandbox.validate().map_err(D::Error::custom)?; + Ok(sandbox) + } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/src/sandbox_details.rs b/lib/crates/fabro-types/src/sandbox_details.rs index 8723b00eb..118b598e9 100644 --- a/lib/crates/fabro-types/src/sandbox_details.rs +++ b/lib/crates/fabro-types/src/sandbox_details.rs @@ -4,11 +4,11 @@ use chrono::{DateTime, Utc}; use serde::de::Error as _; use serde::{Deserialize, Serialize}; -use crate::RunSandbox; +use crate::RunSandboxInstance; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SandboxDetails { - pub sandbox: RunSandbox, + pub sandbox: RunSandboxInstance, pub state: SandboxState, #[serde(default, skip_serializing_if = "Option::is_none")] pub native_state: Option, @@ -187,11 +187,11 @@ mod tests { #[test] fn serializes_with_snake_case_state() { let details = SandboxDetails { - sandbox: RunSandbox { + sandbox: RunSandboxInstance { provider: crate::SandboxProviderKind::Docker, image: Some("ghcr.io/fabro/sandbox:latest".to_string()), snapshot: None, - runtime: Some(crate::RunSandboxRuntime { + runtime: crate::RunSandboxRuntime { id: "container-abc123".to_string(), working_directory: "/workspace".to_string(), repo_cloned: None, @@ -201,7 +201,7 @@ mod tests { repos_root: None, primary_repo_path: None, primary_repo_link: None, - }), + }, }, state: SandboxState::Running, native_state: Some("running".to_string()), @@ -232,10 +232,10 @@ mod tests { "sandbox": { "provider": "docker", "image": "ghcr.io/fabro/sandbox:latest", - "runtime": { - "id": "container-abc123", - "working_directory": "/workspace" - } + "runtime": { + "id": "container-abc123", + "working_directory": "/workspace" + } }, "state": "running", "native_state": "running", @@ -271,10 +271,10 @@ mod tests { "provider": "local", "image": null, "snapshot": null, - "runtime": { - "id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z", - "working_directory": "/Users/client/project" - } + "runtime": { + "id": "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z", + "working_directory": "/Users/client/project" + } }, "state": "unknown", "resources": {}, @@ -284,20 +284,12 @@ mod tests { assert_eq!(details.sandbox.provider, crate::SandboxProviderKind::Local); assert_eq!( - details - .sandbox - .runtime - .as_ref() - .map(|runtime| runtime.id.as_str()), - Some("local:01JNQVR7M0EJ5GKAT2SC4ERS1Z") + details.sandbox.runtime.id.as_str(), + "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z" ); assert_eq!( - details - .sandbox - .runtime - .as_ref() - .map(|runtime| runtime.working_directory.as_str()), - Some("/Users/client/project") + details.sandbox.runtime.working_directory.as_str(), + "/Users/client/project" ); assert_eq!(details.state, SandboxState::Unknown); assert!(details.sandbox.image.is_none()); diff --git a/lib/crates/fabro-types/tests/sandbox_model_serde.rs b/lib/crates/fabro-types/tests/sandbox_model_serde.rs index 4db0f34b2..d6dcc5fee 100644 --- a/lib/crates/fabro-types/tests/sandbox_model_serde.rs +++ b/lib/crates/fabro-types/tests/sandbox_model_serde.rs @@ -2,60 +2,83 @@ use std::collections::BTreeMap; use chrono::{TimeZone, Utc}; use fabro_types::{ - RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxProviderKind, - SandboxResources, SandboxState, SandboxTimestamps, + RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, SandboxDetails, + SandboxNetwork, SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps, }; use serde_json::json; #[test] fn run_sandbox_serializes_canonical_identity_without_identifier() { - let sandbox = RunSandbox { - provider: SandboxProviderKind::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()), - workspace_root: Some("/workspace".to_string()), - repos_root: Some("/repos".to_string()), - primary_repo_path: Some("/repos/fabro-sh/fabro".to_string()), - primary_repo_link: Some("/workspace/fabro".to_string()), - }), - }; + let sandbox = RunSandbox::ready( + RunSandboxPlan { + provider: SandboxProviderKind::Docker, + image: None, + snapshot: None, + }, + RunSandboxInstance { + provider: SandboxProviderKind::Docker, + image: None, + snapshot: None, + runtime: 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()), + workspace_root: Some("/workspace".to_string()), + repos_root: Some("/repos".to_string()), + primary_repo_path: Some("/repos/fabro-sh/fabro".to_string()), + primary_repo_link: Some("/workspace/fabro".to_string()), + }, + }, + ); let value = serde_json::to_value(&sandbox).unwrap(); assert_eq!( value, json!({ - "provider": "docker", - "runtime": { - "id": "container-abc123", - "working_directory": "/workspace", - "repo_cloned": true, - "clone_origin_url": "https://github.com/fabro-sh/fabro.git", - "clone_branch": "main", - "workspace_root": "/workspace", - "repos_root": "/repos", - "primary_repo_path": "/repos/fabro-sh/fabro", - "primary_repo_link": "/workspace/fabro" + "kind": "ready", + "plan": { + "provider": "docker" + }, + "instance": { + "provider": "docker", + "runtime": { + "id": "container-abc123", + "working_directory": "/workspace", + "repo_cloned": true, + "clone_origin_url": "https://github.com/fabro-sh/fabro.git", + "clone_branch": "main", + "workspace_root": "/workspace", + "repos_root": "/repos", + "primary_repo_path": "/repos/fabro-sh/fabro", + "primary_repo_link": "/workspace/fabro" + } } }) ); assert!(value.get("identifier").is_none()); } +#[test] +fn run_sandbox_ready_requires_instance() { + let sandbox = json!({ + "kind": "ready", + "plan": { "provider": "docker" } + }); + + assert!(serde_json::from_value::(sandbox).is_err()); +} + #[test] fn sandbox_details_requires_canonical_id_and_working_directory() { let details = SandboxDetails { - sandbox: RunSandbox { + sandbox: RunSandboxInstance { provider: SandboxProviderKind::Daytona, image: Some("ubuntu:24.04".to_string()), snapshot: None, - runtime: Some(RunSandboxRuntime { + runtime: RunSandboxRuntime { id: "daytona-sandbox-name".to_string(), working_directory: "/workspace".to_string(), repo_cloned: None, @@ -65,7 +88,7 @@ fn sandbox_details_requires_canonical_id_and_working_directory() { repos_root: Some("/home/daytona/repos".to_string()), primary_repo_path: None, primary_repo_link: None, - }), + }, }, state: SandboxState::Running, native_state: Some("started".to_string()), diff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/crates/fabro-workflow/src/operations/retry.rs index 66c37c022..7a80033df 100644 --- a/lib/crates/fabro-workflow/src/operations/retry.rs +++ b/lib/crates/fabro-workflow/src/operations/retry.rs @@ -406,7 +406,7 @@ mod tests { retry_state .sandbox .as_ref() - .and_then(|sandbox| sandbox.runtime.as_ref()) + .and_then(fabro_types::RunSandbox::instance) .is_none() ); diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 11c0833bc..cc1b0777a 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -333,6 +333,9 @@ pub async fn initialize( let record = run_state.sandbox.ok_or_else(|| { Error::Precondition("cannot resume run: run sandbox is missing".to_string()) })?; + let instance = record.instance().ok_or_else(|| { + Error::Precondition("cannot resume run: run sandbox was not initialized".to_string()) + })?; let daytona_api_key = match &options.vault { Some(vault) => vault .read() @@ -342,7 +345,7 @@ pub async fn initialize( None => None, }; let sandbox = reconnect_for_run_with_callback( - &record, + instance, daytona_api_key, Some(options.run_id), Some(Arc::clone(&sandbox_event_callback)), @@ -404,11 +407,8 @@ pub async fn initialize( if sandbox_initialized { let run_sandbox = options .sandbox - .to_run_sandbox(&*sandbox, options.run_options.run_id); - let runtime = run_sandbox - .runtime - .as_ref() - .ok_or_else(|| Error::engine("initialized sandbox missing runtime metadata"))?; + .to_run_sandbox_instance(&*sandbox, options.run_options.run_id); + let runtime = &run_sandbox.runtime; options.emitter.emit(&Event::SandboxInitialized { working_directory: runtime.working_directory.clone(), provider: run_sandbox.provider, diff --git a/lib/crates/fabro-workflow/tests/it/cp_integration.rs b/lib/crates/fabro-workflow/tests/it/cp_integration.rs index 60aafd9bb..6fd43fe70 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, RunSandboxRuntime, SandboxProviderKind}; +use fabro_types::{RunSandboxInstance, RunSandboxRuntime, SandboxProviderKind}; const DOCKER_MANAGED_LABEL: &str = "sh.fabro.managed"; const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble"; @@ -24,12 +24,12 @@ const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble"; // Local sandbox // --------------------------------------------------------------------------- -fn local_record(working_directory: &std::path::Path) -> RunSandbox { - RunSandbox { +fn local_record(working_directory: &std::path::Path) -> RunSandboxInstance { + RunSandboxInstance { provider: SandboxProviderKind::Local, image: None, snapshot: None, - runtime: Some(RunSandboxRuntime { + runtime: RunSandboxRuntime { id: "local:test".to_string(), working_directory: working_directory.to_string_lossy().to_string(), repo_cloned: None, @@ -39,7 +39,7 @@ fn local_record(working_directory: &std::path::Path) -> RunSandbox { repos_root: None, primary_repo_path: None, primary_repo_link: None, - }), + }, } } @@ -135,12 +135,12 @@ async fn local_cp_creates_parent_dirs() { // Docker sandbox // --------------------------------------------------------------------------- -fn docker_record(container_id: &str) -> RunSandbox { - RunSandbox { +fn docker_record(container_id: &str) -> RunSandboxInstance { + RunSandboxInstance { provider: SandboxProviderKind::Docker, image: None, snapshot: None, - runtime: Some(RunSandboxRuntime { + runtime: RunSandboxRuntime { id: container_id.to_string(), working_directory: "/workspace".to_string(), repo_cloned: Some(false), @@ -150,7 +150,7 @@ fn docker_record(container_id: &str) -> RunSandbox { repos_root: Some("/repos".to_string()), primary_repo_path: None, primary_repo_link: None, - }), + }, } } diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 2d223a550..346825e7e 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -1703,7 +1703,7 @@ async fn daytona_toolbox_idle_diagnostic() { #[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] async fn daytona_cp_upload_download_round_trip() { use fabro_sandbox::reconnect::reconnect; - use fabro_types::{RunSandbox, SandboxProviderKind}; + use fabro_types::{RunSandboxInstance, SandboxProviderKind}; // 1. Create and initialize a real Daytona sandbox let env = create_env().await; @@ -1715,12 +1715,12 @@ async fn daytona_cp_upload_download_round_trip() { "sandbox_info() should return the Daytona sandbox name" ); - // 2. Build a RunSandbox (same as `fabro run` would persist) - let record = RunSandbox { + // 2. Build initialized sandbox metadata (same as `fabro run` would persist) + let record = RunSandboxInstance { provider: SandboxProviderKind::Daytona, image: None, snapshot: None, - runtime: Some(fabro_types::RunSandboxRuntime { + runtime: fabro_types::RunSandboxRuntime { id: sandbox_name.clone(), working_directory: env.working_directory().to_string(), repo_cloned: Some(false), @@ -1730,7 +1730,7 @@ async fn daytona_cp_upload_download_round_trip() { repos_root: Some("/home/daytona/repos".to_string()), primary_repo_path: None, primary_repo_link: 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 a3e517248..47d63f903 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -352,6 +352,10 @@ models/run-provenance.ts models/run-question.ts models/run-reference.ts models/run-runnable-source.ts +models/run-sandbox-failure.ts +models/run-sandbox-instance.ts +models/run-sandbox-kind.ts +models/run-sandbox-plan.ts models/run-sandbox-runtime.ts models/run-sandbox.ts models/run-scm-settings.ts diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 2ee3e1593..eea7c071d 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -328,6 +328,10 @@ export * from './run-question'; export * from './run-reference'; export * from './run-runnable-source'; export * from './run-sandbox'; +export * from './run-sandbox-failure'; +export * from './run-sandbox-instance'; +export * from './run-sandbox-kind'; +export * from './run-sandbox-plan'; export * from './run-sandbox-runtime'; export * from './run-scm-settings'; export * from './run-server-provenance'; diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts new file mode 100644 index 000000000..c30582f99 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts @@ -0,0 +1,28 @@ +/* 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. + */ + + + +/** + * Sandbox initialization failure details. + */ +export interface RunSandboxFailure { + /** + * Provider reported by the sandbox initialization event. + */ + 'provider': string; + 'error': string; + 'causes': Array; + 'duration_ms': number; +} diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts new file mode 100644 index 000000000..1fd11d41c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts @@ -0,0 +1,31 @@ +/* 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 { RunSandboxRuntime } from './run-sandbox-runtime'; +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxProviderKind } from './sandbox-provider-kind'; + +/** + * Initialized sandbox provider and runtime metadata. + */ +export interface RunSandboxInstance { + 'provider': SandboxProviderKind; + 'image'?: string | null; + 'snapshot'?: string | null; + 'runtime': RunSandboxRuntime; +} diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts new file mode 100644 index 000000000..5839dafad --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts @@ -0,0 +1,28 @@ +/* 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. + */ + + + +/** + * Lifecycle state for a run sandbox request. + */ + +export const RunSandboxKind = { + PLANNED: 'planned', + INITIALIZING: 'initializing', + READY: 'ready', + FAILED: 'failed' +} as const; + +export type RunSandboxKind = typeof RunSandboxKind[keyof typeof RunSandboxKind]; diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts new file mode 100644 index 000000000..6a7e2d032 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-sandbox-plan.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. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxProviderKind } from './sandbox-provider-kind'; + +/** + * Requested sandbox provider and base image/snapshot from run settings. + */ +export interface RunSandboxPlan { + 'provider': SandboxProviderKind; + 'image'?: string | null; + 'snapshot'?: 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 7e5102b1b..12c15b34f 100644 --- a/lib/packages/fabro-api-client/src/models/run-sandbox.ts +++ b/lib/packages/fabro-api-client/src/models/run-sandbox.ts @@ -15,17 +15,23 @@ // May contain unused imports in some cases // @ts-ignore -import type { RunSandboxRuntime } from './run-sandbox-runtime'; +import type { RunSandboxFailure } from './run-sandbox-failure'; // May contain unused imports in some cases // @ts-ignore -import type { SandboxProviderKind } from './sandbox-provider-kind'; +import type { RunSandboxInstance } from './run-sandbox-instance'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunSandboxKind } from './run-sandbox-kind'; +// May contain unused imports in some cases +// @ts-ignore +import type { RunSandboxPlan } from './run-sandbox-plan'; /** - * Canonical sandbox environment record for a run. + * Sandbox lifecycle record for a run. A run can have a requested sandbox plan before it has an initialized sandbox instance. */ export interface RunSandbox { - 'provider': SandboxProviderKind; - 'image': string | null; - 'snapshot': string | null; - 'runtime': RunSandboxRuntime | null; + 'kind': RunSandboxKind; + 'plan': RunSandboxPlan; + 'instance'?: RunSandboxInstance | null; + 'failure'?: RunSandboxFailure | null; } 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 5c733abec..016fd2661 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 { RunSandbox } from './run-sandbox'; +import type { RunSandboxInstance } from './run-sandbox-instance'; // May contain unused imports in some cases // @ts-ignore import type { SandboxNetwork } from './sandbox-network'; @@ -33,7 +33,7 @@ import type { SandboxTimestamps } from './sandbox-timestamps'; * Provider-neutral details about the sandbox owned by a run. */ export interface SandboxDetails { - 'sandbox': RunSandbox; + 'sandbox': RunSandboxInstance; 'state': SandboxState; /** * Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`.