mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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` --- [](https://github.com/EveryInc/compound-engineering-plugin) 🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)
This commit is contained in:
parent
352b7c5de4
commit
e18772888e
52 changed files with 1452 additions and 390 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
91
apps/fabro-web/app/lib/run-sandbox-lifecycle.ts
Normal file
91
apps/fabro-web/app/lib/run-sandbox-lifecycle.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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", &[]),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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"))]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
28
lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts
generated
Normal file
28
lib/packages/fabro-api-client/src/models/run-sandbox-failure.ts
generated
Normal 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;
|
||||
}
|
||||
31
lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts
generated
Normal file
31
lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts
generated
Normal 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;
|
||||
}
|
||||
28
lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts
generated
Normal file
28
lib/packages/fabro-api-client/src/models/run-sandbox-kind.ts
generated
Normal 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];
|
||||
27
lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts
generated
Normal file
27
lib/packages/fabro-api-client/src/models/run-sandbox-plan.ts
generated
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue