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 (
+
@@ -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`.