From e75a3bc7247f728e16614ab4c95e5b7c6cf1ef58 Mon Sep 17 00:00:00 2001 From: Armand Date: Mon, 29 Jun 2026 20:37:10 -0500 Subject: [PATCH] fix(evidence): persist prepared quorum artifacts --- aragora/cli/commands/review_queue.py | 10 +++++ aragora/cli/parser.py | 10 +++++ aragora/swarm/quorum_evidence.py | 12 +++++ scripts/collect_quorum_evidence.py | 10 +++++ tests/cli/commands/test_review_queue.py | 3 ++ tests/swarm/test_quorum_evidence.py | 59 +++++++++++++++++++++++++ 6 files changed, 104 insertions(+) diff --git a/aragora/cli/commands/review_queue.py b/aragora/cli/commands/review_queue.py index 9041b11e7d..0172e522a4 100644 --- a/aragora/cli/commands/review_queue.py +++ b/aragora/cli/commands/review_queue.py @@ -732,6 +732,15 @@ def add_review_queue_parser(subparsers: argparse._SubParsersAction) -> None: collect_evidence_arg( "--apply", action="store_true", help="Post Tier 0-2 evidence; Tier 3-4 prepare only." ) + collect_evidence_arg( + "--out", + type=Path, + default=None, + help=( + "Write the dry-run collect-evidence JSON artifact to this path so it " + "can later be reused with --prepared-json." + ), + ) collect_evidence_arg("--json", dest="json_output", action="store_true", help="Output as JSON") lint_comment_p = sub.add_parser( @@ -1367,6 +1376,7 @@ def _cmd_collect_evidence(args: argparse.Namespace) -> int: author=getattr(args, "author", None), apply=bool(getattr(args, "apply", False)), json_output=json_output, + out_path=getattr(args, "out", None), ) diff --git a/aragora/cli/parser.py b/aragora/cli/parser.py index 7d1663ad05..247bc4ff6e 100644 --- a/aragora/cli/parser.py +++ b/aragora/cli/parser.py @@ -7,6 +7,7 @@ import argparse import os +from pathlib import Path from aragora.cli._mission_parser import add_mission_parser from aragora.config import DEFAULT_AGENTS, DEFAULT_CONSENSUS, DEFAULT_ROUNDS @@ -2353,6 +2354,15 @@ def _add_review_queue_parser(subparsers) -> None: action="store_true", help="Post evidence for Tier 0-2 PRs (Tier 3-4 always prepare-only).", ) + collect_evidence_parser.add_argument( + "--out", + type=Path, + default=None, + help=( + "Write the dry-run collect-evidence JSON artifact to this path so it " + "can later be reused with --prepared-json." + ), + ) collect_evidence_parser.add_argument( "--json", dest="json_output", action="store_true", help="Output as JSON" ) diff --git a/aragora/swarm/quorum_evidence.py b/aragora/swarm/quorum_evidence.py index 7901c78033..5eb52f3c66 100644 --- a/aragora/swarm/quorum_evidence.py +++ b/aragora/swarm/quorum_evidence.py @@ -2279,6 +2279,7 @@ def run_collect_cli( apply: bool, json_output: bool, prepared_json: Path | None = None, + out_path: Path | None = None, printer: Callable[[str], None] = print, ) -> int: """Shared entry point for the script and ``review-queue collect-evidence``. @@ -2289,6 +2290,14 @@ def run_collect_cli( return 1 (quorum is enforced as N-of-M elsewhere). Inspect ``posted_families`` in the JSON output rather than treating exit-code 1 as "nothing posted". """ + if out_path is not None and apply: + msg = "--out is only valid for dry-run evidence preparation" + if json_output: + printer(json.dumps({"mode": "collect_evidence", "error": msg}, indent=2)) + else: + printer(f"error: {msg}") + return 2 + fams = tuple(families) if families else DEFAULT_FAMILIES resolved_author = author or resolve_author() try: @@ -2313,6 +2322,9 @@ def run_collect_cli( env=merge_quorum_io.aragora_env(), quorum_reconciler=default_quorum_reconciler if apply else None, ) + if out_path is not None: + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(outcome.to_dict(), indent=2) + "\n", encoding="utf-8") except (ValueError, RuntimeError, OSError, subprocess.SubprocessError) as exc: if json_output: printer(json.dumps({"mode": "collect_evidence", "error": str(exc)}, indent=2)) diff --git a/scripts/collect_quorum_evidence.py b/scripts/collect_quorum_evidence.py index bf1683d09f..8eab0a8e0b 100644 --- a/scripts/collect_quorum_evidence.py +++ b/scripts/collect_quorum_evidence.py @@ -65,6 +65,15 @@ def main(argv: list[str] | None = None) -> int: "re-running reviewers." ), ) + parser.add_argument( + "--out", + type=Path, + default=None, + help=( + "Write the dry-run collect-evidence JSON artifact to this path so it " + "can later be reused with --prepared-json." + ), + ) parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") args = parser.parse_args(argv) @@ -76,6 +85,7 @@ def main(argv: list[str] | None = None) -> int: apply=args.apply, json_output=args.json_output, prepared_json=args.prepared_json, + out_path=args.out, ) diff --git a/tests/cli/commands/test_review_queue.py b/tests/cli/commands/test_review_queue.py index 703e39772f..5b46de84f9 100644 --- a/tests/cli/commands/test_review_queue.py +++ b/tests/cli/commands/test_review_queue.py @@ -5753,6 +5753,8 @@ def test_parser_registers_build_packet_run_and_act(self) -> None: "openai", "--author", "an0mium", + "--out", + "/tmp/prepared-evidence.json", "--json", ] ) @@ -5761,6 +5763,7 @@ def test_parser_registers_build_packet_run_and_act(self) -> None: assert ns_collect.pr == 6280 assert ns_collect.reviewers == ["claude", "openai"] assert ns_collect.author == "an0mium" + assert str(ns_collect.out) == "/tmp/prepared-evidence.json" assert ns_collect.apply is False assert ns_collect.json_output is True # run invocation parses diff --git a/tests/swarm/test_quorum_evidence.py b/tests/swarm/test_quorum_evidence.py index f2c75ec8ed..2a227b4009 100644 --- a/tests/swarm/test_quorum_evidence.py +++ b/tests/swarm/test_quorum_evidence.py @@ -2318,6 +2318,65 @@ def fake_apply_prepared_evidence(**kwargs) -> CollectOutcome: assert seen["families"] == ("claude", "grok") +def test_run_collect_cli_writes_prepared_artifact(monkeypatch, tmp_path, capsys) -> None: + def fake_collect(**kwargs) -> CollectOutcome: + return CollectOutcome( + repo="o/r", + pr=1, + head_sha=HEAD, + head_committed_at=COMMITTED, + tier=1, + action="prepare", + action_reason="dry-run", + items=[ + EvidenceItem("claude", _prepared_body("claude"), True, ["claude"], [], "pass"), + EvidenceItem("grok", _prepared_body("grok"), True, ["grok"], [], "pass"), + ], + ) + + monkeypatch.setattr(qe, "collect_evidence", fake_collect) + monkeypatch.setattr(qe, "resolve_author", lambda default="local": "me") + + out_path = tmp_path / "artifacts" / "prepared.json" + rc = qe.run_collect_cli( + repo="o/r", + pr=1, + families=None, + author=None, + apply=False, + json_output=True, + out_path=out_path, + ) + + assert rc == 0 + stdout_payload = json.loads(capsys.readouterr().out) + artifact_payload = json.loads(out_path.read_text(encoding="utf-8")) + assert artifact_payload == stdout_payload + assert artifact_payload["head_sha"] == HEAD + assert artifact_payload["items"][0]["family"] == "claude" + + +def test_run_collect_cli_rejects_out_with_apply(monkeypatch, tmp_path, capsys) -> None: + def boom_collect(**kwargs) -> CollectOutcome: + raise AssertionError("collect_evidence must not run when --out and --apply conflict") + + monkeypatch.setattr(qe, "collect_evidence", boom_collect) + + rc = qe.run_collect_cli( + repo="o/r", + pr=1, + families=None, + author=None, + apply=True, + json_output=True, + out_path=tmp_path / "prepared.json", + ) + + assert rc == 2 + payload = json.loads(capsys.readouterr().out) + assert payload["error"] == "--out is only valid for dry-run evidence preparation" + + def test_run_collect_cli_exit_code_quorum_incomplete(monkeypatch) -> None: def fake_collect(**kwargs) -> CollectOutcome: return CollectOutcome(