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