diff --git a/apps/fabro-web/app/routes/run-settings.tsx b/apps/fabro-web/app/routes/run-settings.tsx
index 77bc744f4..42796d5e5 100644
--- a/apps/fabro-web/app/routes/run-settings.tsx
+++ b/apps/fabro-web/app/routes/run-settings.tsx
@@ -163,6 +163,9 @@ function SandboxPanel({ snapshot }: { snapshot: Snapshot }) {
+
+
+
diff --git a/apps/fabro-web/app/routes/workflow-detail.tsx b/apps/fabro-web/app/routes/workflow-detail.tsx
index ce9ac37d4..4f36d5d19 100644
--- a/apps/fabro-web/app/routes/workflow-detail.tsx
+++ b/apps/fabro-web/app/routes/workflow-detail.tsx
@@ -66,6 +66,7 @@ function sampleSettings({
sandbox: {
provider: "daytona",
preserve: false,
+ stop_on_terminal: true,
devcontainer: true,
env: {},
local: { worktree_mode: "dirty" },
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml
index 71933b9d3..1751fa1b2 100644
--- a/docs/public/api-reference/fabro-api.yaml
+++ b/docs/public/api-reference/fabro-api.yaml
@@ -922,11 +922,17 @@ paths:
operationId: deleteRun
tags: [Runs]
summary: Delete Run
- description: Deletes durable store state for a run. This does not remove any local run directory. Active runs require `force=true`.
+ description: Deletes durable store state, local run scratch data, and the run-owned sandbox unless sandbox preservation is enabled. Active runs require `force=true`.
parameters:
- $ref: "#/components/parameters/RunId"
- $ref: "#/components/parameters/ForceRunDelete"
responses:
+ "200":
+ description: Run deleted and sandbox preservation details returned
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DeleteRunResponse"
"204":
description: Run deleted or already absent
"404":
@@ -5105,6 +5111,27 @@ components:
`server.web.url` is non-empty; absent otherwise.
example: "http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z"
+ DeleteRunResponse:
+ description: Returned when a run is deleted but its sandbox is intentionally preserved.
+ type: object
+ required: [deleted, sandbox_preserved, sandbox]
+ properties:
+ deleted:
+ type: boolean
+ sandbox_preserved:
+ type: boolean
+ sandbox:
+ $ref: "#/components/schemas/DeleteRunSandbox"
+
+ DeleteRunSandbox:
+ type: object
+ required: [provider, identifier]
+ properties:
+ provider:
+ type: string
+ identifier:
+ type: string
+
ApiQuestionOption:
description: A selectable option for a multiple-choice or multi-select question.
type: object
@@ -8083,12 +8110,14 @@ components:
RunSandboxSettings:
type: object
- required: [provider, preserve, devcontainer, env, local, docker, daytona]
+ required: [provider, preserve, stop_on_terminal, devcontainer, env, local, docker, daytona]
properties:
provider:
type: string
preserve:
type: boolean
+ stop_on_terminal:
+ type: boolean
devcontainer:
type: boolean
env:
diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs
index d094f67bf..bb29e8d8b 100644
--- a/lib/crates/fabro-api/build.rs
+++ b/lib/crates/fabro-api/build.rs
@@ -134,6 +134,23 @@ fn patch_codegen_request_body_media_types(value: &mut serde_json::Value) {
}
}
+/// Progenitor currently panics when an operation advertises a typed success
+/// response alongside a no-content success response. Keep the source spec
+/// accurate for docs and other generators, but preserve the existing Rust
+/// client shape for `DELETE /runs/{id}`: success with no body.
+fn patch_codegen_delete_run_responses(value: &mut serde_json::Value) {
+ let Some(responses) = value
+ .get_mut("paths")
+ .and_then(|paths| paths.get_mut("/api/v1/runs/{id}"))
+ .and_then(|path| path.get_mut("delete"))
+ .and_then(|operation| operation.get_mut("responses"))
+ .and_then(serde_json::Value::as_object_mut)
+ else {
+ return;
+ };
+ responses.remove("200");
+}
+
fn spec_path_from_manifest_dir(manifest_dir: &Path) -> PathBuf {
manifest_dir
.ancestors()
@@ -161,6 +178,7 @@ fn main() {
spec_value["openapi"] = serde_json::Value::String("3.0.3".to_string());
patch_nullable(&mut spec_value);
patch_codegen_request_body_media_types(&mut spec_value);
+ patch_codegen_delete_run_responses(&mut spec_value);
let spec: openapiv3::OpenAPI =
serde_json::from_value(spec_value).expect("failed to deserialize OpenAPI spec");
diff --git a/lib/crates/fabro-config/src/defaults.toml b/lib/crates/fabro-config/src/defaults.toml
index d673e64a9..2654f0eb2 100644
--- a/lib/crates/fabro-config/src/defaults.toml
+++ b/lib/crates/fabro-config/src/defaults.toml
@@ -19,6 +19,7 @@ timeout = "5m"
[run.sandbox]
provider = "docker"
preserve = false
+stop_on_terminal = true
devcontainer = false
[run.sandbox.local]
diff --git a/lib/crates/fabro-config/src/layers/run.rs b/lib/crates/fabro-config/src/layers/run.rs
index b4aaabda1..927c515cb 100644
--- a/lib/crates/fabro-config/src/layers/run.rs
+++ b/lib/crates/fabro-config/src/layers/run.rs
@@ -256,20 +256,22 @@ pub struct RunCheckpointLayer {
#[serde(deny_unknown_fields)]
pub struct RunSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub provider: Option,
+ pub provider: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub preserve: Option,
+ pub preserve: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub devcontainer: Option,
+ pub stop_on_terminal: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub devcontainer: Option,
/// Sticky merge-by-key across layers.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
- pub env: StickyMap,
+ pub env: StickyMap,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub local: Option,
+ pub local: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub docker: Option,
+ pub docker: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub daytona: Option,
+ pub daytona: Option,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs
index c538d70ec..433714e93 100644
--- a/lib/crates/fabro-config/src/resolve/run.rs
+++ b/lib/crates/fabro-config/src/resolve/run.rs
@@ -179,6 +179,9 @@ fn resolve_sandbox(
preserve: sandbox
.preserve
.expect("defaults.toml should provide run.sandbox.preserve"),
+ stop_on_terminal: sandbox
+ .stop_on_terminal
+ .expect("defaults.toml should provide run.sandbox.stop_on_terminal"),
devcontainer: sandbox
.devcontainer
.expect("defaults.toml should provide run.sandbox.devcontainer"),
diff --git a/lib/crates/fabro-config/src/tests/resolve_run.rs b/lib/crates/fabro-config/src/tests/resolve_run.rs
index acb887813..d8b3b1e5f 100644
--- a/lib/crates/fabro-config/src/tests/resolve_run.rs
+++ b/lib/crates/fabro-config/src/tests/resolve_run.rs
@@ -14,6 +14,7 @@ fn resolves_run_defaults_from_empty_settings() {
assert!(settings.execution.retros);
assert_eq!(settings.prepare.timeout_ms, 300_000);
assert_eq!(settings.sandbox.provider, "docker");
+ assert!(settings.sandbox.stop_on_terminal);
assert_eq!(settings.sandbox.local.worktree_mode, WorktreeMode::Always);
let docker = settings
.sandbox
@@ -27,6 +28,22 @@ fn resolves_run_defaults_from_empty_settings() {
assert!(settings.pull_request.is_none());
}
+#[test]
+fn resolves_explicit_stop_on_terminal_false() {
+ let settings = WorkflowSettingsBuilder::from_toml(
+ r"
+_version = 1
+
+[run.sandbox]
+stop_on_terminal = false
+",
+ )
+ .expect("sandbox stop_on_terminal setting should resolve")
+ .run;
+
+ assert!(!settings.sandbox.stop_on_terminal);
+}
+
#[test]
fn resolves_minimal_local_provider_without_docker_table() {
let settings = WorkflowSettingsBuilder::from_toml(
diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs
index 8230bcaee..c0ee9a87b 100644
--- a/lib/crates/fabro-sandbox/src/daytona/mod.rs
+++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs
@@ -425,7 +425,8 @@ impl DaytonaSandbox {
name: Some(name),
auto_stop_interval: self.config.auto_stop_interval,
labels: self.config.labels.clone(),
- ephemeral: Some(true),
+ auto_delete_interval: Some(-1),
+ ephemeral: Some(false),
network_block_all,
network_allow_list,
..Default::default()
@@ -902,16 +903,62 @@ impl Sandbox for DaytonaSandbox {
Ok(())
}
- async fn cleanup(&self) -> crate::Result<()> {
- self.emit(SandboxEvent::CleanupStarted {
+ async fn start(&self) -> crate::Result<()> {
+ self.emit(SandboxEvent::StartStarted {
+ provider: "daytona".into(),
+ });
+ let start = Instant::now();
+ let sandbox = self.sandbox()?;
+ if let Err(e) = self.client.start(&sandbox.name).await {
+ let err = crate::Error::context("Failed to start Daytona sandbox", e);
+ self.emit(SandboxEvent::StartFailed {
+ provider: "daytona".into(),
+ error: err.to_string(),
+ causes: err.causes(),
+ });
+ return Err(err);
+ }
+ let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
+ self.emit(SandboxEvent::StartCompleted {
+ provider: "daytona".into(),
+ duration_ms,
+ });
+ Ok(())
+ }
+
+ async fn stop(&self) -> crate::Result<()> {
+ self.emit(SandboxEvent::StopStarted {
+ provider: "daytona".into(),
+ });
+ let start = Instant::now();
+ let sandbox = self.sandbox()?;
+ if let Err(e) = self.client.stop(&sandbox.name).await {
+ let err = crate::Error::context("Failed to stop Daytona sandbox", e);
+ self.emit(SandboxEvent::StopFailed {
+ provider: "daytona".into(),
+ error: err.to_string(),
+ causes: err.causes(),
+ });
+ return Err(err);
+ }
+ let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
+ self.emit(SandboxEvent::StopCompleted {
+ provider: "daytona".into(),
+ duration_ms,
+ });
+ Ok(())
+ }
+
+ async fn delete(&self) -> crate::Result<()> {
+ self.emit(SandboxEvent::DeleteStarted {
provider: "daytona".into(),
});
let start = Instant::now();
if let Some(sandbox) = self.sandbox.get() {
- tracing::info!("Destroying Daytona sandbox");
+ tracing::info!("Deleting Daytona sandbox");
if let Err(e) = sandbox.delete().await {
let err = crate::Error::context("Failed to delete Daytona sandbox", e);
- self.emit(SandboxEvent::CleanupFailed {
+ self.emit(SandboxEvent::DeleteFailed {
provider: "daytona".into(),
error: err.to_string(),
causes: err.causes(),
@@ -920,13 +967,17 @@ impl Sandbox for DaytonaSandbox {
}
}
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::CleanupCompleted {
+ self.emit(SandboxEvent::DeleteCompleted {
provider: "daytona".into(),
duration_ms,
});
Ok(())
}
+ async fn cleanup(&self) -> crate::Result<()> {
+ self.delete().await
+ }
+
fn working_directory(&self) -> &str {
WORKING_DIRECTORY
}
@@ -1969,6 +2020,25 @@ mod tests {
assert!(config.labels.is_none());
}
+ #[tokio::test]
+ async fn base_params_create_run_owned_non_ephemeral_sandbox() {
+ let sandbox = DaytonaSandbox::new(
+ DaytonaConfig::default(),
+ None,
+ None,
+ None,
+ None,
+ Some("dtn_test".to_string()),
+ )
+ .await
+ .expect("sandbox config should be valid");
+
+ let params = sandbox.base_params();
+
+ assert_eq!(params.ephemeral, Some(false));
+ assert_eq!(params.auto_delete_interval, Some(-1));
+ }
+
#[test]
fn command_kind_classifies_known_prefixes() {
assert_eq!(command_kind(" git status"), "git");
diff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/crates/fabro-sandbox/src/docker.rs
index 5e9dcce85..b1e743d9d 100644
--- a/lib/crates/fabro-sandbox/src/docker.rs
+++ b/lib/crates/fabro-sandbox/src/docker.rs
@@ -130,11 +130,12 @@ impl DockerSandbox {
repo_cloned: bool,
clone_origin_url: Option,
clone_branch: Option,
+ run_id: Option,
) -> crate::Result {
let sandbox = Self::new(
DockerSandboxOptions::default(),
None,
- None,
+ run_id,
clone_origin_url.clone(),
clone_branch,
)?;
@@ -691,8 +692,26 @@ impl DockerSandbox {
.map_err(|e| crate::Error::context("Failed to upload file to container", e))
}
- fn cleanup_error(&self, error: crate::Error) -> crate::Result<()> {
- self.emit(SandboxEvent::CleanupFailed {
+ fn start_error(&self, error: crate::Error) -> crate::Result<()> {
+ self.emit(SandboxEvent::StartFailed {
+ provider: "docker".into(),
+ error: error.to_string(),
+ causes: error.causes(),
+ });
+ Err(error)
+ }
+
+ fn stop_error(&self, error: crate::Error) -> crate::Result<()> {
+ self.emit(SandboxEvent::StopFailed {
+ provider: "docker".into(),
+ error: error.to_string(),
+ causes: error.causes(),
+ });
+ Err(error)
+ }
+
+ fn delete_error(&self, error: crate::Error) -> crate::Result<()> {
+ self.emit(SandboxEvent::DeleteFailed {
provider: "docker".into(),
error: error.to_string(),
causes: error.causes(),
@@ -1110,15 +1129,64 @@ impl Sandbox for DockerSandbox {
Ok(())
}
- async fn cleanup(&self) -> crate::Result<()> {
- self.emit(SandboxEvent::CleanupStarted {
+ async fn start(&self) -> crate::Result<()> {
+ self.emit(SandboxEvent::StartStarted {
+ provider: "docker".into(),
+ });
+ let start = Instant::now();
+ let container_id = self.container_id()?.to_string();
+ let labels = match self.inspect_labels(&container_id).await {
+ Ok(labels) => labels,
+ Err(e) => return self.start_error(e),
+ };
+ if let Err(e) = verify_managed_labels(&container_id, &labels, self.run_id.as_ref()) {
+ return self.start_error(e);
+ }
+
+ if let Err(e) = self
+ .docker
+ .start_container(&container_id, None::>)
+ .await
+ {
+ if !docker_already_stopped(&e) {
+ return self.start_error(crate::Error::context(
+ format!(
+ "Failed to start Docker container '{container_id}' with labels {labels:?}"
+ ),
+ e,
+ ));
+ }
+ }
+
+ let (_, stderr, exit_code) = self
+ .docker_exec(vec!["true".to_string()], None, None)
+ .await
+ .map_err(|e| {
+ crate::Error::context(format!("Docker container '{container_id}' health check"), e)
+ })?;
+ if exit_code != 0 {
+ return self.start_error(crate::Error::message(format!(
+ "Docker container '{container_id}' health check failed: {stderr}"
+ )));
+ }
+
+ let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
+ self.emit(SandboxEvent::StartCompleted {
+ provider: "docker".into(),
+ duration_ms,
+ });
+ Ok(())
+ }
+
+ async fn stop(&self) -> crate::Result<()> {
+ self.emit(SandboxEvent::StopStarted {
provider: "docker".into(),
});
let start = Instant::now();
let Some(container_id) = self.container_id.get().cloned() else {
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::CleanupCompleted {
+ self.emit(SandboxEvent::StopCompleted {
provider: "docker".into(),
duration_ms,
});
@@ -1127,10 +1195,10 @@ impl Sandbox for DockerSandbox {
let labels = match self.inspect_labels(&container_id).await {
Ok(labels) => labels,
- Err(e) => return self.cleanup_error(e),
+ Err(e) => return self.stop_error(e),
};
if let Err(e) = verify_managed_labels(&container_id, &labels, self.run_id.as_ref()) {
- return self.cleanup_error(e);
+ return self.stop_error(e);
}
let stop_opts = StopContainerOptions { t: 1 };
@@ -1140,7 +1208,7 @@ impl Sandbox for DockerSandbox {
.await
{
if !docker_not_found(&e) && !docker_already_stopped(&e) {
- return self.cleanup_error(crate::Error::context(
+ return self.stop_error(crate::Error::context(
format!(
"Failed to stop Docker container '{container_id}' with labels {labels:?}"
),
@@ -1149,6 +1217,38 @@ impl Sandbox for DockerSandbox {
}
}
+ let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
+ self.emit(SandboxEvent::StopCompleted {
+ provider: "docker".into(),
+ duration_ms,
+ });
+
+ Ok(())
+ }
+
+ async fn delete(&self) -> crate::Result<()> {
+ self.emit(SandboxEvent::DeleteStarted {
+ provider: "docker".into(),
+ });
+ let start = Instant::now();
+
+ let Some(container_id) = self.container_id.get().cloned() else {
+ let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
+ self.emit(SandboxEvent::DeleteCompleted {
+ provider: "docker".into(),
+ duration_ms,
+ });
+ return Ok(());
+ };
+
+ let labels = match self.inspect_labels(&container_id).await {
+ Ok(labels) => labels,
+ Err(e) => return self.delete_error(e),
+ };
+ if let Err(e) = verify_managed_labels(&container_id, &labels, self.run_id.as_ref()) {
+ return self.delete_error(e);
+ }
+
let remove_opts = RemoveContainerOptions {
force: true,
..Default::default()
@@ -1159,7 +1259,7 @@ impl Sandbox for DockerSandbox {
.await
{
if !docker_not_found(&e) {
- return self.cleanup_error(crate::Error::context(
+ return self.delete_error(crate::Error::context(
format!(
"Failed to remove Docker container '{container_id}' with labels {labels:?}"
),
@@ -1169,7 +1269,7 @@ impl Sandbox for DockerSandbox {
}
let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
- self.emit(SandboxEvent::CleanupCompleted {
+ self.emit(SandboxEvent::DeleteCompleted {
provider: "docker".into(),
duration_ms,
});
@@ -1177,6 +1277,10 @@ impl Sandbox for DockerSandbox {
Ok(())
}
+ async fn cleanup(&self) -> crate::Result<()> {
+ self.delete().await
+ }
+
async fn exec_command(
&self,
command: &str,
diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs
index 44b44c038..a17296239 100644
--- a/lib/crates/fabro-sandbox/src/lib.rs
+++ b/lib/crates/fabro-sandbox/src/lib.rs
@@ -35,6 +35,7 @@ pub use docker::{DockerSandbox, DockerSandboxOptions};
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
pub use local::LocalSandbox;
pub use read_guard::ReadBeforeWriteSandbox;
+pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callback};
pub use sandbox::{
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions, Sandbox, SandboxEvent,
diff --git a/lib/crates/fabro-sandbox/src/local.rs b/lib/crates/fabro-sandbox/src/local.rs
index a8b49e215..7deb20a38 100644
--- a/lib/crates/fabro-sandbox/src/local.rs
+++ b/lib/crates/fabro-sandbox/src/local.rs
@@ -620,6 +620,10 @@ impl Sandbox for LocalSandbox {
Ok(())
}
+ async fn delete(&self) -> crate::Result<()> {
+ Ok(())
+ }
+
fn working_directory(&self) -> &str {
self.working_directory.to_str().unwrap_or(".")
}
diff --git a/lib/crates/fabro-sandbox/src/reconnect.rs b/lib/crates/fabro-sandbox/src/reconnect.rs
index 288682d1e..7f90d11a9 100644
--- a/lib/crates/fabro-sandbox/src/reconnect.rs
+++ b/lib/crates/fabro-sandbox/src/reconnect.rs
@@ -5,7 +5,9 @@ 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;
+use crate::SandboxEventCallback;
#[cfg(feature = "daytona")]
use crate::daytona::DaytonaSandbox;
#[cfg(feature = "docker")]
@@ -25,10 +27,30 @@ use crate::sandbox_record::SandboxRecord;
pub async fn reconnect(
record: &SandboxRecord,
daytona_api_key: Option,
+) -> Result> {
+ reconnect_for_run(record, daytona_api_key, None).await
+}
+
+pub async fn reconnect_for_run(
+ record: &SandboxRecord,
+ daytona_api_key: Option,
+ run_id: Option,
+) -> Result> {
+ reconnect_for_run_with_callback(record, daytona_api_key, run_id, None).await
+}
+
+pub async fn reconnect_for_run_with_callback(
+ record: &SandboxRecord,
+ daytona_api_key: Option,
+ run_id: Option,
+ event_callback: Option,
) -> Result> {
match record.provider.as_str() {
"local" => {
- let sandbox = LocalSandbox::new(PathBuf::from(&record.working_directory));
+ let mut sandbox = LocalSandbox::new(PathBuf::from(&record.working_directory));
+ if let Some(callback) = event_callback {
+ sandbox.set_event_callback(callback);
+ }
Ok(Box::new(sandbox))
}
#[cfg(feature = "docker")]
@@ -40,14 +62,18 @@ pub async fn reconnect(
let repo_cloned = record
.repo_cloned
.context("Docker sandbox record missing repo_cloned metadata")?;
- let sandbox = DockerSandbox::reconnect(
+ let mut sandbox = DockerSandbox::reconnect(
identifier,
repo_cloned,
record.clone_origin_url.clone(),
record.clone_branch.clone(),
+ run_id,
)
.await
.context("Failed to reconnect Docker sandbox")?;
+ if let Some(callback) = event_callback {
+ sandbox.set_event_callback(callback);
+ }
Ok(Box::new(sandbox))
}
#[cfg(feature = "daytona")]
@@ -60,7 +86,7 @@ pub async fn reconnect(
.repo_cloned
.context("Daytona sandbox record missing repo_cloned metadata")?;
- let sandbox = DaytonaSandbox::reconnect(
+ let mut sandbox = DaytonaSandbox::reconnect(
name,
daytona_api_key,
repo_cloned,
@@ -69,6 +95,9 @@ pub async fn reconnect(
)
.await
.map_err(anyhow::Error::new)?;
+ if let Some(callback) = event_callback {
+ sandbox.set_event_callback(callback);
+ }
Ok(Box::new(sandbox))
}
other => bail!("Unknown sandbox provider: {other}"),
diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs
index bfd31d91b..ef7ae3b90 100644
--- a/lib/crates/fabro-sandbox/src/sandbox.rs
+++ b/lib/crates/fabro-sandbox/src/sandbox.rs
@@ -145,6 +145,18 @@ macro_rules! delegate_sandbox {
self.$field.initialize().await
}
+ async fn start(&self) -> $crate::Result<()> {
+ self.$field.start().await
+ }
+
+ async fn stop(&self) -> $crate::Result<()> {
+ self.$field.stop().await
+ }
+
+ async fn delete(&self) -> $crate::Result<()> {
+ self.$field.delete().await
+ }
+
async fn cleanup(&self) -> $crate::Result<()> {
self.$field.cleanup().await
}
@@ -263,6 +275,45 @@ pub enum SandboxEvent {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
causes: Vec,
},
+ StartStarted {
+ provider: String,
+ },
+ StartCompleted {
+ provider: String,
+ duration_ms: u64,
+ },
+ StartFailed {
+ provider: String,
+ error: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ causes: Vec,
+ },
+ StopStarted {
+ provider: String,
+ },
+ StopCompleted {
+ provider: String,
+ duration_ms: u64,
+ },
+ StopFailed {
+ provider: String,
+ error: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ causes: Vec,
+ },
+ DeleteStarted {
+ provider: String,
+ },
+ DeleteCompleted {
+ provider: String,
+ duration_ms: u64,
+ },
+ DeleteFailed {
+ provider: String,
+ error: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ causes: Vec,
+ },
// -- Snapshot lifecycle --
SnapshotPulling {
@@ -337,6 +388,54 @@ impl SandboxEvent {
} => {
warn!(provider, error, causes = ?causes, "Sandbox cleanup failed");
}
+ Self::StartStarted { provider } => {
+ info!(provider, "Sandbox start started");
+ }
+ Self::StartCompleted {
+ provider,
+ duration_ms,
+ } => {
+ info!(provider, duration_ms, "Sandbox start completed");
+ }
+ Self::StartFailed {
+ provider,
+ error,
+ causes,
+ } => {
+ warn!(provider, error, causes = ?causes, "Sandbox start failed");
+ }
+ Self::StopStarted { provider } => {
+ info!(provider, "Sandbox stop started");
+ }
+ Self::StopCompleted {
+ provider,
+ duration_ms,
+ } => {
+ info!(provider, duration_ms, "Sandbox stop completed");
+ }
+ Self::StopFailed {
+ provider,
+ error,
+ causes,
+ } => {
+ warn!(provider, error, causes = ?causes, "Sandbox stop failed");
+ }
+ Self::DeleteStarted { provider } => {
+ info!(provider, "Sandbox delete started");
+ }
+ Self::DeleteCompleted {
+ provider,
+ duration_ms,
+ } => {
+ info!(provider, duration_ms, "Sandbox delete completed");
+ }
+ Self::DeleteFailed {
+ provider,
+ error,
+ causes,
+ } => {
+ warn!(provider, error, causes = ?causes, "Sandbox delete failed");
+ }
Self::SnapshotPulling { name } => {
debug!(name, "Snapshot pulling");
}
@@ -675,6 +774,15 @@ pub trait Sandbox: Send + Sync {
remote_path: &str,
) -> crate::Result<()>;
async fn initialize(&self) -> crate::Result<()>;
+ async fn start(&self) -> crate::Result<()> {
+ Ok(())
+ }
+ async fn stop(&self) -> crate::Result<()> {
+ Ok(())
+ }
+ async fn delete(&self) -> crate::Result<()> {
+ self.cleanup().await
+ }
async fn cleanup(&self) -> crate::Result<()>;
fn working_directory(&self) -> &str;
fn platform(&self) -> &str;
diff --git a/lib/crates/fabro-sandbox/src/test_support.rs b/lib/crates/fabro-sandbox/src/test_support.rs
index 2566930fd..0c7af3dd9 100644
--- a/lib/crates/fabro-sandbox/src/test_support.rs
+++ b/lib/crates/fabro-sandbox/src/test_support.rs
@@ -32,6 +32,9 @@ pub struct MockSandbox {
pub captured_working_dirs: Mutex>>,
/// Captures the `env_vars` argument from `exec_command` calls.
pub captured_env_vars: Mutex