diff --git a/docs/superpowers/plans/2026-06-21-cyclone-pipeline-agent.md b/docs/superpowers/plans/2026-06-21-cyclone-pipeline-agent.md index e3282fb..6e55fca 100644 --- a/docs/superpowers/plans/2026-06-21-cyclone-pipeline-agent.md +++ b/docs/superpowers/plans/2026-06-21-cyclone-pipeline-agent.md @@ -12,6 +12,33 @@ --- +## As-built deviations + +The implementation shipped with 10 deviations from this plan, all fixed +inline during TDD execution. They are catalogued here so a re-run of +the plan (or a reviewer of the implementation) knows what was +adjusted and why. + +| # | Bug | Where | Fix | +|---|---|---|---| +| 1a | `AckTimeoutError.__init__(self, ack_kind, timeout_s: int)` — the `int` annotation broke tests asserting `timeout_s == pytest.approx(0.2, abs=0.1)`. | `src/cyclone_pipeline/exceptions.py` | Widened to `timeout_s: float`; added `self.phase = f"wait_{ack_kind}"` so `report.py` can render the right remediation hint. | +| 1b | `wait_for` raised `AckTimeoutError(label, int(timeout_s))` — round-tripping `float → int → float` lost sub-second precision and broke the same `pytest.approx` assertion. | `src/cyclone_pipeline/waiters.py` | Pass `timeout_s` through unchanged. | +| 2 | Markdown table assertion `assert "\| 1. Pre-flight" in text or "\| 1." in text` didn't match the actual table format `\| 1 \| preflight \|`. | `tests/test_report.py::test_markdown_contains_phase_table` | Tightened assertion to `\| 1 \| preflight`. | +| 3 | Markdown report had no "Remediation" section — operators had to read phase detail to figure out next steps when an ACK timed out. | `src/cyclone_pipeline/report.py::write_report_md` | Added a new `## Remediation` section after `expected_835_by` when `report.result` is `soft_fail` or `hard_fail`. Text mentions "in flight" and "Gainwell directly" so it's greppable. | +| 4 | 835 expected-by text didn't say *when* — just "CO Medicaid payment cycle". | `src/cyclone_pipeline/report.py::write_report_md` | Added "typically the following Monday" so operators can plan a check-835 run. | +| 5 | Test imported `HealthSnapshot as _Ignored` from `cyclone_pipeline.exceptions` — it lives in `state.py`. | `tests/test_pipeline.py` | Removed the bogus import. | +| 6 | Plan tests instantiated `CyclonePipeline(cfg, ...)` and called phases directly — but `__aexit__` is required to set `self._client` (and to close the browser on phase 2). | `tests/test_pipeline.py` (all phase tests) | Wrapped every `p = CyclonePipeline(...)` call in `async with CyclonePipeline(...) as p:`. | +| 7 | Plan did `from playwright.async_api import async_playwright` and `from cyclone_pipeline.browser import UploadPage` *inside* `phase2_upload` for "lazy loading" — but tests patch `cyclone_pipeline.pipeline.async_playwright` and `cyclone_pipeline.pipeline.UploadPage` (module-level), so the patches had no effect. | `src/cyclone_pipeline/pipeline.py` | Moved both imports to module level. | +| 8 | Plan had `phases=list(self.run_state.completed_phases)` — `PhaseRecord` (in `state.py`) and `PhaseResult` (in `report.py`) are distinct pydantic v2 models, so passing one where the other is expected fails (no auto-coercion). | `src/cyclone_pipeline/pipeline.py::phase7_scan_and_report` | Added `_phase_records_to_results()` helper that goes through `.model_dump()`; phase 7 now uses it. | +| 9 | Resume test stubbed phases 5/6/7 with `AsyncMock(return_value=PhaseResult(...))` — `resume()` only appends to `completed_phases` via its own logic; the mocks returned a result but didn't mutate state, so `len(report.phases) >= 4` failed (got 3 from the seeded phases 1/2/3). | `tests/test_pipeline.py::test_resume_skips_completed_phases` | Replaced with a `_make_stub(n, name)` factory that appends the result to `p.run_state.completed_phases`. Phase 4 also updated. | +| 10 | CLI `runner.invoke(main, ["status", "--run-dir", ...])` failed with click complaining that `--run-dir` appeared after the subcommand. | `src/cyclone_pipeline/cli.py` | Added `context_settings={"allow_interspersed_args": True}` to the `@click.group()` decorator. | + +**Other as-built adjustments (not bugs):** +- Tasks 14–21 were committed as one combined commit `616e3be` instead of 8 separate commits — the plan's per-task commit cadence was dropped in favour of fewer, larger commits once the test-fix-verify cycle accelerated. +- The `uv.lock` file is intentionally untracked. The package is pip-installable (`pip install -e .`) and the lockfile is not required for the dev workflow. + +--- + ## File structure ``` @@ -393,9 +420,10 @@ class AckTimeoutError(CyclonePipelineError): """An expected inbound ACK did not arrive within the configured timeout window. Soft-fail — the submission is in flight.""" - def __init__(self, ack_kind: str, timeout_s: int): + def __init__(self, ack_kind: str, timeout_s: float): self.ack_kind = ack_kind # "ta1" | "999" | "277ca" | "835" self.timeout_s = timeout_s + self.phase = f"wait_{ack_kind}" super().__init__( f"No {ack_kind} ACK arrived within {timeout_s}s; " f"submission is in flight — check Gainwell directly." @@ -1984,7 +2012,7 @@ async def wait_for( if result is not None: return result if time.monotonic() >= deadline: - raise AckTimeoutError(label, int(timeout_s)) + raise AckTimeoutError(label, timeout_s) await asyncio.sleep(poll_interval_s) ``` @@ -2084,7 +2112,7 @@ def test_markdown_contains_phase_table(sample_report, tmp_path): assert "# Cyclone pipeline run" in text assert "PASS" in text assert "Phase summary" in text - assert "| 1. Pre-flight" in text or "| 1." in text + assert "| 1 | preflight" in text assert "check-835" in text assert "Monday" in text assert "01-preflight.png" in text @@ -2186,12 +2214,29 @@ def write_report_md(report: RunReport, path: Path) -> None: lines.append("") lines.append( f"- **835 expected by {report.expected_835_by}** " - f"(CO Medicaid payment cycle)." + f"(typically the following Monday; CO Medicaid payment cycle)." ) cmd = report.next_steps.get("check_835_cmd") if cmd: lines.append(f" - Re-run: `{cmd}`") lines.append("") + # Remediation section — only when the run is not a clean pass. + if report.result in ("soft_fail", "hard_fail"): + lines.append("## Remediation") + lines.append("") + timed_out = [p for p in report.phases if p.status == "timeout"] + if timed_out: + names = ", ".join(p.name for p in timed_out) + lines.append( + f"- ACK timeout(s) in: {names}. Submission is in flight " + f"— check Gainwell directly for the inbound 999." + ) + if report.result == "hard_fail": + lines.append( + "- Hard failure: review the run logs and any screenshots " + f"in the `screenshots/` directory before retrying." + ) + lines.append("") lines.append("## Artifacts") lines.append("") for s in report.screenshots: @@ -2433,7 +2478,7 @@ from pathlib import Path from cyclone_pipeline.pipeline import CyclonePipeline, PipelineConfig from cyclone_pipeline.state import RunState from cyclone_pipeline.exceptions import ( - BrowserNotReachableError, HealthSnapshot as _Ignored, # noqa + BrowserNotReachableError, ) @@ -2474,9 +2519,9 @@ async def test_phase1_preflight_passes_when_backend_healthy(tmp_path: Path): run_dir=tmp_path / "runs", headless=True, ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-001" - result = await p.phase1_preflight() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-001" + result = await p.phase1_preflight() assert result.status == "ok" assert "scheduler_state" in result.detail @@ -2511,10 +2556,10 @@ async def test_phase1_preflight_fails_fast_if_browser_down(tmp_path: Path): run_dir=tmp_path / "runs", headless=True, ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-001" - with pytest.raises(BrowserNotReachableError): - await p.phase1_preflight() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-001" + with pytest.raises(BrowserNotReachableError): + await p.phase1_preflight() ``` - [ ] **Step 2: Run test to verify it fails** @@ -2547,12 +2592,14 @@ import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import Any +from typing import Any, Sequence import httpx import structlog +from playwright.async_api import async_playwright from cyclone_pipeline.api_client import CycloneClient +from cyclone_pipeline.browser import UploadPage from cyclone_pipeline.exceptions import ( BrowserNotReachableError, CyclonePipelineError, UploadError, ParseError, SubmitError, AckTimeoutError, IdempotencyError, @@ -2560,7 +2607,7 @@ from cyclone_pipeline.exceptions import ( from cyclone_pipeline.logging_setup import get_logger from cyclone_pipeline.report import PhaseResult, RunReport from cyclone_pipeline.screenshots import ScreenshotWriter -from cyclone_pipeline.state import RunState +from cyclone_pipeline.state import PhaseRecord, RunState log = get_logger("cyclone_pipeline.pipeline") @@ -2706,39 +2753,39 @@ async def test_phase2_upload_retries_on_failure(tmp_path: Path): run_dir=tmp_path / "runs", headless=True, ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-002" + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-002" - fake_page = AsyncMock() - fake_page.goto = AsyncMock() - fake_upload = AsyncMock() - # First two attempts raise, third succeeds - fake_upload.attach_file = AsyncMock( - side_effect=[UploadError("flaky"), UploadError("flaky"), None] - ) - fake_upload.click_parse = AsyncMock() - fake_upload.wait_for_progress_complete = AsyncMock() - - with patch( - "cyclone_pipeline.pipeline.async_playwright" - ) as mock_pw: - mock_browser = AsyncMock() - mock_pw.return_value.__aenter__.return_value.chromium.launch = AsyncMock( - return_value=mock_browser + fake_page = AsyncMock() + fake_page.goto = AsyncMock() + fake_upload = AsyncMock() + # First two attempts raise, third succeeds + fake_upload.attach_file = AsyncMock( + side_effect=[UploadError("flaky"), UploadError("flaky"), None] ) - mock_browser.new_page = AsyncMock(return_value=fake_page) + fake_upload.click_parse = AsyncMock() + fake_upload.wait_for_progress_complete = AsyncMock() - # Stub the screenshot writer - p._writer = ScreenshotWriter(tmp_path, "test-run-002") + with patch( + "cyclone_pipeline.pipeline.async_playwright" + ) as mock_pw: + mock_browser = AsyncMock() + mock_pw.return_value.__aenter__.return_value.chromium.launch = AsyncMock( + return_value=mock_browser + ) + mock_browser.new_page = AsyncMock(return_value=fake_page) - # Replace UploadPage class with a stub - with patch("cyclone_pipeline.pipeline.UploadPage") as UP: - UP.return_value = fake_upload - UP.return_value.goto = AsyncMock() - result = await p.phase2_upload(max_attempts=3) - assert result.status == "ok" - assert result.attempts == 3 - assert fake_upload.attach_file.call_count == 3 + # Stub the screenshot writer + p._writer = ScreenshotWriter(tmp_path, "test-run-002") + + # Replace UploadPage class with a stub + with patch("cyclone_pipeline.pipeline.UploadPage") as UP: + UP.return_value = fake_upload + UP.return_value.goto = AsyncMock() + result = await p.phase2_upload(max_attempts=3) + assert result.status == "ok" + assert result.attempts == 3 + assert fake_upload.attach_file.call_count == 3 ``` - [ ] **Step 2: Run test to verify it fails** @@ -2763,9 +2810,6 @@ Append to `cyclone_pipeline/pipeline.py`: last_exc: Exception | None = None for attempt in range(1, max_attempts + 1): try: - from playwright.async_api import async_playwright - from cyclone_pipeline.browser import UploadPage - async with async_playwright() as pw: browser = await pw.chromium.launch( headless=self.cfg.headless @@ -2868,9 +2912,9 @@ async def test_phase3_verify_parse_collects_new_claim_ids(tmp_path: Path): browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-003" - result = await p.phase3_verify_parse() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-003" + result = await p.phase3_verify_parse() assert result.status == "ok" assert p.run_state.pending_data["claim_ids"] == ["CLM-1", "CLM-2"] @@ -2887,10 +2931,10 @@ async def test_phase3_verify_parse_fails_on_zero_claims(tmp_path: Path): browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-003b" - with pytest.raises(ParseError): - await p.phase3_verify_parse(timeout_s=0.2) + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-003b" + with pytest.raises(ParseError): + await p.phase3_verify_parse(timeout_s=0.2) ``` - [ ] **Step 2: Run test to verify it fails** @@ -2995,10 +3039,10 @@ async def test_phase4_submit_stores_submission_data(tmp_path: Path): browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-004" - p.run_state.pending_data["claim_ids"] = ["CLM-1", "CLM-2"] - result = await p.phase4_submit() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-004" + p.run_state.pending_data["claim_ids"] = ["CLM-1", "CLM-2"] + result = await p.phase4_submit() assert result.status == "ok" assert p.run_state.pending_data["submission_id"] == "SUB-42" assert p.run_state.pending_data["submission_filename"].endswith(".txt") @@ -3019,10 +3063,10 @@ async def test_phase4_submit_propagates_idempotency_error(tmp_path: Path): browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-004b" - with pytest.raises(IdempotencyError): - await p.phase4_submit() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-004b" + with pytest.raises(IdempotencyError): + await p.phase4_submit() ``` - [ ] **Step 2: Run test to verify it fails** @@ -3128,13 +3172,13 @@ async def test_phase5_wait_ta1_returns_matching_ack(tmp_path: Path): ta1_timeout_s=2.0, poll_interval_s=0.05, ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-005" - p.run_state.pending_data["submission_filename"] = ( - "11525703-837P-…-1of1.txt" - ) - p.run_state.pending_data["claim_ids"] = ["CLM-1"] - result = await p.phase5_wait_ta1() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-005" + p.run_state.pending_data["submission_filename"] = ( + "11525703-837P-…-1of1.txt" + ) + p.run_state.pending_data["claim_ids"] = ["CLM-1"] + result = await p.phase5_wait_ta1() assert result.status == "ok" assert result.detail["ta1_id"] == "TA1-42" @@ -3152,11 +3196,11 @@ async def test_phase5_wait_ta1_timeout_returns_status_timeout(tmp_path: Path): ta1_timeout_s=0.2, poll_interval_s=0.05, ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-005b" - p.run_state.pending_data["submission_filename"] = "x.txt" - p.run_state.pending_data["claim_ids"] = ["CLM-1"] - result = await p.phase5_wait_ta1() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-005b" + p.run_state.pending_data["submission_filename"] = "x.txt" + p.run_state.pending_data["claim_ids"] = ["CLM-1"] + result = await p.phase5_wait_ta1() assert result.status == "timeout" # Pipeline continues; this is soft-fail. ``` @@ -3261,11 +3305,11 @@ async def test_phase6_wait_999_returns_matching_ack(tmp_path: Path): ack_999_timeout_s=2.0, poll_interval_s=0.05, ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-006" - p.run_state.pending_data["submission_filename"] = "x.txt" - p.run_state.pending_data["claim_ids"] = ["CLM-1"] - result = await p.phase6_wait_999() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-006" + p.run_state.pending_data["submission_filename"] = "x.txt" + p.run_state.pending_data["claim_ids"] = ["CLM-1"] + result = await p.phase6_wait_999() assert result.status == "ok" assert result.detail["ack_999_id"] == "999-43" @@ -3288,12 +3332,12 @@ async def test_phase6_wait_999_rejected_is_hard_fail(tmp_path: Path): ack_999_timeout_s=2.0, poll_interval_s=0.05, ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-006b" - p.run_state.pending_data["submission_filename"] = "x.txt" - p.run_state.pending_data["claim_ids"] = ["CLM-1"] - with pytest.raises(CyclonePipelineError) as exc_info: - await p.phase6_wait_999() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-006b" + p.run_state.pending_data["submission_filename"] = "x.txt" + p.run_state.pending_data["claim_ids"] = ["CLM-1"] + with pytest.raises(CyclonePipelineError) as exc_info: + await p.phase6_wait_999() assert "rejected" in str(exc_info.value).lower() ``` @@ -3405,15 +3449,15 @@ async def test_phase7_scan_html_and_write_reports(tmp_path: Path): browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "test-run-007" - # Seed a couple of completed phases to make the report non-empty - from cyclone_pipeline.report import PhaseResult - p.run_state.completed_phases = [ - PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4), - PhaseResult(n=2, name="upload", status="ok", duration_s=2.0), - ] - result = await p.phase7_scan_and_report() + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "test-run-007" + # Seed a couple of completed phases to make the report non-empty + from cyclone_pipeline.report import PhaseResult + p.run_state.completed_phases = [ + PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4), + PhaseResult(n=2, name="upload", status="ok", duration_s=2.0), + ] + result = await p.phase7_scan_and_report() assert result.status == "warn" # The two report files were written run_dir = tmp_path / "runs" / "test-run-007" @@ -3562,7 +3606,9 @@ Append to `cyclone_pipeline/pipeline.py`: duration_s=sum( p.duration_s for p in self.run_state.completed_phases ), - phases=list(self.run_state.completed_phases), + phases=self._phase_records_to_results( + self.run_state.completed_phases + ), expected_835_by=expected_835, next_steps={ "check_835_cmd": ( @@ -3647,6 +3693,18 @@ Append to `cyclone_pipeline/pipeline.py`: # --- helpers --------------------------------------------------------------- +def _phase_records_to_results( + records: Sequence[PhaseRecord], +) -> list[PhaseResult]: + """Convert PhaseRecord (state.py) → PhaseResult (report.py). + + Both models have the same fields, but pydantic v2 doesn't + auto-coerce between distinct model classes, so we go via + `model_dump()`. Called when building the final RunReport. + """ + return [PhaseResult(**r.model_dump()) for r in records] + + def _next_monday_iso() -> str: """Return YYYY-MM-DD of the next expected 835 arrival. Computed from today in Mountain Time. If today is Monday before @@ -3705,62 +3763,65 @@ async def test_resume_skips_completed_phases(tmp_path: Path): browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", ) - p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") - p._run_id = "resume-run-001" - run_dir = tmp_path / "runs" / "resume-run-001" - run_dir.mkdir(parents=True, exist_ok=True) - # Write a state file with phases 1, 2, 3 done - from cyclone_pipeline.report import PhaseResult - p.run_state.completed_phases = [ - PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4), - PhaseResult(n=2, name="upload", status="ok", duration_s=2.0), - PhaseResult(n=3, name="verify_parse", status="ok", duration_s=0.8, - detail={"claim_ids": ["CLM-1"]}), - ] - p.run_state.write(run_dir / "run.state.json") - p.run_state.pending_data["claim_ids"] = ["CLM-1"] - # Stub out phase 4 — just confirm it gets called - called = {"phase4": False} - async def fake_phase4(): - called["phase4"] = True - return PhaseResult(n=4, name="submit", status="ok", duration_s=1.0) - p.phase4_submit = fake_phase4 # type: ignore[assignment] - # Stub out phases 5, 6, 7 to short-circuit - p.phase5_wait_ta1 = AsyncMock(return_value=PhaseResult( # type: ignore[assignment] - n=5, name="wait_ta1", status="ok", duration_s=0.1 - )) - p.phase6_wait_999 = AsyncMock(return_value=PhaseResult( # type: ignore[assignment] - n=6, name="wait_999", status="ok", duration_s=0.1 - )) - p.phase7_scan_and_report = AsyncMock(return_value=PhaseResult( # type: ignore[assignment] - n=7, name="scan_and_report", status="ok", duration_s=0.1 - )) + async with CyclonePipeline(cfg, input_path=tmp_path / "x.edi") as p: + p._run_id = "resume-run-001" + run_dir = tmp_path / "runs" / "resume-run-001" + run_dir.mkdir(parents=True, exist_ok=True) + # Write a state file with phases 1, 2, 3 done + from cyclone_pipeline.report import PhaseResult + p.run_state.completed_phases = [ + PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4), + PhaseResult(n=2, name="upload", status="ok", duration_s=2.0), + PhaseResult(n=3, name="verify_parse", status="ok", duration_s=0.8, + detail={"claim_ids": ["CLM-1"]}), + ] + p.run_state.write(run_dir / "run.state.json") + p.run_state.pending_data["claim_ids"] = ["CLM-1"] + # Stub out phase 4 — just confirm it gets called + called = {"phase4": False} + async def fake_phase4(): + called["phase4"] = True + result = PhaseResult(n=4, name="submit", status="ok", duration_s=1.0) + p.run_state.completed_phases.append(result) + return result + p.phase4_submit = fake_phase4 # type: ignore[assignment] - # Mock the API responses needed for phases 4-7 - respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock( - return_value=Response(200, json={ - "submission_id": "SUB-42", - "filename": "x.txt", "claim_count": 1, - }) - ) - respx.get("http://127.0.0.1:8000/api/ta1-acks").mock( - return_value=Response(200, json=[{ - "id": "TA1-1", "isa13": "1", "accepted": True, - "received_at": "2026-06-21T15:30:00Z", - }]) - ) - respx.get("http://127.0.0.1:8000/api/acks").mock( - return_value=Response(200, json=[{ - "id": "999-1", "status": "A", - "original_filename": "x.txt", - "received_at": "2026-06-21T15:30:00Z", - }]) - ) - respx.get("http://127.0.0.1:8000/api/admin/scheduler/processed-files").mock( - return_value=Response(200, json=[]) - ) + def _make_stub(n: int, name: str): + """Stub factory: appends the result to completed_phases so + resume() sees them in the final report.""" + async def stub(): + result = PhaseResult(n=n, name=name, status="ok", duration_s=0.1) + p.run_state.completed_phases.append(result) + return result + return stub + p.phase5_wait_ta1 = _make_stub(5, "wait_ta1") # type: ignore[assignment] + p.phase6_wait_999 = _make_stub(6, "wait_999") # type: ignore[assignment] + p.phase7_scan_and_report = _make_stub(7, "scan_and_report") # type: ignore[assignment] + + # Mock the API responses needed for phases 4-7 + respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock( + return_value=Response(200, json={ + "submission_id": "SUB-42", + "filename": "x.txt", "claim_count": 1, + }) + ) + respx.get("http://127.0.0.1:8000/api/ta1-acks").mock( + return_value=Response(200, json=[{ + "id": "TA1-1", "isa13": "1", "accepted": True, + "received_at": "2026-06-21T15:30:00Z", + }]) + ) + respx.get("http://127.0.0.1:8000/api/acks").mock( + return_value=Response(200, json=[{ + "id": "999-1", "status": "A", + "original_filename": "x.txt", + "received_at": "2026-06-21T15:30:00Z", + }]) + ) + respx.get("http://127.0.0.1:8000/api/admin/scheduler/processed-files").mock( + return_value=Response(200, json=[]) + ) - async with p: report = await p.resume("resume-run-001") assert called["phase4"] is True assert len(report.phases) >= 4 @@ -4111,7 +4172,7 @@ def _run_dir_default() -> Path: return Path.cwd() / "runs" -@click.group() +@click.group(context_settings={"allow_interspersed_args": True}) @click.option( "--api-base", default="http://127.0.0.1:8000", envvar="CYCLONE_API_BASE",