use std::fs;
use std::path::PathBuf;
use std::process::{Command, Output};
struct Scratch(PathBuf);
static SCRATCH: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
impl Scratch {
fn new(name: &str) -> Self {
let n = SCRATCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("pma-test-{}-{name}-{n}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
Scratch(dir.canonicalize().unwrap())
}
fn project(&self, name: &str, todo: &str) -> PathBuf {
let dir = self.0.join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("TODO.md"), todo).unwrap();
dir
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn command(program: impl AsRef<std::ffi::OsStr>) -> Command {
let mut cmd = Command::new(program);
cmd.env_remove("GIT_CONFIG_COUNT")
.env_remove("GIT_CONFIG_PARAMETERS");
cmd
}
fn pma(args: &[&std::ffi::OsStr]) -> Output {
command(env!("CARGO_BIN_EXE_pma"))
.args(args)
.output()
.unwrap()
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn clean_file_exits_zero_silently() {
let s = Scratch::new("clean");
let dir = s.project("a", "# TODO\n\n## High\n\n- [ ] thing\n");
let out = pma(&["lint".as_ref(), dir.as_os_str()]);
assert!(out.status.success());
assert_eq!(stdout(&out), "");
assert!(out.stderr.is_empty());
}
#[test]
fn warnings_alone_exit_zero() {
let s = Scratch::new("warn");
let dir = s.project("a", "# TODO\n\n## Low\n\n- [x] finished #42\n");
let out = pma(&["lint".as_ref(), dir.as_os_str()]);
assert!(out.status.success());
let todo = dir.join("TODO.md");
assert_eq!(
stdout(&out),
format!(
"{}:5: warning: `#42` is text; write `gh:42` to link the issue\n",
todo.display()
)
);
}
#[test]
fn prune_lists_then_removes_finished_items() {
let s = Scratch::new("prune");
let text = "# TODO\n\n## High\n\n- [x] finished\n notes\n- [ ] open\n\n## Done\n\n- [x] old\n- [ ] stray\n";
let a = s.project("a", text);
let bad = s.project("bad", "## High\n\n- [x] kept\n");
let todo = a.join("TODO.md");
let out = pma(&["prune".as_ref(), a.as_os_str(), bad.as_os_str()]);
assert!(out.status.success());
assert_eq!(
stdout(&out),
format!(
"{0}:9: remove `## Done`, lines 9-12\n\
{0}:12: remove open item with `## Done`: `- [ ] stray`\n\
{0}:5: remove `finished`\n\
{1}: skipped: lint errors; see `pma lint`\n\
2 removals; run `pma prune --apply` to make them\n",
todo.display(),
bad.join("TODO.md").display()
)
);
assert_eq!(fs::read_to_string(&todo).unwrap(), text);
let out = pma(&["prune".as_ref(), "--apply".as_ref(), a.as_os_str()]);
assert!(out.status.success());
assert!(
stdout(&out).ends_with("2 removals made; TODO.md edits are uncommitted\n"),
"{}",
stdout(&out)
);
assert_eq!(
fs::read_to_string(&todo).unwrap(),
"# TODO\n\n## High\n\n- [ ] open\n"
);
let out = pma(&[
"prune".as_ref(),
a.as_os_str(),
s.0.join("none").as_os_str(),
]);
assert_eq!(out.status.code(), Some(1));
assert!(
stdout(&out).ends_with("nothing to prune\n"),
"{}",
stdout(&out)
);
}
#[test]
fn errors_in_any_file_exit_one_and_every_file_is_checked() {
let s = Scratch::new("errors");
let bad = s.project("bad", "## High\n");
let good = s.project("good", "# TODO\n");
let missing = s.0.join("missing");
fs::create_dir_all(&missing).unwrap();
let named = s.0.join("gone.md");
let out = pma(&[
"lint".as_ref(),
bad.as_os_str(),
good.as_os_str(),
missing.as_os_str(),
named.as_os_str(),
]);
assert_eq!(out.status.code(), Some(1));
let text = stdout(&out);
assert!(
text.contains(&format!("{}:1: error:", bad.join("TODO.md").display())),
"{text}"
);
assert!(
text.contains(&format!("{}: error:", named.display())),
"a file named outright must exist: {text}"
);
assert!(!text.contains(&good.display().to_string()), "{text}");
assert_eq!(
String::from_utf8_lossy(&out.stderr),
"2 errors, 0 warnings\n"
);
}
#[test]
fn a_directory_without_a_todo_is_skipped() {
let s = Scratch::new("no-todo");
let good = s.project("good", "# TODO\n");
let missing = s.0.join("missing");
fs::create_dir_all(&missing).unwrap();
for command in ["lint", "prune"] {
let out = pma(&[command.as_ref(), good.as_os_str(), missing.as_os_str()]);
assert_eq!(out.status.code(), Some(0), "{command}");
assert!(
stdout(&out).contains(&format!(
"{}: skipped: no TODO.md",
missing.join("TODO.md").display()
)),
"{command}: {}",
stdout(&out)
);
}
}
const DAY: i64 = 86_400;
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
fn git(dir: &std::path::Path, args: &[&str], date: Option<i64>) {
let mut cmd = command("git");
cmd.arg("-C")
.arg(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
.args(args);
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1");
if let Some(d) = date {
cmd.env("GIT_AUTHOR_DATE", format!("@{d} +0000"))
.env("GIT_COMMITTER_DATE", format!("@{d} +0000"));
}
let out = cmd.output().unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn pma_in(home: &std::path::Path, args: &[&str]) -> (String, String, bool) {
let out = command(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", home)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
fn ok(home: &std::path::Path, args: &[&str]) -> String {
let (out, err, success) = pma_in(home, args);
assert!(success, "pma {args:?} failed\nstdout: {out}\nstderr: {err}");
out
}
fn plan_id(out: &str) -> String {
let at = out
.find("--approve ")
.unwrap_or_else(|| panic!("no plan to approve: {out}"));
out[at + "--approve ".len()..]
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect()
}
#[test]
fn scan_rank_and_explain_a_real_repo() {
let s = Scratch::new("stage2");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
fs::create_dir_all(root.join("beta")).unwrap();
git(&root.join("beta"), &["init", "-q"], None);
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n### core\n\n- [ ] old high\n\n## Low\n\n- [ ] old low\n",
)
.unwrap();
fs::write(alpha.join("lib.rs"), "\n").unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], Some(now() - 100 * DAY));
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n### core\n\n- [ ] old high\n\n## Low\n\n- [ ] old low #later\n",
)
.unwrap();
git(&alpha, &["commit", "-qam", "retag"], Some(now() - 50 * DAY));
git(&alpha, &["branch", "pma/stray"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n### core\n\n- [ ] old high\n- [ ] new high\n\n## Low\n\n- [ ] old low #later\n",
)
.unwrap();
let (_, err, success) = pma_in(&home, &["scan"]);
assert!(!success && err.contains("pma root add"), "{err}");
let (_, err, success) = pma_in(&home, &["matrix"]);
assert!(!success && err.contains("pma scan"), "{err}");
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["project", "tier", "1", "alpha"]);
assert!(ok(&home, &["project"]).contains("alpha tier 1"));
let (_, err, success) = pma_in(&home, &["project", "tier", "6", "alpha"]);
assert!(!success && err.contains("1 to 5"), "{err}");
let scanned = ok(&home, &["scan", "--offline"]);
assert_eq!(
scanned.split(" in ").next(),
Some("scanned 2 projects"),
"{scanned}"
);
assert!(
scanned.ends_with(": 1 with TODO.md, 3 open items\n"),
"{scanned}"
);
let matrix = ok(&home, &["matrix", "--all"]);
let expected = "\
Q1 Do right away: 0
Q2 Schedule for later: 2
alpha:7 T1 high open 100d core old high
alpha:8 T1 high open 0d core new high
Q3 Delegate or avoid: 0
Q4 Later: 3
alpha T1 medium open 0d resolve local changes: 1 changed file, 1 leftover pma branch
alpha:12 T1 low open 100d old low
alpha T1 low open 70d review project: no code commits in 100 days
";
let (head, body) = matrix.split_once("\n\n").unwrap();
assert_eq!(
head,
"last scan just now; 1 tiered projects, 1 untiered (pma project tier <1-5> <project>...)"
);
assert_eq!(body, expected);
let status = ok(&home, &["status", "--explain"]);
assert!(
status.contains("\nalpha 1 unpublished +1"),
"{status}"
);
assert!(
status.contains("\nalpha tier 1\n unpublished 1 leftover pma branch\n"),
"{status}"
);
assert!(
status.contains(" idle no code commit in 100 days, horizon 30\n"),
"{status}"
);
assert!(status.contains(" CI not measured: offline"), "{status}");
assert!(!status.contains("beta"), "{status}");
let status = ok(&home, &["status", "--all", "--explain"]);
assert!(
status.starts_with("last scan just now; 2 projects, 1 untiered ranked as tier 5\n"),
"{status}"
);
let rows: Vec<&str> = status.lines().skip(3).take(2).collect();
assert!(
rows[0].starts_with("alpha 1 unpublished +1")
&& rows[1].starts_with("beta - idle"),
"{status}"
);
assert!(
status.contains("\nbeta tier 5, untiered\n idle no code commits found\n"),
"{status}"
);
ok(&home, &["config", "tiers.1", "0.3"]);
assert_eq!(ok(&home, &["config", "tiers.1"]), "0.3\n");
assert!(ok(&home, &["config"]).contains("tiers.1 = 0.3 (set)\n"));
assert!(ok(&home, &["matrix", "-q", "q1"]).ends_with("Q1 Do right away: 0\n"));
let (_, err, success) = pma_in(&home, &["config", "tiers.1", "much"]);
assert!(
!success && err.contains("tiers.1: expected a number"),
"{err}"
);
ok(&home, &["config", "tiers.1", "--reset"]);
assert!(ok(&home, &["matrix", "-q", "q2"]).contains("Q2 Schedule for later: 2\n"));
assert!(ok(&home, &["matrix"]).contains("1 untiered"));
ok(&home, &["config", "default_tier", "5"]);
let matrix = ok(&home, &["matrix"]);
assert!(matrix.contains("2 tiered projects, 0 untiered"), "{matrix}");
ok(&home, &["config", "default_tier", "--reset"]);
let stale = ok(&home, &["stale", "-n", "2"]);
assert!(
stale.contains("alpha:7 T1 high open 100d old high"),
"{stale}"
);
assert!(stale.trim_end().ends_with("and 1 more"), "{stale}");
ok(&home, &["scan", "--offline", "alpha"]);
assert!(ok(&home, &["project"]).contains("alpha tier 1"));
}
const FAKE_CLAUDE: &str = r#"#!/bin/sh
case "$2" in
*"reviewer's feedback"*) echo hi > hello.txt ;;
*"add greeting"*) echo hi > hello.txt ;;
*"second task"*) echo two > second.txt ;;
*"third task"*) echo three > third.txt ;;
esac
if git push -q origin HEAD:refs/heads/agent 2>/dev/null; then echo pushed; else echo blocked; fi >> "$PUSH_LOG"
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
const PLAIN_WORKER: &str = r#"#!/bin/sh
cd "$3" || exit 1
echo hi > hello.txt
echo "wrote hello.txt in $3"
"#;
const STRAYING_CLAUDE: &str = r#"#!/bin/sh
mkdir -p .github/workflows
echo "on: push" > .github/workflows/ci.yml
echo bump >> Cargo.lock
git mv Makefile build.mk
git rm -q notes.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"updated Cargo.lock","total_cost_usd":0.1}'
"#;
struct Env {
home: PathBuf,
bin: PathBuf,
push_log: PathBuf,
}
impl Env {
fn command(&self, args: &[&str]) -> Command {
let path = format!(
"{}:{}",
self.bin.display(),
std::env::var("PATH").unwrap_or_default()
);
let mut cmd = command(env!("CARGO_BIN_EXE_pma"));
cmd.env("PMA_HOME", &self.home)
.env("PATH", path)
.env("PUSH_LOG", &self.push_log)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_AUTHOR_NAME", "u")
.env("GIT_AUTHOR_EMAIL", "u@example.com")
.env("GIT_COMMITTER_NAME", "u")
.env("GIT_COMMITTER_EMAIL", "u@example.com")
.args(args);
cmd
}
fn run(&self, args: &[&str]) -> (String, String, bool) {
let out = self.command(args).output().unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
}
fn ok(&self, args: &[&str]) -> String {
let (out, err, success) = self.run(args);
assert!(success, "pma {args:?} failed\nstdout: {out}\nstderr: {err}");
out
}
}
fn git_out(dir: &std::path::Path, args: &[&str]) -> String {
let out = command("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn dispatch_env(s: &Scratch, scripts: &[(&str, &str)]) -> (Env, PathBuf, PathBuf) {
use std::os::unix::fs::PermissionsExt;
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
for (name, script) in scripts {
fs::write(bin.join(name), script).unwrap();
fs::set_permissions(bin.join(name), fs::Permissions::from_mode(0o755)).unwrap();
}
let env = Env {
home: s.0.join("home"),
bin,
push_log: s.0.join("push.log"),
};
let seed = s.0.join("seed");
fs::create_dir_all(&seed).unwrap();
git(&seed, &["init", "-q", "-b", "main"], None);
fs::write(
seed.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] add greeting #agent\n say hello in hello.txt\n- [ ] second task #agent gh:7\n- [ ] third task #agent\n\n## Low\n\n- [ ] guarded task #manual\n",
)
.unwrap();
fs::write(seed.join("Makefile"), "test:\n\ttest -f hello.txt\n").unwrap();
git(&seed, &["add", "."], None);
git(&seed, &["commit", "-qm", "init"], None);
let origin = s.0.join("origin.git");
git(&s.0, &["clone", "-q", "--bare", "seed", "origin.git"], None);
let root = s.0.join("root");
fs::create_dir_all(&root).unwrap();
git(&root, &["clone", "-q", "../origin.git", "alpha"], None);
let alpha = root.join("alpha");
env.ok(&["root", "add", root.to_str().unwrap()]);
env.ok(&["project", "tier", "1", "alpha"]);
env.ok(&["scan", "--offline"]);
(env, origin, alpha)
}
#[test]
fn dispatch_review_rework_and_push() {
let s = Scratch::new("stage3");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let out = env.ok(&["dispatch", "--auto", "-n", "2"]);
assert!(
out.contains("#1 alpha: add greeting\n#2 alpha: second task\n"),
"{out}"
);
assert!(
out.ends_with("2 ready, 0 failed, $0.20 spent; see `pma review`\n"),
"{out}"
);
assert_eq!(
fs::read_to_string(&env.push_log).unwrap(),
"blocked\nblocked\n"
);
let (_, err, success) = env.run(&["dispatch", "alpha:5"]);
assert!(!success && err.contains("already has a run"), "{err}");
let list = env.ok(&["review"]);
assert!(
list.contains("#1 ready alpha verify ok $0.10"),
"{list}"
);
assert!(
list.contains("#2 ready alpha verify FAILED $0.10"),
"{list}"
);
let detail = env.ok(&["review", "1"]);
assert!(detail.starts_with("#1 ready alpha Q2\n"), "{detail}");
assert!(
detail.contains("accept: every recorded gate is clean"),
"{detail}"
);
assert!(list.contains("clean"), "{list}");
let failed = env.ok(&["review", "2"]);
assert!(
failed.contains("read this run because:\n - verify failed at the head"),
"{failed}"
);
assert!(
detail.contains("`make test`: base FAILED, head passed"),
"{detail}"
);
assert!(detail.contains("+hi"), "{detail}");
assert!(detail.contains("2 of 5 (v1)"), "{detail}");
assert!(failed.contains("4 of 5 (v1)"), "{failed}");
let (out, _, _) = env.run(&["review", "2", "--approve"]);
assert_eq!(out, "");
let out = env.ok(&["review", "2", "--rework", "tests need hello.txt"]);
assert!(out.contains("verify ok $0.20"), "{out}");
let detail = env.ok(&["review", "2", "--minutes", "7"]);
let attempts = detail
.split_once("attempts:\n")
.unwrap_or_default()
.1
.lines()
.take(2)
.collect::<Vec<_>>();
assert!(attempts[0].contains("verify FAILED"), "{detail}");
assert!(attempts[1].contains("verify passed"), "{detail}");
assert!(
detail.contains("reviewed") && detail.contains("7m00s"),
"{detail}"
);
let detail = env.ok(&["review", "2", "--minutes", "3"]);
assert!(detail.contains("10m00s"), "{detail}");
assert!(!env.ok(&["review", "1"]).contains("attempts:"));
env.ok(&["review", "1", "--approve"]);
env.ok(&["review", "2", "--approve"]);
env.ok(&["review", "2", "--approve"]);
let (_, err, success) = env.run(&["review", "9", "--approve"]);
assert!(!success && err.contains("no run #9"), "{err}");
let local = fs::read_to_string(alpha.join("TODO.md")).unwrap().replace(
"- [ ] third task #agent\n",
"- [ ] third task #agent\n- [ ] local only #agent\n",
);
fs::write(alpha.join("TODO.md"), local).unwrap();
env.ok(&["scan", "--offline"]);
let (_, err, success) = env.run(&["dispatch", "alpha:9"]);
assert!(
!success && err.contains("commit and push it first"),
"{err}"
);
assert!(!env.home.join("state/worktrees/alpha/local-only").exists());
env.ok(&["dispatch", "alpha:8"]);
env.ok(&["review", "3", "--reject"]);
assert!(!env.home.join("state/worktrees/alpha/third-task").exists());
assert_eq!(git_out(&alpha, &["branch", "--list", "pma/third-task"]), "");
let out = env.ok(&["push", "1", "2"]);
assert!(out.contains("#1 alpha: pushed "), "{out}");
assert!(out.contains("#2 alpha: pushed "), "{out}");
let log = git_out(&origin, &["log", "--format=%B|", "main"]);
assert!(
log.starts_with("second task\n\nCloses #7\n|\nadd greeting\n|"),
"{log:?}"
);
assert_eq!(
git_out(&origin, &["show", "main:TODO.md"]),
"# TODO\n\n## High\n\n- [x] add greeting #agent\n say hello in hello.txt\n- [x] second task #agent gh:7\n- [ ] third task #agent\n\n## Low\n\n- [ ] guarded task #manual\n"
);
assert_eq!(git_out(&origin, &["show", "main:second.txt"]), "two\n");
assert_eq!(git_out(&origin, &["branch", "--list", "agent"]), "");
assert!(!env.home.join("state/worktrees/alpha/add-greeting").exists());
assert_eq!(git_out(&alpha, &["branch", "--list", "pma/*"]), "");
assert_eq!(env.ok(&["review"]), "no runs to review\n");
let (_, err, success) = env.run(&["push", "1"]);
assert!(
!success && err.contains("run #1 is pushed; only an approved run is published"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:8"]);
assert!(
!success && err.contains("2 attempts on `third task` were used without an accepted result"),
"{err}"
);
env.ok(&["config", "batch_budget", "0.05"]);
let out = env.ok(&["dispatch", "alpha:8", "--retry"]);
assert!(
out.contains("not started: batch budget $0.05 reached"),
"{out}"
);
assert!(
out.ends_with("0 ready, 1 failed, $0.00 spent; see `pma review`\n"),
"{out}"
);
let report = env.ok(&["report"]);
assert!(
report.contains("67% of 3 decided runs accepted"),
"{report}"
);
let b: Vec<&str> = report
.lines()
.find(|l| l.starts_with("B "))
.unwrap()
.split_whitespace()
.collect();
assert_eq!(
&b[..8],
["B", "4", "1", "2", "2", "0", "4", "$0.40+1?"],
"{report}"
);
assert!(report.contains("10m00s"), "review time: {report}");
assert!(
env.ok(&["report", "--by", "project"])
.contains("by project"),
"{report}"
);
let (_, err, ok) = env.run(&["report", "--by", "model"]);
assert!(!ok && err.contains("unknown dimension `model`"), "{err}");
env.ok(&["config", "batch_budget", "5.0"]);
env.ok(&["review", "4", "--reject"]);
env.ok(&["dispatch", "alpha:8"]);
env.ok(&["review", "5", "--reject"]);
env.ok(&["dispatch", "alpha:8"]);
env.ok(&["review", "6", "--reject"]);
let (_, err, success) = env.run(&["dispatch", "alpha:8"]);
assert!(!success && err.contains("2 attempts on"), "{err}");
}
#[test]
fn an_infrastructure_rejection_uses_no_attempt_and_is_in_no_share() {
let s = Scratch::new("infra");
let (env, _, _) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["dispatch", "alpha:8"]);
env.ok(&["review", "1", "--reject", "--infra"]);
let report = env.ok(&["report"]);
assert!(report.starts_with("nothing decided yet"), "{report}");
assert!(
report.contains("rejected for infrastructure, in no share: #1"),
"{report}"
);
env.ok(&["dispatch", "alpha:8"]);
env.ok(&["review", "2", "--reject"]);
let (_, err, success) = env.run(&["dispatch", "alpha:8"]);
assert!(!success && err.contains("2 attempts"), "{err}");
assert!(
env.ok(&["report"])
.starts_with("0% of 1 decided run accepted"),
"the plain rejection counts"
);
let (_, err, success) = env.run(&["review", "2", "--infra"]);
assert!(!success && err.contains("--reject"), "{err}");
}
const FAKE_GH_PR: &str = r#"#!/bin/sh
case "$1 $2" in
"pr create")
if [ -e "$PMA_HOME/pr-create-fails" ]; then echo "HTTP 502" >&2; exit 1; fi
echo "https://github.com/me/alpha/pull/1" ;;
"pr list") if [ -e "$PMA_HOME/pr-list" ]; then cat "$PMA_HOME/pr-list"; fi ;;
"pr view") cat "$PMA_HOME/pr-state" ;;
*) echo "fake gh: unexpected $*" >&2; exit 1 ;;
esac
"#;
#[test]
fn a_pull_request_holds_its_task_until_merged_or_closed() {
let s = Scratch::new("pr");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE), ("gh", FAKE_GH_PR)]);
let pr_state = env.home.join("pr-state");
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
let out = env.ok(&["pr", "1"]);
assert_eq!(out, "#1 alpha: https://github.com/me/alpha/pull/1\n");
assert_eq!(
git_out(&origin, &["branch", "--list", "pma/*"]),
" pma/add-greeting\n"
);
fs::write(&pr_state, "OPEN\n").unwrap();
assert!(env.ok(&["review"]).contains("#1 pr-open alpha"));
let out = env.ok(&["dispatch", "--auto", "-n", "1"]);
assert!(out.contains("#2 alpha: second task"), "{out}");
env.ok(&["review", "2", "--reject"]);
let (_, err, success) = env.run(&["dispatch", "alpha:5"]);
assert!(!success && err.contains("already has a run"), "{err}");
let (_, err, success) = env.run(&["review", "1", "--reject"]);
assert!(
!success && err.contains("open pull request; merge or close it"),
"{err}"
);
fs::write(&pr_state, "CLOSED\n").unwrap();
let out = env.ok(&["review"]);
assert!(
out.starts_with("#1 alpha: pull request closed without merging: https://"),
"{out}"
);
assert!(
env.ok(&["review", "1"]).starts_with("#1 closed alpha"),
"a closed pull request is `closed`, not rejected"
);
let out = env.ok(&["dispatch", "alpha:5"]);
assert!(out.contains("#3 alpha: add greeting"), "{out}");
assert!(env.ok(&["review", "3"]).contains("pma/add-greeting-2 in"));
env.ok(&["review", "3", "--approve"]);
env.ok(&["pr", "3"]);
fs::write(&pr_state, "OPEN\tCHANGES_REQUESTED\t2\t1\n").unwrap();
let out = env.ok(&["review"]);
assert!(
out.starts_with(
"#3 alpha: pull request open: changes requested, 2 comments, 1 review: https://"
),
"{out}"
);
assert!(out.contains("#3 pr-open"), "{out}");
fs::write(&pr_state, "MERGED\n").unwrap();
let out = env.ok(&["review"]);
assert_eq!(
out,
"#3 alpha: pull request merged: https://github.com/me/alpha/pull/1\nno runs to review\n"
);
assert!(!alpha.join("hello.txt").exists(), "the clone is untouched");
assert!(env.ok(&["review", "3"]).starts_with("#3 merged alpha"));
}
#[test]
fn publishing_resumes_after_a_partial_failure() {
let s = Scratch::new("resume");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE), ("gh", FAKE_GH_PR)]);
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
let worktree = env.home.join("state/worktrees/alpha/add-greeting");
git(
&alpha,
&["worktree", "lock", worktree.to_str().unwrap()],
None,
);
let out = env.ok(&["push", "1"]);
assert!(
out.starts_with("#1 alpha: pushed ") && out.contains("; warning: worktree not removed: "),
"{out}"
);
assert_eq!(
env.ok(&["review"]),
"no runs to review\n",
"published all the same"
);
rusqlite::Connection::open(env.home.join("projects.db"))
.unwrap()
.execute("UPDATE runs SET state = 'approved' WHERE id = 1", [])
.unwrap();
git(
&alpha,
&["worktree", "unlock", worktree.to_str().unwrap()],
None,
);
let out = env.ok(&["push", "1"]);
assert!(out.starts_with("#1 alpha: already pushed "), "{out}");
assert!(!worktree.exists());
assert_eq!(
git_out(&origin, &["log", "--format=%s", "main"]),
"add greeting\ninit\n",
"pushed once"
);
env.ok(&["dispatch", "alpha:7"]);
env.ok(&["review", "2", "--approve"]);
fs::write(env.home.join("pr-create-fails"), "").unwrap();
let (out, _, success) = env.run(&["pr", "2"]);
assert!(!success && out.contains("gh pr create: HTTP 502"), "{out}");
assert!(env.ok(&["review"]).contains("#2 approved"));
fs::remove_file(env.home.join("pr-create-fails")).unwrap();
fs::write(
env.home.join("pr-list"),
"https://github.com/me/alpha/pull/9\n",
)
.unwrap();
let (_, err, success) = env.run(&["pr", "2", "--all-approved"]);
assert!(!success && err.contains("cannot be used with"), "{err}");
assert_eq!(
env.ok(&["pr", "--all-approved"]),
"#2 alpha: https://github.com/me/alpha/pull/9\n"
);
assert_eq!(env.ok(&["push", "--all-approved"]), "no approved runs\n");
}
#[test]
fn a_retried_publish_publishes_only_what_was_approved() {
let s = Scratch::new("retry-approved");
let (env, origin, _) = dispatch_env(&s, &[("claude", FAKE_CLAUDE), ("gh", FAKE_GH_PR)]);
let fail = s.0.join("fail");
let verify = format!(
"if [ -e {0} ]; then echo out > output.txt; echo out >> Makefile; exit 1; fi; test -f hello.txt",
fail.display()
);
env.ok(&["config", "projects.alpha.verify", &verify]);
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
fs::write(&fail, "").unwrap();
let (out, _, success) = env.run(&["push", "1"]);
assert!(
!success && out.contains("failed on the integrated tree"),
"{out}"
);
let worktree = env.home.join("state/worktrees/alpha/add-greeting");
assert!(worktree.join("output.txt").exists(), "verify wrote output");
fs::write(worktree.join("hello.txt"), "edited\n").unwrap();
fs::write(worktree.join("extra.txt"), "extra\n").unwrap();
fs::remove_file(&fail).unwrap();
assert!(env.ok(&["push", "1"]).contains("#1 alpha: pushed "));
assert_eq!(git_out(&origin, &["show", "main:hello.txt"]), "hi\n");
assert_eq!(
git_out(&origin, &["ls-tree", "--name-only", "main"]),
"Makefile\nTODO.md\nhello.txt\n"
);
assert!(!git_out(&origin, &["show", "main:Makefile"]).contains("out"));
assert!(git_out(&origin, &["show", "main:TODO.md"]).contains("- [x] add greeting"));
assert_eq!(
git_out(&origin, &["log", "--format=%s", "main"]),
"add greeting\ninit\n"
);
env.ok(&["dispatch", "alpha:7"]);
env.ok(&["review", "2", "--approve"]);
fs::write(env.home.join("pr-create-fails"), "").unwrap();
let (out, _, success) = env.run(&["pr", "2"]);
assert!(!success && out.contains("gh pr create: HTTP 502"), "{out}");
let pushed = git_out(&origin, &["rev-parse", "pma/second-task"]);
fs::remove_file(env.home.join("pr-create-fails")).unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
assert_eq!(
env.ok(&["pr", "2"]),
"#2 alpha: https://github.com/me/alpha/pull/1\n"
);
assert_eq!(
git_out(&origin, &["rev-parse", "pma/second-task"]),
pushed,
"the branch is not pushed again"
);
}
const COMMITTING_CLAUDE: &str = r#"#!/bin/sh
echo key > secret.txt
git add secret.txt && git commit -qm "wip: secret"
case "$2" in *"second task"*) f=second.txt ;; *) f=hello.txt ;; esac
git rm -q secret.txt && echo hi > $f && git add $f
git commit -qm "tidy up"
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
#[test]
fn the_agents_own_commits_are_not_published() {
let s = Scratch::new("agent-commits");
let (env, origin, _) = dispatch_env(&s, &[("claude", COMMITTING_CLAUDE), ("gh", FAKE_GH_PR)]);
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
assert!(env.ok(&["push", "1"]).contains("#1 alpha: pushed "));
assert_eq!(
git_out(&origin, &["log", "--format=%s", "main"]),
"add greeting\ninit\n"
);
assert_eq!(
git_out(
&origin,
&["log", "--all", "--format=%s", "--", "secret.txt"]
),
""
);
env.ok(&["dispatch", "alpha:7"]);
env.ok(&["review", "2", "--approve"]);
env.ok(&["pr", "2"]);
assert_eq!(
git_out(&origin, &["log", "--format=%s", "main..pma/second-task"]),
"second task\n"
);
}
#[test]
fn an_approval_requires_the_tree_last_shown() {
let s = Scratch::new("shown");
let (env, origin, _) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["dispatch", "alpha:5"]);
let worktree = env.home.join("state/worktrees/alpha/add-greeting");
fs::write(worktree.join("late.txt"), "late\n").unwrap();
let (_, err, ok) = env.run(&["review", "1", "--approve"]);
assert!(
!ok && err.contains("changed after it was last shown"),
"{err}"
);
assert!(env.ok(&["review", "1"]).contains("late.txt"));
env.ok(&["review", "1", "--approve"]);
assert!(env.ok(&["push", "1"]).contains("#1 alpha: pushed "));
assert_eq!(git_out(&origin, &["show", "main:late.txt"]), "late\n");
env.ok(&["dispatch", "alpha:7"]);
env.ok(&["review", "2"]);
let worktree = env.home.join("state/worktrees/alpha/second-task");
fs::write(worktree.join("second.txt"), "changed\n").unwrap();
let (_, err, ok) = env.run(&["review", "2", "--approve"]);
assert!(
!ok && err.contains("changed after it was last shown"),
"{err}"
);
}
const IDLE_CLAUDE: &str = r#"#!/bin/sh
echo '{"type":"result","subtype":"success","is_error":false,"result":"nothing to do","total_cost_usd":0.1}'
"#;
#[test]
fn a_run_with_no_changes_is_not_published() {
let s = Scratch::new("no-changes");
let (env, origin, _) = dispatch_env(&s, &[("claude", IDLE_CLAUDE)]);
env.ok(&["config", "projects.alpha.verify", "none"]);
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
let (out, _, success) = env.run(&["push", "1"]);
assert!(
!success && out.contains("nothing to publish: no changes"),
"{out}"
);
assert_eq!(git_out(&origin, &["log", "--format=%s", "main"]), "init\n");
}
#[test]
fn auto_dispatch_passes_over_refused_tasks_and_names_the_cause() {
let s = Scratch::new("refused");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let todo = fs::read_to_string(alpha.join("TODO.md")).unwrap();
fs::write(
alpha.join("TODO.md"),
todo.replace(
"- [ ] add greeting",
"- [ ] local only #agent\n- [ ] add greeting",
),
)
.unwrap();
let other = s.0.join("other");
git(
&s.0,
&["clone", "-q", origin.to_str().unwrap(), "other"],
None,
);
let ticked = fs::read_to_string(other.join("TODO.md"))
.unwrap()
.replace("- [ ] third task", "- [x] third task");
fs::write(other.join("TODO.md"), ticked).unwrap();
git(&other, &["commit", "-qam", "tick"], None);
git(&other, &["push", "-q"], None);
env.ok(&["scan", "--offline"]);
let (out, err, success) = env.run(&["dispatch", "--auto", "-n", "1"]);
assert!(success, "{err}");
assert!(
err.contains("warning: alpha: `local only` is not in TODO.md on origin/main; commit and push it first"),
"{err}"
);
assert!(out.starts_with("#1 alpha: add greeting\n"), "{out}");
let (_, err, success) = env.run(&["dispatch", "alpha:9"]);
assert!(
!success && err.contains("`third task` is already done on origin/main; pull the clone"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:13"]);
assert!(
!success && err.contains("`guarded task` is class D and is not dispatched"),
"{err}"
);
assert!(!env.home.join("state/worktrees/alpha/guarded-task").exists());
assert_eq!(
git_out(&alpha, &["branch", "--list", "pma/guarded-task"]),
""
);
}
#[test]
fn the_scope_check_reads_the_whole_change_not_the_agents_report() {
let s = Scratch::new("scope");
let (env, _, alpha) = dispatch_env(&s, &[("claude", STRAYING_CLAUDE)]);
env.ok(&["config", "projects.alpha.verify", "none"]);
fs::write(alpha.join("Cargo.lock"), "lock\n").unwrap();
fs::write(alpha.join("notes.txt"), "notes\n").unwrap();
git(&alpha, &["add", "Cargo.lock", "notes.txt"], None);
git(&alpha, &["commit", "-qm", "lock"], None);
git(&alpha, &["push", "-q"], None);
env.ok(&["scan", "--offline"]);
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("B, any path"), "{detail}");
assert!(
detail.contains("1 of 5 files OUTSIDE: .github/workflows/ci.yml"),
"{detail}"
);
assert!(detail.contains("updated Cargo.lock"), "{detail}");
assert!(
detail.contains(" - outside class B: .github/workflows/ci.yml"),
"{detail}"
);
assert!(detail.contains(" - no verify command"), "{detail}");
assert!(env.ok(&["review"]).contains("2 to read"), "{detail}");
}
#[test]
fn unknown_cost_counts_against_the_batch_budget() {
let s = Scratch::new("unknown-cost");
let (env, _, _) = dispatch_env(&s, &[("plain", PLAIN_WORKER)]);
env.ok(&["agent", "set", "plain", "command", "plain"]);
env.ok(&[
"agent",
"set",
"plain",
"args",
r#"["--task","{prompt}","{dir}"]"#,
]);
env.ok(&["config", "agent", "plain"]);
env.ok(&["config", "max_parallel", "1"]);
env.ok(&["config", "agent_budget", "1"]);
env.ok(&["config", "batch_budget", "1.5"]);
let out = env.ok(&["dispatch", "--auto", "-n", "2"]);
assert!(
out.contains("not started: batch budget $1.5 reached"),
"{out}"
);
}
#[test]
fn a_worker_without_json_output_or_an_allowlist_runs_the_same_path() {
let s = Scratch::new("adapter");
let (env, origin, _) = dispatch_env(&s, &[("plain", PLAIN_WORKER)]);
let listed = env.ok(&["agent"]);
assert!(listed.contains("* claude"), "{listed}");
assert!(listed.contains("cost,allowlist"), "{listed}");
env.ok(&["agent", "set", "plain", "command", "plain"]);
env.ok(&[
"agent",
"set",
"plain",
"args",
r#"["--task","{prompt}","{dir}"]"#,
]);
env.ok(&["config", "agent", "plain"]);
let listed = env.ok(&["agent"]);
assert!(listed.contains("* plain"), "{listed}");
assert!(listed.contains("text-tail"), "{listed}");
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("plain, cost not reported"), "{detail}");
assert!(detail.contains("wrote hello.txt in"), "{detail}");
assert!(
detail.contains("`make test`: base FAILED, head passed"),
"{detail}"
);
assert!(
detail.contains("accept: every recorded gate is clean"),
"{detail}"
);
env.ok(&["review", "1", "--approve"]);
assert!(
env.ok(&["push", "1"]).contains("#1 alpha: pushed "),
"pushed"
);
assert_eq!(git_out(&origin, &["show", "main:hello.txt"]), "hi\n");
let report = env.ok(&["report", "--by", "agent"]);
assert!(report.contains("plain"), "{report}");
assert!(report.contains("$0.00+1?"), "{report}");
env.ok(&["agent", "rm", "plain"]);
env.ok(&["dispatch", "alpha:7"]);
let detail = env.ok(&["review", "2"]);
assert!(detail.contains("unknown agent `plain`"), "{detail}");
let (_, err, ok) = env.run(&["agent", "set", "x", "parse", "codex-json"]);
assert!(!ok && err.contains("unknown parser `codex-json`"), "{err}");
let (_, err, ok) = env.run(&["agent", "set", "x", "args", "not json"]);
assert!(!ok && err.contains("JSON array"), "{err}");
}
const MODEL_CLAUDE: &str = r#"#!/bin/sh
model=unset
while [ $# -gt 0 ]; do
case "$1" in --model) model="$2" ;; esac
shift
done
echo hi > hello.txt
printf '{"type":"result","subtype":"success","is_error":false,"result":"ran as %s","total_cost_usd":0.1}\n' "$model"
"#;
const CAMPAIGN_WORKER: &str = r#"#!/bin/sh
if [ "$(basename "$(dirname "$PWD")")" = "beta" ] && [ ! -e "$PMA_HOME/fixed" ]; then
printf '{"type":"result","subtype":"error","is_error":true,"result":"gave up","total_cost_usd":0.1}
'
exit 0
fi
mkdir -p .github/workflows
echo "on: push" > .github/workflows/ci.yml
printf '{"type":"result","subtype":"success","is_error":false,"result":"added the workflow","total_cost_usd":0.1}
'
"#;
#[test]
fn a_campaign_applies_one_definition_across_repositories() {
let s = Scratch::new("campaign");
let (env, _, _) = dispatch_env(&s, &[("claude", CAMPAIGN_WORKER)]);
let root = s.0.join("root");
for name in ["beta", "gamma"] {
git(&root, &["clone", "-q", "../origin.git", name], None);
env.ok(&["project", "tier", "3", name]);
}
env.ok(&["scan", "--offline"]);
for name in ["alpha", "beta", "gamma"] {
env.ok(&["config", &format!("projects.{name}.verify"), "true"]);
}
assert!(env.ok(&["campaign"]).contains("no campaigns"), "empty");
env.ok(&[
"campaign",
"add",
"workflows",
"add a workflow",
"--projects",
"alpha,beta,gamma",
"--class",
"A-",
"--describe",
"Create .github/workflows/ci.yml that runs on push.",
]);
let listed = env.ok(&["campaign"]);
assert!(
listed.contains("class A-") && listed.contains("0/3 dispatched"),
"{listed}"
);
let out = env.ok(&["campaign", "run", "workflows"]);
assert!(out.contains("2 ready, 1 failed"), "{out}");
let show = env.ok(&["campaign", "show", "workflows"]);
assert!(
show.starts_with(
"workflows: add a workflow
"
),
"{show}"
);
assert!(show.contains("failed"), "{show}");
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("A-, within .github/**"), "{detail}");
assert!(detail.contains("1 files, all permitted"), "{detail}");
assert!(detail.contains("base passed, head passed"), "{detail}");
assert!(detail.contains("every recorded gate is clean"), "{detail}");
let ready: Vec<String> = env
.ok(&["review"])
.lines()
.filter(|l| l.contains(" ready "))
.map(|l| l.split_whitespace().next().unwrap()[1..].to_string())
.collect();
assert_eq!(ready.len(), 2, "two runs to approve");
let mut args = vec!["review"];
args.extend(ready.iter().map(String::as_str));
args.push("--approve");
let approved = env.ok(&args);
assert_eq!(approved.lines().count(), 2, "{approved}");
let out = env.ok(&["campaign", "run", "workflows"]);
assert!(
out.contains("beta: #2 is failed; reject or rework it"),
"{out}"
);
assert!(
out.contains("every member of `workflows` has a run"),
"{out}"
);
fs::write(env.home.join("fixed"), "").unwrap();
env.ok(&["review", "2", "--reject"]);
let out = env.ok(&["campaign", "run", "workflows"]);
assert!(out.contains("1 ready, 0 failed"), "{out}");
assert_eq!(out.matches(": add a workflow").count(), 1, "{out}");
let show = env.ok(&["campaign", "show", "workflows"]);
assert_eq!(show.matches("approved").count(), 2, "{show}");
assert_eq!(show.matches("ready").count(), 1, "{show}");
env.ok(&["review", "3", "--approve"]);
let out = env.ok(&["campaign", "run", "workflows"]);
assert!(
out.contains("every member of `workflows` has a run"),
"{out}"
);
}
#[test]
fn an_approval_is_evidence_about_a_tree_not_a_state() {
let s = Scratch::new("evidence");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
let worktree = env.home.join("state/worktrees/alpha/add-greeting");
fs::write(worktree.join("sneaked.txt"), "later\n").unwrap();
let (out, err, success) = env.run(&["push", "1"]);
assert!(!success, "{out}{err}");
assert!(
out.contains("the worktree changed after it was approved"),
"{out}"
);
assert_eq!(git_out(&origin, &["log", "--format=%s", "main"]), "init\n");
fs::remove_file(worktree.join("sneaked.txt")).unwrap();
env.ok(&["review", "1", "--approve"]);
assert!(
env.ok(&["push", "1"]).contains("#1 alpha: pushed "),
"pushed"
);
env.ok(&["dispatch", "alpha:7"]);
env.ok(&["review", "2", "--approve"]);
env.ok(&["review", "2", "--rework", "add hello.txt too"]);
let detail = env.ok(&["review", "2"]);
assert!(!detail.contains("approved by"), "{detail}");
env.ok(&["review", "2", "--approve"]);
let other = s.0.join("other");
git(
&s.0,
&["clone", "-q", origin.to_str().unwrap(), "other"],
None,
);
fs::write(other.join("Makefile"), "test:\n\ttest -f never.txt\n").unwrap();
git(&other, &["commit", "-qam", "stricter test"], None);
git(&other, &["push", "-q"], None);
let (out, err, success) = env.run(&["push", "2"]);
assert!(!success, "{out}{err}");
assert!(
out.contains("`make test` failed on the integrated tree"),
"{out}"
);
assert_eq!(
git_out(&origin, &["log", "--format=%s", "main"]),
"stricter test\nadd greeting\ninit\n",
"nothing of the run reached the branch"
);
let _ = alpha;
}
#[test]
fn a_batch_approval_refuses_a_list_with_anything_to_read_in_it() {
let s = Scratch::new("batch");
let (env, _, _) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["dispatch", "--auto", "-n", "3"]);
let list = env.ok(&["review"]);
assert!(list.contains("clean"), "{list}");
let (_, err, success) = env.run(&["review", "1", "2", "--approve"]);
assert!(!success && err.contains("#2 is not clean"), "{err}");
assert!(err.contains("verify failed at the head"), "{err}");
assert_eq!(
env.ok(&["review"]).matches("ready").count(),
3,
"nothing was approved"
);
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[{"name":"draft-only","match":{},"approval":"propose"}]}"#,
)
.unwrap();
env.ok(&["route", "propose", doc.to_str().unwrap()]);
env.ok(&["route", "activate", "1"]);
env.ok(&["review", "1", "--reject"]);
env.ok(&["dispatch", "alpha:5", "--retry"]);
let (_, err, success) = env.run(&["review", "4", "--approve"]);
assert!(!success && err.contains("which is `propose`"), "{err}");
}
const ESCALATING_CLAUDE: &str = r#"#!/bin/sh
model=unset
while [ $# -gt 0 ]; do
case "$1" in --model) model="$2" ;; esac
shift
done
if [ "$model" = "opus" ]; then echo hi > hello.txt; fi
printf '{"type":"result","subtype":"success","is_error":false,"result":"ran as %s","total_cost_usd":0.1}
' "$model"
"#;
#[test]
fn a_failed_check_escalates_once_and_records_both_attempts() {
let s = Scratch::new("escalate");
let (env, _, _) = dispatch_env(&s, &[("claude", ESCALATING_CLAUDE)]);
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[{"name":"try-twice","match":{},"model":"haiku",
"escalate":{"model":"opus","attempts":1},"approval":"each"}]}"#,
)
.unwrap();
env.ok(&["route", "propose", doc.to_str().unwrap()]);
env.ok(&["route", "activate", "1"]);
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("base FAILED, head passed"), "{detail}");
let attempts = detail.split_once("attempts:\n").expect("two attempts").1;
let lines: Vec<&str> = attempts.lines().take(2).collect();
assert!(
lines[0].contains("haiku") && lines[0].contains("verify FAILED"),
"{detail}"
);
assert!(
lines[1].contains("opus") && lines[1].contains("verify passed"),
"{detail}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:5"]);
assert!(!success && err.contains("already has a run"), "{err}");
assert!(detail.contains("claude haiku, $0.20"), "{detail}");
}
#[test]
fn a_shadow_policy_refuses_scopes_and_approves_nothing() {
let s = Scratch::new("route-shadow");
let (env, _, _) = dispatch_env(&s, &[("claude", MODEL_CLAUDE)]);
let propose = |name: &str, doc: &str| {
let path = s.0.join(name);
fs::write(&path, doc).unwrap();
env.ok(&["route", "propose", path.to_str().unwrap(), "--by", "me"]);
};
propose(
"all.json",
r#"{"route":[{"name":"all","match":{},"model":"opus",
"scope":["docs/**"],"approval":"propose"}]}"#,
);
env.ok(&["route", "activate", "1", "--shadow", "--by", "me"]);
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("all in revision 1, in shadow"), "{detail}");
assert!(detail.contains("ran as unset"), "{detail}");
assert!(
!detail.contains("docs/**"),
"the class scope holds: {detail}"
);
env.ok(&["review", "1", "--approve"]);
propose(
"narrow.json",
r#"{"route":[{"name":"narrow","match":{"class":"A"},"approval":"each"}]}"#,
);
env.ok(&["route", "activate", "2", "--shadow", "--by", "me"]);
env.ok(&["dispatch", "alpha:7"]);
let detail = env.ok(&["review", "2"]);
assert!(
detail.contains("no route in revision 2, in shadow"),
"{detail}"
);
}
#[test]
fn an_applied_route_outranks_the_default_preset() {
let s = Scratch::new("route-preset");
let (env, _, _) = dispatch_env(&s, &[("claude", MODEL_CLAUDE)]);
env.ok(&["preset", "set", "cheap", "claude", "haiku"]);
env.ok(&["preset", "use", "cheap"]);
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[{"name":"all","match":{},"model":"opus","approval":"each"}]}"#,
)
.unwrap();
env.ok(&["route", "propose", doc.to_str().unwrap(), "--by", "me"]);
env.ok(&["route", "activate", "1", "--by", "me"]);
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("ran as opus"), "{detail}");
env.ok(&["dispatch", "-p", "cheap", "alpha:7"]);
let detail = env.ok(&["review", "2"]);
assert!(detail.contains("ran as haiku"), "{detail}");
}
#[test]
fn an_absent_project_is_not_ranked() {
let s = Scratch::new("absent");
let (env, _, alpha) = dispatch_env(&s, &[]);
let root = alpha.parent().unwrap();
git(root, &["clone", "-q", "../origin.git", "beta"], None);
env.ok(&["project", "tier", "1", "beta"]);
env.ok(&["scan", "--offline"]);
assert!(
env.ok(&["matrix"]).contains("beta:"),
"ranked while present"
);
fs::rename(root.join("beta"), s.0.join("beta-moved")).unwrap();
env.ok(&["scan", "--offline"]);
for view in [&["matrix"][..], &["status"], &["next"]] {
let out = env.ok(view);
assert!(!out.contains("beta"), "{view:?}: {out}");
assert!(out.contains("alpha"), "{view:?}: {out}");
}
}
const CLONING_GH: &str = r#"#!/bin/sh
[ "$1 $2" = "repo clone" ] || { echo "fake gh: unexpected $*" >&2; exit 1; }
git clone -q "$PMA_HOME/../origin.git" "$4"
"#;
#[test]
fn a_missing_checkout_is_cloned_from_its_recorded_slug() {
let s = Scratch::new("clone");
let (env, _, alpha) = dispatch_env(&s, &[("gh", CLONING_GH)]);
let root = alpha.parent().unwrap().to_path_buf();
git(&root, &["clone", "-q", "../origin.git", "beta"], None);
git(
&root.join("beta"),
&[
"remote",
"set-url",
"origin",
"https://github.com/me/beta.git",
],
None,
);
env.ok(&["scan", "--offline"]);
env.ok(&["project", "tag", "add", "work", "alpha", "beta"]);
fs::rename(root.join("beta"), s.0.join("beta-moved")).unwrap();
env.ok(&["scan", "--offline"]);
let out = env.ok(&["project", "clone", "--tag", "work"]);
assert!(out.contains("alpha: already checked out"), "{out}");
assert!(
out.contains(&format!(
"beta: would clone me/beta into {}",
root.join("beta").display()
)),
"{out}"
);
assert!(!root.join("beta").exists(), "a dry run clones nothing");
let out = env.ok(&["project", "clone", "--tag", "work", "--apply"]);
assert!(out.contains("beta: cloned me/beta"), "{out}");
assert!(root.join("beta/TODO.md").exists());
git(
&root.join("beta"),
&[
"remote",
"set-url",
"origin",
"https://github.com/me/beta.git",
],
None,
);
env.ok(&["scan", "--offline"]);
assert!(
env.ok(&["status", "--all"]).contains("beta"),
"present again"
);
fs::rename(root.join("beta"), s.0.join("beta-again")).unwrap();
env.ok(&["scan", "--offline"]);
fs::create_dir(root.join("beta")).unwrap();
let out = env.ok(&["project", "clone", "beta", "--apply"]);
assert!(out.contains("already exists; not cloned"), "{out}");
}
#[test]
fn an_untiered_project_is_routed_at_the_default_tier() {
let s = Scratch::new("default-tier");
let (env, _, _) = dispatch_env(&s, &[("claude", MODEL_CLAUDE)]);
env.ok(&["project", "tier", "none", "alpha"]);
env.ok(&["config", "default_tier", "2"]);
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[
{"name":"tier-two","match":{"tier":"2-2"},"model":"haiku","approval":"each"},
{"name":"rest","match":{},"model":"opus","approval":"each"}
]}"#,
)
.unwrap();
env.ok(&["route", "propose", doc.to_str().unwrap()]);
env.ok(&["route", "activate", "1"]);
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("ran as haiku"), "{detail}");
}
#[test]
fn a_policy_is_a_draft_until_activated_and_replays_exactly() {
let s = Scratch::new("route");
let (env, _, _) = dispatch_env(&s, &[("claude", MODEL_CLAUDE)]);
assert!(env.ok(&["route"]).contains("no routing policy"), "empty");
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[
{"name":"specified","match":{"class":"B","complexity":"1-3"},
"model":"haiku","approval":"batch"},
{"name":"rest","match":{},"model":"opus","approval":"each"}
]}"#,
)
.unwrap();
let out = env.ok(&["route", "propose", doc.to_str().unwrap(), "--by", "me"]);
assert!(out.contains("revision 1 stored as a draft"), "{out}");
assert!(env.ok(&["route"]).contains("draft"), "still a draft");
env.ok(&["dispatch", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("ran as unset"), "{detail}");
assert!(!detail.contains("revision 1"), "{detail}");
env.ok(&["review", "1", "--reject"]);
env.ok(&["route", "activate", "1", "--shadow", "--by", "me"]);
env.ok(&["dispatch", "alpha:5", "--retry"]);
let detail = env.ok(&["review", "2"]);
assert!(
detail.contains("specified in revision 1, in shadow"),
"{detail}"
);
assert!(detail.contains("ran as unset"), "shadow applies nothing");
env.ok(&["review", "2", "--reject"]);
env.ok(&["route", "activate", "1", "--by", "me"]);
assert!(env.ok(&["route"]).contains("active, by me"), "activated");
env.ok(&["dispatch", "alpha:5", "--retry"]);
let detail = env.ok(&["review", "3"]);
assert!(detail.contains("ran as haiku"), "{detail}");
let replay = env.ok(&["route", "replay", "1"]);
assert!(
replay.contains("3 runs replayed, 2 routed differently"),
"{replay}"
);
assert!(replay.contains("#1"), "dispatched before it: {replay}");
assert!(replay.contains("#2"), "shadowed, so not applied: {replay}");
assert!(!replay.contains("#3"), "applied, so unchanged: {replay}");
assert!(replay.contains("Cost is not projected"), "{replay}");
let other = s.0.join("other.json");
fs::write(
&other,
r#"{"route":[
{"name":"specified","match":{"class":"B","complexity":"1-1"},
"model":"haiku","approval":"batch"},
{"name":"rest","match":{},"model":"opus","approval":"each"}
]}"#,
)
.unwrap();
let replay = env.ok(&["route", "replay", other.to_str().unwrap()]);
assert!(
replay.contains("3 runs replayed, 3 routed differently"),
"{replay}"
);
assert!(replay.contains("rest claude/opus"), "{replay}");
let narrow = s.0.join("narrow.json");
fs::write(
&narrow,
r#"{"route":[{"name":"only-a","match":{"class":"A"},"approval":"each"}]}"#,
)
.unwrap();
env.ok(&["route", "propose", narrow.to_str().unwrap()]);
env.ok(&["route", "activate", "2"]);
env.ok(&["review", "3", "--reject"]);
let (_, err, success) = env.run(&["dispatch", "alpha:5", "--retry"]);
assert!(
!success && err.contains("matches no route in policy revision 2"),
"{err}"
);
let bad = s.0.join("bad.json");
fs::write(&bad, r#"{"route":[{"match":{},"approval":"whenever"}]}"#).unwrap();
let (_, err, success) = env.run(&["route", "propose", bad.to_str().unwrap()]);
assert!(
!success && err.contains("unknown approval `whenever`"),
"{err}"
);
assert_eq!(env.ok(&["route"]).lines().count(), 2, "still two revisions");
}
const SLOW_CLAUDE: &str = r#"#!/bin/sh
n=0
while [ ! -e "$PMA_HOME/release" ] && [ $n -lt 1200 ]; do sleep 0.05; n=$((n + 1)); done
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
#[test]
fn a_second_session_leaves_running_runs_alone() {
let s = Scratch::new("session");
let (env, _, _) = dispatch_env(&s, &[("claude", SLOW_CLAUDE)]);
struct Release(PathBuf);
impl Drop for Release {
fn drop(&mut self) {
let _ = fs::write(&self.0, "");
}
}
let release = Release(env.home.join("release"));
let mut dispatch = env
.command(&["dispatch", "alpha:5"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let started = std::time::Instant::now();
let mut last = String::new();
while !last.contains("#1 running") {
if dispatch.try_wait().unwrap().is_some() || started.elapsed().as_secs() >= 30 {
let _ = dispatch.kill();
let out = dispatch.wait_with_output().unwrap();
panic!(
"the run never started; review: {last}\ndispatch: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
std::thread::sleep(std::time::Duration::from_millis(50));
last = env.ok(&["review"]);
}
assert!(
env.ok(&["review"]).contains("#1 running"),
"review does not fail a live run"
);
for args in [
&["dispatch", "alpha:6"][..],
&["push", "1"],
&["review", "1", "--reject"],
] {
let (_, err, success) = env.run(args);
assert!(
!success && err.contains("another pma session (pid "),
"{args:?}: {err}"
);
}
drop(release);
let out = dispatch.wait_with_output().unwrap();
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success() && stdout.contains("1 ready, 0 failed"),
"{stdout}"
);
assert!(env.ok(&["review"]).contains("#1 ready"));
env.ok(&["review", "1", "--reject"]);
}
const FAKE_GH: &str = r#"#!/usr/bin/env python3
import json, os, sys
state = os.environ["GH_STATE"]
issues = json.load(open(state))
args = sys.argv[1:]
def opt(name):
return args[args.index(name) + 1] if name in args else None
def log(line):
with open(state + ".log", "a") as f:
f.write(line + "\n")
if args[:2] == ["api", "user"]:
print("me")
elif args[:2] == ["issue", "list"]:
print(json.dumps([dict(i, author={"login": i["author"]}, labels=[{"name": l} for l in i["labels"]]) for i in issues]))
elif args[:2] == ["label", "create"]:
log("label create " + args[2])
elif args[:2] == ["issue", "create"]:
n = max([i["number"] for i in issues] + [0]) + 1
issues.append({"number": n, "title": opt("--title"), "state": "OPEN", "author": "me", "labels": [opt("--label")]})
log("issue create %d %s | %s" % (n, opt("--title"), opt("--body")))
print("https://github.com/me/alpha/issues/%d" % n)
elif args[:2] == ["issue", "edit"]:
issue = next(i for i in issues if i["number"] == int(args[2]))
if opt("--title"):
issue["title"] = opt("--title")
if opt("--add-label"):
issue["labels"].append(opt("--add-label"))
if opt("--remove-label"):
issue["labels"].remove(opt("--remove-label"))
log("issue edit " + " ".join(args[2:]))
else:
sys.exit("fake gh: unexpected " + " ".join(args))
json.dump(issues, open(state, "w"))
"#;
#[test]
fn sync_plans_then_applies_and_settles() {
use std::os::unix::fs::PermissionsExt;
let s = Scratch::new("stage4");
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
fs::write(bin.join("gh"), FAKE_GH).unwrap();
fs::set_permissions(bin.join("gh"), fs::Permissions::from_mode(0o755)).unwrap();
let state = s.0.join("issues.json");
fs::write(
&state,
r#"[{"number": 1, "title": "fixed elsewhere", "state": "CLOSED", "author": "me", "labels": []},
{"number": 2, "title": "old crash title", "state": "OPEN", "author": "me", "labels": ["pma:critical"]},
{"number": 3, "title": "please add X", "state": "OPEN", "author": "alice", "labels": []}]"#,
)
.unwrap();
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = s.project(
"root/alpha",
"# TODO\n\n## Critical\n\n- [ ] segfault on empty input\n when stdin is empty\n- [ ] renamed crash gh:2\n\n## High\n\n- [ ] fixed elsewhere gh:1\n",
);
git(&alpha, &["init", "-q"], None);
git(
&alpha,
&["remote", "add", "origin", "https://github.com/me/alpha.git"],
None,
);
let beta = s.project("root/beta", "## High\n");
git(&beta, &["init", "-q"], None);
let sync = |args: &[&str]| {
let out = command(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.env(
"PATH",
format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
)
.env("GH_STATE", &state)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
};
sync(&["root", "add", root.to_str().unwrap()]);
sync(&["scan", "--offline"]);
let original = fs::read_to_string(alpha.join("TODO.md")).unwrap();
let (out, err) = sync(&["sync"]);
assert_eq!(
out,
"alpha:5: open an issue for `segfault on empty input`\n\
alpha: retitle #2 to `renamed crash`\n\
alpha:11: mark done, #1 is closed\n\
alpha: untriaged #3 by alice: please add X\n\
beta: skipped: TODO.md has lint errors; see `pma lint`\n\
3 changes; run `pma sync --apply` to make them\n",
"{err}"
);
assert_eq!(fs::read_to_string(alpha.join("TODO.md")).unwrap(), original);
assert!(
!s.0.join("issues.json.log").exists(),
"a dry run changes nothing"
);
let (out, err) = sync(&["sync", "--apply", "alpha"]);
assert!(
out.ends_with(
"3 changes made\nTODO.md changed, uncommitted, in: alpha; commit, then `pma scan`\n"
),
"{out}{err}"
);
assert_eq!(
fs::read_to_string(s.0.join("issues.json.log")).unwrap(),
"label create pma:critical\n\
issue create 4 segfault on empty input | when stdin is empty\n\n\
Opened by pma from a `## Critical` item in TODO.md.\n\
issue edit 2 -R me/alpha --title renamed crash\n"
);
assert_eq!(
fs::read_to_string(alpha.join("TODO.md")).unwrap(),
"# TODO\n\n## Critical\n\n- [ ] segfault on empty input gh:4\n when stdin is empty\n- [ ] renamed crash gh:2\n\n## High\n\n- [x] fixed elsewhere gh:1\n"
);
let (out, _) = sync(&["sync", "alpha"]);
assert_eq!(out, "alpha: untriaged #3 by alice: please add X\nin sync\n");
}
#[test]
fn notes_and_measured_dependencies() {
use std::os::unix::fs::PermissionsExt;
let s = Scratch::new("stage5");
let home = s.0.join("home");
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
fs::write(
bin.join("uv"),
"#!/bin/sh\nprintf 'alpha v1\\n|-- ruff v0.14.10 (group: dev) (latest: v0.16.7)\\n`-- ty v0.0.8 (latest: v0.0.81)\\n'\n",
)
.unwrap();
fs::set_permissions(bin.join("uv"), fs::Permissions::from_mode(0o755)).unwrap();
let run = |args: &[&str]| {
let out = command(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.env(
"PATH",
format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
};
let ok = |args: &[&str]| {
let (out, err, success) = run(args);
assert!(success, "pma {args:?}: {err}");
out
};
assert_eq!(
ok(&["note"]),
"no notes; add one with `pma note add <text>`\n"
);
assert_eq!(
ok(&["note", "add", "move", "CI", "to", "one", "workflow"]),
"#1\n"
);
assert_eq!(ok(&["note", "add", "second"]), "#2\n");
ok(&["note", "edit", "1", "move CI to reusable workflows"]);
ok(&["note", "rm", "2"]);
let (_, err, success) = run(&["note", "rm", "2"]);
assert!(!success && err.contains("no note #2"), "{err}");
let list = ok(&["note"]);
assert!(list.starts_with("#1 20"), "{list}");
assert!(
list.ends_with(" move CI to reusable workflows\n"),
"{list}"
);
let root = s.0.join("root");
let alpha = s.project("root/alpha", "# TODO\n\n## High\n");
git(&alpha, &["init", "-q"], None);
fs::write(alpha.join("uv.lock"), "").unwrap();
ok(&["root", "add", root.to_str().unwrap()]);
ok(&["project", "tier", "1", "alpha"]);
ok(&["scan", "--offline"]);
assert!(ok(&["status", "--explain"]).contains("not measured; `pma scan --deps`"));
let (_, err, success) = run(&["dispatch", "alpha:deps"]);
assert!(
!success && err.contains("no outdated dependencies"),
"{err}"
);
let scanned = ok(&["scan", "--deps"]);
assert!(
scanned.ends_with(", outdated dependencies in 1\n"),
"{scanned}"
);
ok(&["scan", "--offline"]);
let status = ok(&["status", "--explain"]);
assert!(
status.contains("2 outdated, measured today"),
"kept: {status}"
);
assert!(
ok(&["matrix", "--all"])
.contains("alpha T1 medium open 0d update dependencies: 2 outdated"),
"the deps signal task"
);
}
const MODEL_WORKER: &str = r#"#!/bin/sh
cd "$3" || exit 1
echo hi > hello.txt
echo "model=$4"
# Whatever a preset added arrives after the model, so the test can see it.
shift 4 2>/dev/null || shift $#
while [ $# -gt 0 ]; do
case "$1" in --thinking) echo "thinking=$2"; shift ;; esac
shift
done
"#;
#[test]
fn a_target_can_name_a_heading_or_a_quadrant() {
let s = Scratch::new("select");
let (env, _, _) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let (_, err, success) = env.run(&["dispatch", "-a", "pi", "alpha:5"]);
assert!(
!success && err.contains("unknown agent `pi`; `pma agent` lists claude"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:soon"]);
assert!(
!success && err.contains("expected a TODO.md line, ci, deps, critical"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:critical"]);
assert!(
!success && err.contains("alpha: no open critical items at the last scan"),
"{err}"
);
let (_, err, success) = env.run(&["dispatch", "alpha"]);
assert!(
!success && err.contains("opens a list, which needs a terminal"),
"{err}"
);
let out = env.ok(&["dispatch", "alpha:q2"]);
assert!(
out.starts_with("#1 alpha: add greeting\n#2 alpha: second task\n#3 alpha: third task\n"),
"{out}"
);
let (_, err, success) = env.run(&["dispatch", "alpha:high"]);
assert!(
!success && err.ends_with("pma: nothing to dispatch: every task named was passed over\n"),
"{err}"
);
assert_eq!(err.matches("already has a run").count(), 3, "{err}");
assert!(err.contains("warning: alpha:5: already has a run"), "{err}");
let (_, err, success) = env.run(&["dispatch", "alpha:low"]);
assert!(
!success && err.contains("`guarded task` is class D and is not dispatched"),
"{err}"
);
}
#[test]
fn a_worker_no_longer_names_a_model() {
let s = Scratch::new("flags");
let (env, _, _) = dispatch_env(&s, &[("mw", MODEL_WORKER)]);
env.ok(&["agent", "set", "mw", "command", "mw"]);
env.ok(&[
"agent",
"set",
"mw",
"args",
r#"["--task","{prompt}","{dir}","{model}","{extra}"]"#,
]);
let (_, err, ok) = env.run(&["agent", "set", "mw", "model", "haiku"]);
assert!(!ok, "the field is gone");
assert!(
err.contains("pma preset set"),
"it says where it went: {err}"
);
let (_, err, ok) = env.run(&["config", "model", "haiku"]);
assert!(!ok && err.contains("pma preset set"), "{err}");
env.ok(&["config", "agent", "mw"]);
env.ok(&["dispatch", "alpha:5"]);
assert!(
env.ok(&["review", "1"]).contains("model=\n"),
"no model named"
);
env.ok(&["dispatch", "-m", "sonnet", "alpha:7"]);
assert!(
env.ok(&["review", "2"]).contains("model=sonnet"),
"the flag"
);
let listed = env.ok(&["agent"]);
assert!(listed.contains("* mw"), "{listed}");
assert!(!listed.contains("sonnet"), "no model column: {listed}");
assert!(
env.ok(&["agent", "show", "mw"]).contains("{extra}"),
"args shown"
);
}
#[test]
fn a_preset_names_a_worker_a_model_and_its_configuration() {
let s = Scratch::new("presets");
let (env, _, _) = dispatch_env(&s, &[("mw", MODEL_WORKER)]);
env.ok(&["agent", "set", "mw", "command", "mw"]);
env.ok(&[
"agent",
"set",
"mw",
"args",
r#"["--task","{prompt}","{dir}","{model}","{extra}"]"#,
]);
assert!(env.ok(&["preset"]).contains("no presets"), "empty");
let (_, err, ok) = env.run(&["preset", "set", "bad", "nosuch", "haiku"]);
assert!(!ok && err.contains("no worker `nosuch`"), "{err}");
env.ok(&["preset", "set", "plain", "mw"]);
env.ok(&["preset", "set", "cheap", "mw", "haiku"]);
env.ok(&[
"preset",
"set",
"cheap-high",
"mw",
"haiku",
"--thinking",
"high",
]);
let listed = env.ok(&["preset"]);
assert!(
listed.contains("its own default"),
"a preset may name no model"
);
assert!(listed.contains("--thinking high"), "{listed}");
env.ok(&["dispatch", "-p", "cheap-high", "alpha:5"]);
let detail = env.ok(&["review", "1"]);
assert!(detail.contains("model=haiku"), "{detail}");
assert!(
detail.contains("thinking=high"),
"the effort reached it: {detail}"
);
env.ok(&["dispatch", "-p", "cheap-high", "-m", "sonnet", "alpha:7"]);
let detail = env.ok(&["review", "2"]);
assert!(detail.contains("model=sonnet"), "{detail}");
assert!(
detail.contains("thinking=high"),
"the rest of the preset stands"
);
env.ok(&["preset", "use", "cheap"]);
assert!(env.ok(&["preset"]).contains("* cheap"), "marked default");
env.ok(&["dispatch", "alpha:8"]);
let detail = env.ok(&["review", "3"]);
assert!(detail.contains("model=haiku"), "{detail}");
assert!(
!detail.contains("thinking=high"),
"a different preset: {detail}"
);
env.ok(&["preset", "rm", "cheap"]);
let listed = env.ok(&["preset"]);
assert!(!listed.contains("* cheap"), "the default is gone: {listed}");
let settings = env.ok(&["config"]);
let preset_line = settings
.lines()
.find(|l| l.starts_with("preset ="))
.unwrap_or_default();
assert_eq!(
preset_line.trim_end_matches(" (set)").trim(),
"preset =",
"the setting is cleared with it"
);
}
#[test]
fn a_workflow_document_is_a_draft_until_its_cost_is_accepted() {
let s = Scratch::new("workflow");
let home = s.0.join("home");
fs::create_dir_all(&home).unwrap();
let run = |args: &[&str]| {
let out = command(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
};
let script = s.0.join("lib.rhai");
fs::write(
&script,
r#"
fn reviewer(node, what) {
|g| g.expand(node, "finding", "{$breadth}",
"Review `{name}` for " + what + ". Write findings to {out}.")
}
let graph = source("project")
.fan([reviewer("bugs", "correctness"), reviewer("tests", "coverage")])
.join("merge", ["title"])
.filter("confirm", ["reason"], "Confirm each unit in {in}.")
.output();
document(#{ finding: #{ fields: #{
title: req(unique(line(200))),
reason: line(200),
}}}, [
workflow("look", graph, #{
params: #{ breadth: bounded("int", 4, 6) },
caps: #{ max_units: 40, max_edits: 0 },
}),
])
"#,
)
.unwrap();
assert!(run(&["workflow"]).0.contains("no workflow revisions"));
let (out, err, ok) = run(&["workflow", "check", script.to_str().unwrap()]);
assert!(ok, "{err}");
assert!(
out.contains("look(in: [project], breadth = 4) -> [finding] pure"),
"{out}"
);
assert!(
out.contains("worst case: 14 agent runs, 0 edits, $14.00"),
"{out}"
);
let (json, err, ok) = run(&["workflow", "check", script.to_str().unwrap(), "--emit-json"]);
assert!(ok, "{err}");
let from_json = s.0.join("lib.json");
fs::write(&from_json, &json).unwrap();
let (a, _, _) = run(&["workflow", "check", from_json.to_str().unwrap()]);
assert_eq!(a, out, "the generated document estimates the same");
let (out, err, ok) = run(&[
"workflow",
"propose",
script.to_str().unwrap(),
"--by",
"me",
]);
assert!(ok, "{err}");
assert!(
out.contains("revision 1 stored as a draft: at most $14.00"),
"{out}"
);
let listed = run(&["workflow"]).0;
assert!(
listed.contains("draft") && listed.contains("look"),
"{listed}"
);
run(&["config", "workflow_budget", "10"]);
let (_, err, ok) = run(&["workflow", "activate", "1", "--by", "me"]);
assert!(!ok, "a revision over budget must not activate");
assert!(err.contains("$14.00") && err.contains("$10.00"), "{err}");
assert!(run(&["workflow"]).0.contains("draft"), "still a draft");
run(&["config", "workflow_budget", "20"]);
let (out, err, ok) = run(&["workflow", "activate", "1", "--by", "me"]);
assert!(ok, "{err}");
assert!(out.contains("in effect"), "{out}");
assert!(run(&["workflow"]).0.contains("active, by me"));
let shown = run(&["workflow", "show"]).0;
assert!(shown.contains("\"name\": \"look\""), "{shown}");
assert!(shown.contains("\"max_units\": \"{$breadth}\""), "{shown}");
assert!(!shown.contains("fan("), "the script is not the document");
}
#[test]
fn a_pass_runs_the_free_nodes_and_never_spends_without_approval() {
let s = Scratch::new("pass");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## Critical\n\n## High\n\n- [ ] first thing\n- [x] done thing\n\n## Medium\n\n## Low\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let free = s.0.join("free.rhai");
fs::write(
&free,
r#"
let graph = source("project")
.check("lint", "lint-todo")
.rule_expand("items", "item", "todo-items", 100)
.rule_filter("open", "where:done=false")
.emit_note("record", #{ text: "open: {text}" })
.output();
document(#{}, [
workflow("stocktake", graph, #{ caps: #{ max_units: 200, max_edits: 0 }}),
])
"#,
)
.unwrap();
let out = ok(&home, &["workflow", "propose", free.to_str().unwrap()]);
assert!(
out.contains("at most $0.00"),
"a graph of rules is free: {out}"
);
ok(&home, &["workflow", "activate", "1"]);
for _ in 0..2 {
let out = ok(
&home,
&["workflow", "run", "stocktake", "alpha", "--dry-run"],
);
assert!(out.contains("`stocktake` over 1 unit(s)"), "{out}");
assert!(
out.contains("would run: lint") && out.contains("a rule, free"),
"{out}"
);
}
assert!(
ok(&home, &["note"]).contains("no notes"),
"a dry run writes nothing"
);
assert!(
!ok(&home, &["workflow"]).contains("instance"),
"a dry run starts no instance"
);
ok(&home, &["workflow", "run", "stocktake", "alpha"]);
let out = ok(&home, &["workflow", "run", "stocktake", "--instance", "1"]);
assert!(out.contains("nothing left to run"), "{out}");
let notes = ok(&home, &["note"]);
assert!(notes.contains("open: first thing"), "{notes}");
assert!(
!notes.contains("done thing"),
"the filter dropped it: {notes}"
);
let out = ok(&home, &["workflow", "run", "stocktake", "--instance", "1"]);
assert!(out.contains("nothing left to run"), "{out}");
assert_eq!(
ok(&home, &["note"]).lines().count(),
notes.lines().count(),
"a second pass writes no second note"
);
}
#[test]
fn an_agent_node_is_priced_then_run_on_approval() {
let s = Scratch::new("gate");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] a thing\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
let log = s.0.join("spend.log");
fs::write(
bin.join("claude"),
format!(
"#!/bin/sh\n\
echo invoked >> {}\n\
out=$(printf '%s' \"$2\" | sed -n 's/.*findings to //p')\n\
printf '[{{\"title\":\"a finding\",\"@id\":\"u1\"}}]' > \"$out\"\n\
printf '{{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"total_cost_usd\":0.1}}\\n'\n",
log.display()
),
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(bin.join("claude"), fs::Permissions::from_mode(0o755)).unwrap();
}
let run = |args: &[&str]| {
let path = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = command(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.env("PATH", path)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
};
run(&["root", "add", root.to_str().unwrap()]);
run(&["scan", "--offline"]);
let wf = s.0.join("review.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.expand("review", "finding", 3, "Review `{name}`. Write findings to {out}")
.emit_note("record", #{ text: "found: {title}" })
.output();
document(#{ finding: #{ fields: #{ title: req(line(200)) }}}, [
workflow("review", graph, #{ caps: #{ max_units: 10, max_edits: 0 }}),
])
"#,
)
.unwrap();
let (out, err, success) = run(&["workflow", "propose", wf.to_str().unwrap()]);
assert!(success, "{err}");
assert!(out.contains("at most $1.00"), "{out}");
run(&["workflow", "activate", "1"]);
let (out, err, success) = run(&["workflow", "run", "review", "alpha", "-m", "haiku"]);
assert!(success, "{err}");
assert!(out.contains("next: review"), "{out}");
assert!(
out.contains("claude/haiku"),
"the cheap model it would use: {out}"
);
assert!(out.contains("Nothing was spent"), "{out}");
assert!(out.contains("--approve"), "it says how to approve: {out}");
assert!(!log.exists(), "the worker must not have run");
let id = plan_id(&out);
let (_, err, success) = run(&[
"workflow",
"run",
"review",
"--instance",
"1",
"--approve",
&id,
]);
assert!(!success && err.contains("not the plan"), "{err}");
assert!(!log.exists(), "a refused approval spends nothing");
let (out, err, success) = run(&[
"workflow",
"run",
"review",
"--instance",
"1",
"-m",
"haiku",
"--approve",
&id,
]);
assert!(success, "{err}");
assert!(out.contains("nothing left to run"), "{out}");
assert_eq!(
fs::read_to_string(&log).unwrap().lines().count(),
1,
"one run per unit"
);
assert!(
run(&["note"]).0.contains("found: a finding"),
"the unit carried on"
);
assert_eq!(run(&["review"]).0, "no runs to review\n");
let detail = run(&["review", "1"]).0;
assert!(detail.starts_with("#1 done"), "{detail}");
assert!(
detail.contains("(none: this node changes no files)"),
"{detail}"
);
let report = run(&["report"]).0;
assert!(
report.contains("0.10"),
"the run's cost is recorded: {report}"
);
}
#[test]
fn tiers_and_tags_go_out_to_a_file_and_come_back() {
let s = Scratch::new("bulk");
let home = s.0.join("home");
let root = s.0.join("root");
for name in ["alpha", "beta", "gamma"] {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q"], None);
}
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
ok(&home, &["project", "tier", "2", "alpha", "beta", "gamma"]);
ok(&home, &["project", "tag", "add", "rust", "alpha"]);
let csv = s.0.join("projects.csv");
let out = ok(&home, &["project", "export", csv.to_str().unwrap()]);
assert!(out.starts_with("3 projects to "), "{out}");
assert_eq!(
fs::read_to_string(&csv).unwrap(),
"name,tier,tags\nalpha,2,rust\nbeta,2\ngamma,2\n"
);
fs::write(&csv, "name,tier,tags\nalpha,1\nbeta,none,python,cli\n").unwrap();
let out = ok(&home, &["project", "import", csv.to_str().unwrap()]);
assert!(out.contains("alpha: tier 2 -> 1"), "{out}");
assert!(out.contains("alpha: tags rust -> none"), "{out}");
assert!(out.contains("beta: tier 2 -> none"), "{out}");
assert!(out.contains("beta: tags none -> python,cli"), "{out}");
assert!(out.contains("4 changes; --apply to make them"), "{out}");
assert!(
out.contains("1 projects the file leaves out keep what they have"),
"{out}"
);
assert!(ok(&home, &["project"]).contains("alpha tier 2 rust"));
let out = ok(
&home,
&["project", "import", csv.to_str().unwrap(), "--apply"],
);
assert!(out.contains("4 changes applied"), "{out}");
let listed = ok(&home, &["project"]);
assert!(listed.contains("alpha tier 1"), "{listed}");
assert!(listed.contains("beta untiered cli,python"), "{listed}");
assert!(listed.contains("gamma tier 2"), "{listed}");
let out = ok(
&home,
&["project", "import", csv.to_str().unwrap(), "--apply"],
);
assert!(out.contains("nothing to change"), "{out}");
let json = s.0.join("projects.json");
ok(&home, &["project", "export", json.to_str().unwrap()]);
let text = fs::read_to_string(&json).unwrap();
assert!(
text.starts_with(r#"[{"name":"alpha","tags":[],"tier":1}"#),
"{text}"
);
fs::write(&json, text.replace(r#""tier":1"#, r#""tier":5"#)).unwrap();
ok(
&home,
&["project", "import", json.to_str().unwrap(), "--apply"],
);
assert!(ok(&home, &["project"]).contains("alpha tier 5"));
let (_, err, success) = pma_in(
&home,
&["project", "export", &s.0.join("p.txt").to_string_lossy()],
);
assert!(!success && err.contains(".csv` or `.json"), "{err}");
fs::write(&csv, "name,tier,tags\nalpha,3\ndelta,1\n").unwrap();
let (_, err, success) = pma_in(
&home,
&["project", "import", csv.to_str().unwrap(), "--apply"],
);
assert!(!success && err.contains("not projects: delta"), "{err}");
assert!(
ok(&home, &["project"]).contains("alpha tier 5"),
"nothing applied"
);
}
#[test]
fn an_instance_resumes_under_its_own_revision() {
let s = Scratch::new("resume");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] a thing\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let write = |file: &str, node: &str| {
let path = s.0.join(file);
fs::write(
&path,
format!(
r#"
let graph = source("project")
.expand("{node}", "finding", 3, "Review `{{name}}`. Findings to {{out}}.")
.output();
document(#{{ finding: #{{ fields: #{{ title: req(line(200)) }}}}}}, [
workflow("look", graph, #{{ caps: #{{ max_units: 10, max_edits: 0 }}}}),
])
"#
),
)
.unwrap();
path
};
let first = write("one.rhai", "review");
let second = write("two.rhai", "audit");
ok(&home, &["workflow", "propose", first.to_str().unwrap()]);
ok(&home, &["workflow", "activate", "1"]);
let out = ok(&home, &["workflow", "run", "look", "alpha"]);
assert!(out.contains("next: review"), "{out}");
ok(&home, &["workflow", "propose", second.to_str().unwrap()]);
ok(&home, &["workflow", "activate", "2"]);
let out = ok(&home, &["workflow", "run", "look", "--instance", "1"]);
assert!(
out.contains("next: review") && !out.contains("audit"),
"an open instance keeps the graph it started with: {out}"
);
let (_, err, success) = pma_in(&home, &["workflow", "run", "other", "--instance", "1"]);
assert!(
!success && err.contains("runs `look`, not `other`"),
"{err}"
);
let (_, err, success) = pma_in(&home, &["workflow", "run", "look", "--instance", "9"]);
assert!(!success && err.contains("no workflow instance 9"), "{err}");
let (_, err, success) = pma_in(
&home,
&["workflow", "run", "look", "alpha", "--instance", "1"],
);
assert!(
!success && err.contains("target and arguments it started with"),
"{err}"
);
}
#[test]
fn a_rule_that_overflows_its_cap_stops_the_pass() {
let s = Scratch::new("cap");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] one\n- [ ] two\n- [ ] three\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let wf = s.0.join("narrow.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.rule_expand("items", "item", "todo-items", 2)
.emit_note("record", #{ text: "open: {text}" })
.output();
document(#{}, [
workflow("narrow", graph, #{ caps: #{ max_units: 50, max_edits: 0 }}),
])
"#,
)
.unwrap();
ok(&home, &["workflow", "propose", wf.to_str().unwrap()]);
ok(&home, &["workflow", "activate", "1"]);
let (_, err, success) = pma_in(&home, &["workflow", "run", "narrow", "alpha"]);
assert!(!success, "an over-cap expansion must not be written");
assert!(
err.contains("node `items`") && err.contains("max_units") && err.contains("3"),
"the cap and the overflow are named: {err}"
);
assert!(
ok(&home, &["note"]).contains("no notes"),
"nothing downstream ran"
);
assert!(ok(&home, &["workflow"]).contains("capped"));
let (_, err, success) = pma_in(&home, &["workflow", "run", "narrow", "--instance", "1"]);
assert!(!success && err.contains("stopped: capped"), "{err}");
}
#[test]
fn an_edit_node_dispatches_a_run_and_routes_on_its_verdict() {
let s = Scratch::new("wfedit");
let worker = r#"#!/bin/sh
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
let (env, _origin, _alpha) = dispatch_env(&s, &[("claude", worker)]);
let wf = s.0.join("fix.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.edit("greet", "verify", "Say hello in hello.txt for `{name}`.")
.when(#{ "@verify": ["passed"] })
.emit_note("record", #{ text: "verified: {name}" })
.output();
document(#{}, [
workflow("greet", graph, #{ caps: #{ max_units: 10, max_edits: 1 }}),
])
"#,
)
.unwrap();
env.ok(&["config", "workflow_budget", "20"]);
let out = env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
assert!(out.contains("!repo") || out.contains("repo"), "{out}");
env.ok(&["workflow", "activate", "1"]);
let out = env.ok(&["workflow", "run", "greet", "alpha"]);
assert!(out.contains("next: greet") && out.contains("edit"), "{out}");
assert!(
env.ok(&["review"]).contains("no runs to review"),
"no run yet: {out}"
);
let id = plan_id(&out);
let (out, err, success) = env.run(&[
"workflow",
"run",
"greet",
"--instance",
"1",
"--approve",
&id,
]);
assert!(success, "{err}\n{out}");
let review = env.ok(&["review"]);
assert!(
review.contains("alpha"),
"the run is in the queue: {review}"
);
let report = env.ok(&["report"]);
assert!(report.contains("0.10"), "the spend is recorded: {report}");
assert!(
env.ok(&["note"]).contains("verified: alpha"),
"the verdict routed the unit"
);
}
#[test]
fn a_local_unattended_instance_leaves_verified_edits_unstaged() {
let s = Scratch::new("wflocal");
let worker = r#"#!/bin/sh
case "$2" in
*"findings to "*)
out=$(printf '%s' "$2" | sed -n 's/.*findings to //p')
printf '[{"title":"greet"},{"title":"break"},{"title":"break2"},{"title":"append"}]' > "$out" ;;
*"Fix greet"*) echo hi > hello.txt; printf 'A\nb\nc\n\n' > notes.txt ;;
*"Fix append"*) echo more >> hello.txt ;;
*) rm -f hello.txt; echo x > other.txt ;;
esac
echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.1}'
"#;
let (env, _origin, alpha) = dispatch_env(&s, &[("claude", worker)]);
fs::write(alpha.join("local.txt"), "mine\n").unwrap();
fs::write(alpha.join("notes.txt"), "a\nb\nc\n\n").unwrap();
git(&alpha, &["add", "local.txt", "notes.txt"], None);
git(&alpha, &["commit", "-qm", "local"], None);
git(&alpha, &["remote", "remove", "origin"], None);
let head = git_out(&alpha, &["rev-parse", "HEAD"]);
let wf = s.0.join("review-fix.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.expand("review", "finding", 4, "Review `{name}`. Write findings to {out}")
.edit("fix", "verify", "Fix {title}.")
.output();
document(#{ finding: #{ fields: #{ title: req(line(200)) }}}, [
workflow("review-fix", graph, #{ caps: #{ max_units: 10, max_edits: 4 }}),
])
"#,
)
.unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["config", "max_parallel", "1"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let (out, err, success) = env.run(&[
"workflow",
"run",
"review-fix",
"alpha",
"--local",
"--unattended",
]);
assert!(success, "{err}\n{out}");
assert_eq!(
out.matches("approved unattended").count(),
2,
"the review plan, then the fix plan: {out}"
);
assert!(out.contains("nothing left to run"), "{out}");
assert!(out.contains("Fix greet.: applied"), "{out}");
assert!(
out.contains("Fix break.: discarded: verify failed"),
"{out}"
);
assert!(
out.contains("Fix break2.: discarded: verify failed"),
"{out}"
);
assert!(out.contains("Fix append.: applied"), "{out}");
assert!(!out.contains("refused"), "{out}");
assert_eq!(
fs::read_to_string(alpha.join("hello.txt")).unwrap(),
"hi\nmore\n"
);
assert!(!alpha.join("other.txt").exists());
assert_eq!(
fs::read_to_string(alpha.join("notes.txt")).unwrap(),
"A\nb\nc\n\n"
);
assert_eq!(
git_out(&alpha, &["status", "--porcelain"]),
" M notes.txt\n?? hello.txt\n"
);
assert_eq!(
git_out(&alpha, &["rev-parse", "HEAD"]),
head,
"nothing committed"
);
let review = env.ok(&["review"]);
assert!(!review.contains("fix"), "{review}");
}
fn wait_for_job(env: &Env, seconds: u64, done: impl Fn(&str) -> bool) -> String {
let until = std::time::Instant::now() + std::time::Duration::from_secs(seconds);
loop {
let jobs = env.ok(&["job"]);
if done(&jobs) {
return jobs;
}
assert!(std::time::Instant::now() < until, "timed out: {jobs}");
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
const GREET: &str = r#"
let graph = source("project")
.edit("greet", "verify", "Say hello in hello.txt for `{name}`.")
.output();
document(#{}, [
workflow("greet", graph, #{ caps: #{ max_units: 10, max_edits: 1 }}),
])
"#;
#[test]
fn a_detached_workflow_runs_to_the_end_in_the_background() {
let s = Scratch::new("detach");
let worker = r#"#!/bin/sh
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.1}'
"#;
let (env, _origin, alpha) = dispatch_env(&s, &[("claude", worker)]);
let wf = s.0.join("greet.rhai");
fs::write(&wf, GREET).unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let out = env.ok(&[
"workflow",
"run",
"greet",
"alpha",
"--local",
"--unattended",
"--detach",
]);
assert!(out.contains("job 1 queued"), "{out}");
let jobs = wait_for_job(&env, 60, |j| {
!j.contains("queued") && !j.contains("running")
});
assert!(jobs.contains("#1") && jobs.contains("done"), "{jobs}");
assert!(
jobs.contains("pma workflow run greet alpha --local --unattended"),
"the command, less --detach: {jobs}"
);
let log = env.ok(&["job", "log", "1"]);
assert!(log.contains("nothing left to run"), "{log}");
assert_eq!(fs::read_to_string(alpha.join("hello.txt")).unwrap(), "hi\n");
}
#[test]
fn stopping_a_running_job_kills_its_agent() {
let s = Scratch::new("jobstop");
let pidfile = s.0.join("agent.pid");
let worker = format!(
"#!/bin/sh\n\
echo '{{\"type\":\"assistant\",\"message\":{{\"content\":[{{\"type\":\"tool_use\",\"name\":\"Bash\",\"input\":{{\"command\":\"make test\"}}}}]}}}}'\n\
echo $$ > {}\nexec sleep 60\n",
pidfile.display()
);
let (env, _origin, _alpha) = dispatch_env(&s, &[("claude", &worker)]);
let wf = s.0.join("greet.rhai");
fs::write(&wf, GREET).unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
env.ok(&[
"workflow",
"run",
"greet",
"alpha",
"--local",
"--unattended",
"--detach",
]);
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
let pid = loop {
if let Ok(p) = fs::read_to_string(&pidfile)
&& !p.trim().is_empty()
{
break p.trim().to_string();
}
assert!(std::time::Instant::now() < until, "the agent never started");
std::thread::sleep(std::time::Duration::from_millis(100));
};
let alive = |pid: &str| {
command("kill")
.args(["-0", pid])
.status()
.unwrap()
.success()
};
assert!(alive(&pid));
let watch = env.ok(&["watch", "--once"]);
assert!(watch.contains("job #1 running"), "{watch}");
assert!(
watch.contains("1 tool calls") && watch.contains("Bash: make test"),
"{watch}"
);
let out = env.ok(&["job", "stop", "1"]);
assert!(out.contains("job 1 stopped"), "{out}");
let until = std::time::Instant::now() + std::time::Duration::from_secs(10);
while alive(&pid) {
assert!(
std::time::Instant::now() < until,
"the agent outlived its job"
);
std::thread::sleep(std::time::Duration::from_millis(100));
}
let jobs = wait_for_job(&env, 10, |j| j.contains("stopped"));
assert!(!jobs.contains("running"), "{jobs}");
}
#[test]
fn a_detached_job_waits_for_approval_and_notifies() {
let s = Scratch::new("jobapprove");
let worker = r#"#!/bin/sh
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.1}'
"#;
let (env, _origin, alpha) = dispatch_env(&s, &[("claude", worker)]);
let wf = s.0.join("greet.rhai");
fs::write(&wf, GREET).unwrap();
let heard = s.0.join("heard.log");
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&[
"config",
"notify",
&format!("echo \"$PMA_JOB_ID $PMA_JOB_STATE\" >> {}", heard.display()),
]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
env.ok(&[
"workflow", "run", "greet", "alpha", "--local", "-m", "haiku", "--detach",
]);
let jobs = wait_for_job(&env, 60, |j| j.contains("awaiting-approval"));
assert!(jobs.contains("pma job approve 1"), "{jobs}");
assert!(env.ok(&["job", "log", "1"]).contains("next: greet"));
assert!(!alpha.join("hello.txt").exists(), "nothing ran unapproved");
let out = env.ok(&["job", "approve", "1"]);
assert!(out.contains("job 2 queued"), "{out}");
assert!(
out.contains("-m haiku"),
"the plan was priced at haiku: {out}"
);
let (_, err, success) = env.run(&["job", "approve", "1"]);
assert!(!success && err.contains("not awaiting approval"), "{err}");
let jobs = wait_for_job(&env, 60, |j| j.contains("#2") && j.contains("done"));
assert!(jobs.contains("approved"), "job 1 is settled: {jobs}");
assert_eq!(fs::read_to_string(alpha.join("hello.txt")).unwrap(), "hi\n");
let log = env.ok(&["job", "log", "2"]);
assert!(log.contains("nothing left to run"), "{log}");
assert_eq!(
fs::read_to_string(&heard).unwrap(),
"1 awaiting-approval\n2 done\n"
);
}
struct Killed(std::process::Child);
impl Drop for Killed {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn http(port: u16, raw: &str) -> String {
use std::io::{Read, Write};
let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
s.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.unwrap();
s.write_all(raw.as_bytes()).unwrap();
let mut out = String::new();
let _ = s.read_to_string(&mut out);
out
}
fn post(port: u16, path: &str, origin: &str, cookie: &str, body: &str) -> String {
http(
port,
&format!(
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nOrigin: {origin}\r\n\
Content-Type: application/json\r\nCookie: {cookie}\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
),
)
}
#[test]
fn the_web_view_is_local_and_shares_the_store() {
use std::io::{BufRead, BufReader, Read};
let s = Scratch::new("web");
let env = Env {
home: s.0.join("home"),
bin: s.0.join("bin"),
push_log: s.0.join("push.log"),
};
let mut child = env
.command(&["web", "--no-open"])
.stdout(std::process::Stdio::piped())
.spawn()
.map(Killed)
.unwrap();
let mut first = String::new();
BufReader::new(child.0.stdout.take().unwrap())
.read_line(&mut first)
.unwrap();
let url = first.trim().strip_prefix("pma web: ").expect("the address");
let rest = url
.strip_prefix("http://127.0.0.1:")
.expect("loopback only");
let (port, token) = rest.split_once("/#").expect("the token in the fragment");
let port: u16 = port.parse().unwrap();
let origin = format!("http://127.0.0.1:{port}");
let get = |path: &str, host: &str, cookie: &str| {
http(
port,
&format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nCookie: {cookie}\r\n\r\n"),
)
};
let host = format!("127.0.0.1:{port}");
let page = get("/", &host, "");
assert!(page.starts_with("HTTP/1.1 200"), "{page}");
assert!(page.contains("Content-Security-Policy: default-src 'none'; script-src 'self'"));
assert!(!page.contains(token), "the page holds no secret");
assert!(get("/", &format!("evil.example:{port}"), "").starts_with("HTTP/1.1 421"));
assert!(get("/api/keys", &host, "").starts_with("HTTP/1.1 403"));
assert!(get("/api/keys", &host, "pma_session=nope").starts_with("HTTP/1.1 403"));
let ok = format!("{{\"token\":\"{token}\"}}");
assert!(post(port, "/session", "http://evil.example", "", &ok).starts_with("HTTP/1.1 403"));
assert!(
post(port, "/session", &origin, "", "{\"token\":\"0000\"}").starts_with("HTTP/1.1 403")
);
let session = post(port, "/session", &origin, "", &ok);
assert!(session.starts_with("HTTP/1.1 204"), "{session}");
assert!(session.contains("HttpOnly; SameSite=Strict"), "{session}");
let cookie = format!("pma_session={token}");
assert!(get("/api/keys", "127.0.0.1:9000", &cookie).starts_with("HTTP/1.1 200"));
let forwarded = http(
port,
&format!(
"POST /session HTTP/1.1\r\nHost: localhost:9000\r\nOrigin: http://localhost:9000\r\n\
Content-Type: application/json\r\nContent-Length: {}\r\n\r\n{ok}",
ok.len()
),
);
assert!(forwarded.starts_with("HTTP/1.1 204"), "{forwarded}");
let crossed = http(
port,
&format!(
"POST /session HTTP/1.1\r\nHost: 127.0.0.1:9000\r\nOrigin: {origin}\r\n\
Content-Type: application/json\r\nContent-Length: {}\r\n\r\n{ok}",
ok.len()
),
);
assert!(crossed.starts_with("HTTP/1.1 403"), "{crossed}");
let keys = get("/api/keys", &host, &cookie);
assert!(keys.contains("\"AddNote\""), "{keys}");
let note =
r#"{"tab":"notes","act":"AddNote","pick":{"kind":"nothing"},"input":"from the web"}"#;
assert!(
post(port, "/api/act", "http://evil.example", &cookie, note).starts_with("HTTP/1.1 403")
);
let plain = http(
port,
&format!(
"POST /api/act HTTP/1.1\r\nHost: {host}\r\nOrigin: {origin}\r\nCookie: {cookie}\r\n\
Content-Type: text/plain\r\nContent-Length: {}\r\n\r\n{note}",
note.len()
),
);
assert!(plain.starts_with("HTTP/1.1 415"), "{plain}");
let acted = post(port, "/api/act", &origin, &cookie, note);
assert!(acted.contains("\"said\":[\"#1\"]"), "{acted}");
assert!(
env.ok(&["note"]).contains("from the web"),
"the CLI sees it"
);
let notes = get("/api/screen?tab=notes", &host, &cookie);
assert!(
notes.contains("from the web") && notes.contains("\"kind\":\"note\""),
"{notes}"
);
let ask = post(
port,
"/api/ask",
&origin,
&cookie,
r#"{"tab":"runs","act":"RejectRun","pick":{"kind":"nothing"}}"#,
);
assert!(
ask.starts_with("HTTP/1.1 400") && ask.contains("select a run first"),
"{ask}"
);
let runs = get("/api/screen?tab=runs", &host, &cookie);
assert!(
runs.contains("Runs not yet final") && runs.contains("\"items\":[]"),
"{runs}"
);
let mut events = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
events
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.unwrap();
std::io::Write::write_all(
&mut events,
format!("GET /api/events HTTP/1.1\r\nHost: {host}\r\nCookie: {cookie}\r\n\r\n").as_bytes(),
)
.unwrap();
let mut buf = [0u8; 2048];
let mut got = String::new();
while !got.contains("\n\n") || !got.contains("event: tick") {
let n = events.read(&mut buf).unwrap();
assert!(n > 0, "the stream closed: {got}");
got.push_str(&String::from_utf8_lossy(&buf[..n]));
}
assert!(got.contains("text/event-stream"), "{got}");
drop(child);
}
#[test]
fn the_web_view_listens_on_the_port_named() {
use std::io::{BufRead, BufReader};
let s = Scratch::new("webport");
let env = Env {
home: s.0.join("home"),
bin: s.0.join("bin"),
push_log: s.0.join("push.log"),
};
let free = std::net::TcpListener::bind(("127.0.0.1", 0))
.unwrap()
.local_addr()
.unwrap()
.port();
let mut child = env
.command(&["web", "--no-open", "--port", &free.to_string()])
.stdout(std::process::Stdio::piped())
.spawn()
.map(Killed)
.unwrap();
let mut first = String::new();
BufReader::new(child.0.stdout.take().unwrap())
.read_line(&mut first)
.unwrap();
assert!(
first.starts_with(&format!("pma web: http://127.0.0.1:{free}/#")),
"{first}"
);
let page = http(
free,
&format!("GET / HTTP/1.1\r\nHost: 127.0.0.1:{free}\r\n\r\n"),
);
assert!(page.starts_with("HTTP/1.1 200"), "{page}");
drop(child);
let (_, err, success) = env.run(&["web", "--no-open", "--app"]);
assert!(!success && err.contains("--app"), "{err}");
}
fn start_web(env: &Env) -> (Killed, u16, String) {
use std::io::{BufRead, BufReader};
let mut child = env
.command(&["web", "--no-open"])
.stdout(std::process::Stdio::piped())
.spawn()
.map(Killed)
.unwrap();
let mut first = String::new();
BufReader::new(child.0.stdout.take().unwrap())
.read_line(&mut first)
.unwrap();
let rest = first
.trim()
.strip_prefix("pma web: http://127.0.0.1:")
.expect("the address");
let (port, token) = rest.split_once("/#").unwrap();
let port: u16 = port.parse().unwrap();
let signed = post(
port,
"/session",
&format!("http://127.0.0.1:{port}"),
"",
&format!("{{\"token\":\"{token}\"}}"),
);
assert!(signed.starts_with("HTTP/1.1 204"), "{signed}");
(child, port, format!("pma_session={token}"))
}
fn web(
port: u16,
cookie: &str,
path: &str,
body: Option<serde_json::Value>,
) -> (u16, serde_json::Value) {
let raw = match body {
None => http(
port,
&format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nCookie: {cookie}\r\n\r\n"),
),
Some(b) => post(
port,
path,
&format!("http://127.0.0.1:{port}"),
cookie,
&b.to_string(),
),
};
let status = raw[9..12].parse().unwrap();
let body = raw.split_once("\r\n\r\n").map_or("", |(_, b)| b);
(
status,
serde_json::from_str(body).unwrap_or(serde_json::Value::Null),
)
}
fn first_pick(port: u16, cookie: &str, tab: &str) -> serde_json::Value {
let (status, screen) = web(port, cookie, &format!("/api/screen?tab={tab}"), None);
assert_eq!(status, 200, "{screen}");
screen["body"]["panes"][0]["items"][0]["pick"].clone()
}
#[test]
fn front_ends_racing_on_one_run_act_only_on_what_they_were_shown() {
let s = Scratch::new("racerun");
let (env, _origin, _alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["dispatch", "alpha:5"]);
let (child, port, cookie) = start_web(&env);
let seen = first_pick(port, &cookie, "runs");
assert_eq!(seen["state"], "ready", "{seen}");
env.ok(&["review", "1", "--approve"]);
let reject = |pick: &serde_json::Value| {
web(
port,
&cookie,
"/api/act",
Some(serde_json::json!({ "tab": "runs", "act": "RejectRun", "pick": pick })),
)
};
let (status, body) = reject(&seen);
assert_eq!(status, 400);
assert!(
body["error"]
.as_str()
.unwrap()
.contains("is now approved, not ready"),
"{body}"
);
assert!(
env.ok(&["review"]).contains("#1 approved"),
"nothing changed"
);
let fresh = first_pick(port, &cookie, "runs");
assert_eq!(fresh["state"], "approved");
let (status, body) = reject(&fresh);
assert_eq!(status, 200, "{body}");
let (_, err, success) = env.run(&["review", "1", "--approve"]);
assert!(!success && err.contains("rejected"), "{err}");
drop(child);
}
#[test]
fn front_ends_racing_on_one_plan_approve_it_once() {
let s = Scratch::new("raceplan");
let worker = r#"#!/bin/sh
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.1}'
"#;
let (env, _origin, alpha) = dispatch_env(&s, &[("claude", worker)]);
let wf = s.0.join("greet.rhai");
fs::write(&wf, GREET).unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
env.ok(&[
"workflow", "run", "greet", "alpha", "--local", "-m", "haiku", "--detach",
]);
wait_for_job(&env, 60, |j| j.contains("awaiting-approval"));
let log = env.ok(&["job", "log", "1"]);
let at = log.find("\nplan ").expect("a plan") + "\nplan ".len();
let plan: String = log[at..]
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect();
let (child, port, cookie) = start_web(&env);
let job = first_pick(port, &cookie, "jobs");
assert_eq!(job["awaiting"], true, "{job}");
let (status, body) = web(
port,
&cookie,
"/api/act",
Some(serde_json::json!({ "tab": "jobs", "act": "ApproveJob", "pick": job })),
);
assert_eq!(status, 200, "{body}");
let (_, err, success) = env.run(&["job", "approve", "1"]);
assert!(!success && err.contains("not awaiting approval"), "{err}");
wait_for_job(&env, 60, |j| j.contains("#2") && j.contains("done"));
assert_eq!(fs::read_to_string(alpha.join("hello.txt")).unwrap(), "hi\n");
let (_, err, success) = env.run(&[
"workflow",
"run",
"greet",
"--instance",
"1",
"-m",
"haiku",
"--approve",
&plan,
]);
assert!(!success, "a spent plan runs again: {err}");
drop(child);
}
#[test]
fn the_web_understands_every_act_in_the_key_table() {
let s = Scratch::new("webacts");
let env = Env {
home: s.0.join("home"),
bin: s.0.join("bin"),
push_log: s.0.join("push.log"),
};
let (child, port, cookie) = start_web(&env);
let (_, keys) = web(port, &cookie, "/api/keys", None);
let mut asked = 0;
for b in keys["bindings"].as_array().unwrap() {
if b["command"]["kind"] != "act" {
continue;
}
let tab = b["tabs"][0].as_str().unwrap_or("next");
let (status, body) = web(
port,
&cookie,
"/api/ask",
Some(serde_json::json!({
"tab": tab,
"act": b["command"]["act"],
"pick": { "kind": "nothing" },
})),
);
assert!(
status == 200 || body["error"].is_string(),
"{}: {status} {body}",
b["command"]["act"]
);
asked += 1;
}
assert!(asked >= 21, "{asked}");
drop(child);
}
#[test]
fn a_project_shows_its_state_tasks_and_runs() {
let s = Scratch::new("projectshow");
let (env, _origin, _alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
env.ok(&["dispatch", "alpha:5"]);
let out = env.ok(&["project", "show", "alpha"]);
assert!(out.starts_with("alpha "), "{out}");
assert!(
out.contains("Open tasks:") && out.contains("add greeting"),
"{out}"
);
assert!(out.contains("Runs: 1") && out.contains("#1"), "{out}");
let (_, err, success) = env.run(&["project", "show", "nope"]);
assert!(!success && err.contains("unknown project `nope`"), "{err}");
}
#[test]
fn settings_toml_is_the_configuration() {
let s = Scratch::new("settingstoml");
let env = Env {
home: s.0.join("home"),
bin: s.0.join("bin"),
push_log: s.0.join("push.log"),
};
let file = env.home.join("settings.toml");
env.ok(&["config", "agent_budget", "3"]);
let text = fs::read_to_string(&file).unwrap();
assert!(text.contains("[config]\nagent_budget = 3\n"), "{text}");
fs::write(
&file,
text.replace("agent_budget = 3", "# mine\nagent_budget = 4"),
)
.unwrap();
assert_eq!(env.ok(&["config", "agent_budget"]).trim(), "4");
env.ok(&["config", "max_parallel", "1"]);
let text = fs::read_to_string(&file).unwrap();
assert!(text.contains("# mine\nagent_budget = 4\n"), "{text}");
assert!(text.contains("max_parallel = 1"), "{text}");
fs::write(&file, text.replace("max_parallel = 1", "max_parallel = 0")).unwrap();
let (_, err, success) = env.run(&["job"]);
assert!(!success && err.contains("config.max_parallel"), "{err}");
}
#[test]
fn changelog_entries_are_drafted_beside_the_change() {
let s = Scratch::new("changelog");
let worker = r#"#!/bin/sh
case "$2" in
*"findings to "*)
out=$(printf '%s' "$2" | sed -n 's/.*findings to //p')
printf '[{"title":"drafted"},{"title":"silent"}]' > "$out" ;;
*"Fix drafted"*".pma/changelog.md"*)
echo hi > hello.txt; mkdir -p .pma; printf '**Greets.** It said nothing.\n' > .pma/changelog.md ;;
*) echo more >> hello.txt ;;
esac
echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.1}'
"#;
let (env, _origin, alpha) = dispatch_env(&s, &[("claude", worker)]);
fs::write(alpha.join("CHANGELOG.md"), "# Changelog\n").unwrap();
git(&alpha, &["add", "CHANGELOG.md"], None);
git(&alpha, &["commit", "-qm", "changelog"], None);
let wf = s.0.join("review-fix.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.expand("review", "finding", 2, "Review `{name}`. Write findings to {out}")
.edit("fix", "verify", "Fix {title}.")
.output();
document(#{ finding: #{ fields: #{ title: req(line(200)) }}}, [
workflow("review-fix", graph, #{ caps: #{ max_units: 10, max_edits: 2 }}),
])
"#,
)
.unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["config", "max_parallel", "1"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let out = env.ok(&[
"workflow",
"run",
"review-fix",
"alpha",
"--local",
"--unattended",
]);
assert!(out.contains("Fix drafted.: applied"), "{out}");
assert!(out.contains("unstaged; changelog entries in"), "{out}");
assert!(
out.contains("Fix silent.: applied")
&& out.contains("unstaged; no changelog entry drafted"),
"flagged, not discarded: {out}"
);
assert_eq!(
git_out(&alpha, &["status", "--porcelain", "--untracked-files=all"]),
"?? hello.txt\n",
"the draft is not part of the change"
);
let detail = env.ok(&["review", "2"]);
assert!(
detail.contains("changelog:\n **Greets.** It said nothing.\n"),
"{detail}"
);
assert!(
env.ok(&["review", "3"]).contains("changelog: none drafted"),
"the run that drafted none says so"
);
}
#[test]
fn a_foreground_approval_settles_the_waiting_job() {
let s = Scratch::new("jobfgapprove");
let worker = r#"#!/bin/sh
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.1}'
"#;
let (env, _origin, alpha) = dispatch_env(&s, &[("claude", worker)]);
let wf = s.0.join("greet.rhai");
fs::write(&wf, GREET).unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
env.ok(&[
"workflow", "run", "greet", "alpha", "--local", "-m", "haiku", "--detach",
]);
wait_for_job(&env, 60, |j| j.contains("awaiting-approval"));
let log = env.ok(&["job", "log", "1"]);
assert!(log.contains("Approve it with `pma job approve 1`"), "{log}");
let at = log.find("\nplan ").expect("a plan") + "\nplan ".len();
let plan: String = log[at..]
.chars()
.take_while(|c| c.is_ascii_hexdigit())
.collect();
env.ok(&[
"workflow",
"run",
"greet",
"--instance",
"1",
"-m",
"haiku",
"--approve",
&plan,
]);
assert_eq!(fs::read_to_string(alpha.join("hello.txt")).unwrap(), "hi\n");
let jobs = env.ok(&["job"]);
assert!(
jobs.contains("approved") && !jobs.contains("awaiting"),
"{jobs}"
);
let (_, err, success) = env.run(&["job", "approve", "1"]);
assert!(!success && err.contains("not awaiting approval"), "{err}");
}
#[test]
fn an_instance_may_not_exceed_its_edit_cap() {
let s = Scratch::new("wfcap");
let worker = r#"#!/bin/sh
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
let (env, _origin, _alpha) = dispatch_env(&s, &[("claude", worker)]);
let wf = s.0.join("none.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.edit("greet", "verify", "Say hello in hello.txt for `{name}`.")
.output();
document(#{}, [
workflow("greet", graph, #{ caps: #{ max_units: 10, max_edits: 0 }}),
])
"#,
)
.unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let id = plan_id(&env.ok(&["workflow", "run", "greet", "alpha", "--dry-run"]));
let (_, err, success) = env.run(&["workflow", "run", "greet", "alpha", "--approve", &id]);
assert!(!success, "an instance with no edit budget must not edit");
assert!(err.contains("max_edits"), "{err}");
assert!(
env.ok(&["review"]).contains("no runs to review"),
"no run was made"
);
}
#[test]
fn a_plan_id_names_its_target_and_arguments() {
let s = Scratch::new("wfplanid");
let (env, _origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let root = alpha.parent().unwrap();
git(root, &["clone", "-q", "../origin.git", "beta"], None);
env.ok(&["project", "tier", "1", "beta"]);
env.ok(&["scan", "--offline"]);
let wf = s.0.join("greet.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.edit("greet", "verify", "Say {$word} in hello.txt for `{name}`.")
.output();
document(#{}, [
workflow("greet", graph, #{
params: #{ word: param("line", "hello") },
caps: #{ max_units: 10, max_edits: 10 },
}),
])
"#,
)
.unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let dry = |args: &[&str]| {
let mut all = vec!["workflow", "run", "greet"];
all.extend_from_slice(args);
all.push("--dry-run");
plan_id(&env.ok(&all))
};
let alpha_id = dry(&["alpha"]);
assert_ne!(alpha_id, dry(&["beta"]));
assert_ne!(alpha_id, dry(&["alpha", "--set", "word=bye"]));
assert_eq!(alpha_id, dry(&["alpha"]), "the same plan has the same id");
let (_, err, success) = env.run(&["workflow", "run", "greet", "beta", "--approve", &alpha_id]);
assert!(
!success && err.contains("is not the plan for this pass"),
"{err}"
);
assert!(
env.ok(&["review"]).contains("no runs to review"),
"no run was made"
);
}
#[test]
fn a_target_yields_the_type_the_workflow_reads() {
let s = Scratch::new("targets");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] first thing\n- [ ] second thing\n\n## Low\n\n- [ ] later\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let wf = s.0.join("items.rhai");
fs::write(
&wf,
r#"
let graph = source("item")
.emit_note("record", #{ text: "{$label}: {text}" })
.output();
document(#{}, [
workflow("log", graph, #{
params: #{ label: param("line", "item") },
caps: #{ max_units: 20, max_edits: 0 },
}),
])
"#,
)
.unwrap();
ok(&home, &["workflow", "propose", wf.to_str().unwrap()]);
ok(&home, &["workflow", "activate", "1"]);
let (_, err, success) = pma_in(&home, &["workflow", "run", "log", "alpha"]);
assert!(
!success && err.contains("reads `item`") && err.contains("yields `project`"),
"{err}"
);
let (_, err, success) = pma_in(&home, &["workflow", "run", "log", "alpha:q1"]);
assert!(!success && err.contains("not an argument"), "{err}");
let (_, err, success) = pma_in(
&home,
&["workflow", "run", "log", "alpha:high", "--set", "depth=2"],
);
assert!(
!success && err.contains("declares no parameter `depth`"),
"{err}"
);
let out = ok(
&home,
&[
"workflow",
"run",
"log",
"alpha:high",
"--set",
"label=seen",
],
);
assert!(out.contains("2 unit(s)"), "{out}");
let notes = ok(&home, &["note"]);
assert!(notes.contains("seen: first thing"), "{notes}");
assert!(notes.contains("seen: second thing"), "{notes}");
assert!(
!notes.contains("later"),
"the Low heading was not named: {notes}"
);
let out = ok(&home, &["workflow", "run", "log", "alpha:10"]);
assert!(out.contains("1 unit(s)"), "{out}");
assert!(
ok(&home, &["note"]).contains("item: later"),
"the default label"
);
}
#[test]
fn a_sink_refuses_a_unit_alone_and_does_not_repeat_what_it_wrote() {
let s = Scratch::new("sink");
let home = s.0.join("home");
let root = s.0.join("root");
let good = "# TODO\n\n## Critical\n\n## High\n\n- [ ] a thing\n\n## Medium\n\n## Low\n";
for (name, text) in [("alpha", good), ("beta", "## High\n")] {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q"], None);
fs::write(dir.join("TODO.md"), text).unwrap();
git(&dir, &["add", "."], None);
git(&dir, &["commit", "-qm", "init"], None);
}
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let wf = s.0.join("stamp.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.emit_todo("stamp", "add", #{ text: "reviewed {name}", priority: "high" })
.output();
document(#{}, [
workflow("stamp", graph, #{ caps: #{ max_units: 20, max_edits: 0 }}),
])
"#,
)
.unwrap();
ok(&home, &["workflow", "propose", wf.to_str().unwrap()]);
ok(&home, &["workflow", "activate", "1"]);
let (out, err, success) = pma_in(&home, &["workflow", "run", "stamp", "alpha", "beta"]);
assert!(success, "{err}");
assert!(
err.contains("refused: stamp: u2") && err.contains("lint errors"),
"{err}"
);
assert!(out.contains("nothing left to run"), "{out}");
let alpha = root.join("alpha").join("TODO.md");
let written = fs::read_to_string(&alpha).unwrap();
assert_eq!(
written.matches("reviewed alpha").count(),
1,
"the first project's write committed: {written}"
);
assert!(
written.contains("- [ ] reviewed alpha ^"),
"pma wrote the line, so it gave the item an id: {written}"
);
assert!(
!fs::read_to_string(root.join("beta").join("TODO.md"))
.unwrap()
.contains("reviewed beta"),
"a refused unit writes nothing"
);
let out = ok(&home, &["workflow", "run", "stamp", "--instance", "1"]);
assert!(out.contains("nothing left to run"), "{out}");
assert_eq!(
fs::read_to_string(&alpha)
.unwrap()
.matches("reviewed alpha")
.count(),
1,
"still once"
);
fs::write(
root.join("beta").join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] something\n",
)
.unwrap();
ok(&home, &["workflow", "run", "stamp", "alpha", "beta"]);
assert_eq!(
fs::read_to_string(&alpha)
.unwrap()
.matches("reviewed alpha")
.count(),
1,
"an item already open is not added again"
);
assert!(
fs::read_to_string(root.join("beta").join("TODO.md"))
.unwrap()
.contains("reviewed beta")
);
}
#[test]
fn a_call_around_a_rule_only_callee_runs_as_one_graph() {
let s = Scratch::new("compose");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] first thing\n- [ ] second thing\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let wf = s.0.join("compose.rhai");
fs::write(
&wf,
r#"
let inner = source("project")
.rule_expand("items", "item", "todo-items", 50)
.output();
let outer = source("project")
.invoke("listing", "inner", #{}, "item")
.emit_note("record", #{ text: "{$tag}: {text}" })
.output();
document(#{}, [
workflow("inner", inner, #{ caps: #{ max_units: 50, max_edits: 0 }}),
workflow("outer", outer, #{
params: #{ tag: param("line", "open") },
caps: #{ max_units: 50, max_edits: 0 },
effects: ["writes"],
}),
])
"#,
)
.unwrap();
let out = ok(&home, &["workflow", "propose", wf.to_str().unwrap()]);
assert!(
out.contains("at most $0.00"),
"a graph of rules is free: {out}"
);
assert!(out.contains("listing/items"), "{out}");
ok(&home, &["workflow", "activate", "1"]);
let out = ok(
&home,
&["workflow", "run", "outer", "alpha", "--set", "tag=todo"],
);
assert!(out.contains("nothing left to run"), "{out}");
let notes = ok(&home, &["note"]);
assert!(notes.contains("todo: first thing"), "{notes}");
assert!(notes.contains("todo: second thing"), "{notes}");
}
#[test]
fn a_check_that_cannot_run_is_unknown_and_takes_the_default_edge() {
let s = Scratch::new("verdict");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] a thing\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let wf = s.0.join("verdicts.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.check("lint", "lint-todo")
.when(#{ "@lint-todo": ["passed"] })
.check("merged", "pr-merged")
.otherwise(|g| g.emit_note("waiting", #{ text: "waiting: {name}" }))
.when(#{ "@pr-merged": ["passed"] })
.emit_note("record", #{ text: "merged: {name}" })
.output();
document(#{}, [
workflow("gate", graph, #{ caps: #{ max_units: 10, max_edits: 0 }}),
])
"#,
)
.unwrap();
ok(&home, &["workflow", "propose", wf.to_str().unwrap()]);
ok(&home, &["workflow", "activate", "1"]);
let out = ok(&home, &["workflow", "run", "gate", "alpha"]);
assert!(out.contains("nothing left to run"), "{out}");
let notes = ok(&home, &["note"]);
assert!(notes.contains("waiting: alpha"), "{notes}");
assert!(!notes.contains("merged: alpha"), "{notes}");
}
#[test]
fn a_reduce_applies_the_rule_it_names() {
let s = Scratch::new("reduce");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] first\n- [ ] second\n- [ ] third\n\n## Low\n\n- [ ] later\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], None);
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let write = |file: &str, rule: &str| {
let path = s.0.join(file);
fs::write(
&path,
format!(
r#"
let graph = source("project")
.rule_expand("items", "item", "todo-items", 50)
.join_by("pick", ["priority"], "{rule}")
.emit_note("record", #{{ text: "kept: {{text}}" }})
.output();
document(#{{}}, [
workflow("pick", graph, #{{ caps: #{{ max_units: 50, max_edits: 0 }}}}),
])
"#
),
)
.unwrap();
path
};
ok(
&home,
&[
"workflow",
"propose",
write("a.rhai", "limit:1").to_str().unwrap(),
],
);
ok(&home, &["workflow", "activate", "1"]);
ok(&home, &["workflow", "run", "pick", "alpha"]);
let notes = ok(&home, &["note"]);
assert_eq!(notes.matches("kept:").count(), 2, "{notes}");
assert!(
notes.contains("kept: later"),
"the Low group kept one: {notes}"
);
ok(
&home,
&[
"workflow",
"propose",
write("b.rhai", "limit:2").to_str().unwrap(),
],
);
ok(&home, &["workflow", "activate", "2"]);
ok(&home, &["workflow", "run", "pick", "alpha"]);
let notes = ok(&home, &["note"]);
assert_eq!(
notes.matches("kept:").count(),
5,
"two more High, one more Low: {notes}"
);
let bad = write("c.rhai", "limit:0");
let (_, err, success) = pma_in(&home, &["workflow", "check", bad.to_str().unwrap()]);
assert!(!success && err.contains("a count of 1 or more"), "{err}");
}
#[test]
fn agent_runs_go_in_parallel_and_the_batch_budget_bounds_the_pass() {
let s = Scratch::new("parallel");
let home = s.0.join("home");
let root = s.0.join("root");
for name in ["alpha", "beta"] {
let dir = root.join(name);
fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q"], None);
fs::write(dir.join("TODO.md"), "# TODO\n\n## High\n\n- [ ] a thing\n").unwrap();
git(&dir, &["add", "."], None);
git(&dir, &["commit", "-qm", "init"], None);
}
let bin = s.0.join("bin");
fs::create_dir_all(&bin).unwrap();
let log = s.0.join("order.log");
fs::write(
bin.join("claude"),
format!(
"#!/bin/sh\n\
echo start >> {0}\n\
sleep 1\n\
out=$(printf '%s' \"$2\" | sed -n 's/.*findings to //p')\n\
printf '[{{\"title\":\"a finding\"}}]' > \"$out\"\n\
echo end >> {0}\n\
printf '{{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"ok\",\"total_cost_usd\":0.1}}\\n'\n",
log.display()
),
)
.unwrap();
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(bin.join("claude"), fs::Permissions::from_mode(0o755)).unwrap();
}
let run = |args: &[&str]| {
let path = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
let out = command(env!("CARGO_BIN_EXE_pma"))
.env("PMA_HOME", &home)
.env("PATH", path)
.args(args)
.output()
.unwrap();
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.success(),
)
};
run(&["root", "add", root.to_str().unwrap()]);
run(&["scan", "--offline"]);
let wf = s.0.join("review.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.expand("review", "finding", 3, "Review `{name}`. Write findings to {out}")
.output();
document(#{ finding: #{ fields: #{ title: req(line(200)) }}}, [
workflow("review", graph, #{ caps: #{ max_units: 10, max_edits: 0 }}),
])
"#,
)
.unwrap();
run(&["workflow", "propose", wf.to_str().unwrap()]);
run(&["workflow", "activate", "1"]);
run(&["config", "max_parallel", "2"]);
let id = plan_id(&run(&["workflow", "run", "review", "alpha", "beta", "--dry-run"]).0);
let (out, err, success) = run(&[
"workflow",
"run",
"review",
"alpha",
"beta",
"--approve",
&id,
]);
assert!(success, "{err}\n{out}");
let order: Vec<String> = fs::read_to_string(&log)
.unwrap()
.lines()
.map(str::to_string)
.collect();
assert_eq!(order.len(), 4, "two runs, bracketed: {order:?}");
assert_eq!(
&order[..2],
["start", "start"],
"the second run started before the first finished: {order:?}"
);
fs::remove_file(&log).unwrap();
run(&["config", "batch_budget", "1"]);
let dry = run(&["workflow", "run", "review", "alpha", "beta", "--dry-run"]).0;
assert!(
dry.contains("1 unit(s)"),
"the plan holds what the budget admits: {dry}"
);
let (out, err, success) = run(&[
"workflow",
"run",
"review",
"alpha",
"beta",
"--approve",
&plan_id(&dry),
]);
assert!(success, "{err}\n{out}");
assert!(out.contains("next: review"), "the rest is priced: {out}");
assert_eq!(
fs::read_to_string(&log).unwrap().matches("start").count(),
1,
"the batch budget admitted one run"
);
let (out, err, success) = run(&[
"workflow",
"run",
"review",
"--instance",
"2",
"--approve",
&plan_id(&out),
]);
assert!(success, "{err}\n{out}");
assert!(out.contains("nothing left to run"), "{out}");
assert_eq!(
fs::read_to_string(&log).unwrap().matches("start").count(),
2,
"the next pass took the other"
);
}
const REPLACING_CLAUDE: &str = r#"#!/bin/sh
printf '#!/bin/sh\ntouch "%s"\n' "$PMA_HOME/../pwned" > "$PMA_HOME/../payload"
chmod +x "$PMA_HOME/../payload"
echo hi > hello.txt
rm .git
git init -q .
git config core.fsmonitor "$PMA_HOME/../payload"
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
#[test]
fn a_replaced_git_dir_is_refused_before_pma_runs_git_in_it() {
let s = Scratch::new("gitdir");
let (env, _, alpha) = dispatch_env(&s, &[("claude", REPLACING_CLAUDE)]);
let out = env.ok(&["dispatch", "alpha:5"]);
assert!(out.contains("0 ready, 1 failed"), "{out}");
let (out, err, _) = env.run(&["review", "1"]);
assert!(
format!("{out}{err}").contains("is not the file `git worktree add` wrote"),
"{out}{err}"
);
env.ok(&["review", "1", "--reject"]);
assert!(!env.home.join("state/worktrees/alpha/add-greeting").exists());
assert_eq!(git_out(&alpha, &["branch", "--list", "pma/*"]), "");
assert!(!s.0.join("pwned").exists(), "the monitor ran");
}
#[test]
fn a_hook_added_during_a_run_stops_its_push() {
use std::os::unix::fs::PermissionsExt;
let s = Scratch::new("hooks");
let (env, origin, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let own = alpha.join(".git/hooks/post-commit");
fs::write(
&own,
format!("#!/bin/sh\necho ran >> {}\n", s.0.join("own.log").display()),
)
.unwrap();
fs::set_permissions(&own, fs::Permissions::from_mode(0o755)).unwrap();
let (mark, hooked) = (s.0.join("mark"), s.0.join("hooked"));
let verify = format!(
"if [ -e {mark} ]; then h=\"$(git rev-parse --git-common-dir)/hooks/pre-push\"; \
printf '#!/bin/sh\\ntouch {hooked}\\n' > \"$h\"; chmod +x \"$h\"; fi; test -f hello.txt",
mark = mark.display(),
hooked = hooked.display()
);
env.ok(&["config", "projects.alpha.verify", &verify]);
env.ok(&["dispatch", "alpha:5"]);
env.ok(&["review", "1", "--approve"]);
assert!(env.ok(&["push", "1"]).contains("#1 alpha: pushed "));
assert!(s.0.join("own.log").exists(), "the user's hook did not run");
env.ok(&["dispatch", "alpha:7"]);
env.ok(&["review", "2", "--approve"]);
fs::write(&mark, "").unwrap();
let (out, _, success) = env.run(&["push", "2"]);
assert!(!success, "{out}");
assert!(
out.contains("hooks or git settings changed since run #2 was dispatched")
&& out.contains(" + hook pre-push "),
"{out}"
);
assert!(!hooked.exists(), "the added hook ran");
assert_eq!(
git_out(&origin, &["log", "--format=%s", "main"]),
"add greeting\ninit\n"
);
fs::remove_file(&mark).unwrap();
fs::remove_file(alpha.join(".git/hooks/pre-push")).unwrap();
assert!(env.ok(&["push", "2"]).contains("#2 alpha: pushed "));
}
const FILTERING_CLAUDE: &str = r#"#!/bin/sh
echo hi > hello.txt
git config filter.x.clean "touch MARKER; cat"
echo '* filter=x' > .gitattributes
hook="$(git rev-parse --git-common-dir)/hooks/pre-commit"
printf '#!/bin/sh\ntouch MARKER\n' > "$hook"
chmod +x "$hook"
cp "$hook" MARKER.sh
git config core.fsmonitor MARKER.sh
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
#[test]
fn a_git_setting_added_by_the_agent_fails_the_run_before_pma_runs_git() {
let s = Scratch::new("filter");
let marker = s.0.join("filtered");
let agent = FILTERING_CLAUDE.replace("MARKER", marker.to_str().unwrap());
let (env, _, alpha) = dispatch_env(&s, &[("claude", &agent)]);
env.ok(&["config", "projects.alpha.verify", "none"]);
let out = env.ok(&["dispatch", "alpha:5"]);
assert!(out.contains("0 ready, 1 failed"), "{out}");
let (out, err, _) = env.run(&["review", "1"]);
let shown = format!("{out}{err}");
assert!(
shown.contains("hooks or git settings changed since run #1 was dispatched")
&& shown.contains(" + config filter.x.clean ")
&& shown.contains(" + hook pre-commit "),
"{shown}"
);
let (_, err, ok) = env.run(&["review", "1", "--approve"]);
assert!(!ok, "a failed run is not approved: {err}");
let (_, err, ok) = env.run(&["dispatch", "alpha:7"]);
assert!(!ok && err.contains("and they are still in"), "{err}");
let (_, err, _) = env.run(&["verify", "alpha"]);
assert!(err.contains("and they are still in"), "{err}");
let (_, err, ok) = env.run(&["review", "1", "--reject"]);
assert!(!ok && err.contains("Restore them first"), "{err}");
let (_, err, ok) = env.run(&["scan", "--offline"]);
assert!(
ok && err.contains("warning: alpha: ") && err.contains("still in"),
"{err}"
);
assert!(!marker.exists(), "scan ran the monitor");
git(&alpha, &["config", "--unset", "filter.x.clean"], None);
git(&alpha, &["config", "--unset", "core.fsmonitor"], None);
fs::remove_file(alpha.join(".git/hooks/pre-commit")).unwrap();
let (_, err, _) = env.run(&["verify", "alpha"]);
assert!(!err.contains("still in"), "{err}");
env.ok(&["review", "1", "--reject"]);
assert!(!marker.exists(), "the filter ran");
assert_eq!(git_out(&alpha, &["branch", "--list", "pma/*"]), "");
}
#[test]
fn a_refused_dispatch_leaves_no_worktree_behind() {
let s = Scratch::new("refused");
let (env, _, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let doc = s.0.join("policy.json");
fs::write(
&doc,
r#"{"route":[{"name":"chores","match":{"class":"A"},"approval":"each"}]}"#,
)
.unwrap();
env.ok(&["route", "propose", doc.to_str().unwrap(), "--by", "me"]);
env.ok(&["route", "activate", "1", "--by", "me"]);
let (_, err, success) = env.run(&["dispatch", "alpha:5"]);
assert!(!success && err.contains("matches no route"), "{err}");
assert!(!env.home.join("state/worktrees/alpha/add-greeting").exists());
assert_eq!(git_out(&alpha, &["branch", "--list", "pma/*"]), "");
}
const LINGERING_CLAUDE: &str = r#"#!/bin/sh
echo $$ > "$PMA_HOME/agent.pid"
exec sleep 60
"#;
#[test]
fn an_agent_does_not_outlive_a_killed_session() {
let s = Scratch::new("killed");
let (env, _, _) = dispatch_env(&s, &[("claude", LINGERING_CLAUDE)]);
let pid_file = env.home.join("agent.pid");
let mut dispatch = env
.command(&["dispatch", "alpha:5"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
let started = std::time::Instant::now();
let pid = loop {
if let Ok(pid) = fs::read_to_string(&pid_file)
&& !pid.trim().is_empty()
{
break pid.trim().to_string();
}
if started.elapsed().as_secs() >= 30 {
let _ = dispatch.kill();
panic!("the agent never started");
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
dispatch.kill().unwrap();
dispatch.wait().unwrap();
let alive = || {
Command::new("kill")
.args(["-0", &pid])
.stderr(std::process::Stdio::null())
.status()
.unwrap()
.success()
};
let killed = std::time::Instant::now();
while alive() && killed.elapsed().as_secs() < 5 {
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(!alive(), "agent {pid} outlived its session");
assert!(env.ok(&["review"]).contains("#1 failed"));
env.ok(&["review", "1", "--reject"]);
}
#[test]
fn verify_runs_the_check_where_a_dispatch_would() {
let s = Scratch::new("preflight");
let (env, _, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let (out, err, success) = env.run(&["verify", "alpha"]);
assert!(!success, "{out}");
assert!(out.starts_with("alpha: `make test` FAILED at "), "{out}");
assert!(
err.contains("1 of 1 projects did not pass their check"),
"{err}"
);
env.ok(&[
"config",
"projects.alpha.verify",
"git push -q origin HEAD:refs/heads/x",
]);
let (out, _, success) = env.run(&["verify", "alpha"]);
assert!(!success && out.contains("FAILED"), "{out}");
env.ok(&["config", "projects.alpha.verify", "true"]);
let out = env.ok(&["verify", "alpha"]);
assert!(out.starts_with("alpha: `true` passed at "), "{out}");
assert!(!env.home.join("state/verify-trees/alpha").exists());
assert_eq!(git_out(&alpha, &["worktree", "list"]).lines().count(), 1);
let (_, err, success) = env.run(&["verify"]);
assert!(!success && err.contains("name a project"), "{err}");
}
#[test]
fn verify_cannot_write_outside_its_worktree() {
let s = Scratch::new("sandbox");
let (env, _, _) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let outside = PathBuf::from(env!("CARGO_TARGET_TMPDIR"))
.join(format!("pma-sandbox-{}", std::process::id()));
let _ = fs::remove_file(&outside);
let escape = format!("touch '{}'", outside.display());
env.ok(&["config", "projects.alpha.verify", &escape]);
let (out, _, success) = env.run(&["verify", "alpha"]);
assert!(!success && out.contains("FAILED"), "{out}");
assert!(
!outside.exists(),
"a confined verify wrote {}",
outside.display()
);
env.ok(&[
"config",
"projects.alpha.verify",
"touch built && t=$(mktemp) && rm \"$t\" && git status --short && git diff --quiet HEAD",
]);
let out = env.ok(&["verify", "alpha"]);
assert!(out.contains("passed"), "{out}");
env.ok(&["config", "projects.alpha.verify", &escape]);
env.ok(&["config", "projects.alpha.sandbox", "off"]);
env.ok(&["verify", "alpha"]);
assert!(outside.exists(), "`sandbox off` runs verify unconfined");
fs::remove_file(&outside).unwrap();
}
const NODE_CLAUDE: &str = r#"#!/usr/bin/env python3
import json, os, re, sys
home = os.path.dirname(os.environ["PMA_HOME"])
prompt = sys.argv[2]
with open(os.path.join(home, "worker.log"), "a") as f:
f.write(" ".join(sys.argv[1:]) + "\n")
out = re.search(r"write (\S+)", prompt).group(1)
if prompt.startswith("Review"):
titles = open(os.path.join(home, "findings")).read().split("\n")
json.dump([{"title": t} for t in titles if t], open(out, "w"))
elif prompt.startswith("Confirm"):
if os.path.exists(os.path.join(home, "fail-confirm")):
sys.exit(1)
units = json.load(open(re.search(r"read (\S+)", prompt).group(1)))
json.dump([{"@id": u["@id"], "reason": "proved"} for u in units], open(out, "w"))
print(json.dumps({"type": "result", "subtype": "success", "is_error": False,
"result": "ok", "total_cost_usd": 0.1}))
"#;
const REVIEW_CONFIRM: &str = r#"
let graph = source("project")
.expand("review", "finding", 3, "Review {name}. write {out}")
.filter("confirm", ["reason"], "Confirm: read {in} write {out}")
.emit_note("record", #{ text: "confirmed: {title}: {reason}" })
.output();
document(#{ finding: #{ fields: #{ title: req(line(200)), reason: line(200) }}}, [
workflow("look", graph, #{ caps: #{ max_units: 10, max_edits: 0 }}),
])
"#;
fn approve_next(env: &Env, name: &str, instance: &str) -> String {
let out = env.ok(&["workflow", "run", name, "--instance", instance]);
env.ok(&[
"workflow",
"run",
name,
"--instance",
instance,
"--approve",
&plan_id(&out),
])
}
#[test]
fn nodes_are_routed_apart_and_a_failed_validator_passes_nothing_on() {
let s = Scratch::new("routed");
let (env, _, _) = dispatch_env(&s, &[("claude", NODE_CLAUDE)]);
fs::write(s.0.join("findings"), "a finding\n").unwrap();
let wf = s.0.join("look.rhai");
fs::write(&wf, REVIEW_CONFIRM).unwrap();
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let policy = s.0.join("policy.json");
fs::write(
&policy,
r#"{"route": [
{"name": "reviewers", "match": {"node": "*/review"}, "model": "opus", "approval": "each"},
{"name": "confirmers", "match": {"node": "confirm"}, "model": "haiku", "approval": "each"},
{"name": "tasks", "approval": "each"}
]}"#,
)
.unwrap();
env.ok(&["route", "propose", policy.to_str().unwrap()]);
env.ok(&["route", "activate", "1"]);
let out = env.ok(&["workflow", "run", "look", "alpha"]);
assert!(
out.contains("next: review") && out.contains("claude/opus"),
"{out}"
);
env.ok(&[
"workflow",
"run",
"look",
"--instance",
"1",
"--approve",
&plan_id(&out),
]);
let out = env.ok(&["workflow", "run", "look", "--instance", "1"]);
assert!(
out.contains("next: confirm") && out.contains("claude/haiku"),
"{out}"
);
let out = approve_next(&env, "look", "1");
assert!(out.contains("nothing left to run"), "{out}");
let log = fs::read_to_string(s.0.join("worker.log")).unwrap();
assert!(log.contains("opus") && log.contains("haiku"), "{log}");
assert!(
env.ok(&["note"]).contains("confirmed: a finding: proved"),
"the title the confirm left out is the one it was given"
);
fs::write(s.0.join("fail-confirm"), "").unwrap();
let out = env.ok(&["workflow", "run", "look", "alpha"]);
env.ok(&[
"workflow",
"run",
"look",
"--instance",
"2",
"--approve",
&plan_id(&out),
]);
let out = env.ok(&["workflow", "run", "look", "--instance", "2"]);
let (out, err, success) = env.run(&[
"workflow",
"run",
"look",
"--instance",
"2",
"--approve",
&plan_id(&out),
]);
assert!(success, "{err}");
assert!(err.contains("refused: confirm"), "{err}");
assert!(out.contains("nothing left to run"), "{out}");
assert_eq!(
env.ok(&["note"]).matches("confirmed:").count(),
1,
"an unconfirmed finding does not reach the sink"
);
}
#[test]
fn a_reduce_waits_for_every_branch() {
let s = Scratch::new("barrier");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] alpha\n- [ ] beta\n- [ ] gamma\n",
)
.unwrap();
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["scan", "--offline"]);
let wf = s.0.join("join.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.fan([
|g| g.rule_expand("first", "item", "todo-items", 10),
|g| g.rule_expand("second", "item", "todo-items", 10),
])
.join("a-join", ["text"])
.emit_note("record", #{ text: "kept: {text}" })
.output();
document(#{}, [
workflow("join", graph, #{ caps: #{ max_units: 50, max_edits: 0 }}),
])
"#,
)
.unwrap();
ok(&home, &["workflow", "propose", wf.to_str().unwrap()]);
ok(&home, &["workflow", "activate", "1"]);
let out = ok(&home, &["workflow", "run", "join", "alpha"]);
assert!(out.contains("nothing left to run"), "{out}");
assert_eq!(ok(&home, &["note"]).matches("kept:").count(), 3);
}
#[test]
fn a_check_waits_until_the_run_is_decided() {
let s = Scratch::new("waiting");
let worker = r#"#!/bin/sh
echo hi > hello.txt
echo '{"type":"result","subtype":"success","is_error":false,"result":"did it","total_cost_usd":0.1}'
"#;
let (env, _, _) = dispatch_env(&s, &[("claude", worker)]);
let wf = s.0.join("merge.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.edit("greet", "verify", "Say hello in hello.txt for `{name}`.")
.check("merged", "pr-merged")
.when(#{ "@pr-merged": ["passed"] })
.emit_note("done", #{ text: "merged: {name}" })
.output();
document(#{}, [
workflow("greet", graph, #{ caps: #{ max_units: 10, max_edits: 1 }}),
])
"#,
)
.unwrap();
env.ok(&["config", "workflow_budget", "20"]);
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let out = env.ok(&["workflow", "run", "greet", "alpha"]);
let out = env.ok(&[
"workflow",
"run",
"greet",
"--instance",
"1",
"--approve",
&plan_id(&out),
]);
assert!(out.contains("waiting at merged: 1 unit(s)"), "{out}");
let out = env.ok(&["workflow", "run", "greet", "--instance", "1"]);
assert!(out.contains("waiting at merged"), "still waiting: {out}");
assert!(!env.ok(&["workflow"]).contains("finished"));
env.ok(&["review", "1", "--reject"]);
let out = env.ok(&["workflow", "run", "greet", "--instance", "1"]);
assert!(out.contains("nothing left to run"), "{out}");
assert!(
env.ok(&["note"]).contains("no notes"),
"a rejected run never merged"
);
}
#[test]
fn a_cap_rolls_the_batch_back_and_a_raised_cap_resumes() {
let s = Scratch::new("capped");
let (env, _, _) = dispatch_env(&s, &[("claude", NODE_CLAUDE)]);
fs::write(s.0.join("findings"), "one\ntwo\n").unwrap();
let wf = s.0.join("review.rhai");
fs::write(
&wf,
r#"
let graph = source("project")
.expand("review", "finding", 3, "Review {name}. write {out}")
.emit_note("record", #{ text: "found: {title}" })
.output();
document(#{ finding: #{ fields: #{ title: req(line(200)) }}}, [
workflow("look", graph, #{ caps: #{ max_units: 2, max_edits: 0 }}),
])
"#,
)
.unwrap();
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let out = env.ok(&["workflow", "run", "look", "alpha"]);
let (_, err, success) = env.run(&[
"workflow",
"run",
"look",
"--instance",
"1",
"--approve",
&plan_id(&out),
]);
assert!(!success && err.contains("max_units"), "{err}");
assert!(env.ok(&["workflow"]).contains("capped"));
assert!(
env.ok(&["note"]).contains("no notes"),
"no finding was half minted"
);
let (_, err, success) = env.run(&["workflow", "run", "look", "--instance", "1"]);
assert!(!success && err.contains("--cap max_units"), "{err}");
let (_, err, success) = env.run(&[
"workflow",
"run",
"look",
"--instance",
"1",
"--cap",
"max_units=1",
]);
assert!(!success && err.contains("must be higher"), "{err}");
let out = env.ok(&[
"workflow",
"run",
"look",
"--instance",
"1",
"--cap",
"max_units=5",
]);
assert!(
out.contains("next: review"),
"the batch is waiting again: {out}"
);
let out = env.ok(&[
"workflow",
"run",
"look",
"--instance",
"1",
"--approve",
&plan_id(&out),
]);
assert!(out.contains("nothing left to run"), "{out}");
let notes = env.ok(&["note"]);
assert!(
notes.contains("found: one") && notes.contains("found: two"),
"{notes}"
);
}
#[test]
fn a_stopped_instance_runs_nothing_more() {
let s = Scratch::new("stop");
let (env, _, _) = dispatch_env(&s, &[("claude", NODE_CLAUDE)]);
let wf = s.0.join("look.rhai");
fs::write(&wf, REVIEW_CONFIRM).unwrap();
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
env.ok(&["workflow", "run", "look", "alpha"]);
env.ok(&["workflow", "stop", "1"]);
let (_, err, success) = env.run(&["workflow", "run", "look", "--instance", "1"]);
assert!(!success && err.contains("was stopped"), "{err}");
assert!(!s.0.join("worker.log").exists());
}
#[test]
fn a_document_the_runtime_cannot_take_is_refused_at_propose() {
let s = Scratch::new("unbuilt");
let home = s.0.join("home");
fs::create_dir_all(&home).unwrap();
let wf = s.0.join("split.json");
fs::write(
&wf,
r#"{"types": {"task": {"fields": {"text": {"type": "line", "required": true}}}},
"workflow": [{"name": "decompose", "in": "task", "out": "task", "effects": [],
"params": {"depth": {"type": "int", "default": 2, "max": 3}},
"caps": {"max_units": 20, "max_edits": 0},
"nodes": [{"name": "split", "op": "map", "out": "0..n", "in": "task", "emits": "task",
"max_units": 2, "max_depth": "{$depth}", "task": "split {text} into {out}"}],
"edges": [{"from": "@input", "to": "split"},
{"from": "split", "to": "split", "when": {"@depth": {"<": "{$depth}"}}},
{"from": "split", "to": "@output"}]}]}"#,
)
.unwrap();
let out = ok(&home, &["workflow", "check", wf.to_str().unwrap()]);
assert!(out.contains("worst case: 15 agent runs"), "{out}");
assert!(
out.contains("recursion (a self-edge) is not taken yet"),
"{out}"
);
let (_, err, success) = pma_in(&home, &["workflow", "propose", wf.to_str().unwrap()]);
assert!(!success && err.contains("not runnable yet"), "{err}");
}
#[test]
fn a_dry_run_names_a_node_the_policy_would_refuse() {
let s = Scratch::new("unrouted");
let (env, _, _) = dispatch_env(&s, &[("claude", NODE_CLAUDE)]);
let wf = s.0.join("look.rhai");
fs::write(&wf, REVIEW_CONFIRM).unwrap();
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let policy = s.0.join("policy.json");
fs::write(
&policy,
r#"{"route": [
{"name": "reviewers", "match": {"node": "*/review"}, "approval": "each"},
{"name": "tasks", "approval": "each"}
]}"#,
)
.unwrap();
env.ok(&["route", "propose", policy.to_str().unwrap()]);
env.ok(&["route", "activate", "1"]);
let (_, err, success) = env.run(&["workflow", "run", "look", "alpha", "--dry-run"]);
assert!(success, "{err}");
assert!(err.contains("node `confirm`: no route"), "{err}");
assert!(
!err.contains("node `review`"),
"a named node is not warned about: {err}"
);
}
#[test]
fn an_id_keeps_an_items_age_and_its_run_across_a_reword() {
let s = Scratch::new("ids");
let (env, _, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let alpha_dir = alpha.to_str().unwrap();
let out = env.ok(&["lint", "--ids", alpha_dir]);
assert!(
out.contains(":5: ^") && out.contains("4 items without an id"),
"{out}"
);
let before = fs::read_to_string(alpha.join("TODO.md")).unwrap();
assert!(!before.contains(" ^"), "a listing writes nothing");
env.ok(&["dispatch", "alpha:5"]);
let out = env.ok(&["lint", "--ids", "--apply", alpha_dir]);
assert!(out.contains("4 ids written"), "{out}");
assert!(
env.ok(&["lint", "--ids", alpha_dir])
.contains("every open item has an id")
);
let written = fs::read_to_string(alpha.join("TODO.md")).unwrap();
assert_eq!(written.matches(" ^").count(), 4, "{written}");
assert!(
env.ok(&["lint", alpha_dir]).is_empty(),
"the file stays clean"
);
let reworded = written.replace(
"- [ ] add greeting #agent",
"- [ ] add a friendly greeting #agent",
);
assert_ne!(reworded, written, "the item was reworded");
fs::write(alpha.join("TODO.md"), reworded).unwrap();
env.ok(&["scan", "--offline"]);
let (_, err, success) = env.run(&["dispatch", "alpha:5"]);
assert!(
!success && err.contains("already has a run"),
"the reworded item is the task the run holds: {err}"
);
}
#[test]
fn an_id_keeps_the_age_git_gave_an_item() {
let s = Scratch::new("idage");
let home = s.0.join("home");
let root = s.0.join("root");
let alpha = root.join("alpha");
fs::create_dir_all(&alpha).unwrap();
git(&alpha, &["init", "-q"], None);
fs::write(
alpha.join("TODO.md"),
"# TODO\n\n## High\n\n- [ ] old thing\n",
)
.unwrap();
git(&alpha, &["add", "."], None);
git(&alpha, &["commit", "-qm", "init"], Some(now() - 100 * DAY));
ok(&home, &["root", "add", root.to_str().unwrap()]);
ok(&home, &["project", "tier", "1", "alpha"]);
ok(
&home,
&["lint", "--ids", "--apply", alpha.to_str().unwrap()],
);
ok(&home, &["scan", "--offline"]);
assert!(ok(&home, &["stale"]).contains("open 100d old thing"));
let text = fs::read_to_string(alpha.join("TODO.md")).unwrap();
fs::write(
alpha.join("TODO.md"),
text.replace("old thing", "renamed thing"),
)
.unwrap();
ok(&home, &["scan", "--offline"]);
let stale = ok(&home, &["stale"]);
assert!(stale.contains("open 100d renamed thing"), "{stale}");
}
#[test]
fn next_lists_work_for_agents_and_for_you() {
let s = Scratch::new("next");
let (env, _, alpha) = dispatch_env(&s, &[("claude", FAKE_CLAUDE)]);
let text = fs::read_to_string(alpha.join("TODO.md")).unwrap();
fs::write(
alpha.join("TODO.md"),
text.replace(
"# TODO\n",
"# TODO\n\n## Critical\n\n- [ ] rotate the signing key\n\
- [ ] revoke the old tokens #agent #manual\n",
),
)
.unwrap();
env.ok(&["scan", "--offline"]);
let out = env.ok(&["next"]);
assert!(out.contains("For agents: 3\n"), "{out}");
assert!(
out.contains("add greeting") && out.contains("third task"),
"{out}"
);
assert!(
!out.contains("guarded task"),
"#manual is not for agents: {out}"
);
assert!(
out.contains("For you: 2\n tasks: 2\n")
&& out.contains("not for agents rotate the signing key")
&& out.contains("not for agents revoke the old tokens"),
"{out}"
);
assert_eq!(env.ok(&[]), out, "`pma` alone is `pma next`");
env.ok(&["dispatch", "alpha:10"]);
let out = env.ok(&["next"]);
assert!(
out.contains("For agents: 2\n"),
"the dispatched task is held: {out}"
);
assert!(
out.contains("runs: 1\n #1 ready to review alpha add greeting pma review 1"),
"{out}"
);
}
#[test]
fn the_documented_workflow_library_reads_as_documented() {
let doc = fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/dev/workflows.md"),
)
.unwrap();
let blocks = |text: &str| -> Vec<serde_json::Value> {
text.split("```json\n")
.skip(1)
.map(|b| serde_json::from_str(b.split("\n```").next().unwrap()).unwrap())
.collect()
};
let section = |from: &str, to: &str| {
let start = doc.find(from).unwrap();
doc[start..start + doc[start..].find(to).unwrap()].to_string()
};
let types = blocks(§ion("## 6. The document", "### 6.1"))[0]["types"].clone();
let library = section("## 15. A library", "## 16. Acceptance");
let workflows: Vec<serde_json::Value> = blocks(&library);
assert_eq!(workflows.len(), 12, "the library's twelve examples");
let named = |name: &str| {
workflows
.iter()
.find(|w| w["name"] == name)
.unwrap()
.clone()
};
let s = Scratch::new("library");
let home = s.0.join("home");
fs::create_dir_all(&home).unwrap();
let runnable = [
"find-issues",
"ensemble-review",
"triage-issues",
"specify",
"record",
"settle",
"portfolio-sweep",
];
for w in &workflows {
let name = w["name"].as_str().unwrap();
let mut members = vec![w.clone()];
let mut i = 0;
while i < members.len() {
for node in members[i]["nodes"].as_array().unwrap().clone() {
if node["op"] == "call" {
let callee = named(node["workflow"].as_str().unwrap());
if !members.iter().any(|m| m["name"] == callee["name"]) {
members.push(callee);
}
}
}
i += 1;
}
let file = s.0.join(format!("{name}.json"));
fs::write(
&file,
serde_json::json!({"types": types, "workflow": members}).to_string(),
)
.unwrap();
let (_, err, success) = pma_in(&home, &["workflow", "check", file.to_str().unwrap()]);
assert!(success, "{name} does not read: {err}");
let (_, err, success) = pma_in(&home, &["workflow", "propose", file.to_str().unwrap()]);
assert_eq!(
success,
runnable.contains(&name),
"{name}: the text says it is {}runnable: {err}",
if runnable.contains(&name) { "" } else { "not " }
);
}
}
#[test]
fn a_git_setting_added_by_a_read_node_fails_its_run() {
let s = Scratch::new("read-filter");
let marker = s.0.join("filtered");
let agent = format!(
"#!/bin/sh\ngit config filter.x.clean \"touch {}; cat\"\n\
echo '{{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"done\",\"total_cost_usd\":0.1}}'\n",
marker.display()
);
let (env, _, _) = dispatch_env(&s, &[("claude", &agent)]);
let wf = s.0.join("look.rhai");
fs::write(&wf, REVIEW_CONFIRM).unwrap();
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let out = env.ok(&["workflow", "run", "look", "alpha"]);
let (_, err, _) = env.run(&[
"workflow",
"run",
"look",
"--instance",
"1",
"--approve",
&plan_id(&out),
]);
assert!(
err.contains("refused: review")
&& err.contains("hooks or git settings changed")
&& err.contains("+ config filter.x.clean"),
"{err}"
);
assert!(!marker.exists(), "the filter ran");
let (_, err, _) = env.run(&["verify", "alpha"]);
assert!(err.contains("and they are still in"), "{err}");
env.ok(&["review", "1", "--reject", "--keep-git-changes"]);
let (_, err, _) = env.run(&["verify", "alpha"]);
assert!(!err.contains("still in"), "{err}");
}
#[test]
fn a_node_that_writes_no_output_failed() {
let s = Scratch::new("silent");
let silent = r#"#!/bin/sh
echo '{"type":"result","subtype":"success","is_error":false,"result":"done","total_cost_usd":0.1}'
"#;
let (env, _, _) = dispatch_env(&s, &[("claude", silent)]);
let wf = s.0.join("look.rhai");
fs::write(&wf, REVIEW_CONFIRM).unwrap();
env.ok(&["workflow", "propose", wf.to_str().unwrap()]);
env.ok(&["workflow", "activate", "1"]);
let out = env.ok(&["workflow", "run", "look", "alpha"]);
let (out, err, success) = env.run(&[
"workflow",
"run",
"look",
"--instance",
"1",
"--approve",
&plan_id(&out),
]);
assert!(success, "{err}");
assert!(
err.contains("refused: review") && err.contains("wrote no"),
"{err}"
);
assert!(out.contains("nothing left to run"), "{out}");
let kept = env.home.join("state/artifacts/1/review/1/in.json");
assert!(kept.exists(), "the run's input is kept: {}", kept.display());
assert!(
!env.home.join("state/workflow-trees/1").exists(),
"the scratch trees are gone"
);
}