use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use octl_core::{append_and_apply_event, ensure_root, NodeId, RunPaths};
use serde_json::{json, Value};
use tempfile::TempDir;
fn bin(home: &TempDir) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_orchestratectl"));
c.env("ORCHESTRATECTL_HOME", home.path());
c
}
fn node_id() -> NodeId {
NodeId::parse_str("n-0001").unwrap()
}
fn seed_run(home: &Path, run_id: &str) -> RunPaths {
ensure_root(home).unwrap();
let dir = home.join("runs").join(run_id);
std::fs::create_dir_all(&dir).unwrap();
let paths = RunPaths::new(dir, run_id).unwrap();
append_and_apply_event(
&paths,
"run.created",
None,
None,
json!({ "kind": "spinoff", "lifecycle": "autonomous", "title": "salvage-test" }),
)
.unwrap();
paths
}
fn add_worker_node(paths: &RunPaths, worktree: Option<&Path>, branch: Option<&str>, extra: Value) {
let mut data = json!({ "kind": "spinoff" });
let obj = data.as_object_mut().unwrap();
if let Some(wt) = worktree {
obj.insert("worktree_path".into(), json!(wt.display().to_string()));
}
if let Some(b) = branch {
obj.insert("branch".into(), json!(b));
}
if let Some(e) = extra.as_object() {
for (k, v) in e {
obj.insert(k.clone(), v.clone());
}
}
append_and_apply_event(paths, "node.created", Some(&node_id()), None, data).unwrap();
}
fn record_clean_exit(paths: &RunPaths) {
append_and_apply_event(
paths,
"worker.exited",
Some(&node_id()),
None,
json!({ "exit_code": 0 }),
)
.unwrap();
}
fn fake_merge_sh(dir: &Path, code: i32) -> std::path::PathBuf {
let p = dir.join("fake-merge.sh");
std::fs::write(&p, format!("#!/bin/bash\nexit {code}\n")).unwrap();
let mut perms = std::fs::metadata(&p).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&p, perms).unwrap();
p
}
fn read_events(paths: &RunPaths) -> Vec<Value> {
std::fs::read_to_string(paths.events())
.unwrap_or_default()
.lines()
.map(|l| serde_json::from_str::<Value>(l).unwrap())
.collect()
}
fn fresh_run_id() -> String {
octl_core::new_run_id()
}
fn salvage_err(cmd: &mut Command) -> Value {
let out = cmd.output().expect("spawn");
assert!(
!out.status.success(),
"expected failure, got success; stdout={}",
String::from_utf8_lossy(&out.stdout)
);
serde_json::from_slice(&out.stderr).expect("stderr is a JSON error envelope")
}
#[test]
fn attention_required_run_is_finished() {
let home = TempDir::new().unwrap();
let scratch = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(
&paths,
Some(worktree.path()),
Some("wt/salvage-x"),
json!({}),
);
record_clean_exit(&paths);
let merge_sh = fake_merge_sh(scratch.path(), 0);
let out = bin(&home)
.env("OCTL_MERGE_SH", &merge_sh)
.args([
"--output", "json", "run", "salvage", &run_id, "--source", "main",
])
.output()
.expect("spawn");
assert!(
out.status.success(),
"stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["data"]["worker_state"], "exited");
assert_eq!(v["data"]["fenced"], false);
assert_eq!(v["data"]["merge"]["merged"], true);
assert_eq!(v["data"]["merge"]["branch"], "wt/salvage-x");
let reports: Vec<Value> = read_events(&paths)
.into_iter()
.filter(|e| e["kind"] == "node.report")
.collect();
assert_eq!(reports.len(), 1, "one terminal node.report");
assert_eq!(reports[0]["data"]["via"], "explicit-merge");
assert_eq!(reports[0]["data"]["success"], true);
}
#[test]
fn dry_run_previews_without_mutating() {
let home = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(&paths, Some(worktree.path()), Some("wt/dry"), json!({}));
record_clean_exit(&paths);
let before = read_events(&paths).len();
let out = bin(&home)
.args([
"--output",
"json",
"run",
"salvage",
&run_id,
"--source",
"main",
"--dry-run",
])
.output()
.expect("spawn");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["data"]["dry_run"], true);
assert_eq!(v["data"]["merge"]["merged"], false);
assert_eq!(read_events(&paths).len(), before, "dry-run appends nothing");
}
#[test]
fn refuses_done_run() {
let home = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(&paths, Some(worktree.path()), Some("wt/done"), json!({}));
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "done" }),
)
.unwrap();
let v = salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id]));
assert_eq!(v["error"]["code"], "run_already_terminal");
}
#[test]
fn refuses_cancelled_run() {
let home = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(&paths, Some(worktree.path()), Some("wt/c"), json!({}));
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "cancelled" }),
)
.unwrap();
let v = salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id]));
assert_eq!(v["error"]["code"], "run_already_terminal");
}
#[test]
fn refuses_multi_node_run() {
let home = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(&paths, Some(worktree.path()), Some("wt/a"), json!({}));
append_and_apply_event(
&paths,
"node.created",
Some(&NodeId::parse_str("n-0002").unwrap()),
None,
json!({ "kind": "spinoff", "worktree_path": worktree.path().display().to_string(), "branch": "wt/b" }),
)
.unwrap();
let v = salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id]));
assert_eq!(v["error"]["code"], "ambiguous_multi_node");
}
#[test]
fn refuses_run_without_worktree() {
let home = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(&paths, None, Some("wt/no-wt"), json!({}));
record_clean_exit(&paths);
let v = salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id]));
assert_eq!(v["error"]["code"], "no_worktree");
}
#[test]
fn refuses_torn_down_worktree() {
let home = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(
&paths,
Some(Path::new("/nonexistent/salvage/worktree")),
Some("wt/gone"),
json!({}),
);
record_clean_exit(&paths);
let v = salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id]));
assert_eq!(v["error"]["code"], "worktree_missing");
}
#[test]
fn refuses_unverifiable_live_worker() {
let home = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
let mut child = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep");
let pid = child.id();
add_worker_node(
&paths,
Some(worktree.path()),
Some("wt/live"),
json!({ "agent_pid": pid }),
);
let v =
salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id, "--fence"]));
assert_eq!(v["error"]["code"], "worker_unfenceable");
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn refuses_never_started_pending_run() {
let home = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(&paths, Some(worktree.path()), Some("wt/pending"), json!({}));
let v = salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id]));
assert_eq!(v["error"]["code"], "run_not_started");
}
#[test]
fn done_run_with_live_worktree_points_at_reattach() {
let home = TempDir::new().unwrap();
let worktree = TempDir::new().unwrap();
let run_id = fresh_run_id();
let paths = seed_run(home.path(), &run_id);
add_worker_node(&paths, Some(worktree.path()), Some("wt/done-wt"), json!({}));
append_and_apply_event(
&paths,
"run.status",
None,
None,
json!({ "status": "done" }),
)
.unwrap();
let v = salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id]));
assert_eq!(v["error"]["code"], "run_already_terminal");
assert!(
v["error"]["message"]
.as_str()
.unwrap()
.contains("run reattach"),
"done-with-worktree must point at reattach: {}",
v["error"]["message"]
);
}
#[test]
fn refuses_unknown_run() {
let home = TempDir::new().unwrap();
ensure_root(home.path()).unwrap();
let run_id = fresh_run_id();
let v = salvage_err(bin(&home).args(["--output", "json", "run", "salvage", &run_id]));
assert_eq!(v["error"]["code"], "run_not_found");
}