Model run sandbox lifecycle explicitly (#431)

## Summary

Fixes sandbox state reporting by separating a requested sandbox plan
from an initialized sandbox instance. Runs now project sandbox lifecycle
as `planned`, `initializing`, `ready`, or `failed`, and live sandbox
operations only proceed once a real instance exists.

## Changes

- Introduces `RunSandboxPlan`, `RunSandboxInstance`, and
lifecycle-backed `RunSandbox` domain types, with serde validation that
prevents `ready` sandboxes without an instance.
- Updates store projection behavior so sandbox events transition through
planned, initializing, ready, and failed states while preserving
requested provider/image/snapshot separately from runtime metadata.
- Tightens server sandbox handlers so
details/files/services/terminal/VNC helpers require an initialized
instance and return a clear 404 when the sandbox was never created.
- Updates the OpenAPI contract and regenerated clients so `Run.sandbox`
exposes lifecycle state while `SandboxDetails.sandbox` contains only
initialized instance metadata.
- Updates the web UI to render lifecycle state directly from run
summaries, hide the Sandbox tab for pure planned sandboxes, and disable
sandbox controls until the instance is ready.
- Cleans up duplicated lifecycle display/type logic and duplicate
server-side sandbox instance loading found during review.

| Lifecycle state | Meaning | Live controls |
| --- | --- | --- |
| `planned` | Sandbox was requested but no provider instance exists |
Hidden/disabled |
| `initializing` | Provider setup has started | State view only |
| `ready` | Runtime instance exists | Enabled |
| `failed` | Provider setup failed with error details | State view only
|

## Testing

- `cargo check --workspace`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `git diff --check`
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun test app/routes/run-detail.test.ts
app/routes/run-sandbox.test.tsx
app/components/run-summary-panel.test.tsx`
- `cargo nextest run -p fabro-types --test sandbox_model_serde`
- `cargo nextest run -p fabro-store
run_created_projects_planned_sandbox_lifecycle
sandbox_lifecycle_events_update_projected_sandbox_state
run_failed_before_sandbox_events_leaves_sandbox_planned`
- `cargo nextest run -p fabro-server
planned_sandbox_returns_404_from_details_endpoint
planned_sandbox_rejects_live_operations
failed_sandbox_rejects_live_operations
local_sandbox_returns_provider_neutral_details`
- `cargo nextest run -p fabro-api --test run_sandbox_round_trip`
- `cargo nextest run -p fabro-api --test sandbox_details_round_trip`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
Bryan Helmkamp 2026-05-27 12:48:56 -04:00 committed by GitHub
parent 352b7c5de4
commit e18772888e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1452 additions and 390 deletions

View file

@ -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);

View file

@ -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 (
<div className="flex items-center gap-2">
<Tooltip label={display.description}>
<span
aria-hidden="true"
className={`size-2 rounded-full ${display.dot}`}
/>
</Tooltip>
<span className={`${VALUE_CLASS} ${display.text}`}>{display.label}</span>
</div>
);
}
export function RunSummaryPanelView({
run,
runLoading,
@ -95,6 +119,7 @@ export function RunSummaryPanelView({
const created = run?.created_by ? principalDisplay(run.created_by) : null;
const diff = run?.diff ?? null;
const cost = formatUsdMicros(run?.billing?.total_usd_micros);
const sandboxKind = sandboxLifecycleKind(run?.sandbox);
return (
<div className="rounded-md border border-line bg-panel/60 px-6 py-4">
@ -135,6 +160,8 @@ export function RunSummaryPanelView({
<Skeleton widthClass="w-24" />
) : sandboxState ? (
<SandboxValue state={sandboxState} resources={sandboxResources} />
) : sandboxKind ? (
<SandboxLifecycleValue kind={sandboxKind} />
) : (
<EmptyValue />
)}
@ -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 (
<RunSummaryPanelView
@ -186,7 +216,7 @@ export function RunSummaryPanel({ runId }: { runId: string }) {
runLoading={runQuery.isLoading && !runQuery.data}
sandboxState={sandboxQuery.data?.state ?? null}
sandboxResources={sandboxQuery.data?.resources ?? null}
sandboxLoading={sandboxQuery.isLoading && !sandboxQuery.data}
sandboxLoading={sandboxReady && sandboxQuery.isLoading && !sandboxQuery.data}
artifactsCount={artifactsQuery.data?.data.length ?? null}
artifactsLoading={artifactsQuery.isLoading && !artifactsQuery.data}
/>

View file

@ -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;
}

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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"),

View file

@ -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;
}

View file

@ -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",
});

View file

@ -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={
<RepositoryPopover
repository={summary.repository}
cloneBranch={summary.sandbox?.runtime?.clone_branch}
cloneBranch={sandboxRuntime(summary.sandbox)?.clone_branch}
/>
}
>

View file

@ -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({

View file

@ -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", () => {

View file

@ -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 ? (
<div className={`mt-3 space-y-1 text-xs text-fg-muted ${compact ? "" : "max-w-lg"}`}>
{failure.causes.map((cause) => (
<p key={cause}>{cause}</p>
))}
</div>
) : null;
return <EmptyState title={title} description={description} action={action} />;
}
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 ? (
<ModeToggle
mode={mode}
onChange={setMode}
vncAvailable={vncTabAvailable(provider)}
/>
),
[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 } }) {
<aside
className={`w-80 shrink-0 min-h-0 overflow-y-auto pt-3 pr-6 ${TERMINAL_DOCK_CLEARANCE_CLASS}`}
>
{sandboxQuery.error ? (
{!ready && lifecycleSandbox ? (
<SandboxLifecycleStateView sandbox={lifecycleSandbox} compact />
) : sandboxQuery.error ? (
<ErrorState
title="Sandbox unavailable"
description={
@ -317,14 +369,18 @@ export default function RunSandbox({ params }: { params: { id: string } }) {
}
/>
) : sandboxQuery.isLoading && !sandboxQuery.data ? null : (
<DetailsColumn details={sandboxQuery.data ?? null} />
<DetailsColumn details={details} />
)}
</aside>
<div className="flex min-w-0 min-h-0 flex-1 flex-col border-l border-line">
<div
className={`flex min-h-0 flex-1 flex-col pt-3 pl-6 ${TERMINAL_DOCK_CLEARANCE_CLASS}`}
>
{(() => {
{!ready ? (
<div className="flex min-h-0 flex-1 items-center justify-center">
<SandboxLifecycleStateView sandbox={lifecycleSandbox} />
</div>
) : (() => {
if (mode === "terminal") {
return <TerminalView runId={params.id} leading={modeToggle} />;
}
@ -332,7 +388,10 @@ export default function RunSandbox({ params }: { params: { id: string } }) {
return <ServicesPanel runId={params.id} leading={modeToggle} />;
}
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 (
<FilesystemPanel
key={rootDirectory ?? "default-root"}

View file

@ -10438,13 +10438,55 @@ components:
- docker
- daytona
RunSandbox:
description: Canonical sandbox environment record for a run.
RunSandboxKind:
description: Lifecycle state for a run sandbox request.
type: string
enum:
- planned
- initializing
- ready
- failed
RunSandboxPlan:
description: Requested sandbox provider and base image/snapshot from run settings.
type: object
required:
- provider
properties:
provider:
$ref: "#/components/schemas/SandboxProviderKind"
image:
type: ["string", "null"]
snapshot:
type: ["string", "null"]
RunSandbox:
description: Sandbox lifecycle record for a run. A run can have a requested sandbox plan before it has an initialized sandbox instance.
type: object
required:
- kind
- plan
properties:
kind:
$ref: "#/components/schemas/RunSandboxKind"
plan:
$ref: "#/components/schemas/RunSandboxPlan"
instance:
oneOf:
- $ref: "#/components/schemas/RunSandboxInstance"
- type: "null"
description: Present only when `kind` is `ready`.
failure:
oneOf:
- $ref: "#/components/schemas/RunSandboxFailure"
- type: "null"
description: Present only when `kind` is `failed`.
RunSandboxInstance:
description: Initialized sandbox provider and runtime metadata.
type: object
required:
- provider
- image
- snapshot
- runtime
properties:
provider:
@ -10454,9 +10496,30 @@ components:
snapshot:
type: ["string", "null"]
runtime:
oneOf:
- $ref: "#/components/schemas/RunSandboxRuntime"
- type: "null"
$ref: "#/components/schemas/RunSandboxRuntime"
RunSandboxFailure:
description: Sandbox initialization failure details.
type: object
required:
- provider
- error
- causes
- duration_ms
properties:
provider:
type: string
description: Provider reported by the sandbox initialization event.
error:
type: string
causes:
type: array
items:
type: string
duration_ms:
type: integer
format: uint64
minimum: 0
RunSandboxRuntime:
type: object
@ -11326,7 +11389,7 @@ components:
- timestamps
properties:
sandbox:
$ref: "#/components/schemas/RunSandbox"
$ref: "#/components/schemas/RunSandboxInstance"
state:
$ref: "#/components/schemas/SandboxState"
native_state:

View file

@ -534,6 +534,10 @@ fn main() {
&[],
),
("RunSandboxRuntime", "fabro_types::RunSandboxRuntime", &[]),
("RunSandboxKind", "fabro_types::RunSandboxKind", &[]),
("RunSandboxPlan", "fabro_types::RunSandboxPlan", &[]),
("RunSandboxInstance", "fabro_types::RunSandboxInstance", &[]),
("RunSandboxFailure", "fabro_types::RunSandboxFailure", &[]),
("PullRequestUser", "fabro_types::PullRequestUser", &[]),
("PullRequestRef", "fabro_types::PullRequestRef", &[]),
(

View file

@ -48,12 +48,13 @@ pub mod types {
PullRequestLink, PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, Run,
RunApproval, RunApprovalState, RunClientProvenance, RunEvent, RunEventDetailContentKind,
RunEventDetailResponse, RunFailure, RunPairStatusResponse, RunProjection, RunProvenance,
RunRunnableSource, RunSandbox, RunSandboxRuntime, RunServerProvenance, RunSize,
SandboxDetails, SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork,
SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProviderKind,
SandboxProviderLookupError, SandboxResources, SandboxService, SandboxServiceListResponse,
SandboxState, SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail,
SessionId, SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
RunRunnableSource, RunSandbox, RunSandboxFailure, RunSandboxInstance, RunSandboxKind,
RunSandboxPlan, RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails,
SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError,
SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState,
SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId,
SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
StageContextWindowStaleness, StageContextWindowUnavailableReason,

View file

@ -35,13 +35,19 @@ fn run_projection_round_trips_populated_projection() {
],
"conclusion": null,
"sandbox": {
"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"
"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"
}
}
},
"pull_request": null,

View file

@ -1,49 +1,69 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProviderKind as ApiSandboxProvider};
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind};
use fabro_api::types::{
RunSandbox as ApiRunSandbox, RunSandboxInstance as ApiRunSandboxInstance,
RunSandboxPlan as ApiRunSandboxPlan, SandboxProviderKind as ApiSandboxProvider,
};
use fabro_types::{
RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, SandboxProviderKind,
};
use serde_json::json;
#[test]
fn run_sandbox_reuses_domain_types() {
assert_same_type::<ApiRunSandbox, RunSandbox>();
assert_same_type::<ApiRunSandboxPlan, RunSandboxPlan>();
assert_same_type::<ApiRunSandboxInstance, RunSandboxInstance>();
assert_same_type::<ApiSandboxProvider, SandboxProviderKind>();
}
#[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"
}
}
})
);

View file

@ -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());

View file

@ -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.
");
}

View file

@ -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)

View file

@ -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());

View file

@ -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<String>,
daytona_organization_id: Option<String>,
run_id: Option<RunId>,
@ -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<RunId>,
) -> Result<SandboxDetails> {
let docker =
Docker::connect_with_local_defaults().context("Failed to connect to Docker daemon")?;
let runtime = record
.runtime
.as_ref()
.context("Docker run sandbox missing runtime metadata")?;
let runtime = &record.runtime;
let inspect = docker
.inspect_container(&runtime.id, None::<InspectContainerOptions>)
.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<String>,
) -> Result<SandboxDetails> {
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());

View file

@ -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;

View file

@ -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<String>,
) -> Result<Box<dyn crate::Sandbox>> {
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<String>,
run_id: Option<RunId>,
) -> Result<Box<dyn crate::Sandbox>> {
@ -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<String>,
run_id: Option<RunId>,
event_callback: Option<SandboxEventCallback>,
) -> Result<Box<dyn crate::Sandbox>> {
let runtime = record
.runtime
.as_ref()
.context("run sandbox missing runtime metadata")?;
let runtime = &record.runtime;
match record.provider {
SandboxProviderKind::Local => {
let mut sandbox = LocalSandbox::new(PathBuf::from(&runtime.working_directory));

View file

@ -9,7 +9,7 @@ use fabro_github::GitHubCredentials;
unused_imports,
reason = "Daytona-enabled builds persist RunId in the sandbox spec."
)]
use fabro_types::{RunId, RunSandbox, 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));

View file

@ -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<String>,
daytona_organization_id: Option<String>,
run_id: Option<RunId>,
size: TerminalSize,
) -> crate::Result<Box<dyn TerminalSession>> {
#[cfg(any(feature = "daytona", feature = "docker"))]
let runtime = record
.runtime
.as_ref()
.ok_or_else(|| crate::Error::message("Run sandbox is missing runtime metadata"))?;
let runtime = &record.runtime;
#[cfg(not(feature = "daytona"))]
let _ = (&daytona_api_key, &daytona_organization_id);
#[cfg(not(feature = "docker"))]

View file

@ -1213,8 +1213,10 @@ async fn reconnect_run_sandbox(
) -> std::result::Result<Box<dyn Sandbox>, 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

View file

@ -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,

View file

@ -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<AppState>, 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<AppState>,
run_id: &RunId,
) -> Result<Box<dyn Sandbox>, 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<AppState>,
run_id: &RunId,
record: &RunSandboxInstance,
) -> Result<Box<dyn Sandbox>, 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<AppState>,
run_id: &RunId,
) -> Result<DaytonaSandbox, Response> {
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<AppState>,
record: &RunSandboxInstance,
) -> Result<DaytonaSandbox, Response> {
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<AppState>,
run_id: &RunId,
) -> Result<fabro_types::RunSandbox, Response> {
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<AppState>,
run_id: &RunId,
) -> Result<fabro_types::RunSandbox, Response> {
) -> Result<fabro_types::RunSandboxInstance, Response> {
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]

View file

@ -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),
)

View file

@ -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"));

View file

@ -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.")
);
}
}

View file

@ -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;

View file

@ -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<RunProjection> {
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,

View file

@ -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());

View file

@ -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,

View file

@ -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<String>,
}
#[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<String>,
pub duration_ms: u64,
}
pub type SandboxFailedProps = RunSandboxFailure;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxCleanupStartedProps {

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSandboxInstance {
pub provider: SandboxProviderKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<RunSandboxRuntime>,
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
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<String>,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RunSandbox {
kind: RunSandboxKind,
plan: RunSandboxPlan,
instance: Option<RunSandboxInstance>,
failure: Option<RunSandboxFailure>,
}
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<RunSandboxInstance> {
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<RunSandboxInstance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
failure: Option<RunSandboxFailure>,
}
#[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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<D>(deserializer: D) -> Result<Self, D::Error>
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)]

View file

@ -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<String>,
@ -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());

View file

@ -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::<RunSandbox>(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()),

View file

@ -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()
);

View file

@ -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,

View file

@ -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,
}),
},
}
}

View file

@ -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

View file

@ -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

View file

@ -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';

View file

@ -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<string>;
'duration_ms': number;
}

View file

@ -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;
}

View file

@ -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];

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// 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;
}

View file

@ -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;
}

View file

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