From 9ef749c783356528317ed0e218cb96d3cd195691 Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 7 Jul 2026 12:53:51 -0600 Subject: [PATCH] feat(sp38): 'cyclone ack-orphans {status,reconcile}' CLI subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements tasks 4-5 of docs/superpowers/plans/2026-07-07-cyclone-orphan-ack-housekeeping.md. Adds a new 'ack-orphans' group under the main click CLI with two subcommands: cyclone ack-orphans status Prints a table of orphan 999 acks: ST02 ACK COUNT HAS BATCH --------------- ---------- --------- 991102989 419 no 991102988 226 no ... TOTAL 804 Empty-DB case prints 'no orphans' instead of a blank table. cyclone ack-orphans reconcile [--dry-run] One-shot synthetic-batch seeder. --dry-run prints the plan without writing. Real call inserts one synthetic batches row per orphan ST02 lacking one, marked with the '' sentinel so they're trivially distinguishable in queries. Exit codes per cyclone-cli convention: 0 = success, 1 = DB error. CLI is operator-invoked only — no auto-run on boot, no scheduler hook, no cron integration. 7 new tests (test_ack_orphans_cli.py) use click.testing.CliRunner (matching the SP37-followup #5 pattern that replaced subprocess.run with the in-process runner). Cover: table shape, has-batch column, empty-DB handling, reconcile creates synthetic rows, --dry-run doesn't write, idempotency, no-orphan graceful handling. Live-verified against ~/.local/share/cyclone/cyclone.db: status prints the 4 distinct ST02s (419/226/106/53 = 804 total). Reconcile inserts 4 synthetic batches. Subsequent status shows all 4 marked has_batch=yes. --- backend/src/cyclone/cli.py | 92 +++++++++++ backend/tests/test_ack_orphans_cli.py | 210 ++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 backend/tests/test_ack_orphans_cli.py diff --git a/backend/src/cyclone/cli.py b/backend/src/cyclone/cli.py index 7e6b358..6b8a4f3 100644 --- a/backend/src/cyclone/cli.py +++ b/backend/src/cyclone/cli.py @@ -1284,5 +1284,97 @@ def pull_inbound( click.echo(f" {e}", err=True) +# --------------------------------------------------------------------------- +# SP38: ack-orphans status + reconcile +# --------------------------------------------------------------------------- + + +@main.group("ack-orphans") +def ack_orphans_group() -> None: + """Inspect and reconcile 999 acks with no resolvable source claim. + + "Orphans" are 999 acks whose source 837 batch is not present in + the ``batches`` table — typically because the source 837 was + submitted to HPE before the current ``cyclone.db`` snapshot was + created. They are real production data (valid audit history) but + cannot be auto-linked to claims. + + See ``docs/superpowers/specs/2026-07-07-cyclone-orphan-ack-housekeeping-design.md`` + for the full design and operator triage workflow. + """ + + +@ack_orphans_group.command("status") +def ack_orphans_status_cmd() -> None: + """Print a per-ST02 summary of orphan 999 acks. + + One row per distinct orphan ``set_control_number`` (ST02), ranked + by ``ack_count DESC`` so the heaviest backlog surfaces first. + Plus a TOTAL row at the bottom. + + Exit codes: 0 on success, 1 on DB error. + """ + from cyclone import db as db_mod + from cyclone.store import store as cycl_store + + try: + db_mod.init_db() + summary = cycl_store.find_ack_orphan_st02_summary() + except Exception as exc: # noqa: BLE001 + click.echo(f"ack-orphans status failed: {type(exc).__name__}: {exc}", err=True) + sys.exit(1) + + if not summary: + click.echo("no orphans (acks is empty or all are linked)") + return + + # Table layout: ST02 | ACK COUNT | HAS BATCH + click.echo(f"{'ST02':<15} {'ACK COUNT':>10} HAS BATCH") + click.echo(f"{'----':-<15} {'---------':->10} ---------") + total = 0 + for row in summary: + marker = "yes" if row["has_batch"] else "no" + click.echo( + f"{row['st02']:<15} {row['ack_count']:>10} {marker}" + ) + total += row["ack_count"] + click.echo(f"{'TOTAL':<15} {total:>10}") + + +@ack_orphans_group.command("reconcile") +@click.option("--dry-run", is_flag=True, + help="Print the plan but do not insert any rows.") +def ack_orphans_reconcile_cmd(dry_run: bool) -> None: + """Insert synthetic batches rows for orphan ST02s that lack one. + + For every orphan ST02 where no ``batches`` row exists, insert a + synthetic row marked with ``input_filename = + ''`` so future 999 acks for the same + ST02s can resolve against the ``batch_envelope_index`` (though + they still won't link to claims — the source 837s were never + ingested). + + Idempotent: re-running after a successful pass is a no-op. + + Exit codes: 0 on success, 1 on DB error. + """ + from cyclone import db as db_mod + from cyclone.store import store as cycl_store + + try: + db_mod.init_db() + plan = cycl_store.reconcile_orphan_st02s(dry_run=dry_run) + except Exception as exc: # noqa: BLE001 + click.echo(f"ack-orphans reconcile failed: {type(exc).__name__}: {exc}", err=True) + sys.exit(1) + + click.echo( + f"Created: {plan['created']} synthetic batch rows. " + f"Skipped: {plan['skipped']} (already had a batch row)." + ) + if dry_run: + click.echo("(dry-run — no rows inserted)") + + if __name__ == "__main__": main() diff --git a/backend/tests/test_ack_orphans_cli.py b/backend/tests/test_ack_orphans_cli.py new file mode 100644 index 0000000..1535279 --- /dev/null +++ b/backend/tests/test_ack_orphans_cli.py @@ -0,0 +1,210 @@ +"""SP38: CLI tests for ``cyclone ack-orphans {status,reconcile}``. + +Uses ``click.testing.CliRunner`` (matching the SP37-followup #5 +pattern that replaced ``subprocess.run`` with the in-process +runner). The CLI commands are thin wrappers over the store helpers +tested in ``test_ack_orphan_summary.py`` — these tests pin the +shell-facing shape (table format, exit codes, --dry-run flag). + +The autouse ``_auto_init_db`` fixture in ``conftest.py`` provides +a fresh ``tmp_path/test.db`` for every test; the CLI commands pick +it up via the ``CYCLONE_DB_URL`` env var the fixture sets. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import pytest +from click.testing import CliRunner + +from cyclone import db +from cyclone.cli import main +from cyclone.db import Ack, Batch + + +def _now(): + return datetime.now(timezone.utc) + + +def _ingest_orphan(s, st02: str) -> int: + """Insert a 999 ack row whose set_responses[0].set_control_number == st02. + + Returns the new ack id. The 999 is an orphan (no claim_acks row) + so it surfaces in the summary. + """ + raw = { + "envelope": {"sender_id": "S", "receiver_id": "R", + "control_number": "000000099", "transaction_date": "2024-01-01", + "implementation_guide": "005010X231A1"}, + "functional_group_acks": [], + "set_responses": [ + {"set_control_number": st02, "transaction_set_identifier": "837", + "ak2": {"functional_id_code": "837"}, "segment_errors": [], + "set_accept_reject": {"code": "A"}} + ], + "summary": {"accepted_count": 1, "rejected_count": 0}, + } + ack = Ack( + source_batch_id=f"999-{st02}-cli-test", + accepted_count=1, rejected_count=0, received_count=1, + ack_code="A", parsed_at=_now(), raw_json=json.dumps(raw), + ) + s.add(ack) + s.flush() + return ack.id + + +# ---- cyclone ack-orphans status ------------------------------------------ # + + +def test_status_prints_table_with_orphans(): + """``cyclone ack-orphans status`` prints a table with ST02 + + ack count + has-batch flag, plus a TOTAL line at the bottom. + + Mirrors the spec's intent: operators want a one-line-per-orphan + view that ranks the heaviest backlog first. + """ + with db.SessionLocal()() as s: + _ingest_orphan(s, "991102989") + _ingest_orphan(s, "991102988") + s.commit() + + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "status"]) + assert result.exit_code == 0, result.output + # Table header + two data rows + total line. + assert "ST02" in result.output + assert "ACK COUNT" in result.output + assert "991102989" in result.output + assert "991102988" in result.output + assert "TOTAL" in result.output + + +def test_status_exits_zero_on_empty_db(): + """With no orphans, status exits 0 and prints a 'no orphans' note.""" + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "status"]) + assert result.exit_code == 0, result.output + # The empty case should be informative, not silent. + assert "no orphans" in result.output.lower() or "0" in result.output + + +def test_status_table_includes_has_batch_column(): + """When a ST02 already has a batches row, the table marks it + with ``yes`` (or similar) so operators can see which orphans + are unbacked vs already-covered. + """ + with db.SessionLocal()() as s: + # Orphan with NO batch. + _ingest_orphan(s, "991102988") + # Orphan WITH a pre-existing batch row. + import uuid + bid = uuid.uuid4().hex + s.add(Batch( + id=bid, kind="837p", input_filename="test.837p", + transaction_set_control_number="991102977", parsed_at=_now(), + )) + _ingest_orphan(s, "991102977") + s.commit() + + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "status"]) + assert result.exit_code == 0, result.output + # Look for "yes" / "no" markers in the table. + lines = result.output.splitlines() + has_yes = any("yes" in ln.lower() for ln in lines) + has_no = any("no" in ln.lower() for ln in lines) + assert has_yes and has_no, ( + f"Expected table to mark 'yes' for ST02s with batches and " + f"'no' for ST02s without. Got:\n{result.output}" + ) + + +# ---- cyclone ack-orphans reconcile --------------------------------------- # + + +def test_reconcile_creates_synthetic_batches(): + """``cyclone ack-orphans reconcile`` inserts a synthetic batch + row for each orphan ST02 without one. Confirms the count + the + sentinel filename. + """ + with db.SessionLocal()() as s: + _ingest_orphan(s, "991102989") + _ingest_orphan(s, "991102988") + s.commit() + + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "reconcile"]) + assert result.exit_code == 0, result.output + + # The CLI should print the created/skipped counts. + assert "Created" in result.output or "created" in result.output + assert "Skipped" in result.output or "skipped" in result.output + + with db.SessionLocal()() as s: + synthetic = ( + s.query(Batch) + .filter(Batch.input_filename == "") + .all() + ) + assert len(synthetic) == 2 + st02s = {r.transaction_set_control_number for r in synthetic} + assert st02s == {"991102989", "991102988"} + + +def test_reconcile_dry_run_does_not_write(): + """``--dry-run`` returns the plan shape but does NOT insert rows. + Operators use this to preview before committing. + """ + with db.SessionLocal()() as s: + _ingest_orphan(s, "991102989") + s.commit() + + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "reconcile", "--dry-run"]) + assert result.exit_code == 0, result.output + # The plan reports the would-create count. + assert "1" in result.output # would-create 1 row + + # But no synthetic batch row was inserted. + with db.SessionLocal()() as s: + n = ( + s.query(Batch) + .filter(Batch.input_filename == "") + .count() + ) + assert n == 0 + + +def test_reconcile_is_idempotent(): + """A second reconcile after a successful pass is a no-op: + created=0, skipped=N. Tested at the CLI shell level so the + user-visible output also pins this invariant. + """ + with db.SessionLocal()() as s: + _ingest_orphan(s, "991102989") + s.commit() + + runner = CliRunner() + first = runner.invoke(main, ["ack-orphans", "reconcile"]) + second = runner.invoke(main, ["ack-orphans", "reconcile"]) + assert first.exit_code == 0 + assert second.exit_code == 0 + + with db.SessionLocal()() as s: + n = ( + s.query(Batch) + .filter(Batch.input_filename == "") + .count() + ) + assert n == 1 # still exactly one — second call was a no-op + + +def test_reconcile_handles_no_orphans_gracefully(): + """With zero orphans, reconcile exits 0 and prints an informative + message (no synthetic rows created, no error).""" + runner = CliRunner() + result = runner.invoke(main, ["ack-orphans", "reconcile"]) + assert result.exit_code == 0, result.output + assert "0" in result.output # created=0, skipped=0 \ No newline at end of file