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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-10 22:54:59 -04:00
parent e248fc5651
commit e8067740f7
10 changed files with 994 additions and 7 deletions

3
Cargo.lock generated
View file

@ -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",

View file

@ -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),

View file

@ -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),

View file

@ -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

View file

@ -213,8 +213,10 @@ pub struct ProgressUI {
/// renderer so that Plain (non-TTY) mode reports accurate stats.
stage_counts: HashMap<String, (u32, u32)>,
setup_command_count: usize,
devcontainer_command_count: usize,
sandbox_bar: Option<ProgressBar>,
setup_bar: Option<ProgressBar>,
devcontainer_bar: Option<ProgressBar>,
cli_ensure_bar: Option<ProgressBar>,
any_stage_started: bool,
parallel_parent: Option<String>,
@ -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}");
}
}
}
_ => {}
}
}

View file

@ -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<String, String> = 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<String, String> = {
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<arc_mcp::config::McpServerConfig> = 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,
}),

View file

@ -112,6 +112,8 @@ pub struct LocalSandboxConfig {
pub struct SandboxConfig {
pub provider: Option<String>,
pub preserve: Option<bool>,
#[serde(default)]
pub devcontainer: Option<bool>,
pub local: Option<LocalSandboxConfig>,
pub daytona: Option<DaytonaConfig>,
#[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")]

View file

@ -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<String, String> {
dc.environment.clone()
}
/// Resolve devcontainer.json from a repo directory.
pub async fn resolve_devcontainer(
repo_path: &Path,
) -> Result<DevcontainerConfig, arc_devcontainer::DevcontainerError> {
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::<Vec<_>>()
.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<Vec<String>>,
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<String> {
self.commands.lock().unwrap().clone()
}
}
#[async_trait]
impl Sandbox for TestSandbox {
async fn read_file(
&self,
_path: &str,
_offset: Option<usize>,
_limit: Option<usize>,
) -> Result<String, String> {
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<bool, String> {
Ok(false)
}
async fn list_directory(
&self,
_path: &str,
_depth: Option<usize>,
) -> Result<Vec<arc_agent::sandbox::DirEntry>, String> {
Ok(vec![])
}
async fn exec_command(
&self,
command: &str,
_timeout_ms: u64,
_working_dir: Option<&str>,
_env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String> {
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<Vec<String>, String> {
Ok(vec![])
}
async fn glob(&self, _pattern: &str, _path: Option<&str>) -> Result<Vec<String>, 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,
}
}
}

View file

@ -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<String, serde_js
|| event_name == "GitCheckpointFailed"
{
default_node_label(fields);
} else if event_name.starts_with("DevcontainerLifecycleCommand")
|| event_name == "DevcontainerLifecycleFailed"
{
rename(fields, "index", "command_index");
}
}
@ -1840,4 +1938,95 @@ mod tests {
matches!(deserialized, WorkflowRunEvent::PullRequestFailed { error } if error == "auth failed")
);
}
#[test]
fn devcontainer_resolved_serializes() {
let event = WorkflowRunEvent::DevcontainerResolved {
dockerfile_lines: 15,
environment_count: 3,
lifecycle_command_count: 5,
workspace_folder: "/workspaces/myrepo".to_string(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("DevcontainerResolved"));
assert!(json.contains("\"dockerfile_lines\":15"));
assert!(json.contains("\"workspace_folder\":\"/workspaces/myrepo\""));
let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(
deserialized,
WorkflowRunEvent::DevcontainerResolved {
dockerfile_lines: 15,
..
}
));
}
#[test]
fn devcontainer_lifecycle_events_serialization() {
let events = vec![
WorkflowRunEvent::DevcontainerLifecycleStarted {
phase: "on_create".into(),
command_count: 2,
},
WorkflowRunEvent::DevcontainerLifecycleCommandStarted {
phase: "on_create".into(),
command: "npm install".into(),
index: 0,
},
WorkflowRunEvent::DevcontainerLifecycleCommandCompleted {
phase: "on_create".into(),
command: "npm install".into(),
index: 0,
exit_code: 0,
duration_ms: 5000,
},
WorkflowRunEvent::DevcontainerLifecycleCompleted {
phase: "on_create".into(),
duration_ms: 8000,
},
WorkflowRunEvent::DevcontainerLifecycleFailed {
phase: "post_create".into(),
command: "npm test".into(),
index: 1,
exit_code: 1,
stderr: "test failed".into(),
},
];
for event in &events {
let json = serde_json::to_string(event).unwrap();
let deserialized: WorkflowRunEvent = serde_json::from_str(&json).unwrap();
let json2 = serde_json::to_string(&deserialized).unwrap();
assert_eq!(json, json2);
}
}
#[test]
fn flatten_devcontainer_lifecycle_command_renames_index() {
let event = WorkflowRunEvent::DevcontainerLifecycleCommandStarted {
phase: "on_create".into(),
command: "npm install".into(),
index: 2,
};
let (name, fields) = flatten_event(&event);
assert_eq!(name, "DevcontainerLifecycleCommandStarted");
assert_eq!(fields["command_index"], 2);
assert!(!fields.contains_key("index"));
}
#[test]
fn flatten_devcontainer_lifecycle_failed_renames_index() {
let event = WorkflowRunEvent::DevcontainerLifecycleFailed {
phase: "post_create".into(),
command: "npm test".into(),
index: 1,
exit_code: 1,
stderr: "fail".into(),
};
let (name, fields) = flatten_event(&event);
assert_eq!(name, "DevcontainerLifecycleFailed");
assert_eq!(fields["command_index"], 1);
assert!(!fields.contains_key("index"));
}
}

View file

@ -33,6 +33,7 @@ pub mod conclusion;
pub mod condition;
pub mod context;
pub mod daytona_sandbox;
pub mod devcontainer_bridge;
pub mod engine;
pub mod error;
pub mod event;