mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
e248fc5651
commit
e8067740f7
10 changed files with 994 additions and 7 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
503
lib/crates/arc-workflows/src/devcontainer_bridge.rs
Normal file
503
lib/crates/arc-workflows/src/devcontainer_bridge.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue