feat(sp38): 'cyclone ack-orphans {status,reconcile}' CLI subcommands
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
'<synthetic:orphan-reconcile>' 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.
This commit is contained in:
@@ -1284,5 +1284,97 @@ def pull_inbound(
|
|||||||
click.echo(f" {e}", err=True)
|
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 =
|
||||||
|
'<synthetic:orphan-reconcile>'`` 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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -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 == "<synthetic:orphan-reconcile>")
|
||||||
|
.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 == "<synthetic:orphan-reconcile>")
|
||||||
|
.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 == "<synthetic:orphan-reconcile>")
|
||||||
|
.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
|
||||||
Reference in New Issue
Block a user