Add git checkpointing for Docker and Daytona execution environments

Git checkpoint commits (stage-level snapshots, diff.patch, GitCheckpoint
events) previously only ran for Local execution. This extends support to
Docker (bind-mount uses host git, same as Local) and Daytona (runs git
commands remotely via exec_command).

- Add GitCheckpointMode enum (Host/Remote) replacing RunConfig.work_dir
- Extract git_checkpoint_host/git_diff_host helpers from inline code
- Add git_checkpoint_remote/git_diff_remote using exec_command
- Enable git_clean check for Docker alongside Local
- Add setup_daytona_git to create run branch in remote sandbox
- Switch Daytona wrap_bash_command from quote-escaping to base64 encoding
  (matches TypeScript/Python/Ruby Daytona SDKs, avoids nested quote issues)
- Add e2e tests for both Host mode and Remote mode (Daytona, live-tested)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-28 15:08:22 -05:00
parent b218c6b280
commit 9ef45204f6
8 changed files with 619 additions and 189 deletions

1
Cargo.lock generated
View file

@ -139,6 +139,7 @@ dependencies = [
"assert_cmd",
"async-trait",
"axum",
"base64",
"chrono",
"clap",
"daytona-api-client",

View file

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

View file

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

View file

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

View file

@ -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<String> {
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<String> {
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<String> {
// 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<String> {
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<PathBuf>,
/// Git checkpoint mode (None = no checkpointing).
pub git_checkpoint: Option<GitCheckpointMode>,
/// SHA of the commit the worktree branched from.
pub base_sha: Option<String>,
/// 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,
};

View file

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

View file

@ -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<Outcome, AttractorError> {
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<dyn ExecutionEnvironment> = 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};

View file

@ -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<dyn arc_agent::ExecutionEnvironment> = 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();
}