Add arc diff command to view workflow run diffs

Surfaces existing final.patch and per-node diff.patch files via CLI.
Supports live diffs from in-progress runs by reconnecting to the sandbox.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-10 12:50:51 -04:00
parent af4cad399b
commit 5f9c211031
3 changed files with 286 additions and 0 deletions

View file

@ -64,6 +64,9 @@ enum Command {
Preview(arc_workflows::cli::preview::PreviewArgs),
/// SSH into a run's Daytona sandbox
Ssh(arc_workflows::cli::ssh::SshArgs),
/// Show the diff of changes from a workflow run
#[command(hide = true)]
Diff(arc_workflows::cli::diff::DiffArgs),
/// List and test LLM models
Model {
#[command(subcommand)]
@ -183,6 +186,7 @@ async fn main_inner() -> Result<()> {
Command::Cp(_) => "cp",
Command::Preview(_) => "preview",
Command::Ssh(_) => "ssh",
Command::Diff(_) => "diff",
Command::Model { .. } => "model",
#[cfg(feature = "server")]
Command::Serve(_) => "serve",
@ -361,6 +365,9 @@ async fn main_inner() -> Result<()> {
Command::Ssh(args) => {
arc_workflows::cli::ssh::ssh_command(args).await?;
}
Command::Diff(args) => {
arc_workflows::cli::diff::diff_command(args).await?;
}
Command::Model { command } => {
let cli_config = cli_config::load_cli_config(None)?;
let resolved =

View file

@ -0,0 +1,278 @@
use std::io::{self, IsTerminal, Write};
use std::path::Path;
use anyhow::{bail, Context, Result};
use clap::Args;
use tracing::{debug, info};
use crate::cli::runs::{default_runs_base, find_run_by_prefix};
use crate::manifest::Manifest;
use crate::sandbox_record::SandboxRecord;
#[derive(Args)]
pub struct DiffArgs {
/// Run ID or prefix
pub run: String,
/// Show diff for a specific node
#[arg(long)]
pub node: Option<String>,
/// Show diffstat instead of full patch (live diffs only)
#[arg(long)]
pub stat: bool,
}
pub async fn diff_command(args: DiffArgs) -> Result<()> {
info!(run_id = %args.run, "Showing diff");
let base = default_runs_base();
let run_dir = find_run_by_prefix(&base, &args.run)?;
let patch = resolve_diff(&run_dir, &args).await?;
let is_tty = io::stdout().is_terminal();
let mut stdout = io::stdout().lock();
if is_tty {
for line in patch.lines() {
writeln!(stdout, "{}", colorize_diff_line(line))?;
}
} else {
stdout.write_all(patch.as_bytes())?;
}
Ok(())
}
async fn resolve_diff(run_dir: &Path, args: &DiffArgs) -> Result<String> {
// --node: read per-node diff.patch
if let Some(ref node_id) = args.node {
debug!(node_id, "Reading per-node diff");
let node_patch = run_dir.join("nodes").join(node_id).join("diff.patch");
return std::fs::read_to_string(&node_patch).with_context(|| {
format!("No diff found for node '{node_id}' — check the node ID and try again")
});
}
let manifest =
Manifest::load(&run_dir.join("manifest.json")).context("Failed to load manifest.json")?;
let base_sha = manifest
.base_sha
.as_deref()
.ok_or_else(|| anyhow::anyhow!("This run was not git-checkpointed; no diff available"))?;
// Completed run with final.patch
let final_patch_path = run_dir.join("final.patch");
if final_patch_path.exists() {
debug!("Reading final.patch");
return std::fs::read_to_string(&final_patch_path).context("Failed to read final.patch");
}
// Check if the run has concluded (no final.patch means no changes or error)
let conclusion_path = run_dir.join("conclusion.json");
if conclusion_path.exists() {
bail!(
"Run completed but no final.patch exists — the run may not have produced any changes"
);
}
// In-progress run: reconnect to sandbox and run git diff
debug!("No final.patch found; attempting live diff from sandbox");
let sandbox_json = run_dir.join("sandbox.json");
let record = SandboxRecord::load(&sandbox_json).context(
"Failed to load sandbox.json — was this run started with a recent version of arc?",
)?;
info!(provider = %record.provider, "Reconnecting to sandbox for live diff");
let sandbox = crate::cli::cp::reconnect(&record).await?;
let stat_flag = if args.stat { " --stat" } else { "" };
let cmd = format!(
"git -c maintenance.auto=0 -c gc.auto=0 diff{stat_flag} {} HEAD",
shlex::try_quote(base_sha).unwrap_or_else(|_| base_sha.into())
);
debug!(cmd, "Running git diff in sandbox");
let result = sandbox
.exec_command(&cmd, 30_000, None, None, None)
.await
.map_err(|e| anyhow::anyhow!("Failed to run git diff in sandbox: {e}"))?;
if result.exit_code != 0 {
let stderr = result.stderr.trim();
bail!("git diff failed (exit {}):\n{stderr}", result.exit_code);
}
Ok(result.stdout)
}
fn colorize_diff_line(line: &str) -> String {
if line.starts_with("+++") || line.starts_with("---") {
format!("\x1b[1m{line}\x1b[0m")
} else if line.starts_with('+') {
format!("\x1b[32m{line}\x1b[0m")
} else if line.starts_with('-') {
format!("\x1b[31m{line}\x1b[0m")
} else if line.starts_with("@@") {
format!("\x1b[36m{line}\x1b[0m")
} else if line.starts_with("diff ") {
format!("\x1b[1m{line}\x1b[0m")
} else {
line.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn create_manifest(dir: &Path, base_sha: Option<&str>) {
let manifest = serde_json::json!({
"run_id": "test-run-001",
"workflow_name": "test",
"goal": "test goal",
"start_time": "2025-01-01T00:00:00Z",
"node_count": 1,
"edge_count": 0,
"base_sha": base_sha,
"labels": {},
});
fs::write(
dir.join("manifest.json"),
serde_json::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
}
fn create_conclusion(dir: &Path) {
let conclusion = serde_json::json!({
"timestamp": "2025-01-01T00:01:00Z",
"status": "success",
"duration_ms": 60000,
});
fs::write(
dir.join("conclusion.json"),
serde_json::to_string_pretty(&conclusion).unwrap(),
)
.unwrap();
}
#[tokio::test]
async fn completed_run_with_final_patch() {
let dir = tempfile::tempdir().unwrap();
create_manifest(dir.path(), Some("abc123"));
let patch_content = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n";
fs::write(dir.path().join("final.patch"), patch_content).unwrap();
let args = DiffArgs {
run: String::new(),
node: None,
stat: false,
};
let result = resolve_diff(dir.path(), &args).await.unwrap();
assert_eq!(result, patch_content);
}
#[tokio::test]
async fn per_node_diff() {
let dir = tempfile::tempdir().unwrap();
let node_dir = dir.path().join("nodes").join("work");
fs::create_dir_all(&node_dir).unwrap();
let patch_content = "diff --git a/src/main.rs b/src/main.rs\n+added line\n";
fs::write(node_dir.join("diff.patch"), patch_content).unwrap();
let args = DiffArgs {
run: String::new(),
node: Some("work".to_string()),
stat: false,
};
let result = resolve_diff(dir.path(), &args).await.unwrap();
assert_eq!(result, patch_content);
}
#[tokio::test]
async fn no_base_sha_errors() {
let dir = tempfile::tempdir().unwrap();
create_manifest(dir.path(), None);
let args = DiffArgs {
run: String::new(),
node: None,
stat: false,
};
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
assert!(
err.to_string().contains("not git-checkpointed"),
"got: {err}"
);
}
#[tokio::test]
async fn completed_run_no_final_patch() {
let dir = tempfile::tempdir().unwrap();
create_manifest(dir.path(), Some("abc123"));
create_conclusion(dir.path());
let args = DiffArgs {
run: String::new(),
node: None,
stat: false,
};
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
assert!(err.to_string().contains("no final.patch"), "got: {err}");
}
#[tokio::test]
async fn node_diff_not_found() {
let dir = tempfile::tempdir().unwrap();
let args = DiffArgs {
run: String::new(),
node: Some("nonexistent".to_string()),
stat: false,
};
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
assert!(err.to_string().contains("nonexistent"), "got: {err}");
}
#[test]
fn colorize_added_line() {
let result = colorize_diff_line("+added");
assert!(result.contains("\x1b[32m"));
assert!(result.contains("+added"));
}
#[test]
fn colorize_removed_line() {
let result = colorize_diff_line("-removed");
assert!(result.contains("\x1b[31m"));
assert!(result.contains("-removed"));
}
#[test]
fn colorize_hunk_header() {
let result = colorize_diff_line("@@ -1,3 +1,4 @@");
assert!(result.contains("\x1b[36m"));
}
#[test]
fn colorize_diff_header() {
let result = colorize_diff_line("diff --git a/file b/file");
assert!(result.contains("\x1b[1m"));
}
#[test]
fn colorize_file_header() {
let plus = colorize_diff_line("+++ b/file.txt");
assert!(plus.contains("\x1b[1m"), "got: {plus}");
let minus = colorize_diff_line("--- a/file.txt");
assert!(minus.contains("\x1b[1m"), "got: {minus}");
}
#[test]
fn colorize_context_line_unchanged() {
let result = colorize_diff_line(" context line");
assert_eq!(result, " context line");
}
}

View file

@ -1,6 +1,7 @@
pub mod backend;
pub mod cli_backend;
pub mod cp;
pub mod diff;
pub mod parse;
pub mod pr;
pub mod preview;