From e8067740f7b7eae5cf1d25d4d39eaca557cdded9 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 10 Mar 2026 22:54:59 -0400 Subject: [PATCH] Wire arc-devcontainer into sandbox creation flow When `devcontainer = true` in TOML sandbox config, Arc now resolves devcontainer.json from the repo, uses its Dockerfile for the Daytona snapshot, runs lifecycle hooks (on_create, post_create, post_start) in the sandbox, and merges devcontainer env vars under TOML env. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 3 + lib/crates/arc-api/src/demo/mod.rs | 8 +- .../arc-api/tests/openapi_conformance.rs | 1 + lib/crates/arc-workflows/Cargo.toml | 3 + lib/crates/arc-workflows/src/cli/progress.rs | 118 ++++ lib/crates/arc-workflows/src/cli/run.rs | 128 ++++- .../arc-workflows/src/cli/run_config.rs | 47 ++ .../arc-workflows/src/devcontainer_bridge.rs | 503 ++++++++++++++++++ lib/crates/arc-workflows/src/event.rs | 189 +++++++ lib/crates/arc-workflows/src/lib.rs | 1 + 10 files changed, 994 insertions(+), 7 deletions(-) create mode 100644 lib/crates/arc-workflows/src/devcontainer_bridge.rs diff --git a/Cargo.lock b/Cargo.lock index 02e5d0459..f2be78580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,7 @@ version = "0.2.0" dependencies = [ "anyhow", "arc-agent", + "arc-devcontainer", "arc-exe", "arc-git-storage", "arc-github", @@ -475,6 +476,7 @@ dependencies = [ "dotenvy", "futures", "git2", + "hex", "indicatif", "mockito", "nom", @@ -485,6 +487,7 @@ dependencies = [ "scopeguard", "serde", "serde_json", + "sha2", "shlex", "strsim", "tempfile", diff --git a/lib/crates/arc-api/src/demo/mod.rs b/lib/crates/arc-api/src/demo/mod.rs index aba42ef48..d25d57126 100644 --- a/lib/crates/arc-api/src/demo/mod.rs +++ b/lib/crates/arc-api/src/demo/mod.rs @@ -1315,6 +1315,7 @@ mod runs { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + devcontainer: None, local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(60), @@ -1472,6 +1473,7 @@ mod workflows { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + devcontainer: None, local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(60), @@ -1541,6 +1543,7 @@ mod workflows { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + devcontainer: None, local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(120), @@ -1622,6 +1625,7 @@ mod workflows { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + devcontainer: None, local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(120), @@ -1694,6 +1698,7 @@ mod workflows { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + devcontainer: None, local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(180), @@ -2676,7 +2681,7 @@ mod signoffs { name: def.repo.into(), }, commit_sha: def.commit_sha.into(), - status: def.status.clone(), + status: def.status, url: def.url.map(Into::into), description: def.description.map(Into::into), source: def.source.map(Into::into), @@ -3265,6 +3270,7 @@ mod settings { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + devcontainer: None, local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(60), diff --git a/lib/crates/arc-api/tests/openapi_conformance.rs b/lib/crates/arc-api/tests/openapi_conformance.rs index 9da69364b..004029dd8 100644 --- a/lib/crates/arc-api/tests/openapi_conformance.rs +++ b/lib/crates/arc-api/tests/openapi_conformance.rs @@ -290,6 +290,7 @@ fn fully_populated_server_config() -> ServerConfig { sandbox: Some(SandboxConfig { provider: Some("daytona".into()), preserve: Some(true), + devcontainer: None, local: None, daytona: Some(DaytonaConfig { auto_stop_interval: Some(60), diff --git a/lib/crates/arc-workflows/Cargo.toml b/lib/crates/arc-workflows/Cargo.toml index 614f6a571..b6ce23a0d 100644 --- a/lib/crates/arc-workflows/Cargo.toml +++ b/lib/crates/arc-workflows/Cargo.toml @@ -21,6 +21,7 @@ clap.workspace = true anyhow.workspace = true dotenvy.workspace = true arc-agent = { path = "../arc-agent" } +arc-devcontainer = { path = "../arc-devcontainer" } arc-exe = { path = "../arc-exe", optional = true } arc-mcp = { path = "../arc-mcp" } arc-github = { path = "../arc-github" } @@ -46,6 +47,8 @@ daytona-api-client.workspace = true base64.workspace = true regex.workspace = true scopeguard = "1" +hex.workspace = true +sha2 = { workspace = true } shlex = "1" strsim = "0.11" git2.workspace = true diff --git a/lib/crates/arc-workflows/src/cli/progress.rs b/lib/crates/arc-workflows/src/cli/progress.rs index 47a41d62e..b8550a710 100644 --- a/lib/crates/arc-workflows/src/cli/progress.rs +++ b/lib/crates/arc-workflows/src/cli/progress.rs @@ -213,8 +213,10 @@ pub struct ProgressUI { /// renderer so that Plain (non-TTY) mode reports accurate stats. stage_counts: HashMap, setup_command_count: usize, + devcontainer_command_count: usize, sandbox_bar: Option, setup_bar: Option, + devcontainer_bar: Option, cli_ensure_bar: Option, any_stage_started: bool, parallel_parent: Option, @@ -235,8 +237,10 @@ impl ProgressUI { active_stages: HashMap::new(), stage_counts: HashMap::new(), setup_command_count: 0, + devcontainer_command_count: 0, sandbox_bar: None, setup_bar: None, + devcontainer_bar: None, cli_ensure_bar: None, any_stage_started: false, parallel_parent: None, @@ -466,6 +470,120 @@ impl ProgressUI { WorkflowRunEvent::CliEnsureFailed { cli_name, .. } => { self.on_cli_ensure_failed(cli_name); } + WorkflowRunEvent::DevcontainerResolved { + dockerfile_lines, + environment_count, + lifecycle_command_count, + workspace_folder, + } => { + let detail = format!( + "{dockerfile_lines} Dockerfile lines, {environment_count} env vars, \ + {lifecycle_command_count} lifecycle cmds, {workspace_folder}" + ); + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let bar = tty.multi.add(ProgressBar::new_spinner()); + bar.set_style(style_header_done()); + bar.finish_with_message("Devcontainer: resolved".to_string()); + let detail_bar = tty.multi.insert_after(&bar, ProgressBar::new_spinner()); + detail_bar.set_style(style_sandbox_detail()); + detail_bar.finish_with_message(detail); + } + ProgressRenderer::Plain => { + eprintln!(" Devcontainer: resolved"); + eprintln!(" {detail}"); + } + } + } + WorkflowRunEvent::DevcontainerLifecycleStarted { + phase, + command_count, + } => { + self.devcontainer_command_count = *command_count; + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let bar = tty.multi.add(ProgressBar::new_spinner()); + bar.set_style(style_header_running()); + bar.set_message(format!( + "Running devcontainer {phase} ({command_count} commands)..." + )); + bar.enable_steady_tick(Duration::from_millis(100)); + self.devcontainer_bar = Some(bar); + } + ProgressRenderer::Plain => { + eprintln!(" Running devcontainer {phase} ({command_count} commands)..."); + } + } + } + WorkflowRunEvent::DevcontainerLifecycleCompleted { + phase, duration_ms, .. + } => { + let dur = format_duration_ms(*duration_ms); + match &self.renderer { + ProgressRenderer::Tty(_) => { + if let Some(bar) = self.devcontainer_bar.take() { + bar.set_style(style_header_done()); + bar.set_prefix(dur); + bar.finish_with_message(format!("Devcontainer: {phase}")); + } + } + ProgressRenderer::Plain => { + eprintln!(" Devcontainer: {phase} ({dur})"); + } + } + } + WorkflowRunEvent::DevcontainerLifecycleFailed { + phase, + command, + exit_code, + stderr, + .. + } => { + if let Some(bar) = self.devcontainer_bar.take() { + bar.abandon(); + } + let red = console::Style::new().red(); + let summary = if stderr.len() > 120 { + &stderr[..120] + } else { + stderr.as_str() + }; + self.insert_info_line(&format!( + "{} Devcontainer {phase} command failed (exit {exit_code}): {command}\n {summary}", + red.apply_to("Error:") + )); + } + WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { + command, + index, + exit_code, + duration_ms, + .. + } if self.verbose => { + let total = self.devcontainer_command_count; + let dur = format_duration_ms(*duration_ms); + let glyph = if *exit_code == 0 { + green_check() + } else { + red_cross() + }; + let msg = format!("{glyph} [{}/{total}] {}", index + 1, truncate(command, 60),); + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let bar = if let Some(ref dc_bar) = self.devcontainer_bar { + tty.multi.insert_before(dc_bar, ProgressBar::new_spinner()) + } else { + tty.multi.add(ProgressBar::new_spinner()) + }; + bar.set_style(style_tool_done()); + bar.set_prefix(dur); + bar.finish_with_message(msg); + } + ProgressRenderer::Plain => { + eprintln!(" {msg} {dur}"); + } + } + } _ => {} } } diff --git a/lib/crates/arc-workflows/src/cli/run.rs b/lib/crates/arc-workflows/src/cli/run.rs index 46941a070..3c661cd05 100644 --- a/lib/crates/arc-workflows/src/cli/run.rs +++ b/lib/crates/arc-workflows/src/cli/run.rs @@ -29,6 +29,7 @@ use super::cli_backend::{AgentCliBackend, BackendRouter}; use super::progress; use super::run_config; use super::run_config::{RunDefaults, WorkflowRunConfig}; +use crate::devcontainer_bridge; use indicatif::HumanDuration; use std::time::Duration; @@ -625,10 +626,78 @@ pub async fn run_command( } let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let daytona_config = resolve_daytona_config(run_cfg.as_ref(), &run_defaults); + let mut daytona_config = resolve_daytona_config(run_cfg.as_ref(), &run_defaults); #[cfg(feature = "exedev")] let exe_config = resolve_exe_config(run_cfg.as_ref(), &run_defaults); + // Resolve devcontainer if enabled + let devcontainer_config = if run_cfg + .as_ref() + .and_then(|c| c.sandbox.as_ref()) + .and_then(|s| s.devcontainer) + .unwrap_or(false) + { + match devcontainer_bridge::resolve_devcontainer(&cwd).await { + Ok(dc) => { + let lifecycle_command_count = dc.on_create_commands.len() + + dc.post_create_commands.len() + + dc.post_start_commands.len(); + emitter.emit(&crate::event::WorkflowRunEvent::DevcontainerResolved { + dockerfile_lines: dc.dockerfile.lines().count(), + environment_count: dc.environment.len(), + lifecycle_command_count, + workspace_folder: dc.workspace_folder.clone(), + }); + + // Override daytona_config with devcontainer dockerfile + let snapshot = devcontainer_bridge::devcontainer_to_snapshot_config(&dc); + let mut cfg = daytona_config.unwrap_or_default(); + cfg.snapshot = Some(snapshot); + daytona_config = Some(cfg); + + // Run initialize_commands on host + let timeout = std::time::Duration::from_millis(300_000); + for cmd in &dc.initialize_commands { + let shell_cmd = match cmd { + arc_devcontainer::Command::Shell(s) => s.clone(), + arc_devcontainer::Command::Args(args) => args.join(" "), + arc_devcontainer::Command::Parallel(_) => continue, + }; + let fut = tokio::process::Command::new("sh") + .arg("-c") + .arg(&shell_cmd) + .current_dir(&cwd) + .output(); + let output = tokio::time::timeout(timeout, fut) + .await + .with_context(|| { + format!("Devcontainer initializeCommand timed out: {shell_cmd}") + })? + .with_context(|| { + format!("Failed to execute devcontainer initializeCommand: {shell_cmd}") + })?; + if !output.status.success() { + let code = output + .status + .code() + .map_or("unknown".to_string(), |c| c.to_string()); + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "Devcontainer initializeCommand failed (exit code {code}): {shell_cmd}\n{stderr}" + ); + } + } + + Some(dc) + } + Err(e) => { + bail!("Failed to resolve devcontainer: {e}"); + } + } + } else { + None + }; + // Wrap emitter in Arc now so we can share it with exec env callbacks let emitter = Arc::new(emitter); @@ -868,6 +937,34 @@ pub async fn run_command( }); } + // Run devcontainer lifecycle hooks inside the sandbox + if let Some(ref dc) = devcontainer_config { + devcontainer_bridge::run_devcontainer_lifecycle( + sandbox.as_ref(), + &emitter, + "on_create", + &dc.on_create_commands, + 300_000, + ) + .await?; + devcontainer_bridge::run_devcontainer_lifecycle( + sandbox.as_ref(), + &emitter, + "post_create", + &dc.post_create_commands, + 300_000, + ) + .await?; + devcontainer_bridge::run_devcontainer_lifecycle( + sandbox.as_ref(), + &emitter, + "post_start", + &dc.post_start_commands, + 300_000, + ) + .await?; + } + // 6. Resolve backend, model, and provider let (dry_run_mode, llm_client) = if args.dry_run { (true, None) @@ -911,11 +1008,22 @@ pub async fn run_command( let fallback_chain = resolve_fallback_chain(provider_enum, &model, run_cfg.as_ref()); // 7. Build engine - let sandbox_env: HashMap = run_cfg - .as_ref() - .and_then(|c| c.sandbox.as_ref()) - .and_then(|s| s.env.clone()) - .unwrap_or_default(); + // Devcontainer env is layered underneath TOML env (TOML wins on conflict) + let sandbox_env: HashMap = { + let mut env = if let Some(ref dc) = devcontainer_config { + devcontainer_bridge::devcontainer_env(dc) + } else { + HashMap::new() + }; + if let Some(toml_env) = run_cfg + .as_ref() + .and_then(|c| c.sandbox.as_ref()) + .and_then(|s| s.env.clone()) + { + env.extend(toml_env); + } + env + }; let mcp_servers: Vec = run_cfg .as_ref() .map(|c| { @@ -2337,6 +2445,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: Some(false), + devcontainer: None, local: None, daytona: None, #[cfg(feature = "exedev")] @@ -2366,6 +2475,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: Some(true), + devcontainer: None, local: None, daytona: None, #[cfg(feature = "exedev")] @@ -2383,6 +2493,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: Some(false), + devcontainer: None, local: None, daytona: None, #[cfg(feature = "exedev")] @@ -2400,6 +2511,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: Some(true), + devcontainer: None, local: None, daytona: None, #[cfg(feature = "exedev")] @@ -2438,6 +2550,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: Some(run_config::LocalSandboxConfig { worktree_mode: run_config::WorktreeMode::Always, }), @@ -2466,6 +2579,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: Some(run_config::LocalSandboxConfig { worktree_mode: run_config::WorktreeMode::Dirty, }), @@ -2494,6 +2608,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: Some(run_config::LocalSandboxConfig { worktree_mode: run_config::WorktreeMode::Never, }), @@ -2513,6 +2628,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: Some(run_config::LocalSandboxConfig { worktree_mode: run_config::WorktreeMode::Dirty, }), diff --git a/lib/crates/arc-workflows/src/cli/run_config.rs b/lib/crates/arc-workflows/src/cli/run_config.rs index d68e637d4..740f4bf38 100644 --- a/lib/crates/arc-workflows/src/cli/run_config.rs +++ b/lib/crates/arc-workflows/src/cli/run_config.rs @@ -112,6 +112,8 @@ pub struct LocalSandboxConfig { pub struct SandboxConfig { pub provider: Option, pub preserve: Option, + #[serde(default)] + pub devcontainer: Option, pub local: Option, pub daytona: Option, #[cfg(feature = "exedev")] @@ -179,6 +181,9 @@ impl WorkflowRunConfig { if task.preserve.is_none() { task.preserve = default.preserve; } + if task.devcontainer.is_none() { + task.devcontainer = default.devcontainer; + } if task.local.is_none() { task.local = default.local.clone(); } @@ -511,6 +516,37 @@ language = "python" assert_eq!(expand_vars("price is $$5", &vars).unwrap(), "price is $5"); } + #[test] + fn parse_toml_with_devcontainer_enabled() { + let toml = r#" +version = 1 +goal = "Run tests" +graph = "workflow.dot" + +[sandbox] +provider = "daytona" +devcontainer = true +"#; + let config = parse_run_config(toml).unwrap(); + let sandbox = config.sandbox.unwrap(); + assert_eq!(sandbox.devcontainer, Some(true)); + } + + #[test] + fn parse_toml_without_devcontainer() { + let toml = r#" +version = 1 +goal = "Run tests" +graph = "workflow.dot" + +[sandbox] +provider = "daytona" +"#; + let config = parse_run_config(toml).unwrap(); + let sandbox = config.sandbox.unwrap(); + assert_eq!(sandbox.devcontainer, None); + } + #[test] fn parse_toml_with_sandbox() { let toml = r#" @@ -1066,6 +1102,7 @@ preserve = true sandbox: Some(SandboxConfig { provider: None, preserve: Some(false), + devcontainer: None, local: None, daytona: None, #[cfg(feature = "exedev")] @@ -1095,6 +1132,7 @@ provider = "docker" sandbox: Some(SandboxConfig { provider: None, preserve: Some(true), + devcontainer: None, local: None, daytona: None, #[cfg(feature = "exedev")] @@ -1124,6 +1162,7 @@ provider = "daytona" sandbox: Some(SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: None, daytona: Some(DaytonaConfig { auto_stop_interval: Some(30), @@ -1162,6 +1201,7 @@ auto_stop_interval = 60 sandbox: Some(SandboxConfig { provider: Some("daytona".into()), preserve: None, + devcontainer: None, local: None, daytona: Some(DaytonaConfig { auto_stop_interval: Some(30), @@ -1198,6 +1238,7 @@ env = "from_task" sandbox: Some(SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: None, daytona: Some(DaytonaConfig { labels: Some(HashMap::from([ @@ -1237,6 +1278,7 @@ cpu = 2 sandbox: Some(SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: None, daytona: Some(DaytonaConfig { snapshot: Some(DaytonaSnapshotConfig { @@ -1278,6 +1320,7 @@ auto_stop_interval = 60 sandbox: Some(SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: None, daytona: Some(DaytonaConfig { snapshot: Some(DaytonaSnapshotConfig { @@ -1508,6 +1551,7 @@ network = "block" sandbox: Some(SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: None, daytona: Some(DaytonaConfig { network: Some(crate::daytona_sandbox::DaytonaNetwork::AllowAll), @@ -1543,6 +1587,7 @@ auto_stop_interval = 60 sandbox: Some(SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: None, daytona: Some(DaytonaConfig { network: Some(crate::daytona_sandbox::DaytonaNetwork::AllowList(vec![ @@ -1757,6 +1802,7 @@ SHARED = "from_task" sandbox: Some(SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: None, daytona: None, #[cfg(feature = "exedev")] @@ -1792,6 +1838,7 @@ provider = "daytona" sandbox: Some(SandboxConfig { provider: None, preserve: None, + devcontainer: None, local: None, daytona: None, #[cfg(feature = "exedev")] diff --git a/lib/crates/arc-workflows/src/devcontainer_bridge.rs b/lib/crates/arc-workflows/src/devcontainer_bridge.rs new file mode 100644 index 000000000..b5277042b --- /dev/null +++ b/lib/crates/arc-workflows/src/devcontainer_bridge.rs @@ -0,0 +1,503 @@ +use std::collections::HashMap; +use std::path::Path; +use std::time::Instant; + +use sha2::{Digest, Sha256}; + +use arc_devcontainer::{DevcontainerConfig, DevcontainerResolver}; + +use crate::daytona_sandbox::{DaytonaSnapshotConfig, DockerfileSource}; +use crate::event::{EventEmitter, WorkflowRunEvent}; + +/// Compute a deterministic snapshot name from Dockerfile content. +pub fn snapshot_name_for_dockerfile(dockerfile: &str) -> String { + let hash = Sha256::digest(dockerfile.as_bytes()); + let hex = hex::encode(hash); + format!("devcontainer-{}", &hex[..12]) +} + +/// Map a `DevcontainerConfig` to a `DaytonaSnapshotConfig`. +pub fn devcontainer_to_snapshot_config(dc: &DevcontainerConfig) -> DaytonaSnapshotConfig { + DaytonaSnapshotConfig { + name: snapshot_name_for_dockerfile(&dc.dockerfile), + dockerfile: Some(DockerfileSource::Inline(dc.dockerfile.clone())), + cpu: None, + memory: None, + disk: None, + } +} + +/// Extract the environment variables from a `DevcontainerConfig`. +pub fn devcontainer_env(dc: &DevcontainerConfig) -> HashMap { + dc.environment.clone() +} + +/// Resolve devcontainer.json from a repo directory. +pub async fn resolve_devcontainer( + repo_path: &Path, +) -> Result { + DevcontainerResolver::resolve(repo_path).await +} + +/// Run a set of devcontainer lifecycle commands inside a sandbox. +/// +/// Follows the same pattern as setup commands in `run.rs`. +pub async fn run_devcontainer_lifecycle( + sandbox: &dyn arc_agent::sandbox::Sandbox, + emitter: &EventEmitter, + phase: &str, + commands: &[arc_devcontainer::Command], + timeout_ms: u64, +) -> anyhow::Result<()> { + if commands.is_empty() { + return Ok(()); + } + + emitter.emit(&WorkflowRunEvent::DevcontainerLifecycleStarted { + phase: phase.to_string(), + command_count: commands.len(), + }); + let phase_start = Instant::now(); + + for (index, cmd) in commands.iter().enumerate() { + match cmd { + arc_devcontainer::Command::Shell(s) => { + run_single_lifecycle_command( + sandbox, + emitter, + phase, + &format!("sh -c {}", shlex::try_quote(s).unwrap_or_else(|_| s.into())), + index, + timeout_ms, + ) + .await?; + } + arc_devcontainer::Command::Args(args) => { + let joined = args + .iter() + .map(|a| shlex::try_quote(a).unwrap_or_else(|_| a.into()).to_string()) + .collect::>() + .join(" "); + run_single_lifecycle_command(sandbox, emitter, phase, &joined, index, timeout_ms) + .await?; + } + arc_devcontainer::Command::Parallel(map) => { + let futs: Vec<_> = map + .iter() + .map(|(name, cmd_str)| { + let command = format!( + "sh -c {}", + shlex::try_quote(cmd_str).unwrap_or_else(|_| cmd_str.into()) + ); + let phase = phase.to_string(); + let name = name.clone(); + async move { + let cmd_start = Instant::now(); + emitter.emit(&WorkflowRunEvent::DevcontainerLifecycleCommandStarted { + phase: phase.clone(), + command: name.clone(), + index, + }); + let result = sandbox + .exec_command(&command, timeout_ms, None, None, None) + .await + .map_err(|e| { + anyhow::anyhow!( + "Devcontainer {phase} parallel command '{name}' failed: {e}" + ) + })?; + let cmd_duration = crate::millis_u64(cmd_start.elapsed()); + if result.exit_code != 0 { + emitter.emit( + &WorkflowRunEvent::DevcontainerLifecycleFailed { + phase: phase.clone(), + command: name.clone(), + index, + exit_code: result.exit_code, + stderr: result.stderr.clone(), + }, + ); + anyhow::bail!( + "Devcontainer {phase} parallel command '{name}' failed (exit code {}): {}", + result.exit_code, + result.stderr, + ); + } + emitter.emit( + &WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { + phase: phase.clone(), + command: name.clone(), + index, + exit_code: result.exit_code, + duration_ms: cmd_duration, + }, + ); + Ok(()) + } + }) + .collect(); + futures::future::try_join_all(futs).await?; + } + } + } + + let phase_duration = crate::millis_u64(phase_start.elapsed()); + emitter.emit(&WorkflowRunEvent::DevcontainerLifecycleCompleted { + phase: phase.to_string(), + duration_ms: phase_duration, + }); + Ok(()) +} + +async fn run_single_lifecycle_command( + sandbox: &dyn arc_agent::sandbox::Sandbox, + emitter: &EventEmitter, + phase: &str, + command: &str, + index: usize, + timeout_ms: u64, +) -> anyhow::Result<()> { + emitter.emit(&WorkflowRunEvent::DevcontainerLifecycleCommandStarted { + phase: phase.to_string(), + command: command.to_string(), + index, + }); + let cmd_start = Instant::now(); + let result = sandbox + .exec_command(command, timeout_ms, None, None, None) + .await + .map_err(|e| anyhow::anyhow!("Devcontainer {phase} command failed: {e}"))?; + let cmd_duration = crate::millis_u64(cmd_start.elapsed()); + if result.exit_code != 0 { + emitter.emit(&WorkflowRunEvent::DevcontainerLifecycleFailed { + phase: phase.to_string(), + command: command.to_string(), + index, + exit_code: result.exit_code, + stderr: result.stderr.clone(), + }); + anyhow::bail!( + "Devcontainer {phase} command failed (exit code {}): {command}\n{}", + result.exit_code, + result.stderr, + ); + } + emitter.emit(&WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { + phase: phase.to_string(), + command: command.to_string(), + index, + exit_code: result.exit_code, + duration_ms: cmd_duration, + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use arc_agent::sandbox::{ExecResult, GrepOptions, Sandbox}; + use async_trait::async_trait; + use std::sync::{Arc, Mutex}; + use tokio_util::sync::CancellationToken; + + /// Simple test sandbox that records commands and returns a fixed exit code. + struct TestSandbox { + commands: Mutex>, + exit_code: i32, + } + + impl TestSandbox { + fn new() -> Self { + Self { + commands: Mutex::new(Vec::new()), + exit_code: 0, + } + } + + fn with_exit_code(exit_code: i32) -> Self { + Self { + commands: Mutex::new(Vec::new()), + exit_code, + } + } + + fn captured_commands(&self) -> Vec { + self.commands.lock().unwrap().clone() + } + } + + #[async_trait] + impl Sandbox for TestSandbox { + async fn read_file( + &self, + _path: &str, + _offset: Option, + _limit: Option, + ) -> Result { + Ok(String::new()) + } + async fn write_file(&self, _path: &str, _content: &str) -> Result<(), String> { + Ok(()) + } + async fn delete_file(&self, _path: &str) -> Result<(), String> { + Ok(()) + } + async fn file_exists(&self, _path: &str) -> Result { + Ok(false) + } + async fn list_directory( + &self, + _path: &str, + _depth: Option, + ) -> Result, String> { + Ok(vec![]) + } + async fn exec_command( + &self, + command: &str, + _timeout_ms: u64, + _working_dir: Option<&str>, + _env_vars: Option<&std::collections::HashMap>, + _cancel_token: Option, + ) -> Result { + self.commands.lock().unwrap().push(command.to_string()); + Ok(ExecResult { + stdout: String::new(), + stderr: if self.exit_code != 0 { + "command failed".to_string() + } else { + String::new() + }, + exit_code: self.exit_code, + timed_out: false, + duration_ms: 10, + }) + } + async fn grep( + &self, + _pattern: &str, + _path: &str, + _options: &GrepOptions, + ) -> Result, String> { + Ok(vec![]) + } + async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result, String> { + Ok(vec![]) + } + async fn download_file_to_local( + &self, + _remote_path: &str, + _local_path: &std::path::Path, + ) -> Result<(), String> { + Ok(()) + } + async fn upload_file_from_local( + &self, + _local_path: &std::path::Path, + _remote_path: &str, + ) -> Result<(), String> { + Ok(()) + } + async fn initialize(&self) -> Result<(), String> { + Ok(()) + } + async fn cleanup(&self) -> Result<(), String> { + Ok(()) + } + fn working_directory(&self) -> &str { + "/work" + } + fn platform(&self) -> &str { + "linux" + } + fn os_version(&self) -> String { + "Linux 6.1.0".into() + } + } + + #[test] + fn snapshot_name_is_deterministic() { + let dockerfile = "FROM ubuntu:22.04\nRUN apt-get update"; + let name1 = snapshot_name_for_dockerfile(dockerfile); + let name2 = snapshot_name_for_dockerfile(dockerfile); + assert_eq!(name1, name2); + } + + #[test] + fn snapshot_name_differs_for_different_dockerfiles() { + let name1 = snapshot_name_for_dockerfile("FROM ubuntu:22.04"); + let name2 = snapshot_name_for_dockerfile("FROM rust:1.85"); + assert_ne!(name1, name2); + } + + #[test] + fn snapshot_name_has_prefix() { + let name = snapshot_name_for_dockerfile("FROM ubuntu:22.04"); + assert!(name.starts_with("devcontainer-"), "name: {name}"); + // prefix + 12 hex chars + assert_eq!(name.len(), "devcontainer-".len() + 12); + } + + #[test] + fn maps_dockerfile_to_inline() { + let dc = test_devcontainer_config("FROM rust:1.85\nRUN cargo install sccache"); + let snapshot = devcontainer_to_snapshot_config(&dc); + assert_eq!( + snapshot.dockerfile, + Some(DockerfileSource::Inline(dc.dockerfile.clone())) + ); + } + + #[test] + fn snapshot_name_from_dockerfile_hash() { + let dc = test_devcontainer_config("FROM ubuntu:22.04"); + let snapshot = devcontainer_to_snapshot_config(&dc); + let expected = snapshot_name_for_dockerfile(&dc.dockerfile); + assert_eq!(snapshot.name, expected); + } + + #[test] + fn returns_environment_map() { + let mut dc = test_devcontainer_config("FROM ubuntu"); + dc.environment.insert("FOO".to_string(), "bar".to_string()); + dc.environment.insert("BAZ".to_string(), "qux".to_string()); + let env = devcontainer_env(&dc); + assert_eq!(env["FOO"], "bar"); + assert_eq!(env["BAZ"], "qux"); + } + + #[tokio::test] + async fn shell_command_executed() { + let sandbox = TestSandbox::new(); + let emitter = EventEmitter::new(); + let commands = vec![arc_devcontainer::Command::Shell("echo hi".to_string())]; + run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000) + .await + .unwrap(); + let captured = sandbox.captured_commands(); + assert_eq!(captured.len(), 1); + assert!(captured[0].contains("echo hi"), "command: {}", captured[0]); + } + + #[tokio::test] + async fn args_command_joins() { + let sandbox = TestSandbox::new(); + let emitter = EventEmitter::new(); + let commands = vec![arc_devcontainer::Command::Args(vec![ + "echo".to_string(), + "hi".to_string(), + ])]; + run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000) + .await + .unwrap(); + let captured = sandbox.captured_commands(); + assert_eq!(captured.len(), 1); + assert!( + captured[0].contains("echo") && captured[0].contains("hi"), + "command: {}", + captured[0] + ); + } + + #[tokio::test] + async fn emits_started_and_completed_events() { + let mut emitter = EventEmitter::new(); + let events = Arc::new(Mutex::new(Vec::new())); + let events_clone = Arc::clone(&events); + emitter.on_event(move |event| { + events_clone.lock().unwrap().push(event.clone()); + }); + let sandbox = TestSandbox::new(); + let commands = vec![arc_devcontainer::Command::Shell("echo hi".to_string())]; + run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000) + .await + .unwrap(); + let events = events.lock().unwrap(); + assert!(matches!( + &events[0], + WorkflowRunEvent::DevcontainerLifecycleStarted { phase, command_count } if phase == "on_create" && *command_count == 1 + )); + assert!(matches!( + &events[1], + WorkflowRunEvent::DevcontainerLifecycleCommandStarted { phase, index, .. } if phase == "on_create" && *index == 0 + )); + assert!(matches!( + &events[2], + WorkflowRunEvent::DevcontainerLifecycleCommandCompleted { phase, index, exit_code, .. } if phase == "on_create" && *index == 0 && *exit_code == 0 + )); + assert!(matches!( + &events[3], + WorkflowRunEvent::DevcontainerLifecycleCompleted { phase, .. } if phase == "on_create" + )); + } + + #[tokio::test] + async fn failed_command_emits_failed_and_returns_error() { + let mut emitter = EventEmitter::new(); + let events = Arc::new(Mutex::new(Vec::new())); + let events_clone = Arc::clone(&events); + emitter.on_event(move |event| { + events_clone.lock().unwrap().push(event.clone()); + }); + let sandbox = TestSandbox::with_exit_code(1); + let commands = vec![arc_devcontainer::Command::Shell("false".to_string())]; + let result = + run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &commands, 300_000).await; + assert!(result.is_err()); + let events = events.lock().unwrap(); + assert!(events.iter().any(|e| matches!( + e, + WorkflowRunEvent::DevcontainerLifecycleFailed { phase, exit_code, .. } if phase == "on_create" && *exit_code == 1 + ))); + } + + #[tokio::test] + async fn empty_commands_is_noop() { + let mut emitter = EventEmitter::new(); + let events = Arc::new(Mutex::new(Vec::new())); + let events_clone = Arc::clone(&events); + emitter.on_event(move |event| { + events_clone.lock().unwrap().push(event.clone()); + }); + let sandbox = TestSandbox::new(); + run_devcontainer_lifecycle(&sandbox, &emitter, "on_create", &[], 300_000) + .await + .unwrap(); + assert!(events.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn parallel_commands_run() { + let sandbox = TestSandbox::new(); + let emitter = EventEmitter::new(); + let mut map = HashMap::new(); + map.insert("install".to_string(), "npm install".to_string()); + map.insert("build".to_string(), "npm run build".to_string()); + let commands = vec![arc_devcontainer::Command::Parallel(map)]; + run_devcontainer_lifecycle(&sandbox, &emitter, "post_create", &commands, 300_000) + .await + .unwrap(); + let captured = sandbox.captured_commands(); + assert_eq!(captured.len(), 2); + } + + fn test_devcontainer_config(dockerfile: &str) -> DevcontainerConfig { + DevcontainerConfig { + dockerfile: dockerfile.to_string(), + build_context: std::path::PathBuf::from("."), + build_args: HashMap::new(), + build_target: None, + initialize_commands: vec![], + on_create_commands: vec![], + post_create_commands: vec![], + post_start_commands: vec![], + environment: HashMap::new(), + container_env: HashMap::new(), + remote_user: None, + workspace_folder: "/workspaces/test".to_string(), + forwarded_ports: vec![], + compose_files: vec![], + compose_service: None, + } + } +} diff --git a/lib/crates/arc-workflows/src/event.rs b/lib/crates/arc-workflows/src/event.rs index c353f0d4e..d9488315c 100644 --- a/lib/crates/arc-workflows/src/event.rs +++ b/lib/crates/arc-workflows/src/event.rs @@ -227,6 +227,39 @@ pub enum WorkflowRunEvent { PullRequestFailed { error: String, }, + DevcontainerResolved { + dockerfile_lines: usize, + environment_count: usize, + lifecycle_command_count: usize, + workspace_folder: String, + }, + DevcontainerLifecycleStarted { + phase: String, + command_count: usize, + }, + DevcontainerLifecycleCommandStarted { + phase: String, + command: String, + index: usize, + }, + DevcontainerLifecycleCommandCompleted { + phase: String, + command: String, + index: usize, + exit_code: i32, + duration_ms: u64, + }, + DevcontainerLifecycleCompleted { + phase: String, + duration_ms: u64, + }, + DevcontainerLifecycleFailed { + phase: String, + command: String, + index: usize, + exit_code: i32, + stderr: String, + }, } impl WorkflowRunEvent { @@ -548,6 +581,67 @@ impl WorkflowRunEvent { Self::PullRequestFailed { error, .. } => { error!(error = %error, "Pull request creation failed"); } + Self::DevcontainerResolved { + dockerfile_lines, + environment_count, + lifecycle_command_count, + workspace_folder, + } => { + info!( + dockerfile_lines, + environment_count, + lifecycle_command_count, + workspace_folder, + "Devcontainer resolved" + ); + } + Self::DevcontainerLifecycleStarted { + phase, + command_count, + } => { + info!(phase, command_count, "Devcontainer lifecycle started"); + } + Self::DevcontainerLifecycleCommandStarted { + phase, + command, + index, + } => { + debug!( + phase, + command, index, "Devcontainer lifecycle command started" + ); + } + Self::DevcontainerLifecycleCommandCompleted { + phase, + command, + index, + exit_code, + duration_ms, + } => { + debug!( + phase, + command, + index, + exit_code, + duration_ms, + "Devcontainer lifecycle command completed" + ); + } + Self::DevcontainerLifecycleCompleted { phase, duration_ms } => { + info!(phase, duration_ms, "Devcontainer lifecycle completed"); + } + Self::DevcontainerLifecycleFailed { + phase, + command, + index, + exit_code, + .. + } => { + error!( + phase, + command, index, exit_code, "Devcontainer lifecycle command failed" + ); + } } } } @@ -802,6 +896,10 @@ fn rename_fields(event_name: &str, fields: &mut serde_json::Map