From a4af6fac92ec4a07b113e3a961fd0a664890b777 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 18 Sep 2026 19:34:56 -0400 Subject: [PATCH] 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 --- .../fabro-web/app/routes/run-sandbox.test.tsx | 12 +++-- apps/fabro-web/app/routes/run-sandbox.tsx | 14 ++++++ docs/public/api-reference/fabro-api.yaml | 11 +++++ .../fabro-server/tests/it/scenario/petri.rs | 8 +++ lib/components/fabro-dump/src/lib.rs | 10 ++-- lib/components/fabro-petri/README.md | 5 +- lib/components/fabro-petri/VIEWS.md | 4 +- lib/components/fabro-petri/src/projection.rs | 49 +++++++++++++------ .../fabro-sandbox/src/sandbox_spec.rs | 10 ++-- .../tests/serializable_projection.rs | 10 ++-- .../fabro-api/tests/run_sandbox_round_trip.rs | 14 ++++-- .../tests/sandbox_details_round_trip.rs | 10 ++-- lib/foundation/fabro-types/src/run_sandbox.rs | 23 +++++++-- .../fabro-types/src/sandbox_details.rs | 10 ++-- .../fabro-types/tests/sandbox_model_serde.rs | 20 +++++--- .../src/models/run-sandbox-instance.ts | 8 +++ 16 files changed, 158 insertions(+), 60 deletions(-) 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; }