diff --git a/Cargo.lock b/Cargo.lock index 80ed409e8..7619ec293 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,7 @@ dependencies = [ "assert_cmd", "async-trait", "axum", + "base64", "chrono", "clap", "daytona-api-client", diff --git a/crates/arc-attractor/Cargo.toml b/crates/arc-attractor/Cargo.toml index 669be12e1..2988d63e1 100644 --- a/crates/arc-attractor/Cargo.toml +++ b/crates/arc-attractor/Cargo.toml @@ -44,6 +44,7 @@ dirs = "6" dialoguer.workspace = true daytona-sdk.workspace = true daytona-api-client.workspace = true +base64.workspace = true scopeguard = "1" git2.workspace = true tokio-util.workspace = true diff --git a/crates/arc-attractor/src/cli/run.rs b/crates/arc-attractor/src/cli/run.rs index 2f38321a6..993d98f2a 100644 --- a/crates/arc-attractor/src/cli/run.rs +++ b/crates/arc-attractor/src/cli/run.rs @@ -8,7 +8,7 @@ use chrono::{Local, Utc}; use arc_util::terminal::Styles; use crate::checkpoint::Checkpoint; -use crate::engine::{PipelineEngine, RunConfig}; +use crate::engine::{GitCheckpointMode, PipelineEngine, RunConfig}; use crate::event::EventEmitter; use crate::handler::default_registry; use crate::interviewer::auto_approve::AutoApproveInterviewer; @@ -107,10 +107,11 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu args.execution_env.or(toml_exec).unwrap_or_default() }; let original_cwd = std::env::current_dir()?; - let git_clean = if execution_env_kind_preview == ExecutionEnvKind::Local { - crate::git::ensure_clean(&original_cwd).is_ok() - } else { - false + let git_clean = match execution_env_kind_preview { + ExecutionEnvKind::Local | ExecutionEnvKind::Docker => { + crate::git::ensure_clean(&original_cwd).is_ok() + } + ExecutionEnvKind::Daytona => false, }; // 3. Create logs directory @@ -346,6 +347,22 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu } }); + // Set up git inside Daytona sandbox (if applicable) + let (daytona_run_id, daytona_base_sha, daytona_branch) = if execution_env_kind == ExecutionEnvKind::Daytona { + match setup_daytona_git(&*execution_env).await { + Ok((rid, base, branch)) => (Some(rid), Some(base), Some(branch)), + Err(e) => { + eprintln!( + "{yellow}Warning:{reset} Daytona git setup failed ({e}), running without git checkpoints.", + yellow = styles.yellow, reset = styles.reset, + ); + (None, None, None) + } + } + } else { + (None, None, None) + }; + // Run setup commands inside the execution environment (once, not per-stage) if !setup_commands.is_empty() { emitter.emit(&crate::event::PipelineEvent::SetupStarted { command_count: setup_commands.len() }); @@ -480,15 +497,24 @@ pub async fn run_command(args: RunArgs, styles: &'static Styles) -> anyhow::Resu let engine = PipelineEngine::with_interviewer(registry, Arc::clone(&emitter), interviewer, Arc::clone(&execution_env)); // 7. Execute - let run_id = worktree_run_id.unwrap_or_else(|| ulid::Ulid::new().to_string()); + let run_id = worktree_run_id + .or(daytona_run_id) + .unwrap_or_else(|| ulid::Ulid::new().to_string()); let config = RunConfig { logs_root: logs_dir.clone(), cancel_token: None, dry_run: dry_run_mode, run_id, - work_dir: worktree_work_dir, - base_sha: worktree_base_sha, - run_branch: worktree_branch, + git_checkpoint: match execution_env_kind { + ExecutionEnvKind::Local | ExecutionEnvKind::Docker => { + worktree_work_dir.map(GitCheckpointMode::Host) + } + ExecutionEnvKind::Daytona => { + daytona_base_sha.as_ref().map(|_| GitCheckpointMode::Remote) + } + }, + base_sha: worktree_base_sha.or(daytona_base_sha), + run_branch: worktree_branch.or(daytona_branch), }; let run_start = Instant::now(); @@ -616,6 +642,33 @@ fn setup_worktree( Ok((run_id, worktree_path.clone(), worktree_path, branch_name, base_sha)) } +/// Set up git inside a Daytona sandbox for checkpoint commits. +/// Returns (run_id, base_sha, branch_name) on success. +async fn setup_daytona_git( + exec_env: &dyn arc_agent::ExecutionEnvironment, +) -> anyhow::Result<(String, String, String)> { + // Get current HEAD as base SHA + let sha_result = exec_env.exec_command("git rev-parse HEAD", 10_000, None, None, None).await + .map_err(|e| anyhow::anyhow!("git rev-parse HEAD failed: {e}"))?; + if sha_result.exit_code != 0 { + anyhow::bail!("git rev-parse HEAD failed (exit {}): {}", sha_result.exit_code, sha_result.stderr); + } + let base_sha = sha_result.stdout.trim().to_string(); + + let run_id = ulid::Ulid::new().to_string(); + let branch_name = format!("arc/run/{run_id}"); + + // Create and checkout a run branch + let checkout_cmd = format!("git checkout -b {branch_name}"); + let checkout_result = exec_env.exec_command(&checkout_cmd, 10_000, None, None, None).await + .map_err(|e| anyhow::anyhow!("git checkout failed: {e}"))?; + if checkout_result.exit_code != 0 { + anyhow::bail!("git checkout -b failed (exit {}): {}", checkout_result.exit_code, checkout_result.stderr); + } + + Ok((run_id, base_sha, branch_name)) +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/arc-attractor/src/daytona_env.rs b/crates/arc-attractor/src/daytona_env.rs index 70935a741..b6133ea69 100644 --- a/crates/arc-attractor/src/daytona_env.rs +++ b/crates/arc-attractor/src/daytona_env.rs @@ -667,9 +667,13 @@ impl ExecutionEnvironment for DaytonaExecutionEnvironment { /// /// The Daytona API uses direct exec (not a shell), so pipes, env vars, /// semicolons, etc. won't work without this wrapper. +/// +/// Uses base64 encoding (matching the TypeScript/Python/Ruby Daytona SDKs) +/// to avoid shell escaping issues with quotes and special characters. fn wrap_bash_command(command: &str) -> String { - // Shell-quote by replacing ' with '\'' then wrapping in single quotes. - format!("bash -c '{}'", command.replace('\'', "'\\''")) + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(command); + format!("sh -c \"echo '{encoded}' | base64 -d | sh\"") } #[cfg(test)] @@ -685,24 +689,29 @@ mod tests { } #[test] - fn wrap_bash_simple() { - assert_eq!(wrap_bash_command("echo hello"), "bash -c 'echo hello'"); + fn wrap_bash_uses_base64_encoding() { + let wrapped = wrap_bash_command("echo hello"); + // Should use base64 pipe to sh + assert!(wrapped.starts_with("sh -c \"echo '"), "should start with sh -c wrapper"); + assert!(wrapped.ends_with("' | base64 -d | sh\""), "should end with base64 -d | sh"); + // The base64 of "echo hello" is "ZWNobyBoZWxsbw==" + assert!(wrapped.contains("ZWNobyBoZWxsbw=="), "should contain base64 of 'echo hello'"); } #[test] - fn wrap_bash_with_pipe() { - assert_eq!( - wrap_bash_command("ls | grep foo"), - "bash -c 'ls | grep foo'" - ); + fn wrap_bash_handles_single_quotes_safely() { + // Single quotes in the original command are safely encoded in base64 + let wrapped = wrap_bash_command("echo 'hello world'"); + assert!(wrapped.starts_with("sh -c \"echo '"), "should use sh -c wrapper"); + // No raw single quotes from the original command should appear in the base64 + assert!(!wrapped.contains("hello world"), "original command should be base64 encoded, not literal"); } #[test] - fn wrap_bash_escapes_single_quotes() { - assert_eq!( - wrap_bash_command("echo 'hello world'"), - "bash -c 'echo '\\''hello world'\\'''" - ); + fn wrap_bash_handles_pipes() { + let wrapped = wrap_bash_command("ls | grep foo"); + assert!(wrapped.starts_with("sh -c \"echo '"), "should use sh -c wrapper"); + assert!(wrapped.ends_with("' | base64 -d | sh\""), "should end with base64 -d | sh"); } #[test] diff --git a/crates/arc-attractor/src/engine.rs b/crates/arc-attractor/src/engine.rs index 9163eb051..f49c9e8ea 100644 --- a/crates/arc-attractor/src/engine.rs +++ b/crates/arc-attractor/src/engine.rs @@ -465,6 +465,70 @@ fn is_terminal(node: &Node) -> bool { // --- Pipeline engine --- +/// How git checkpointing should be performed for a pipeline run. +#[derive(Debug, Clone)] +pub enum GitCheckpointMode { + /// Run git commands on the host filesystem (local & Docker bind-mount). + Host(PathBuf), + /// Run git commands inside the remote execution environment via `exec_command`. + Remote, +} + +/// Run a git checkpoint commit on the host filesystem (local/Docker bind-mount). +async fn git_checkpoint_host(work_dir: PathBuf, run_id: String, node_id: String, status: String) -> Option { + match tokio::task::spawn_blocking(move || { + crate::git::checkpoint_commit(&work_dir, &run_id, &node_id, &status) + }).await { + Ok(Ok(sha)) => Some(sha), + Ok(Err(_)) | Err(_) => None, + } +} + +/// Run a git diff on the host filesystem. +async fn git_diff_host(work_dir: PathBuf, base: String) -> Option { + match tokio::task::spawn_blocking(move || { + crate::git::diff_against(&work_dir, &base) + }).await { + Ok(Ok(patch)) => Some(patch), + Ok(Err(_)) | Err(_) => None, + } +} + +/// Run a git checkpoint commit inside a remote execution environment. +async fn git_checkpoint_remote(exec_env: &dyn ExecutionEnvironment, run_id: &str, node_id: &str, status: &str) -> Option { + // Stage everything + let add_result = exec_env.exec_command("git add -A", 30_000, None, None, None).await; + if add_result.as_ref().map_or(true, |r| r.exit_code != 0) { + return None; + } + + // Commit with arc identity + let message = format!("arc({run_id}): {node_id} ({status})"); + let commit_cmd = format!( + "git -c user.name=arc -c user.email=arc@local commit --allow-empty -m '{message}'" + ); + let commit_result = exec_env.exec_command(&commit_cmd, 30_000, None, None, None).await; + if commit_result.as_ref().map_or(true, |r| r.exit_code != 0) { + return None; + } + + // Get the new HEAD SHA + let sha_result = exec_env.exec_command("git rev-parse HEAD", 10_000, None, None, None).await; + match sha_result { + Ok(r) if r.exit_code == 0 => Some(r.stdout.trim().to_string()), + _ => None, + } +} + +/// Run a git diff inside a remote execution environment. +async fn git_diff_remote(exec_env: &dyn ExecutionEnvironment, base: &str) -> Option { + let cmd = format!("git diff {base} HEAD"); + match exec_env.exec_command(&cmd, 30_000, None, None, None).await { + Ok(r) if r.exit_code == 0 => Some(r.stdout), + _ => None, + } +} + /// Configuration for a pipeline run. pub struct RunConfig { pub logs_root: PathBuf, @@ -472,8 +536,8 @@ pub struct RunConfig { pub dry_run: bool, /// Unique identifier for this pipeline run. pub run_id: String, - /// Git worktree path for checkpoint commits. - pub work_dir: Option, + /// Git checkpoint mode (None = no checkpointing). + pub git_checkpoint: Option, /// SHA of the commit the worktree branched from. pub base_sha: Option, /// Git branch name for the run (e.g. `arc/run/{run_id}`). @@ -714,7 +778,10 @@ impl PipelineEngine { run_id: run_id.clone(), base_sha: config.base_sha.clone(), run_branch: config.run_branch.clone(), - worktree_dir: config.work_dir.as_ref().map(|p| p.display().to_string()), + worktree_dir: match config.git_checkpoint { + Some(GitCheckpointMode::Host(ref p)) => Some(p.display().to_string()), + _ => None, + }, }); self.inform(&format!("Pipeline started: {}", graph.name), "pipeline"); @@ -801,7 +868,7 @@ impl PipelineEngine { // Store run_id and work_dir in context for handlers context.set("internal.run_id", serde_json::json!(run_id)); - if let Some(ref wd) = config.work_dir { + if let Some(GitCheckpointMode::Host(ref wd)) = config.git_checkpoint { context.set("internal.work_dir", serde_json::json!(wd.to_string_lossy().as_ref())); } @@ -1029,58 +1096,57 @@ impl PipelineEngine { }); } - // Step 6b: Git checkpoint commit (when running in a worktree) - if let Some(ref work_dir) = config.work_dir { - let wd = work_dir.clone(); + // Step 6b: Git checkpoint commit + if let Some(ref mode) = config.git_checkpoint { let rid = run_id.clone(); let nid = node.id.clone(); let status_str = outcome.status.to_string(); - match tokio::task::spawn_blocking(move || { - crate::git::checkpoint_commit(&wd, &rid, &nid, &status_str) - }) - .await - { - Ok(Ok(sha)) => { - checkpoint.git_commit_sha = Some(sha.clone()); - if let Err(e) = checkpoint.save(&checkpoint_path) { - context.append_log(format!("checkpoint re-save with SHA failed: {e}")); - } - self.services.emitter.emit(&PipelineEvent::GitCheckpoint { - run_id: run_id.clone(), - node_id: node.id.clone(), - status: outcome.status.to_string(), - git_commit_sha: sha.clone(), - }); - // Save diff.patch for this stage - let prev = last_git_sha.as_deref() - .or(config.base_sha.as_deref()) - .unwrap_or(&sha); - let diff_base = prev.to_string(); - let diff_wd = work_dir.clone(); - let diff_dest = node_dir(&config.logs_root, &node.id, visit).join("diff.patch"); - match tokio::task::spawn_blocking(move || { - crate::git::diff_against(&diff_wd, &diff_base) - }).await { - Ok(Ok(patch)) => { - let _ = std::fs::write(&diff_dest, patch); - } - Ok(Err(e)) => { - context.append_log(format!("git diff failed: {e}")); - } - Err(e) => { - context.append_log(format!("git diff task panicked: {e}")); - } - } + let commit_result = match mode { + GitCheckpointMode::Host(work_dir) => { + git_checkpoint_host(work_dir.clone(), rid, nid, status_str).await + } + GitCheckpointMode::Remote => { + git_checkpoint_remote(&*self.services.execution_env, &run_id, &node.id, &outcome.status.to_string()).await + } + }; - last_git_sha = Some(sha); + if let Some(sha) = commit_result { + checkpoint.git_commit_sha = Some(sha.clone()); + if let Err(e) = checkpoint.save(&checkpoint_path) { + context.append_log(format!("checkpoint re-save with SHA failed: {e}")); } - Ok(Err(e)) => { - context.append_log(format!("git checkpoint commit failed: {e}")); - } - Err(e) => { - context.append_log(format!("git checkpoint commit task panicked: {e}")); + self.services.emitter.emit(&PipelineEvent::GitCheckpoint { + run_id: run_id.clone(), + node_id: node.id.clone(), + status: outcome.status.to_string(), + git_commit_sha: sha.clone(), + }); + + // Save diff.patch for this stage + let prev = last_git_sha.as_deref() + .or(config.base_sha.as_deref()) + .unwrap_or(&sha); + let diff_base = prev.to_string(); + let diff_dest = node_dir(&config.logs_root, &node.id, visit).join("diff.patch"); + + let diff_result = match mode { + GitCheckpointMode::Host(work_dir) => { + git_diff_host(work_dir.clone(), diff_base).await + } + GitCheckpointMode::Remote => { + git_diff_remote(&*self.services.execution_env, &diff_base).await + } + }; + if let Some(patch) = diff_result { + let _ = std::fs::write(&diff_dest, patch); + } else { + context.append_log("git diff failed".to_string()); } + + last_git_sha = Some(sha); + } else { + context.append_log("git checkpoint commit failed".to_string()); } } @@ -1816,7 +1882,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1834,7 +1900,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1861,7 +1927,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1883,7 +1949,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1901,7 +1967,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1932,7 +1998,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1989,7 +2055,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2064,7 +2130,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2091,7 +2157,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2115,7 +2181,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2262,7 +2328,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let g = simple_graph(); let engine = PipelineEngine::new(make_registry(), Arc::new(EventEmitter::new()), local_env()); - let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; engine.run(&g, &config).await.unwrap(); let manifest_path = dir.path().join("manifest.json"); @@ -2284,7 +2350,7 @@ mod tests { g.edges.push(Edge::new("start", "exit")); let engine = PipelineEngine::new(make_registry(), Arc::new(EventEmitter::new()), local_env()); - let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; engine.run(&g, &config).await.unwrap(); let manifest_path = dir.path().join("manifest.json"); @@ -2320,7 +2386,7 @@ mod tests { let mut registry = make_registry(); registry.register("always_fail", Box::new(AlwaysFailHandler)); let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), local_env()); - let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -2356,7 +2422,7 @@ mod tests { let mut registry = make_registry(); registry.register("always_fail", Box::new(AlwaysFailHandler)); let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), local_env()); - let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; let result = engine.run(&g, &config).await; assert!(result.is_ok()); @@ -2395,7 +2461,7 @@ mod tests { let mut registry = make_registry(); registry.register("slow", Box::new(SlowHandler { sleep_ms: 500 })); let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), local_env()); - let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; let result = engine.run(&g, &config).await; assert!(result.is_ok()); @@ -2429,7 +2495,7 @@ mod tests { let mut registry = make_registry(); registry.register("slow", Box::new(SlowHandler { sleep_ms: 10 })); let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), local_env()); - let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); } @@ -2460,7 +2526,7 @@ mod tests { let mut registry = make_registry(); registry.register("slow", Box::new(SlowHandler { sleep_ms: 500 })); let engine = PipelineEngine::new(registry, Arc::new(EventEmitter::new()), local_env()); - let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config = RunConfig { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; let outcome = engine.run(&g, &config).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -2515,7 +2581,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2546,7 +2612,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2575,7 +2641,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2596,7 +2662,7 @@ mod tests { cancel_token: Some(cancel_token), dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2616,7 +2682,7 @@ mod tests { cancel_token: Some(cancel_token), dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2648,7 +2714,7 @@ mod tests { cancel_token: Some(cancel_token), dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2724,7 +2790,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2747,7 +2813,7 @@ mod tests { cancel_token: None, dry_run: true, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2774,7 +2840,7 @@ mod tests { cancel_token: None, dry_run: true, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2862,7 +2928,7 @@ mod tests { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; diff --git a/crates/arc-attractor/src/server.rs b/crates/arc-attractor/src/server.rs index a05f214d4..43a7da7ea 100644 --- a/crates/arc-attractor/src/server.rs +++ b/crates/arc-attractor/src/server.rs @@ -219,7 +219,7 @@ async fn start_pipeline( tokio::spawn(async move { let logs_root = std::env::temp_dir().join(format!("arc-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&logs_root).expect("failed to create logs directory"); - let config = RunConfig { logs_root, cancel_token: Some(cancel_token), dry_run: state_clone.dry_run, run_id: run_id_clone.clone(), work_dir: None, base_sha: None, run_branch: None }; + let config = RunConfig { logs_root, cancel_token: Some(cancel_token), dry_run: state_clone.dry_run, run_id: run_id_clone.clone(), git_checkpoint: None, base_sha: None, run_branch: None }; let result = tokio::select! { result = engine.run(&graph, &config) => result, diff --git a/crates/arc-attractor/tests/daytona_integration.rs b/crates/arc-attractor/tests/daytona_integration.rs index 694691466..8f8527716 100644 --- a/crates/arc-attractor/tests/daytona_integration.rs +++ b/crates/arc-attractor/tests/daytona_integration.rs @@ -271,7 +271,7 @@ async fn daytona_pipeline_artifact_offload_and_sync() { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -317,6 +317,170 @@ async fn daytona_pipeline_artifact_offload_and_sync() { // CLI Backend on Daytona — real CLI tools via exec_command // --------------------------------------------------------------------------- +use arc_attractor::engine::GitCheckpointMode; + +// --------------------------------------------------------------------------- +// Git checkpoint E2E on Daytona (Remote mode) +// --------------------------------------------------------------------------- + +/// Handler that writes a file via exec_command so git has something to commit. +struct FileWriterHandler; + +#[async_trait::async_trait] +impl Handler for FileWriterHandler { + async fn execute( + &self, + node: &Node, + _context: &Context, + _graph: &Graph, + _logs_root: &Path, + services: &arc_attractor::handler::EngineServices, + ) -> Result { + let content = format!("output from {}", node.id); + let cmd = format!("echo '{content}' > {}.txt", node.id); + let _ = services.execution_env.exec_command(&cmd, 10_000, None, None, None).await; + Ok(Outcome::success()) + } +} + +/// Set up git inside a Daytona sandbox for checkpoint commits. +/// Returns (run_id, base_sha, branch_name) on success. +async fn setup_daytona_git( + exec_env: &dyn ExecutionEnvironment, +) -> (String, String, String) { + // Get current HEAD as base SHA + let sha_result = exec_env.exec_command("git rev-parse HEAD", 10_000, None, None, None).await + .expect("git rev-parse HEAD should succeed"); + assert_eq!(sha_result.exit_code, 0, "git rev-parse HEAD failed: {}", sha_result.stderr); + let base_sha = sha_result.stdout.trim().to_string(); + + let run_id = ulid::Ulid::new().to_string(); + let branch_name = format!("arc/run/{run_id}"); + + let checkout_cmd = format!("git checkout -b {branch_name}"); + let checkout_result = exec_env.exec_command(&checkout_cmd, 10_000, None, None, None).await + .expect("git checkout should succeed"); + assert_eq!( + checkout_result.exit_code, 0, + "git checkout -b failed (exit {}): stdout={} stderr={}", + checkout_result.exit_code, checkout_result.stdout, checkout_result.stderr + ); + + (run_id, base_sha, branch_name) +} + +#[tokio::test] +#[ignore] +async fn daytona_git_checkpoint_remote_emits_events() { + let env = create_env().await; + env.initialize().await.unwrap(); + let env: Arc = Arc::new(env); + + // Install git if not available (the default ubuntu:22.04 image may not have it) + let git_check = env.exec_command("git --version", 10_000, None, None, None).await; + if git_check.as_ref().map_or(true, |r| r.exit_code != 0) { + let install = env.exec_command( + "apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1", + 120_000, None, None, None, + ).await.expect("apt-get install git should not error"); + assert_eq!(install.exit_code, 0, "git install failed: {}", install.stderr); + } + + // Set up git in the sandbox + let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await; + + // Pipeline: start -> work -> exit + let mut graph = Graph::new("DaytonaGitCheckpoint"); + graph.attrs.insert( + "goal".to_string(), + AttrValue::String("Test Remote git checkpoint".to_string()), + ); + + let mut start = Node::new("start"); + start.attrs.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string())); + graph.nodes.insert("start".to_string(), start); + + let mut exit = Node::new("exit"); + exit.attrs.insert("shape".to_string(), AttrValue::String("Msquare".to_string())); + graph.nodes.insert("exit".to_string(), exit); + + let mut work = Node::new("work"); + work.attrs.insert("label".to_string(), AttrValue::String("Work".to_string())); + graph.nodes.insert("work".to_string(), work); + + graph.edges.push(Edge::new("start", "work")); + graph.edges.push(Edge::new("work", "exit")); + + // Set up event collection + let dir = tempfile::tempdir().unwrap(); + let mut emitter = EventEmitter::new(); + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + { + let events_clone = Arc::clone(&events); + emitter.on_event(move |event| { + events_clone.lock().unwrap().push(event.clone()); + }); + } + + let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler)); + registry.register("start", Box::new(StartHandler)); + registry.register("exit", Box::new(ExitHandler)); + + let engine = PipelineEngine::new(registry, Arc::new(emitter), env.clone()); + let config = RunConfig { + logs_root: dir.path().to_path_buf(), + cancel_token: None, + dry_run: false, + run_id, + git_checkpoint: Some(GitCheckpointMode::Remote), + base_sha: Some(base_sha), + run_branch: Some(branch_name), + }; + + let outcome = engine.run(&graph, &config).await.expect("pipeline should succeed"); + assert_eq!(outcome.status, StageStatus::Success); + + // Assert GitCheckpoint events were emitted + let events = events.lock().unwrap(); + let git_events: Vec<_> = events + .iter() + .filter_map(|e| { + if let arc_attractor::event::PipelineEvent::GitCheckpoint { node_id, git_commit_sha, .. } = e { + Some((node_id.clone(), git_commit_sha.clone())) + } else { + None + } + }) + .collect(); + assert!( + git_events.len() >= 2, + "expected at least 2 GitCheckpoint events, got {}", + git_events.len() + ); + assert!( + git_events.iter().all(|(_, sha)| sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit())), + "all SHAs should be 40-char hex, got: {git_events:?}" + ); + + // Assert diff.patch was written for the work node + let work_diff = dir.path().join("nodes").join("work").join("diff.patch"); + assert!(work_diff.exists(), "diff.patch should exist for work node"); + + // Verify checkpoint.json has git_commit_sha + let checkpoint = Checkpoint::load(&dir.path().join("checkpoint.json")) + .expect("checkpoint should load"); + assert!( + checkpoint.git_commit_sha.is_some(), + "checkpoint should have git_commit_sha" + ); + + env.cleanup().await.unwrap(); +} + +// --------------------------------------------------------------------------- +// CLI Backend on Daytona — real CLI tools via exec_command +// --------------------------------------------------------------------------- + use arc_attractor::cli::cli_backend::CliBackend; use arc_attractor::handler::codergen::{CodergenBackend, CodergenResult}; diff --git a/crates/arc-attractor/tests/integration.rs b/crates/arc-attractor/tests/integration.rs index 0178ea5d6..3058e4b61 100644 --- a/crates/arc-attractor/tests/integration.rs +++ b/crates/arc-attractor/tests/integration.rs @@ -187,7 +187,7 @@ async fn end_to_end_linear_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -309,7 +309,7 @@ async fn end_to_end_branching_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -418,7 +418,7 @@ async fn end_to_end_human_gate_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -521,7 +521,7 @@ async fn goal_gate_routes_to_retry_target_on_failure() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -633,7 +633,7 @@ async fn goal_gate_routes_to_retry_target_when_present() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -945,7 +945,7 @@ async fn retry_on_failure_then_succeed() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1011,7 +1011,7 @@ async fn pipeline_with_many_nodes() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1329,7 +1329,7 @@ async fn smoke_test_with_mock_codergen_backend() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1427,7 +1427,7 @@ async fn end_to_end_parallel_fan_out_fan_in() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1534,7 +1534,7 @@ async fn resume_from_checkpoint_completes_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1626,7 +1626,7 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1660,7 +1660,7 @@ async fn graph_goal_in_context() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1690,7 +1690,7 @@ async fn event_streaming_lifecycle() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1760,7 +1760,7 @@ async fn context_flow_between_stages() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1803,7 +1803,7 @@ async fn tool_handler_e2e() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1864,7 +1864,7 @@ async fn auto_approve_interviewer_e2e() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1891,7 +1891,7 @@ async fn codergen_without_backend_simulated() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -1992,7 +1992,7 @@ async fn branching_loop_back_on_failure() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2076,7 +2076,7 @@ async fn human_gate_loops_back() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2128,7 +2128,7 @@ async fn scenario_ship_a_feature() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2208,7 +2208,7 @@ async fn scenario_parallel_expert_review() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2282,7 +2282,7 @@ async fn scenario_node_retries_on_retry_status() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2340,7 +2340,7 @@ async fn scenario_loop_restart_resets_context() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2402,7 +2402,7 @@ async fn scenario_bug_triage_router() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2452,7 +2452,7 @@ async fn scenario_crash_recovery() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2534,7 +2534,7 @@ async fn manager_loop_stop_condition_satisfied_e2e() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2584,7 +2584,7 @@ async fn manager_loop_max_cycles_exceeded_e2e() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2714,7 +2714,7 @@ async fn conditional_branching_success_fail_paths() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2762,7 +2762,7 @@ async fn edge_selection_condition_match_wins_over_weight() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2805,7 +2805,7 @@ async fn edge_selection_weight_breaks_ties() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2840,7 +2840,7 @@ async fn edge_selection_lexical_tiebreak() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2892,7 +2892,7 @@ async fn context_updates_visible_across_nodes() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2926,7 +2926,7 @@ async fn stylesheet_applies_model_override() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -2976,7 +2976,7 @@ async fn custom_handler_registration_and_execution() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -3037,7 +3037,7 @@ async fn integration_smoke_plan_implement_review_done() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -3526,7 +3526,7 @@ async fn sub_pipeline_e2e_through_engine() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -3672,7 +3672,7 @@ async fn manager_loop_with_child_observer_e2e() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -3795,7 +3795,7 @@ async fn graph_merge_e2e_through_engine() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -3939,7 +3939,7 @@ async fn fidelity_default_is_compact() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -3982,7 +3982,7 @@ async fn fidelity_graph_default_applied() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4024,7 +4024,7 @@ async fn fidelity_node_overrides_graph_default() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4072,7 +4072,7 @@ async fn fidelity_edge_overrides_node_and_graph() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4110,7 +4110,7 @@ async fn fidelity_full_produces_empty_preamble() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4155,7 +4155,7 @@ async fn fidelity_truncate_preamble_minimal() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4216,7 +4216,7 @@ async fn fidelity_summary_low_mode() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4272,7 +4272,7 @@ async fn fidelity_summary_medium_mode() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4328,7 +4328,7 @@ async fn fidelity_summary_high_mode() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4377,7 +4377,7 @@ async fn fidelity_full_sets_thread_id_in_context() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4437,7 +4437,7 @@ async fn fidelity_full_nodes_share_thread_id() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4504,7 +4504,7 @@ async fn fidelity_resume_degrades_full_to_summary_high() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4587,7 +4587,7 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4657,7 +4657,7 @@ async fn fidelity_resume_no_degrade_when_not_full() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4693,7 +4693,7 @@ async fn fidelity_stored_in_checkpoint_context() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4768,7 +4768,7 @@ async fn fidelity_precedence_multi_node_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4825,7 +4825,7 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -4873,7 +4873,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { registry_low.register("exit", Box::new(ExitHandler)); registry_low.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures_low.clone() })); let engine_low = PipelineEngine::new(registry_low, Arc::new(EventEmitter::new()), local_env()); - let config_low = RunConfig { logs_root: dir_low.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config_low = RunConfig { logs_root: dir_low.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; engine_low.run(&graph_low, &config_low).await.expect("run low"); { @@ -4907,7 +4907,7 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { registry_med.register("exit", Box::new(ExitHandler)); registry_med.register("fidelity_capture", Box::new(FidelityCapturingHandler { captures: captures_med.clone() })); let engine_med = PipelineEngine::new(registry_med, Arc::new(EventEmitter::new()), local_env()); - let config_med = RunConfig { logs_root: dir_med.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), work_dir: None, base_sha: None, run_branch: None }; + let config_med = RunConfig { logs_root: dir_med.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), git_checkpoint: None, base_sha: None, run_branch: None }; engine_med.run(&graph_med, &config_med).await.expect("run med"); let preambles_med = captures_med.preambles.lock().unwrap(); @@ -4961,7 +4961,7 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5004,7 +5004,7 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5050,7 +5050,7 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5097,7 +5097,7 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5151,7 +5151,7 @@ async fn fidelity_from_parsed_dot_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5193,7 +5193,7 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5252,7 +5252,7 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5325,7 +5325,7 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5514,7 +5514,7 @@ mod real_llm { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5622,7 +5622,7 @@ mod real_llm { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5760,7 +5760,7 @@ mod real_llm { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5864,7 +5864,7 @@ mod real_llm { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -5948,7 +5948,7 @@ async fn human_gate_freeform_only_routes_text() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6067,7 +6067,7 @@ async fn human_gate_freeform_with_fixed_choice_match() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6171,7 +6171,7 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6288,7 +6288,7 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6381,7 +6381,7 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6623,7 +6623,7 @@ async fn tool_hooks_pre_success_allows_pipeline_to_proceed() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6662,7 +6662,7 @@ async fn tool_hooks_pre_failure_skips_tool_call() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6704,7 +6704,7 @@ async fn tool_hooks_post_success_does_not_affect_outcome() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6738,7 +6738,7 @@ async fn tool_hooks_post_failure_does_not_block_pipeline() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6774,7 +6774,7 @@ async fn tool_hooks_graph_level_applies_to_all_nodes() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6813,7 +6813,7 @@ async fn tool_hooks_node_level_overrides_graph_level() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6862,7 +6862,7 @@ async fn tool_hooks_pre_receives_node_id_env_var() { logs_root: dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -6958,7 +6958,7 @@ async fn attractor_e2e_with_real_llm() { logs_root: logs_dir.path().to_path_buf(), cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -7065,7 +7065,7 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -7208,7 +7208,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -7383,7 +7383,7 @@ async fn artifact_pointers_rewritten_for_remote_execution_env() { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -7497,7 +7497,7 @@ async fn node_dir_uses_visit_count_on_revisit() { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -8063,7 +8063,7 @@ async fn full_pipeline_with_cli_backend_node() { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -8151,7 +8151,7 @@ async fn stylesheet_backend_property_routes_to_cli() { cancel_token: None, dry_run: false, run_id: "test-run".into(), - work_dir: None, + git_checkpoint: None, base_sha: None, run_branch: None, }; @@ -8265,4 +8265,140 @@ fn parse_real_gemini_json() { assert_eq!(response.text, "4"); assert_eq!(response.input_tokens, 123); assert_eq!(response.output_tokens, 1); +} + +// --------------------------------------------------------------------------- +// Git checkpoint e2e — Host mode (Docker / Local) +// --------------------------------------------------------------------------- + +use arc_attractor::engine::GitCheckpointMode; + +/// End-to-end test: pipeline with `GitCheckpointMode::Host` emits `GitCheckpoint` +/// events with valid commit SHAs and writes `diff.patch` per stage. +#[tokio::test] +async fn git_checkpoint_host_emits_events_and_diff_patch() { + // 1. Create a temporary git repo with an initial commit + let repo = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(repo.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["-c", "user.name=test", "-c", "user.email=test@test", + "commit", "--allow-empty", "-m", "init"]) + .current_dir(repo.path()) + .output() + .unwrap(); + + // 2. Create a branch and worktree (like cli/run.rs setup_worktree) + let base_sha = { + let out = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(repo.path()) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_string() + }; + std::process::Command::new("git") + .args(["branch", "arc/run/test-docker", "HEAD"]) + .current_dir(repo.path()) + .output() + .unwrap(); + let worktree_path = repo.path().join("worktree"); + std::process::Command::new("git") + .args(["worktree", "add"]) + .arg(&worktree_path) + .arg("arc/run/test-docker") + .current_dir(repo.path()) + .output() + .unwrap(); + + // Write a file in the worktree so there's something to commit + std::fs::write(worktree_path.join("hello.txt"), "from docker test").unwrap(); + + // 3. Build a simple pipeline: start -> work -> exit + let mut graph = Graph::new("DockerGitCheckpoint"); + graph.attrs.insert("goal".to_string(), AttrValue::String("Test Host git checkpoint".to_string())); + let mut start = Node::new("start"); + start.attrs.insert("shape".to_string(), AttrValue::String("Mdiamond".to_string())); + graph.nodes.insert("start".to_string(), start); + let mut exit = Node::new("exit"); + exit.attrs.insert("shape".to_string(), AttrValue::String("Msquare".to_string())); + graph.nodes.insert("exit".to_string(), exit); + let mut work = Node::new("work"); + work.attrs.insert("label".to_string(), AttrValue::String("Work".to_string())); + graph.nodes.insert("work".to_string(), work); + graph.edges.push(Edge::new("start", "work")); + graph.edges.push(Edge::new("work", "exit")); + + // 4. Set up event collection and engine + let logs_dir = tempfile::tempdir().unwrap(); + let mut emitter = EventEmitter::new(); + let events = collect_events(&mut emitter); + + let env: Arc = Arc::new( + arc_agent::LocalExecutionEnvironment::new(worktree_path.clone()), + ); + let mut registry = HandlerRegistry::new(Box::new(ContextSetterHandler)); + registry.register("start", Box::new(StartHandler)); + registry.register("exit", Box::new(ExitHandler)); + let engine = PipelineEngine::new(registry, Arc::new(emitter), env); + + let config = RunConfig { + logs_root: logs_dir.path().to_path_buf(), + cancel_token: None, + dry_run: false, + run_id: "test-docker".into(), + git_checkpoint: Some(GitCheckpointMode::Host(worktree_path.clone())), + base_sha: Some(base_sha.clone()), + run_branch: Some("arc/run/test-docker".to_string()), + }; + + // 5. Run pipeline + let outcome = engine.run(&graph, &config).await.expect("pipeline should succeed"); + assert_eq!(outcome.status, StageStatus::Success); + + // 6. Assert GitCheckpoint events were emitted + let events = events.lock().unwrap(); + let git_events: Vec<_> = events + .iter() + .filter_map(|e| { + if let PipelineEvent::GitCheckpoint { node_id, git_commit_sha, .. } = e { + Some((node_id.clone(), git_commit_sha.clone())) + } else { + None + } + }) + .collect(); + // start, work, exit = 3 nodes, each gets a checkpoint commit + assert!( + git_events.len() >= 2, + "expected at least 2 GitCheckpoint events, got {}", + git_events.len() + ); + // Each SHA should be a valid 40-char hex string + assert!( + git_events.iter().all(|(_, sha)| sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit())), + "all SHAs should be 40-char hex, got: {git_events:?}" + ); + + // 7. Assert diff.patch was written for the "work" node + let work_diff = logs_dir.path().join("nodes").join("work").join("diff.patch"); + assert!(work_diff.exists(), "diff.patch should exist for work node"); + + // 8. Verify checkpoint.json has git_commit_sha + let checkpoint = Checkpoint::load(&logs_dir.path().join("checkpoint.json")) + .expect("checkpoint should load"); + assert!( + checkpoint.git_commit_sha.is_some(), + "checkpoint should have git_commit_sha" + ); + + // Cleanup worktree + let _ = std::process::Command::new("git") + .args(["worktree", "remove", "--force"]) + .arg(&worktree_path) + .current_dir(repo.path()) + .output(); } \ No newline at end of file