diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx
index 8772f9283..c661c3a04 100644
--- a/apps/fabro-web/app/routes/run-sandbox.test.tsx
+++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx
@@ -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", () => {
diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx
index c93a3f139..2db6f8c64 100644
--- a/apps/fabro-web/app/routes/run-sandbox.tsx
+++ b/apps/fabro-web/app/routes/run-sandbox.tsx
@@ -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 &&
}
+
+
{status.web_url && (
,
/// 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,
}
@@ -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 {
/// 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,
}
}
diff --git a/lib/components/fabro-sandbox/src/sandbox_spec.rs b/lib/components/fabro-sandbox/src/sandbox_spec.rs
index 3250163c8..2daf2fe0c 100644
--- a/lib/components/fabro-sandbox/src/sandbox_spec.rs
+++ b/lib/components/fabro-sandbox/src/sandbox_spec.rs
@@ -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,
}
}
diff --git a/lib/components/fabro-store/tests/serializable_projection.rs b/lib/components/fabro-store/tests/serializable_projection.rs
index 980d07657..c0171e60c 100644
--- a/lib/components/fabro-store/tests/serializable_projection.rs
+++ b/lib/components/fabro-store/tests/serializable_projection.rs
@@ -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));
diff --git a/lib/foundation/fabro-api/tests/run_sandbox_round_trip.rs b/lib/foundation/fabro-api/tests/run_sandbox_round_trip.rs
index 59aae637b..e38220838 100644
--- a/lib/foundation/fabro-api/tests/run_sandbox_round_trip.rs
+++ b/lib/foundation/fabro-api/tests/run_sandbox_round_trip.rs
@@ -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
}
})
);
diff --git a/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs b/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs
index bdfd35aae..d551f5338 100644
--- a/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs
+++ b/lib/foundation/fabro-api/tests/sandbox_details_round_trip.rs
@@ -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,
};
diff --git a/lib/foundation/fabro-types/src/run_sandbox.rs b/lib/foundation/fabro-types/src/run_sandbox.rs
index 91e3ec5f0..2cc12c9f7 100644
--- a/lib/foundation/fabro-types/src/run_sandbox.rs
+++ b/lib/foundation/fabro-types/src/run_sandbox.rs
@@ -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,
+ pub image: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub snapshot: Option,
- pub runtime: RunSandboxRuntime,
+ pub snapshot: Option,
+ 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,
+ /// 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,
}
#[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 {
self.instance
}
diff --git a/lib/foundation/fabro-types/src/sandbox_details.rs b/lib/foundation/fabro-types/src/sandbox_details.rs
index 362b88168..2a8ac9478 100644
--- a/lib/foundation/fabro-types/src/sandbox_details.rs
+++ b/lib/foundation/fabro-types/src/sandbox_details.rs
@@ -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,
};
diff --git a/lib/foundation/fabro-types/tests/sandbox_model_serde.rs b/lib/foundation/fabro-types/tests/sandbox_model_serde.rs
index 46648a17e..00dd93cc3 100644
--- a/lib/foundation/fabro-types/tests/sandbox_model_serde.rs
+++ b/lib/foundation/fabro-types/tests/sandbox_model_serde.rs
@@ -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,
};
diff --git a/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts b/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts
index 955ef483f..7e639f37c 100644
--- a/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts
+++ b/lib/packages/fabro-api-client/src/models/run-sandbox-instance.ts
@@ -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;
}