Show the sandbox's ready duration and retention outcome on the run

`RunSandboxInstance` carries `ready_duration_ms` from the root scope's
`scope.acquired` and `retained` from its `scope.released`, so the view
says how long the sandbox took and whether it still exists after the run.
The OpenAPI schema, the TypeScript client and the web sandbox tab's
overview show both; the host sandbox scenario asserts them on a real run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-18 19:34:56 -04:00
parent 0fe066d420
commit a4af6fac92
No known key found for this signature in database
16 changed files with 158 additions and 60 deletions

View file

@ -217,12 +217,14 @@ describe("RunSandbox route", () => {
test("renders panels for a fully populated sandbox", () => {
currentDetails = sandboxDetails({
sandbox: {
provider: "docker",
image: "ghcr.io/fabro/sandbox:latest",
runtime: {
provider: "docker",
image: "ghcr.io/fabro/sandbox:latest",
runtime: {
id: "abcdef123456",
working_directory: "/workspace",
},
ready_duration_ms: 1500,
retained: true,
},
status: {
state: "running",
@ -248,6 +250,10 @@ describe("RunSandbox route", () => {
const copy = textContent(renderer);
expect(copy).toContain("Allow all");
expect(copy).toContain("4 GiB");
expect(copy).toContain("Ready in");
expect(copy).toContain("1.5s");
expect(copy).toContain("Retained");
expect(copy).toContain("Yes");
});
test("links to the provider dashboard when a sandbox web URL is present", () => {

View file

@ -9,6 +9,7 @@ import {
formatAbsoluteTs,
formatBytesAsMemory,
formatCpuCores,
formatDurationMs,
} from "../lib/format";
import { useRun, useRunSandboxDetails, useRunState } from "../lib/queries";
import {
@ -57,6 +58,17 @@ function nullableTimestamp(value: string | null | undefined): string {
return value ? formatAbsoluteTs(value) : EMPTY_VALUE;
}
function nullableDuration(ms: number | null | undefined): string {
return ms == null ? EMPTY_VALUE : formatDurationMs(ms);
}
/// The release outcome: "Yes" when the sandbox still exists after the run
/// released it, "No" when it was removed, and empty before the release.
function nullableRetained(retained: boolean | null | undefined): string {
if (retained == null) return EMPTY_VALUE;
return retained ? "Yes" : "No";
}
function nullableMegabytes(megabytes: number | null | undefined): string {
return megabytes != null ? formatBytesAsMemory(megabytes * 1024 * 1024) : EMPTY_VALUE;
}
@ -203,6 +215,8 @@ function OverviewPanel({ details }: { details: SandboxDetails }) {
value={nullable(status.image ?? status.snapshot ?? sandbox.image ?? sandbox.snapshot)}
/>
{status.sandbox_kind && <Row label="Kind" value={status.sandbox_kind} />}
<Row label="Ready in" value={nullableDuration(sandbox.ready_duration_ms)} />
<Row label="Retained" value={nullableRetained(sandbox.retained)} />
{status.web_url && (
<LinkRow
label="Provider"

View file

@ -13664,6 +13664,17 @@ components:
type: ["string", "null"]
runtime:
$ref: "#/components/schemas/RunSandboxRuntime"
ready_duration_ms:
type: ["integer", "null"]
format: uint64
minimum: 0
description: How long the sandbox took to become ready, when recorded.
retained:
type: ["boolean", "null"]
description: |
Whether the sandbox still exists after the run released it
(kept, stopped or running) or was removed. Absent until the
release.
RunSandboxFailure:
description: Sandbox initialization failure details.

View file

@ -742,6 +742,14 @@ async fn a_runs_projection_carries_its_host_sandbox_instance() {
Path::new(working_directory).is_dir(),
"the workspace is retained after the run: {working_directory}"
);
assert!(
instance["ready_duration_ms"].is_u64(),
"the acquisition's duration is on the instance: {instance}"
);
assert_eq!(
instance["retained"], true,
"the release said the sandbox still exists: {instance}"
);
assert!(sandbox.get("failure").is_none(), "{sandbox}");
let run = run_json(&app, &run_id).await;

View file

@ -574,10 +574,10 @@ mod tests {
snapshot: None,
},
RunSandboxInstance {
provider: SandboxProviderKind::LOCAL,
image: None,
snapshot: None,
runtime: fabro_types::RunSandboxRuntime {
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,
@ -588,6 +588,8 @@ mod tests {
primary_repo_path: None,
primary_repo_link: None,
},
ready_duration_ms: None,
retained: None,
},
));
let stage =

View file

@ -125,9 +125,8 @@ engine-derived maps (`completed_nodes`, `node_retries`, `context_values`,
`script_invocation` and `script_timing`, a stage's `notes`,
`StageCompletion` details for a `parsed.note`, the sandbox instance's
clone fields and workspace roots (Petri's checkout is a copy of the bound
repository, not a clone; the roots are the provider's, read live) and the
retention outcome (kept as `FoldState.sandbox_retained`; the view has no
field for it), `Run.ask_fabro`, an interview option's `description` and
repository, not a clone; the roots are the provider's, read live),
`Run.ask_fabro`, an interview option's `description` and
`preview`, the pull request `creation` state, and the run's notices,
notifications and pairings (recorded, not shown).

View file

@ -214,9 +214,9 @@ which is Fabro's `local`; every other kind is spelled the same.
| --- | --- | --- | --- |
| plan | `RunSandbox.plan {provider, image, snapshot}` | `graph.registered`'s `fabro.environment` and `fabro.launch {sandbox_backend}` params; platform record `run.created` | run |
| binding: isolated or inherited | none today | `invocation.declared {sandbox}` | invocation |
| which: planned, initializing, ready, failed | `RunSandbox.kind`, `sandbox.initializing`, `sandbox.ready {duration_ms, name, url}`, `sandbox.failed {error, causes, duration_ms}` | `planned` from the platform record `run.created`; `initializing` from `run.started`; `ready` from the root invocation's `scope.acquired` (`provider`, `image`, `snapshot`); `failed` from its `scope.failed` (`provider`, `error`, `causes`, `duration_ms`). The ready duration (`scope.acquired` `duration_ms`) has no field on `RunSandbox` and is not projected | scope |
| which: planned, initializing, ready, failed | `RunSandbox.kind`, `sandbox.initializing`, `sandbox.ready {duration_ms, name, url}`, `sandbox.failed {error, causes, duration_ms}` | `planned` from the platform record `run.created`; `initializing` from `run.started`; `ready` from the root invocation's `scope.acquired` (`provider`, `image`, `snapshot`); `failed` from its `scope.failed` (`provider`, `error`, `causes`, `duration_ms`). The ready duration (`scope.acquired` `duration_ms`) is `RunSandboxInstance.ready_duration_ms` | scope |
| where: instance id, working directory, clone, workspace roots | `RunSandboxInstance.runtime {id, working_directory, repo_cloned, clone_origin_url, clone_branch, workspace_root, repos_root, primary_repo_path, primary_repo_link}`, `sandbox.initialized` | `scope.acquired` (`instance` is the id a reconnect attaches by, `working_directory`). The clone fields stay unset: `custom attractor.checkout` records Petri's copy of the bound repository into the workspace (`repository`, `commit`, `depth`, `files`), which is not a clone Fabro made, and the workspace roots are the provider's layout, read live | scope |
| retention | none today; the run-end `sandbox_cleanup` hook | `scope.released {outcome, retained, problems}` of the root invocation's lease, kept as `FoldState.sandbox_retained`; the view has no field for it and `Run.sandbox` keeps naming the instance that ran. `run.note.recorded {kind: hook, point: scope_released}` when a hook ran | scope |
| retention | `RunSandboxInstance.retained`; the run-end `sandbox_cleanup` hook | `scope.released {outcome, retained, problems}` of the root invocation's lease: `retained` is set on the instance (and kept as `FoldState.sandbox_retained`), and `Run.sandbox` keeps naming the instance that ran, whether or not it still exists. `run.note.recorded {kind: hook, point: scope_released}` when a hook ran | scope |
| live status, resources, files, services, VNC, preview, SSH | `SandboxStatus`, `SandboxFileEntry`, `SandboxService`, `VncPreviewResponse`, `PreviewUrlResponse`, `SshAccessResponse`, `ssh.ready` | live: the sandbox-driver provider queried by the run label | run |
| setup commands | `setup.started`, `setup.command.completed`, `setup.completed`, `setup.failed`, `cli.ensure.*` | the `run_prepare_N` stages (Stages section); `cli.ensure.*` has no Petri equivalent and is dropped (the image carries the CLI) | stage |

View file

@ -143,7 +143,8 @@ pub struct FoldState {
pub finished_firings: BTreeSet<String>,
/// Whether the run's sandbox still exists after its release
/// (`scope.released` `retained`): kept stopped, or deleted. Absent until
/// the root invocation's lease was released.
/// the root invocation's lease was released. The view carries the same
/// fact as `RunSandboxInstance.retained`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox_retained: Option<bool>,
}
@ -462,9 +463,8 @@ impl RunView {
self.conclude(status.to_string().as_str(), at);
}
// ── Sandbox: the retention outcome (VIEWS.md "Sandbox") ─────────
// The view has no retention field; the fact is kept in the fold
// state for the read side. The instance stays on `Run.sandbox`:
// it names what ran, whether or not it still exists.
// The instance stays on `Run.sandbox`: it names what ran, and
// `retained` says whether it still exists.
CoordinatorEvent::ScopeReleased {
invocation,
retained,
@ -472,6 +472,13 @@ impl RunView {
} => {
if Some(invocation.raw()) == self.state.root {
self.state.sandbox_retained = Some(*retained);
if let Some(sandbox) = self
.projection
.as_mut()
.and_then(|projection| projection.sandbox.as_mut())
{
sandbox.set_retained(*retained);
}
}
}
CoordinatorEvent::GraphRegistered { .. }
@ -707,12 +714,16 @@ impl RunView {
// invocation's scope (a parallel branch) shares or owns another
// one and is not the run's; a re-acquisition (a resume, a
// replaced sandbox) names the current instance.
Event::ScopeAcquired { sandbox, .. } => {
Event::ScopeAcquired {
sandbox,
duration_ms,
..
} => {
if let Some(projection) = self.root_scope_projection(event) {
let plan = sandbox_plan_of(projection);
projection.sandbox = Some(RunSandbox::ready(
plan.clone(),
sandbox_instance(&plan, sandbox),
sandbox_instance(&plan, sandbox, *duration_ms),
));
}
}
@ -1515,20 +1526,26 @@ fn provider_kind(provider: &str) -> Option<SandboxProviderKind> {
/// The run's sandbox instance from Petri's record of the scope's
/// acquisition: the provider, the provider's id for the sandbox (what a
/// reconnect attaches by), its image and snapshot when the provider knows
/// them, and the working directory. The clone fields stay unset: Petri's
/// checkout copies the bound repository into the workspace and is not a
/// clone Fabro made, and the workspace roots are the provider's own layout,
/// read live.
fn sandbox_instance(plan: &RunSandboxPlan, sandbox: &SandboxInstance) -> RunSandboxInstance {
/// them, the working directory, and how long the acquisition took. The
/// clone fields stay unset: Petri's checkout copies the bound repository
/// into the workspace and is not a clone Fabro made, and the workspace
/// roots are the provider's own layout, read live. `retained` waits for
/// the scope's release.
fn sandbox_instance(
plan: &RunSandboxPlan,
sandbox: &SandboxInstance,
ready_duration_ms: u64,
) -> RunSandboxInstance {
RunSandboxInstance {
provider: provider_kind(&sandbox.provider).unwrap_or_else(|| plan.provider.clone()),
image: sandbox
provider: provider_kind(&sandbox.provider)
.unwrap_or_else(|| plan.provider.clone()),
image: sandbox
.image
.as_ref()
.map(ToString::to_string)
.or_else(|| plan.image.clone()),
snapshot: sandbox.snapshot.as_ref().map(ToString::to_string),
runtime: RunSandboxRuntime {
snapshot: sandbox.snapshot.as_ref().map(ToString::to_string),
runtime: RunSandboxRuntime {
id: sandbox.instance.to_string(),
working_directory: sandbox.working_directory.to_string(),
repo_cloned: None,
@ -1539,6 +1556,8 @@ fn sandbox_instance(plan: &RunSandboxPlan, sandbox: &SandboxInstance) -> RunSand
primary_repo_path: None,
primary_repo_link: None,
},
ready_duration_ms: Some(ready_duration_ms),
retained: None,
}
}

View file

@ -93,10 +93,10 @@ impl SandboxSpec {
LayoutSource::ProviderWorkingDirectory => sandbox.workspace_layout(),
};
RunSandboxInstance {
provider: self.kind.clone(),
image: self.image(),
snapshot: sandbox.snapshot_info(),
runtime: RunSandboxRuntime {
provider: self.kind.clone(),
image: self.image(),
snapshot: sandbox.snapshot_info(),
runtime: RunSandboxRuntime {
id,
working_directory,
repo_cloned,
@ -113,6 +113,8 @@ impl SandboxSpec {
.as_ref()
.and_then(|layout| layout.primary_repo_link.clone()),
},
ready_duration_ms: None,
retained: None,
}
}

View file

@ -83,10 +83,10 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
snapshot: None,
};
projection.sandbox = Some(RunSandbox::ready(sandbox_plan, RunSandboxInstance {
provider: SandboxProviderKind::LOCAL,
image: None,
snapshot: None,
runtime: RunSandboxRuntime {
provider: SandboxProviderKind::LOCAL,
image: None,
snapshot: None,
runtime: RunSandboxRuntime {
id: "sandbox-1".to_string(),
working_directory: "/tmp/project".to_string(),
repo_cloned: None,
@ -97,6 +97,8 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
primary_repo_path: None,
primary_repo_link: None,
},
ready_duration_ms: None,
retained: None,
}));
projection.pending_interviews = BTreeMap::new();
let stage = projection.stage_entry(stage_id.node_id(), stage_id.visit(), first_event_seq(2));

View file

@ -26,10 +26,10 @@ fn run_sandbox_json_matches_openapi_shape() {
snapshot: None,
},
RunSandboxInstance {
provider: SandboxProviderKind::DOCKER,
image: None,
snapshot: None,
runtime: RunSandboxRuntime {
provider: SandboxProviderKind::DOCKER,
image: None,
snapshot: None,
runtime: RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(false),
@ -40,6 +40,8 @@ fn run_sandbox_json_matches_openapi_shape() {
primary_repo_path: None,
primary_repo_link: None,
},
ready_duration_ms: Some(1_250),
retained: Some(true),
},
);
@ -63,7 +65,9 @@ fn run_sandbox_json_matches_openapi_shape() {
"clone_branch": "main",
"workspace_root": "/workspace",
"repos_root": "/repos"
}
},
"ready_duration_ms": 1250,
"retained": true
}
})
);

View file

@ -55,10 +55,10 @@ fn sandbox_details_json_matches_openapi_shape() {
));
let details = SandboxDetails {
sandbox: RunSandboxInstance {
provider: SandboxProviderKind::DOCKER,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: RunSandboxRuntime {
provider: SandboxProviderKind::DOCKER,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: None,
@ -69,6 +69,8 @@ fn sandbox_details_json_matches_openapi_shape() {
primary_repo_path: Some("/repos/fabro-sh/fabro".to_string()),
primary_repo_link: Some("/workspace/fabro".to_string()),
},
ready_duration_ms: None,
retained: None,
},
status,
};

View file

@ -24,12 +24,19 @@ pub struct RunSandboxPlan {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSandboxInstance {
pub provider: SandboxProviderKind,
pub provider: SandboxProviderKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
pub runtime: RunSandboxRuntime,
pub snapshot: Option<String>,
pub runtime: RunSandboxRuntime,
/// How long the sandbox took to become ready, when its record says.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ready_duration_ms: Option<u64>,
/// Whether the sandbox still exists after the run released it: kept
/// (stopped or running), or removed. Absent until the release.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retained: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -90,6 +97,14 @@ impl RunSandbox {
self.instance.as_ref()
}
/// Record the release outcome on the instance: whether the sandbox
/// still exists. Nothing to record without an instance.
pub fn set_retained(&mut self, retained: bool) {
if let Some(instance) = self.instance.as_mut() {
instance.retained = Some(retained);
}
}
pub fn into_instance(self) -> Option<RunSandboxInstance> {
self.instance
}

View file

@ -35,10 +35,10 @@ mod tests {
));
let details = SandboxDetails {
sandbox: RunSandboxInstance {
provider: SandboxProviderKind::DOCKER,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: RunSandboxRuntime {
provider: SandboxProviderKind::DOCKER,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: None,
@ -49,6 +49,8 @@ mod tests {
primary_repo_path: None,
primary_repo_link: None,
},
ready_duration_ms: None,
retained: None,
},
status,
};

View file

@ -14,10 +14,10 @@ fn run_sandbox_serializes_canonical_identity_without_identifier() {
snapshot: None,
},
RunSandboxInstance {
provider: SandboxProviderKind::DOCKER,
image: None,
snapshot: None,
runtime: RunSandboxRuntime {
provider: SandboxProviderKind::DOCKER,
image: None,
snapshot: None,
runtime: RunSandboxRuntime {
id: "container-abc123".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: Some(true),
@ -28,6 +28,8 @@ fn run_sandbox_serializes_canonical_identity_without_identifier() {
primary_repo_path: Some("/repos/fabro-sh/fabro".to_string()),
primary_repo_link: Some("/workspace/fabro".to_string()),
},
ready_duration_ms: None,
retained: None,
},
);
@ -83,10 +85,10 @@ fn sandbox_details_keep_the_record_beside_the_status() {
);
let details = SandboxDetails {
sandbox: RunSandboxInstance {
provider: SandboxProviderKind::DAYTONA,
image: Some("ubuntu:24.04".to_string()),
snapshot: None,
runtime: RunSandboxRuntime {
provider: SandboxProviderKind::DAYTONA,
image: Some("ubuntu:24.04".to_string()),
snapshot: None,
runtime: RunSandboxRuntime {
id: "daytona-sandbox-name".to_string(),
working_directory: "/workspace".to_string(),
repo_cloned: None,
@ -97,6 +99,8 @@ fn sandbox_details_keep_the_record_beside_the_status() {
primary_repo_path: None,
primary_repo_link: None,
},
ready_duration_ms: None,
retained: None,
},
status,
};

View file

@ -28,4 +28,12 @@ export interface RunSandboxInstance {
'image'?: string | null;
'snapshot'?: string | null;
'runtime': RunSandboxRuntime;
/**
* How long the sandbox took to become ready, when recorded.
*/
'ready_duration_ms'?: number | null;
/**
* Whether the sandbox still exists after the run released it (kept, stopped or running) or was removed. Absent until the release.
*/
'retained'?: boolean | null;
}