diff --git a/crates/arc-agent/src/docker_sandbox.rs b/crates/arc-agent/src/docker_sandbox.rs index 2abae4a0c..304341ca5 100644 --- a/crates/arc-agent/src/docker_sandbox.rs +++ b/crates/arc-agent/src/docker_sandbox.rs @@ -746,6 +746,13 @@ impl Sandbox for DockerSandbox { .cloned() .unwrap_or_else(|| "linux".to_string()) } + + fn sandbox_info(&self) -> String { + self.container_id + .get() + .cloned() + .unwrap_or_default() + } } #[cfg(test)] diff --git a/crates/arc-agent/src/sandbox.rs b/crates/arc-agent/src/sandbox.rs index cdd8972b6..1d7478bfa 100644 --- a/crates/arc-agent/src/sandbox.rs +++ b/crates/arc-agent/src/sandbox.rs @@ -88,6 +88,10 @@ macro_rules! delegate_sandbox { fn os_version(&self) -> String { self.$field.os_version() } + + fn sandbox_info(&self) -> String { + self.$field.sandbox_info() + } } }; } @@ -320,6 +324,11 @@ pub trait Sandbox: Send + Sync { fn working_directory(&self) -> &str; fn platform(&self) -> &str; fn os_version(&self) -> String; + /// Return a human-readable identifier for the sandbox (e.g. container ID, sandbox name). + /// Used when `--preserve-sandbox` is active to tell the user how to reconnect. + fn sandbox_info(&self) -> String { + String::new() + } } #[cfg(test)] diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index 6117e3b2b..986f43875 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -131,6 +131,10 @@ pub struct RunArgs { /// Create SSH access to the Daytona sandbox and print the connection command #[arg(long)] pub ssh: bool, + + /// Keep the sandbox alive after the run finishes (for debugging) + #[arg(long)] + pub preserve_sandbox: bool, } #[derive(Args)] diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index bd4364308..2415570b4 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -119,6 +119,22 @@ fn resolve_sandbox_provider( Ok(cli.or(toml).or(defaults).unwrap_or_default()) } +/// Resolve preserve-sandbox: CLI flag > TOML config > run defaults > false. +fn resolve_preserve_sandbox( + cli: bool, + run_cfg: Option<&WorkflowRunConfig>, + run_defaults: &RunDefaults, +) -> bool { + if cli { + return true; + } + run_cfg + .and_then(|c| c.sandbox.as_ref()) + .and_then(|s| s.preserve) + .or_else(|| run_defaults.sandbox.as_ref().and_then(|s| s.preserve)) + .unwrap_or(false) +} + /// Resolve daytona config: TOML config > run defaults. fn resolve_daytona_config( run_cfg: Option<&WorkflowRunConfig>, @@ -231,6 +247,8 @@ pub async fn run_command( // 2. Pre-flight: check git cleanliness before creating any files // (must happen before logs dir is created, which may be inside the repo) let sandbox_provider = resolve_sandbox_provider(args.sandbox, run_cfg.as_ref(), &run_defaults)?; + let preserve_sandbox = + resolve_preserve_sandbox(args.preserve_sandbox, run_cfg.as_ref(), &run_defaults); let original_cwd = std::env::current_dir()?; let git_clean = match sandbox_provider { SandboxProvider::Local | SandboxProvider::Docker => { @@ -460,6 +478,9 @@ pub async fn run_command( // Safety net: if we panic or return early, best-effort cleanup via spawn. let sandbox_for_cleanup = Arc::clone(&sandbox); let cleanup_guard = scopeguard::guard((), move |()| { + if preserve_sandbox { + return; + } let rt = tokio::runtime::Handle::try_current(); if let Ok(handle) = rt { handle.spawn(async move { @@ -771,7 +792,20 @@ pub async fn run_command( // 9. Cleanup sandbox (defuse the scopeguard so we await properly) scopeguard::ScopeGuard::into_inner(cleanup_guard); - if let Err(e) = sandbox.cleanup().await { + if preserve_sandbox { + let info = sandbox.sandbox_info(); + if !info.is_empty() { + eprintln!( + "\n{} sandbox preserved: {info}", + styles.bold.apply_to("Info:") + ); + } else { + eprintln!( + "\n{} sandbox preserved", + styles.bold.apply_to("Info:") + ); + } + } else if let Err(e) = sandbox.cleanup().await { tracing::warn!(error = %e, "Sandbox cleanup failed"); eprintln!( "\n{} sandbox cleanup failed: {e}", @@ -1563,6 +1597,72 @@ mod tests { assert_eq!(provider, Some("openai".to_string())); } + #[test] + fn resolve_preserve_sandbox_cli_wins() { + let cfg = run_config::WorkflowRunConfig { + version: 1, + goal: "test".into(), + graph: "w.dot".into(), + directory: None, + llm: None, + setup: None, + sandbox: Some(run_config::SandboxConfig { + provider: None, + preserve: Some(false), + daytona: None, + }), + vars: None, + }; + let defaults = RunDefaults::default(); + assert!(resolve_preserve_sandbox(true, Some(&cfg), &defaults)); + } + + #[test] + fn resolve_preserve_sandbox_toml_wins_over_defaults() { + let cfg = run_config::WorkflowRunConfig { + version: 1, + goal: "test".into(), + graph: "w.dot".into(), + directory: None, + llm: None, + setup: None, + sandbox: Some(run_config::SandboxConfig { + provider: None, + preserve: Some(true), + daytona: None, + }), + vars: None, + }; + let defaults = RunDefaults { + sandbox: Some(run_config::SandboxConfig { + provider: None, + preserve: Some(false), + daytona: None, + }), + ..RunDefaults::default() + }; + assert!(resolve_preserve_sandbox(false, Some(&cfg), &defaults)); + } + + #[test] + fn resolve_preserve_sandbox_defaults_used() { + let defaults = RunDefaults { + sandbox: Some(run_config::SandboxConfig { + provider: None, + preserve: Some(true), + daytona: None, + }), + ..RunDefaults::default() + }; + assert!(resolve_preserve_sandbox(false, None, &defaults)); + } + + #[test] + fn resolve_preserve_sandbox_defaults_to_false() { + let defaults = RunDefaults::default(); + assert!(!resolve_preserve_sandbox(false, None, &defaults)); + } + #[test] fn redact_removes_aws_key_from_compact_json() { let envelope = serde_json::json!({ diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index d5ce139a8..60c6254ec 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -36,6 +36,7 @@ pub struct SetupConfig { #[derive(Clone, Debug, Deserialize)] pub struct SandboxConfig { pub provider: Option, + pub preserve: Option, pub daytona: Option, } @@ -89,6 +90,9 @@ impl WorkflowRunConfig { if task.provider.is_none() { task.provider = default.provider.clone(); } + if task.preserve.is_none() { + task.preserve = default.preserve; + } match (&mut task.daytona, &default.daytona) { (Some(task_d), Some(default_d)) => { if task_d.auto_stop_interval.is_none() { @@ -664,6 +668,87 @@ commands = ["make test"] assert_eq!(setup.timeout_ms, Some(60000)); } + #[test] + fn parse_toml_with_sandbox_preserve() { + let toml = r#" +version = 1 +goal = "Run tests" +graph = "workflow.dot" + +[sandbox] +provider = "docker" +preserve = true +"#; + let config = parse_run_config(toml).unwrap(); + let sandbox = config.sandbox.unwrap(); + assert_eq!(sandbox.preserve, Some(true)); + } + + #[test] + fn parse_toml_sandbox_preserve_defaults_to_none() { + let toml = r#" +version = 1 +goal = "Run tests" +graph = "workflow.dot" + +[sandbox] +provider = "docker" +"#; + let config = parse_run_config(toml).unwrap(); + let sandbox = config.sandbox.unwrap(); + assert_eq!(sandbox.preserve, None); + } + + #[test] + fn apply_defaults_merges_sandbox_preserve_task_wins() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox] +preserve = true +"#, + ) + .unwrap(); + let defaults = RunDefaults { + sandbox: Some(SandboxConfig { + provider: None, + preserve: Some(false), + daytona: None, + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + assert_eq!(cfg.sandbox.unwrap().preserve, Some(true)); + } + + #[test] + fn apply_defaults_merges_sandbox_preserve_from_default() { + let mut cfg = parse_run_config( + r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox] +provider = "docker" +"#, + ) + .unwrap(); + let defaults = RunDefaults { + sandbox: Some(SandboxConfig { + provider: None, + preserve: Some(true), + daytona: None, + }), + ..RunDefaults::default() + }; + cfg.apply_defaults(&defaults); + assert_eq!(cfg.sandbox.unwrap().preserve, Some(true)); + } + #[test] fn apply_defaults_merges_sandbox_fields() { let mut cfg = parse_run_config( @@ -680,6 +765,7 @@ provider = "daytona" let defaults = RunDefaults { sandbox: Some(SandboxConfig { provider: None, + preserve: None, daytona: Some(DaytonaConfig { auto_stop_interval: Some(30), labels: None, @@ -714,6 +800,7 @@ auto_stop_interval = 60 let defaults = RunDefaults { sandbox: Some(SandboxConfig { provider: Some("daytona".into()), + preserve: None, daytona: Some(DaytonaConfig { auto_stop_interval: Some(30), labels: Some(HashMap::from([("env".into(), "prod".into())])), @@ -745,6 +832,7 @@ env = "from_task" let defaults = RunDefaults { sandbox: Some(SandboxConfig { provider: None, + preserve: None, daytona: Some(DaytonaConfig { auto_stop_interval: None, labels: Some(HashMap::from([ @@ -780,6 +868,7 @@ cpu = 2 let defaults = RunDefaults { sandbox: Some(SandboxConfig { provider: None, + preserve: None, daytona: Some(DaytonaConfig { auto_stop_interval: None, labels: None, @@ -817,6 +906,7 @@ auto_stop_interval = 60 let defaults = RunDefaults { sandbox: Some(SandboxConfig { provider: None, + preserve: None, daytona: Some(DaytonaConfig { auto_stop_interval: None, labels: None, diff --git a/crates/arc-workflows/src/daytona_sandbox.rs b/crates/arc-workflows/src/daytona_sandbox.rs index 386f34d0c..c7e929dff 100644 --- a/crates/arc-workflows/src/daytona_sandbox.rs +++ b/crates/arc-workflows/src/daytona_sandbox.rs @@ -525,6 +525,13 @@ impl Sandbox for DaytonaSandbox { "Linux (Daytona)".to_string() } + fn sandbox_info(&self) -> String { + self.sandbox + .get() + .map(|s| s.name.clone()) + .unwrap_or_default() + } + async fn read_file( &self, path: &str,