mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
Add --preserve-sandbox flag to keep sandboxes alive for debugging
Adds a `--preserve-sandbox` CLI flag and `sandbox.preserve` TOML config option that skips sandbox cleanup after a run. Cascade order: CLI flag > run TOML > server.toml defaults > false. When preserving, prints sandbox identity (container ID or Daytona sandbox name) so users can reconnect for debugging. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
49ac81624b
commit
13ad7d948a
6 changed files with 218 additions and 1 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ pub struct SetupConfig {
|
|||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct SandboxConfig {
|
||||
pub provider: Option<String>,
|
||||
pub preserve: Option<bool>,
|
||||
pub daytona: Option<DaytonaConfig>,
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue