use std::process::Command;
use serde_json::{json, Value};
use tempfile::TempDir;
mod common;
use common::TestHome;
fn bin(home: &TempDir) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_orchestratectl"));
c.env("ORCHESTRATECTL_HOME", home.path());
c.env("OCTL_TEST_SKIP_MATERIALIZE", "1");
c
}
fn run_ok(cmd: &mut Command) -> Value {
let out = cmd.output().expect("spawn");
assert!(
out.status.success(),
"exit={:?} stderr={}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("stdout is valid JSON")
}
fn run_exit(cmd: &mut Command, want: i32) -> Value {
let out = cmd.output().expect("spawn");
let code = out.status.code().expect("exit code");
assert_eq!(
code,
want,
"want exit {want}, got {code}; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("stdout is valid JSON")
}
fn run_err(cmd: &mut Command, want: i32) -> Value {
let out = cmd.output().expect("spawn");
let code = out.status.code().expect("exit code");
assert_eq!(code, want, "want exit {want}, got {code}");
let stderr = String::from_utf8(out.stderr).expect("utf8");
let last = stderr.lines().last().expect("stderr has at least one line");
serde_json::from_str(last).expect("error envelope JSON")
}
fn create(home: &TempDir, kind: &str, title: &str) -> String {
let v = run_ok(bin(home).args([
"--output", "json", "run", "create", "--kind", kind, "--title", title,
]));
v["data"]["run_id"]
.as_str()
.expect("run_id is string")
.to_string()
}
fn event_create(home: &TempDir, run_id: &str, kind: &str, node_id: Option<&str>, data: Value) {
let keep = TempDir::new().unwrap();
let f = keep.path().join("data.json");
std::fs::write(&f, serde_json::to_vec(&data).unwrap()).unwrap();
let mut cmd = bin(home);
cmd.args([
"--output", "json", "event", "create", run_id, "--kind", kind,
]);
if let Some(n) = node_id {
cmd.args(["--node-id", n]);
}
cmd.args(["--from-file", f.to_str().unwrap()]);
run_ok(&mut cmd);
}
fn add_node(home: &TempDir, run_id: &str, node_id: &str) {
event_create(
home,
run_id,
"node.created",
Some(node_id),
json!({ "kind": "spinoff" }),
);
}
fn node_report(home: &TempDir, run_id: &str, node_id: &str, data: Value) {
let keep = TempDir::new().unwrap();
let f = keep.path().join("report.json");
std::fs::write(&f, serde_json::to_vec(&data).unwrap()).unwrap();
run_ok(bin(home).args([
"--output",
"json",
"node",
"report",
run_id,
node_id,
"--from-file",
f.to_str().unwrap(),
]));
}
fn settle_run(home: &TempDir, title: &str, status: &str, report: Value) -> String {
let run_id = create(home, "spinoff", title);
add_node(home, &run_id, "n-0001");
node_report(home, &run_id, "n-0001", report);
event_create(
home,
&run_id,
"run.status",
None,
json!({ "status": status }),
);
run_id
}
fn pending_run(home: &TempDir, title: &str) -> String {
create(home, "spinoff", title)
}
#[test]
fn all_happy_path_two_done_runs_exit_zero() {
let home = TestHome::new();
let a = settle_run(
&home,
"a",
"done",
json!({ "success": true, "summary": "did A", "via": "explicit-merge" }),
);
let b = settle_run(
&home,
"b",
"done",
json!({ "success": true, "summary": "did B", "via": "explicit-merge" }),
);
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &a, &b]));
assert_eq!(v["data"]["condition"], "all");
let runs = v["data"]["runs"].as_array().expect("runs array");
assert_eq!(runs.len(), 2);
for r in runs {
assert_eq!(r["status"], "done");
assert_eq!(r["merged"], true);
assert_eq!(r["landed"], true);
assert_eq!(r["landed_method"], "report-marker");
assert!(r["summary"].as_str().unwrap().starts_with("did "));
assert!(r.get("error").is_none(), "done run has no error: {r}");
}
assert!(v["data"]["waited_ms"].is_number());
}
#[test]
fn any_returns_when_one_of_two_is_terminal() {
let home = TestHome::new();
let done = settle_run(
&home,
"done",
"done",
json!({ "success": true, "summary": "ok" }),
);
let pending = pending_run(&home, "pending");
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &done, &pending, "--any"]));
assert_eq!(v["data"]["condition"], "any");
let runs = v["data"]["runs"].as_array().expect("runs array");
assert_eq!(runs.len(), 2);
let by_id = |id: &str| runs.iter().find(|r| r["run_id"] == id).unwrap();
assert_eq!(by_id(&done)["status"], "done");
assert_eq!(by_id(&pending)["status"], "pending");
}
#[test]
fn timeout_without_terminal_run_exits_two() {
let home = TestHome::new();
let pending = pending_run(&home, "pending");
add_node(&home, &pending, "n-0001");
let v = run_exit(
bin(&home).args([
"--output",
"json",
"run",
"wait",
&pending,
"--timeout",
"500ms",
]),
2,
);
assert_eq!(v["data"]["condition"], "all");
let waited = v["data"]["waited_ms"].as_u64().expect("waited_ms u64");
assert!(
(400..=2000).contains(&waited),
"waited_ms {waited} should be ~500ms (the timeout budget)"
);
assert_eq!(v["data"]["runs"][0]["status"], "pending");
}
#[test]
fn fail_on_error_with_failed_run_exits_three() {
let home = TestHome::new();
let failed = settle_run(
&home,
"failed",
"failed",
json!({ "success": false, "summary": "blew up" }),
);
let v = run_exit(
bin(&home).args([
"--output",
"json",
"run",
"wait",
&failed,
"--fail-on-error",
]),
3,
);
assert_eq!(v["data"]["runs"][0]["status"], "failed");
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &failed]));
assert_eq!(v["data"]["runs"][0]["status"], "failed");
}
#[test]
fn unknown_run_id_exits_one() {
let home = TestHome::new();
let ghost = "01arz3ndektsv4rrffq69g5fav";
let v = run_err(
bin(&home).args(["--output", "json", "run", "wait", ghost]),
1,
);
assert_eq!(v["error"]["code"], "unknown_run");
assert_eq!(v["error"]["invalid_value"], ghost);
}
#[test]
fn malformed_timeout_is_rejected_up_front() {
let home = TestHome::new();
let pending = pending_run(&home, "pending");
let v = run_err(
bin(&home).args([
"--output",
"json",
"run",
"wait",
&pending,
"--timeout",
"soon",
]),
1,
);
assert_eq!(v["error"]["code"], "invalid_arguments");
}
#[test]
fn wait_reports_landed_git_verified_after_caller_rebase() {
use std::process::Command;
fn git(repo: &std::path::Path, args: &[&str]) -> String {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path();
git(repo, &["init", "-q", "-b", "main"]);
git(repo, &["config", "user.email", "t@t"]);
git(repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("f"), "base\n").unwrap();
git(repo, &["add", "f"]);
git(repo, &["commit", "-qm", "base"]);
let base = git(repo, &["rev-parse", "HEAD"]);
git(repo, &["checkout", "-q", "-b", "wt/worker"]);
std::fs::write(repo.join("f"), "base\nwork\n").unwrap();
git(repo, &["commit", "-qam", "worker change"]);
git(repo, &["checkout", "-q", "main"]);
std::fs::write(repo.join("g"), "other\n").unwrap();
git(repo, &["add", "g"]);
git(repo, &["commit", "-qm", "other session"]);
let worker_tip = git(repo, &["rev-parse", "wt/worker"]);
git(repo, &["checkout", "-q", "-b", "replay", &worker_tip]);
git(repo, &["rebase", "-q", "main"]);
git(repo, &["checkout", "-q", "main"]);
git(repo, &["merge", "-q", "--ff-only", "replay"]);
git(repo, &["branch", "-q", "-D", "replay"]);
git(repo, &["checkout", "-q", "-b", "tmp", &base]);
std::fs::write(repo.join("h"), "upstream\n").unwrap();
git(repo, &["add", "h"]);
git(repo, &["commit", "-qm", "origin moved"]);
git(repo, &["checkout", "-q", "main"]);
git(repo, &["rebase", "-q", "tmp"]);
let is_ancestor = Command::new("git")
.arg("-C")
.arg(repo)
.args(["merge-base", "--is-ancestor", "wt/worker", "main"])
.status()
.unwrap()
.success();
assert!(
!is_ancestor,
"the rebase-replay case must make --is-ancestor lie"
);
let home = TestHome::new();
let run_id = run_ok(bin(&home).args([
"--output",
"json",
"run",
"create",
"--kind",
"spinoff",
"--title",
"rebase-landed",
"--source-repo",
repo.to_str().unwrap(),
"--source-branch",
"main",
]))["data"]["run_id"]
.as_str()
.unwrap()
.to_string();
event_create(
&home,
&run_id,
"node.created",
Some("n-0001"),
json!({
"kind": "spinoff",
"branch": "wt/worker",
"base_sha": base,
"worktree_path": repo.to_str().unwrap(),
}),
);
node_report(
&home,
&run_id,
"n-0001",
json!({ "success": true, "summary": "landed via rebase-replay" }),
);
event_create(
&home,
&run_id,
"run.status",
None,
json!({ "status": "done" }),
);
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &run_id]));
let r = &v["data"]["runs"][0];
assert_eq!(r["status"], "done");
assert_eq!(
r["landed"], true,
"content is merged → landed must be true despite the rebase: {r}"
);
assert_eq!(
r["landed_method"], "git-verified",
"the landing is confirmed by patch-id, not the report marker: {r}"
);
assert_eq!(r["merged"], false);
}
#[test]
fn stillborn_run_settles_promptly_as_stalled() {
let home = TestHome::new();
let born = pending_run(&home, "stillborn");
let start = std::time::Instant::now();
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &born, "--timeout", "30s"]));
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"stillborn run must settle promptly, took {elapsed:?}"
);
let r = &v["data"]["runs"][0];
assert_eq!(r["status"], "pending");
assert_eq!(r["stalled"], true, "stillborn run must be stalled: {r}");
assert_eq!(
r["error"], "supervisor died before creating any worker node",
"stillborn outcome carries a structured reason: {r}"
);
let waited = v["data"]["waited_ms"].as_u64().expect("waited_ms u64");
assert!(
waited < 5000,
"waited_ms {waited} should be well under the 30s budget"
);
}
#[test]
fn stillborn_run_fail_on_error_exits_three() {
let home = TestHome::new();
let born = pending_run(&home, "stillborn-fail");
let v = run_exit(
bin(&home).args([
"--output",
"json",
"run",
"wait",
&born,
"--fail-on-error",
"--timeout",
"30s",
]),
3,
);
assert_eq!(v["data"]["runs"][0]["stalled"], true);
assert_eq!(v["data"]["runs"][0]["status"], "pending");
assert_eq!(
v["data"]["runs"][0]["error"],
"supervisor died before creating any worker node"
);
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &born, "--timeout", "30s"]));
assert_eq!(v["data"]["runs"][0]["stalled"], true);
}
fn backdate_manifest_updated_at(home: &TempDir, run_id: &str, minutes_ago: i64) {
let path = home.path().join("runs").join(run_id).join("manifest.json");
let mut m: Value =
serde_json::from_slice(&std::fs::read(&path).expect("read manifest")).expect("parse");
let old = chrono::Utc::now() - chrono::Duration::minutes(minutes_ago);
m["updated_at"] = json!(old.to_rfc3339_opts(chrono::SecondsFormat::Micros, true));
std::fs::write(&path, serde_json::to_vec(&m).expect("serialize")).expect("write manifest");
}
#[test]
fn orphaned_run_settles_promptly_as_stalled() {
let home = TestHome::new();
let run = pending_run(&home, "orphaned");
add_node(&home, &run, "n-0001");
backdate_manifest_updated_at(&home, &run, 30);
let start = std::time::Instant::now();
let v = run_ok(bin(&home).args(["--output", "json", "run", "wait", &run, "--timeout", "30s"]));
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"orphaned run must settle promptly, took {elapsed:?}"
);
let r = &v["data"]["runs"][0];
assert_eq!(r["status"], "pending");
assert_eq!(r["stalled"], true, "orphaned run must be stalled: {r}");
assert_eq!(
r["error"], "supervisor died mid-run; work is stranded and cannot be rolled up",
"orphaned outcome carries the mid-run reason, distinct from stillborn: {r}"
);
let waited = v["data"]["waited_ms"].as_u64().expect("waited_ms u64");
assert!(
waited < 5000,
"waited_ms {waited} well under the 30s budget"
);
}
#[test]
fn recently_active_dead_supervisor_run_does_not_settle_early() {
let home = TestHome::new();
let run = pending_run(&home, "fresh-orphan-candidate");
add_node(&home, &run, "n-0001");
let v = run_exit(
bin(&home).args([
"--output",
"json",
"run",
"wait",
&run,
"--timeout",
"500ms",
]),
2,
);
assert_eq!(v["data"]["runs"][0]["status"], "pending");
assert_eq!(
v["data"]["runs"][0]["stalled"], false,
"within the grace window a dead-supervisor run must NOT be flagged stalled"
);
let waited = v["data"]["waited_ms"].as_u64().expect("waited_ms u64");
assert!(
(400..=2000).contains(&waited),
"waited_ms {waited} should be ~500ms (the timeout budget, not an early exit)"
);
}
#[test]
fn all_and_any_are_mutually_exclusive() {
let home = TestHome::new();
let pending = pending_run(&home, "pending");
let out = bin(&home)
.args(["run", "wait", &pending, "--all", "--any"])
.output()
.expect("spawn");
assert_eq!(
out.status.code(),
Some(1),
"conflicting flags are a usage error"
);
}