# Cyclone Pipeline Agent Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a sibling Python package `cyclone-pipeline` that automates the full EDI 837P submission lifecycle through Cyclone to the Gainwell MFT, waits for the inbound TA1 + 999 ACKs, and writes a self-contained run directory. Stops before 835 (next Monday) which is checked via a separate subcommand. **Architecture:** API-first + minimal Playwright shell. Browser is used only for the upload page; everything else (parse verification, SFTP submit, scheduler control, ACK polling, claim-state checks) goes through Cyclone's published HTTP API via `httpx.AsyncClient`. The agent lives at `/Users/openclaw/dev/cyclone-pipeline/`, never imports from cyclone, and talks to cyclone exclusively over HTTP. **Tech Stack:** Python 3.11+, `httpx` (async HTTP), `playwright` (browser), `click` (CLI), `pydantic` v2 (typed I/O), `structlog` (JSON logging). Dev: `pytest`, `pytest-asyncio`, `respx` (httpx mock), `pytest-playwright` (browser tests). **Spec:** [`docs/superpowers/specs/2026-06-21-cyclone-pipeline-agent-design.md`](../specs/2026-06-21-cyclone-pipeline-agent-design.md) --- ## File structure ``` /Users/openclaw/dev/cyclone-pipeline/ ├── pyproject.toml ← Task 1 ├── README.md ← Task 24 ├── src/cyclone_pipeline/ │ ├── __init__.py ← Task 1 (re-exports) │ ├── __main__.py ← Task 1 │ ├── exceptions.py ← Task 2 │ ├── logging_setup.py ← Task 3 │ ├── api_client.py ← Tasks 4–7 │ ├── state.py ← Task 8 │ ├── screenshots.py ← Task 9 │ ├── selectors.py ← Task 10 │ ├── waiters.py ← Task 11 │ ├── report.py ← Task 12 │ ├── browser.py ← Task 13 │ ├── pipeline.py ← Tasks 14–21 │ ├── check_835.py ← Task 22 │ └── cli.py ← Task 23 ├── tests/ │ ├── conftest.py ← Task 1 │ ├── test_exceptions.py ← Task 2 │ ├── test_logging_setup.py ← Task 3 │ ├── test_api_client.py ← Tasks 4–7 │ ├── test_state.py ← Task 8 │ ├── test_screenshots.py ← Task 9 │ ├── test_selectors.py ← Task 10 │ ├── test_waiters.py ← Task 11 │ ├── test_report.py ← Task 12 │ ├── test_browser.py ← Task 13 │ ├── test_pipeline.py ← Tasks 14–21 │ ├── test_check_835.py ← Task 22 │ ├── test_cli.py ← Task 23 │ └── fixtures/ │ └── sample_837p.edi ← Task 1 └── .github/workflows/ci.yml ← Task 25 ``` Each file has one clear responsibility. The `api_client.py` is the largest by design — it owns all HTTP shape. The `pipeline.py` is the orchestrator; everything else is a leaf module. --- ## Task 0: Pre-flight — capture baseline **Goal:** Confirm cyclone is running and the existing test suite still passes. Snapshot the API surface so we know what to mock. **Files:** - Read: `backend/src/cyclone/api.py` (line counts; we mock the endpoints we need) - Write: `/tmp/cyclone-pipeline-baseline.txt` - [ ] **Step 1: Verify cyclone backend is reachable** ```bash curl -fsS http://127.0.0.1:8000/api/health | python -m json.tool ``` Expected: JSON with `"status": "ok"`, `"db": {"ok": true}`. If backend isn't running, start it: `cd /Users/openclaw/dev/cyclone/backend && .venv/bin/python -m cyclone serve &`. - [ ] **Step 2: Snapshot the API endpoints we'll consume** Run these and record the exact response shapes — we mock them in tests: ```bash curl -fsS http://127.0.0.1:8000/api/health | tee /tmp/api-health.json curl -fsS http://127.0.0.1:8000/api/admin/scheduler/status | tee /tmp/api-scheduler-status.json curl -fsS "http://127.0.0.1:8000/api/ta1-acks?limit=1" | tee /tmp/api-ta1-acks.json curl -fsS "http://127.0.0.1:8000/api/acks?limit=1" | tee /tmp/api-acks.json curl -fsS "http://127.0.0.1:8000/api/277ca-acks?limit=1" | tee /tmp/api-277ca-acks.json curl -fsS "http://127.0.0.1:8000/api/claims?limit=1" | tee /tmp/api-claims.json ``` Expected: each command prints a JSON object or array. Some may return 401/403 if the API has changed — if so, the spec's endpoint list needs updating. Report back to the user. - [ ] **Step 3: Verify the create dir target doesn't exist yet** ```bash ls /Users/openclaw/dev/cyclone-pipeline 2>/dev/null && echo "EXISTS" || echo "OK: not yet created" ``` Expected: `OK: not yet created`. If it exists, the operator has already started this work — pause and ask. No commit. Pre-flight only. --- ## Task 1: Project skeleton + install **Goal:** Create the directory tree, `pyproject.toml`, empty package files, install in editable mode, verify import works. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/` (root dir) - Create: `/Users/openclaw/dev/cyclone-pipeline/pyproject.toml` - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/__init__.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/__main__.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/__init__.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/conftest.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/fixtures/sample_837p.edi` - Create: `/Users/openclaw/dev/cyclone-pipeline/.gitignore` - Create: `/Users/openclaw/dev/cyclone-pipeline/README.md` (placeholder, expanded in Task 24) - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/.gitkeep` (placeholder, removed as files are added) - [ ] **Step 1: Create the directory tree** ```bash mkdir -p /Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline mkdir -p /Users/openclaw/dev/cyclone-pipeline/tests/fixtures cd /Users/openclaw/dev/cyclone-pipeline git init -q ``` - [ ] **Step 2: Write `pyproject.toml`** ```toml [build-system] requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [project] name = "cyclone-pipeline" version = "0.1.0" description = "Cyclone EDI pipeline agent — automate 837P upload + SFTP submit + ACK wait" requires-python = ">=3.11" dependencies = [ "httpx>=0.27,<1", "playwright>=1.44,<2", "click>=8.1,<9", "pydantic>=2.6,<3", "structlog>=24.1,<25", ] [project.optional-dependencies] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23,<1", "pytest-playwright>=0.5", "respx>=0.21,<1", "pytest-cov>=4.1", ] [project.scripts] cyclone-pipeline = "cyclone_pipeline.cli:main" [tool.setuptools.packages.find] where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra --strict-markers" asyncio_mode = "auto" markers = [ "browser: requires real Playwright browser (deselect with '-m \"not browser\"')", ] ``` - [ ] **Step 3: Write empty package + test files** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/__init__.py`: ```python """Cyclone pipeline agent — automate 837P upload, SFTP submit, and ACK wait.""" __version__ = "0.1.0" ``` Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/__main__.py`: ```python from cyclone_pipeline.cli import main if __name__ == "__main__": main() ``` Write `/Users/openclaw/dev/cyclone-pipeline/tests/__init__.py`: (empty file) Write `/Users/openclaw/dev/cyclone-pipeline/tests/conftest.py`: ```python """Shared pytest fixtures for cyclone-pipeline tests.""" import pytest from pathlib import Path FIXTURES_DIR = Path(__file__).parent / "fixtures" @pytest.fixture def sample_837p_path() -> Path: """Path to a small valid 837P file used across tests.""" return FIXTURES_DIR / "sample_837p.edi" @pytest.fixture def sample_837p_bytes() -> bytes: return FIXTURES_DIR.read_bytes() ``` - [ ] **Step 4: Copy the minimal 837P fixture from cyclone** ```bash cp /Users/openclaw/dev/cyclone/backend/tests/fixtures/minimal_837p.txt /Users/openclaw/dev/cyclone-pipeline/tests/fixtures/sample_837p.edi ``` - [ ] **Step 5: Write `.gitignore`** ```gitignore __pycache__/ *.pyc *.egg-info/ .pytest_cache/ .coverage htmlcov/ .venv/ runs/ playwright-report/ test-results/ ``` - [ ] **Step 6: Write README placeholder** ```markdown # cyclone-pipeline Automation agent for Cyclone EDI submissions. See [docs/superpowers/specs/2026-06-21-cyclone-pipeline-agent-design.md](https://github.com/.../blob/main/docs/superpowers/specs/2026-06-21-cyclone-pipeline-agent-design.md) for the full design. ``` - [ ] **Step 7: Create venv, install, verify import** ```bash cd /Users/openclaw/dev/cyclone-pipeline python3.11 -m venv .venv source .venv/bin/activate pip install -e '.[dev]' --quiet python -c "import cyclone_pipeline; print('OK:', cyclone_pipeline.__version__)" ``` Expected: `OK: 0.1.0`. - [ ] **Step 8: Verify pytest can discover the test tree** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest --collect-only -q ``` Expected: `no tests ran` (or `0 tests collected`). No errors. - [ ] **Step 9: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add . git commit -m "feat(sp22): project skeleton + install in editable mode" ``` --- ## Task 2: Exceptions module **Goal:** Typed exceptions that the rest of the code can catch and the report can serialize. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/exceptions.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_exceptions.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_exceptions.py`: ```python """Typed exceptions — caught by callers, serialized by report.py.""" import pytest from cyclone_pipeline.exceptions import ( CyclonePipelineError, UploadError, ParseError, SubmitError, AckTimeoutError, SchedulerNotRunningError, IdempotencyError, BrowserNotReachableError, ) def test_all_inherit_from_base(): """Every typed error must inherit from CyclonePipelineError so callers can `except CyclonePipelineError` for a single catch-all.""" assert issubclass(UploadError, CyclonePipelineError) assert issubclass(ParseError, CyclonePipelineError) assert issubclass(SubmitError, CyclonePipelineError) assert issubclass(AckTimeoutError, CyclonePipelineError) assert issubclass(SchedulerNotRunningError, CyclonePipelineError) assert issubclass(IdempotencyError, CyclonePipelineError) assert issubclass(BrowserNotReachableError, CyclonePipelineError) def test_ack_timeout_carries_phase_and_ack_kind(): """AckTimeoutError should expose which phase timed out and which ACK was missing — the report needs both to render the right remediation hint.""" err = AckTimeoutError("ta1", timeout_s=300) assert err.phase == "wait_ta1" assert err.ack_kind == "ta1" assert err.timeout_s == 300 assert "ta1" in str(err).lower() assert "300" in str(err) def test_idempotency_error_lists_collisions(): """IdempotencyError should carry the list of recent submissions that conflict — the report needs them to render the 'already submitted' warning.""" err = IdempotencyError( ["11525703-837P-20260620181814559-1of1.txt", "11525703-837P-20260620182058568-1of1.txt"], ) assert len(err.collisions) == 2 assert "11525703-837P-20260620181814559-1of1.txt" in str(err) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_exceptions.py -v ``` Expected: ImportError (exceptions module doesn't exist yet). - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/exceptions.py`: ```python """Typed exceptions for the cyclone-pipeline agent. Every pipeline-level error inherits from `CyclonePipelineError` so callers (the CLI, the library, the report) can do a single `except` to catch anything we throw. Each subclass carries enough structured context for `report.py` to render a useful remediation hint. """ from __future__ import annotations from typing import Sequence class CyclonePipelineError(Exception): """Base class for every typed error the agent raises.""" class UploadError(CyclonePipelineError): """The browser-driven upload phase failed irrecoverably. Distinct from `ParseError` (the file was uploaded but the parser rejected it) and from `SubmitError` (parsing succeeded but the SFTP submit failed). """ class ParseError(CyclonePipelineError): """The file was uploaded but the parser found no claims in it (or the API returned 0 new claim IDs after a 30s poll).""" class SubmitError(CyclonePipelineError): """`POST /api/clearhouse/submit` failed in a way the agent considers terminal (e.g., the Keychain is misconfigured, the SFTP server returned 5xx after retries, the dedup check tripped without `--force`). """ 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): self.ack_kind = ack_kind # "ta1" | "999" | "277ca" | "835" self.timeout_s = timeout_s super().__init__( f"No {ack_kind} ACK arrived within {timeout_s}s; " f"submission is in flight — check Gainwell directly." ) class SchedulerNotRunningError(CyclonePipelineError): """The MFT scheduler is stopped and `tick` calls are also failing. The agent cannot fetch ACKs without a working MFT loop.""" class IdempotencyError(SubmitError): """A recent SFTP submission matches the file we're about to submit (same `claim_id` set within the dedup window). Use `--force` to override. """ def __init__(self, collisions: Sequence[str]): self.collisions = list(collisions) super().__init__( f"Refusing to submit — {len(self.collisions)} recent " f"submission(s) already cover this batch's claim_ids. " f"Use --force to override. Collisions: {self.collisions}" ) class BrowserNotReachableError(CyclonePipelineError): """The Vite dev server is not reachable on the configured browser base URL. The upload phase can't run without it. Check that `npm run dev` is up.""" def __init__(self, browser_base: str): self.browser_base = browser_base super().__init__( f"Browser-side URL {browser_base!r} is not reachable. " f"Start the frontend with `npm run dev` (default " f"http://localhost:5173) or pass --browser-base." ) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_exceptions.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/exceptions.py tests/test_exceptions.py git commit -m "feat(sp22): typed exceptions (UploadError, ParseError, AckTimeoutError, etc.)" ``` --- ## Task 3: Logging setup **Goal:** `structlog` configured for JSON output with the same shape as cyclone's SP18 logs. Used by every other module. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/logging_setup.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_logging_setup.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_logging_setup.py`: ```python """Logging setup — JSON output to stderr, no PII scrubber needed (we never log claim content; only IDs and paths).""" import io import json import logging import structlog import pytest from cyclone_pipeline.logging_setup import setup_logging, get_logger @pytest.fixture def captured_stderr(monkeypatch): """Redirect structlog's stderr output to a StringIO for assertions.""" buf = io.StringIO() def _capture(_self, msg): buf.write(str(msg) + "\n") return None # Replace the ConsoleRenderer/JSONRenderer chain with a capture sink monkeypatch.setattr( "structlog.PrintLoggerFactory", lambda file=None: structlog.PrintLoggerFactory(file=buf) ) # Simpler: directly patch write to stderr import sys real_stderr = sys.stderr sys.stderr = io.StringIO() yield sys.stderr sys.stderr = real_stderr # Write captured output back to the test buffer buf.write(sys.stderr.getvalue() if hasattr(sys.stderr, "getvalue") else "") def test_setup_logging_emits_json(caplog): """After setup_logging, log records render as one JSON object per line with the keys cyclone uses (ts, level, logger, msg, extra).""" setup_logging(level="INFO") log = get_logger("cyclone_pipeline.test") log.info("hello", claim_id="CLM-1") captured = caplog.text assert "hello" in captured # The structlog JSON renderer writes to stderr, not caplog by default. # Use a direct capture: import sys real = sys.stderr sys.stderr = io.StringIO() try: setup_logging(level="INFO") log = get_logger("cyclone_pipeline.test") log.info("hello", claim_id="CLM-1") out = sys.stderr.getvalue() finally: sys.stderr = real lines = [ln for ln in out.splitlines() if ln.startswith("{")] assert lines, f"expected JSON line on stderr, got: {out!r}" rec = json.loads(lines[-1]) assert rec["event"] == "hello" assert rec["claim_id"] == "CLM-1" assert rec["level"] == "info" def test_get_logger_returns_named_logger(): log = get_logger("foo.bar") assert isinstance(log, structlog.stdlib.BoundLogger) assert log.bind is not None ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_logging_setup.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/logging_setup.py`: ```python """Structured JSON logging — matches cyclone's SP18 style. Every record is one line of JSON on stderr with keys: `event`, `level`, `timestamp`, `logger`. Optional kwargs are merged into the top-level record. No PII scrubber — the agent only logs IDs and paths, never claim content. """ from __future__ import annotations import logging import sys import structlog def setup_logging(level: str = "INFO") -> None: """Configure structlog for JSON output to stderr. Safe to call multiple times — `structlog.configure` is idempotent. """ timestamper = structlog.processors.TimeStamper(fmt="iso", utc=True) shared_processors = [ structlog.contextvars.merge_contextvars, structlog.stdlib.add_log_level, timestamper, structlog.processors.StackInfoRenderer(), ] structlog.configure( processors=shared_processors + [ structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], logger_factory=structlog.stdlib.LoggerFactory(), wrapper_class=structlog.stdlib.BoundLogger, cache_logger_on_first_use=True, ) formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=shared_processors, processor=structlog.processors.JSONRenderer(), ) handler = logging.StreamHandler(sys.stderr) handler.setFormatter(formatter) root = logging.getLogger() # Replace handlers to avoid double-logging if called twice root.handlers = [handler] root.setLevel(level.upper()) def get_logger(name: str) -> structlog.stdlib.BoundLogger: """Return a bound structlog logger. Call `setup_logging()` first.""" return structlog.get_logger(name) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_logging_setup.py -v ``` Expected: 2 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/logging_setup.py tests/test_logging_setup.py git commit -m "feat(sp22): structlog JSON logging matching cyclone SP18 style" ``` --- ## Task 4: API client — health + scheduler status **Goal:** First slice of the `CycloneClient` async HTTP wrapper. Two methods: `health()` and `scheduler_status()`. Both retry on idempotent failures. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/api_client.py` (create) - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_api_client.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_api_client.py`: ```python """CycloneClient — async HTTP wrapper for cyclone's published API. Methods are typed and return Pydantic models where the response shape is stable. All methods retry on idempotent failures (5xx, connection errors) with `1s → 2s → 4s` backoff, max 3 attempts. """ import pytest import respx from httpx import Response, AsyncClient, HTTPError from pydantic import BaseModel from cyclone_pipeline.api_client import CycloneClient, HealthSnapshot, SchedulerStatus @pytest.fixture def client(): return CycloneClient(api_base="http://test:8000") @pytest.mark.asyncio @respx.mock async def test_health_returns_typed_snapshot(client): respx.get("http://test:8000/api/health").mock( return_value=Response( 200, json={ "status": "ok", "version": "0.1.0", "db": {"ok": True}, "scheduler": {"running": True, "interval_s": 60}, "pubsub": {}, "batch": {}, }, ) ) snap = await client.health() assert isinstance(snap, HealthSnapshot) assert snap.status == "ok" assert snap.db_ok is True assert snap.scheduler_running is True @pytest.mark.asyncio @respx.mock async def test_health_retries_on_5xx(client): route = respx.get("http://test:8000/api/health").mock( side_effect=[ Response(503, json={"error": "db down"}), Response(200, json={ "status": "ok", "version": "0.1.0", "db": {"ok": True}, "scheduler": {"running": True, "interval_s": 60}, "pubsub": {}, "batch": {}, }), ] ) snap = await client.health() assert snap.status == "ok" assert route.call_count == 2 @pytest.mark.asyncio @respx.mock async def test_scheduler_status(client): respx.get("http://test:8000/api/admin/scheduler/status").mock( return_value=Response(200, json={ "running": True, "interval_s": 60, "sftp_block": "co_medicaid", "backup_scheduler_running": False, "backup_interval_hours": 24.0, "last_tick_at": "2026-06-21T15:30:00.123Z", }) ) s = await client.scheduler_status() assert isinstance(s, SchedulerStatus) assert s.running is True assert s.interval_s == 60 ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_api_client.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation (first slice)** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/api_client.py`: ```python """Async HTTP wrapper for cyclone's published FastAPI. Each method is async, typed, and retries idempotent failures (5xx, connect/read errors) with `1s → 2s → 4s` backoff, max 3 attempts. POSTs are not auto-retried after a successful send — the caller is responsible for dedup. Tests use `respx` to mock the routes. """ from __future__ import annotations import asyncio from typing import Any, Awaitable, Callable, TypeVar import httpx from pydantic import BaseModel, Field from cyclone_pipeline.logging_setup import get_logger log = get_logger("cyclone_pipeline.api_client") T = TypeVar("T", bound=BaseModel) DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=5.0) MAX_RETRIES = 3 BACKOFF_S = (1.0, 2.0, 4.0) # --- Response models --------------------------------------------------------- class PaginatedResponse(BaseModel): """Cyclone's list endpoints return a paginated wrapper: `{"items": [...], "total": N, "returned": N, "has_more": bool}`. Some endpoints (e.g., /api/ta1-acks) omit `returned` and `has_more`.""" items: list[dict[str, Any]] = Field(default_factory=list) total: int = 0 returned: int | None = None has_more: bool | None = None def _extract_items(raw: dict[str, Any] | list[Any]) -> list[dict[str, Any]]: """Unwrap a paginated response into a plain list. Tolerates both the paginated wrapper and a bare list (so tests can use either).""" if isinstance(raw, list): return raw if isinstance(raw, dict) and "items" in raw: return list(raw["items"]) raise ValueError( f"Unexpected API response shape; expected list or paginated " f"wrapper with 'items'. Got: {type(raw).__name__}" ) class HealthSnapshot(BaseModel): status: str version: str db_ok: bool scheduler_running: bool class SchedulerStatus(BaseModel): running: bool interval_s: int sftp_block: str | None = None backup_scheduler_running: bool = False backup_interval_hours: float = 24.0 last_tick_at: str | None = None # --- Client ------------------------------------------------------------------ class CycloneClient: """Async client for cyclone's API. Owns the `httpx.AsyncClient`.""" def __init__( self, api_base: str, timeout: httpx.Timeout | None = None, ): self.api_base = api_base.rstrip("/") self._timeout = timeout or DEFAULT_TIMEOUT self._http: httpx.AsyncClient | None = None async def __aenter__(self) -> "CycloneClient": self._http = httpx.AsyncClient( base_url=self.api_base, timeout=self._timeout ) return self async def __aexit__(self, *exc) -> None: if self._http is not None: await self._http.aclose() self._http = None # --- internal: idempotent GET with retry ------------------------------- async def _get_typed( self, path: str, model: type[T] ) -> T: assert self._http is not None, "use `async with CycloneClient(...)`" last_exc: Exception | None = None for attempt in range(MAX_RETRIES): try: resp = await self._http.get(path) resp.raise_for_status() return model.model_validate(resp.json()) except (httpx.HTTPStatusError, httpx.RequestError) as e: last_exc = e if attempt < MAX_RETRIES - 1: wait_s = BACKOFF_S[attempt] log.warning( "api_get_retry", path=path, attempt=attempt + 1, wait_s=wait_s, error=str(e), ) await asyncio.sleep(wait_s) assert last_exc is not None raise last_exc # --- methods ----------------------------------------------------------- async def health(self) -> HealthSnapshot: raw = await self._get_typed("/api/health", _RawHealth) return HealthSnapshot( status=raw.status, version=raw.version, db_ok=raw.db.get("ok", False), scheduler_running=raw.scheduler.get("running", False), ) async def scheduler_status(self) -> SchedulerStatus: return await self._get_typed( "/api/admin/scheduler/status", SchedulerStatus ) # --- Private wire-format models --------------------------------------------- # Cyclone's `/api/health` returns a richer shape than `HealthSnapshot` # exposes; the public method maps the wire format to the lean model. class _RawHealth(BaseModel): status: str version: str db: dict[str, Any] scheduler: dict[str, Any] pubsub: dict[str, Any] = Field(default_factory=dict) batch: dict[str, Any] = Field(default_factory=dict) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_api_client.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/api_client.py tests/test_api_client.py git commit -m "feat(sp22): CycloneClient — health + scheduler_status with retry" ``` --- ## Task 5: API client — claims + parse-837 (upload surrogate) **Goal:** Add `list_claims()` and `parse_837()` methods. The latter is used by the orchestrator as a fallback to the browser upload (and for tests that don't want to drive Playwright). **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/api_client.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_api_client.py` - [ ] **Step 1: Append failing tests to `test_api_client.py`** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_api_client.py`: ```python from cyclone_pipeline.api_client import ClaimSummary @pytest.mark.asyncio @respx.mock async def test_list_claims_filters_by_since(client): route = respx.get("http://test:8000/api/claims").mock( return_value=Response(200, json=[ {"id": "CLM-1", "submission_date": "2026-06-21T15:00:00Z"}, {"id": "CLM-2", "submission_date": "2026-06-21T16:00:00Z"}, ]) ) out = await client.list_claims(since="2026-06-21T15:30:00Z") assert len(out) == 2 assert out[0].id == "CLM-1" # Verify the since param was forwarded assert "since" in route.calls.last.request.url.params @pytest.mark.asyncio @respx.mock async def test_parse_837_posts_file_and_returns_summary(client, sample_837p_path): route = respx.post("http://test:8000/api/parse-837").mock( return_value=Response(200, json={ "total_claims": 1, "passed": 1, "failed": 0, "claims": [{"claim_id": "CLM-1"}], }) ) summary = await client.parse_837(sample_837p_path, payer="co_medicaid") assert summary.total_claims == 1 assert summary.claims[0]["claim_id"] == "CLM-1" # Confirm multipart upload content_type = route.calls.last.request.headers["content-type"] assert "multipart/form-data" in content_type ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_api_client.py::test_list_claims_filters_by_since -v ``` Expected: ImportError (ClaimSummary not defined). - [ ] **Step 3: Extend `api_client.py`** Add to `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/api_client.py` (in the "Response models" section near the top): ```python class ClaimSummary(BaseModel): id: str submission_date: str class ParseSummary(BaseModel): total_claims: int passed: int failed: int claims: list[dict[str, Any]] = Field(default_factory=list) ``` Add the methods to the `CycloneClient` class (below `scheduler_status`): ```python async def list_claims( self, since: str | None = None, limit: int = 100 ) -> list[ClaimSummary]: params: dict[str, str] = {"limit": str(limit)} if since: params["since"] = since assert self._http is not None last_exc: Exception | None = None for attempt in range(MAX_RETRIES): try: resp = await self._http.get( "/api/claims", params=params ) resp.raise_for_status() return [ ClaimSummary.model_validate(c) for c in _extract_items(resp.json()) ] except (httpx.HTTPStatusError, httpx.RequestError) as e: last_exc = e if attempt < MAX_RETRIES - 1: await asyncio.sleep(BACKOFF_S[attempt]) assert last_exc is not None raise last_exc async def parse_837( self, file_path, payer: str = "co_medicaid" ) -> ParseSummary: """POST the .edi/.txt file to cyclone's parse endpoint. Returns a typed summary. """ from pathlib import Path # local import keeps the top of the file lean path = Path(file_path) assert self._http is not None with path.open("rb") as f: resp = await self._http.post( "/api/parse-837", files={"file": (path.name, f, "text/plain")}, params={"payer": payer}, ) resp.raise_for_status() return ParseSummary.model_validate(resp.json()) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_api_client.py -v ``` Expected: 5 passed (the 3 from Task 4 + the 2 new). - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/api_client.py tests/test_api_client.py git commit -m "feat(sp22): CycloneClient — list_claims + parse_837 (file upload)" ``` --- ## Task 6: API client — clearhouse submit + scheduler tick + processed-files **Goal:** Add the SFTP submit method, the manual scheduler tick, and the processed-files listing used for dedup + HTML scanning. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/api_client.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_api_client.py` - [ ] **Step 1: Append failing tests** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_api_client.py`: ```python from cyclone_pipeline.api_client import ( SubmitResult, ProcessedFile, AckKind, ) from cyclone_pipeline.exceptions import IdempotencyError @pytest.mark.asyncio @respx.mock async def test_clearhouse_submit_returns_submission_id(client): respx.post("http://test:8000/api/clearhouse/submit").mock( return_value=Response(200, json={ "submission_id": "SUB-42", "filename": "11525703-837P-20260621143012345-1of1.txt", "claim_count": 17, }) ) result = await client.clearhouse_submit(payer_id="CO_TXIX", tx="837P") assert result.submission_id == "SUB-42" assert result.filename.endswith(".txt") assert result.claim_count == 17 @pytest.mark.asyncio @respx.mock async def test_clearhouse_submit_409_raises_idempotency_error(client): respx.post("http://test:8000/api/clearhouse/submit").mock( return_value=Response(409, json={ "error": "duplicate", "detail": "claim_ids already submitted in last 24h", "collisions": ["11525703-837P-20260620181814559-1of1.txt"], }) ) with pytest.raises(IdempotencyError) as exc_info: await client.clearhouse_submit(payer_id="CO_TXIX", tx="837P") assert "11525703-837P-20260620181814559-1of1.txt" in exc_info.value.collisions @pytest.mark.asyncio @respx.mock async def test_scheduler_tick(client): respx.post("http://test:8000/api/admin/scheduler/tick").mock( return_value=Response(200, json={ "ticked_at": "2026-06-21T15:30:00Z", "files_downloaded": 1, }) ) result = await client.scheduler_tick() assert result.files_downloaded == 1 @pytest.mark.asyncio @respx.mock async def test_processed_files_lists_inbound(client): respx.get("http://test:8000/api/admin/scheduler/processed-files").mock( return_value=Response(200, json=[ {"filename": "TP11525703-999_…-1of1.x12", "kind": "999", "processed_at": "2026-06-21T15:30:00Z", "size_bytes": 2048}, {"filename": "gainwell_status.htm", "kind": "html", "processed_at": "2026-06-21T15:30:00Z", "size_bytes": 4096}, ]) ) files = await client.processed_files(since="2026-06-21T15:00:00Z") assert len(files) == 2 html = [f for f in files if f.kind == "html"] assert len(html) == 1 assert html[0].filename == "gainwell_status.htm" ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_api_client.py::test_clearhouse_submit_returns_submission_id -v ``` Expected: ImportError. - [ ] **Step 3: Extend `api_client.py`** Add to the response-models section: ```python class SubmitResult(BaseModel): submission_id: str filename: str claim_count: int = 0 class ProcessedFile(BaseModel): filename: str kind: str # "999" | "ta1" | "271" | "277" | "277ca" | "835" | "html" | "unknown" processed_at: str size_bytes: int = 0 class TickResult(BaseModel): ticked_at: str files_downloaded: int = 0 ``` Add the methods to the `CycloneClient` class: ```python async def clearhouse_submit( self, payer_id: str, tx: str = "837P" ) -> SubmitResult: assert self._http is not None # Do NOT auto-retry — would double-submit. resp = await self._http.post( "/api/clearhouse/submit", json={"payer_id": payer_id, "transaction_type": tx}, ) if resp.status_code == 409: body = resp.json() raise IdempotencyError(body.get("collisions", [])) resp.raise_for_status() return SubmitResult.model_validate(resp.json()) async def scheduler_tick(self) -> TickResult: assert self._http is not None # Manual tick is idempotent; safe to retry. last_exc: Exception | None = None for attempt in range(MAX_RETRIES): try: resp = await self._http.post( "/api/admin/scheduler/tick" ) resp.raise_for_status() return TickResult.model_validate(resp.json()) except (httpx.HTTPStatusError, httpx.RequestError) as e: last_exc = e if attempt < MAX_RETRIES - 1: await asyncio.sleep(BACKOFF_S[attempt]) assert last_exc is not None raise last_exc async def processed_files(self, since: str) -> list[ProcessedFile]: assert self._http is not None last_exc: Exception | None = None for attempt in range(MAX_RETRIES): try: resp = await self._http.get( "/api/admin/scheduler/processed-files", params={"since": since}, ) resp.raise_for_status() return [ ProcessedFile.model_validate(f) for f in _extract_items(resp.json()) ] except (httpx.HTTPStatusError, httpx.RequestError) as e: last_exc = e if attempt < MAX_RETRIES - 1: await asyncio.sleep(BACKOFF_S[attempt]) assert last_exc is not None raise last_exc ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_api_client.py -v ``` Expected: 9 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/api_client.py tests/test_api_client.py git commit -m "feat(sp22): CycloneClient — clearhouse_submit + scheduler_tick + processed_files" ``` --- ## Task 7: API client — ta1-acks + acks + remittances **Goal:** Add the three ACK list methods. TA1 and 999 are the only ACKs the agent polls for; remittances is for `check-835`. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/api_client.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_api_client.py` - [ ] **Step 1: Append failing tests** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_api_client.py`: ```python from cyclone_pipeline.api_client import Ta1Ack, Ack999, RemittanceSummary @pytest.mark.asyncio @respx.mock async def test_list_ta1_acks(client): respx.get("http://test:8000/api/ta1-acks").mock( return_value=Response(200, json=[ {"id": "TA1-1", "isa13": "000000001", "accepted": True, "received_at": "2026-06-21T15:30:00Z"}, ]) ) acks = await client.list_ta1_acks(since="2026-06-21T15:00:00Z") assert len(acks) == 1 assert acks[0].id == "TA1-1" assert acks[0].accepted is True @pytest.mark.asyncio @respx.mock async def test_list_acks_999(client): respx.get("http://test:8000/api/acks").mock( return_value=Response(200, json=[ {"id": "999-1", "status": "A", "original_filename": "11525703-837P-…-1of1.txt", "received_at": "2026-06-21T15:30:00Z"}, ]) ) acks = await client.list_acks(since="2026-06-21T15:00:00Z") assert len(acks) == 1 assert acks[0].status == "A" @pytest.mark.asyncio @respx.mock async def test_list_remittances(client): respx.get("http://test:8000/api/remittances").mock( return_value=Response(200, json=[ {"id": "REM-1", "received_date": "2026-06-22T08:00:00Z", "payer_claim_control_numbers": ["CLM-001"]}, ]) ) remits = await client.list_remittances(since="2026-06-21T15:00:00Z") assert len(remits) == 1 assert remits[0].id == "REM-1" assert "CLM-001" in remits[0].payer_claim_control_numbers ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_api_client.py::test_list_ta1_acks -v ``` Expected: ImportError. - [ ] **Step 3: Extend `api_client.py`** Add to the response-models section: ```python class Ta1Ack(BaseModel): id: str isa13: str accepted: bool received_at: str class Ack999(BaseModel): id: str status: str # "A" | "R" | "E" original_filename: str received_at: str class RemittanceSummary(BaseModel): id: str received_date: str payer_claim_control_numbers: list[str] = Field(default_factory=list) ``` Add the methods to the `CycloneClient` class: ```python async def list_ta1_acks(self, since: str) -> list[Ta1Ack]: assert self._http is not None resp = await self._http.get( "/api/ta1-acks", params={"since": since} ) resp.raise_for_status() return [ Ta1Ack.model_validate(a) for a in _extract_items(resp.json()) ] async def list_acks(self, since: str) -> list[Ack999]: assert self._http is not None resp = await self._http.get( "/api/acks", params={"since": since} ) resp.raise_for_status() return [ Ack999.model_validate(a) for a in _extract_items(resp.json()) ] async def list_remittances( self, since: str ) -> list[RemittanceSummary]: assert self._http is not None resp = await self._http.get( "/api/remittances", params={"since": since} ) resp.raise_for_status() return [ RemittanceSummary.model_validate(r) for r in _extract_items(resp.json()) ] ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_api_client.py -v ``` Expected: 12 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/api_client.py tests/test_api_client.py git commit -m "feat(sp22): CycloneClient — list_ta1_acks + list_acks + list_remittances" ``` --- ## Task 8: State module (resumable run state) **Goal:** A Pydantic model for run state, written to `run.state.json` after each phase. The orchestrator reads it on resume to skip completed phases. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/state.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_state.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_state.py`: ```python """Resumable run state — a Pydantic model written to run.state.json after each phase. Read on resume to skip completed phases.""" import json import pytest from pathlib import Path from cyclone_pipeline.state import RunState, PhaseRecord def test_run_state_round_trip(tmp_path: Path): state = RunState( run_id="2026-06-21-1430-001", input_path="/tmp/foo.edi", started_at="2026-06-21T14:30:00-06:00", api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", completed_phases=[ PhaseRecord(n=1, name="preflight", status="ok", duration_s=0.4), PhaseRecord(n=2, name="upload", status="ok", duration_s=2.1, attempts=1), ], pending_data={ "batch_id": "BATCH-42", "claim_ids": ["CLM-001", "CLM-002"], "submission_id": "SUB-42", "submission_filename": "11525703-837P-…-1of1.txt", }, ) path = tmp_path / "run.state.json" state.write(path) assert path.exists() loaded = RunState.read(path) assert loaded.run_id == "2026-06-21-1430-001" assert len(loaded.completed_phases) == 2 assert loaded.pending_data["claim_ids"] == ["CLM-001", "CLM-002"] def test_resume_phase_returns_lowest_unfinished(): state = RunState( run_id="r", input_path="x", started_at="now", api_base="a", browser_base="b", completed_phases=[ PhaseRecord(n=1, name="preflight", status="ok", duration_s=0.1), PhaseRecord(n=2, name="upload", status="ok", duration_s=0.1), PhaseRecord(n=3, name="verify_parse", status="ok", duration_s=0.1), ], ) # 7 phases total; we want to resume at phase 4 assert state.next_phase_n() == 4 def test_resume_phase_returns_1_if_nothing_completed(): state = RunState( run_id="r", input_path="x", started_at="now", api_base="a", browser_base="b", completed_phases=[], ) assert state.next_phase_n() == 1 ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_state.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/state.py`: ```python """Resumable run state. `RunState` is written to `run.state.json` after every phase's postcondition is met. On `resume`, the orchestrator reads this file and skips every completed phase. `pending_data` is a free-form dict that captures cross-phase outputs (batch_id, claim_ids, submission_id). """ from __future__ import annotations from pathlib import Path from typing import Any from pydantic import BaseModel, Field TOTAL_PHASES = 7 class PhaseRecord(BaseModel): n: int name: str status: str # "ok" | "warn" | "fail" | "soft_fail" | "hard_fail" | "timeout" duration_s: float attempts: int = 1 detail: dict[str, Any] = Field(default_factory=dict) class RunState(BaseModel): run_id: str input_path: str started_at: str api_base: str browser_base: str completed_phases: list[PhaseRecord] = Field(default_factory=list) pending_data: dict[str, Any] = Field(default_factory=dict) def next_phase_n(self) -> int: """Return the 1-based phase number to resume from. If all phases are done, returns TOTAL_PHASES + 1 (caller treats as 'done').""" if not self.completed_phases: return 1 return max(p.n for p in self.completed_phases) + 1 def is_phase_done(self, n: int) -> bool: return any(p.n == n for p in self.completed_phases) def write(self, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(self.model_dump_json(indent=2)) @classmethod def read(cls, path: Path) -> "RunState": return cls.model_validate_json(path.read_text()) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_state.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/state.py tests/test_state.py git commit -m "feat(sp22): RunState pydantic model + JSON round-trip + resume helpers" ``` --- ## Task 9: Screenshots module **Goal:** Stable filenames, dated folder, and a helper to take + save a Playwright screenshot to the right place. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/screenshots.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_screenshots.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_screenshots.py`: ```python """Screenshot helpers — dated folder + stable filename pattern.""" from pathlib import Path import pytest from cyclone_pipeline.screenshots import ( ScreenshotWriter, screenshot_path, ) def test_run_dir_path(tmp_path: Path): """The dated folder name is `YYYY-MM-DD-HHMM-NNN` (3-digit sequence so concurrent runs don't collide).""" p = ScreenshotWriter.root_dir(tmp_path, "2026-06-21-1430-001") assert str(p).endswith("2026-06-21-1430-001") def test_screenshot_path_is_stable(): """The same logical step name always produces the same filename (e.g., `02-upload.png`, never `02-upload-1234567890.png`). The per-attempt suffix is only added when explicitly requested.""" p = screenshot_path( step="02-upload", attempt=None ) assert p.name == "02-upload.png" p2 = screenshot_path(step="02-upload", attempt=1) assert p2.name == "02-upload-1.png" p3 = screenshot_path(step="99-final-inbox", attempt=None) assert p3.name == "99-final-inbox.png" def test_writer_writes_png(tmp_path: Path): writer = ScreenshotWriter(tmp_path, "test-run") out = writer.full_path("01-preflight") out.parent.mkdir(parents=True, exist_ok=True) out.write_bytes(b"\x89PNG\r\n\x1a\n") assert out.exists() assert out.read_bytes()[:4] == b"\x89PNG" ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_screenshots.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/screenshots.py`: ```python """Screenshot writer — dated folder + stable filenames. Every screenshot the agent takes lands in `./runs/{run_id}/screenshots/{step}.png` (or `{step}-{attempt}.png` on retries). The run_id is the human-readable `YYYY-MM-DD-HHMM-NNN` pattern; the screenshot step is a stable string from a small enum (01-preflight, 02-upload, 03-parse-complete, …). """ from __future__ import annotations from pathlib import Path from dataclasses import dataclass @dataclass class ScreenshotWriter: base_dir: Path run_id: str def __post_init__(self) -> None: self._root = self.base_dir / self.run_id / "screenshots" @classmethod def root_dir(cls, base_dir: Path, run_id: str) -> Path: return base_dir / run_id def full_path(self, step: str, attempt: int | None = None) -> Path: """Return the absolute path the screenshot should be written to. Does NOT create the directory — caller decides when to mkdir.""" return screenshot_path(step, attempt, base=self._root) def screenshot_path( step: str, attempt: int | None = None, base: Path | None = None ) -> Path: """Compute a stable filename for a given step. `step` is the logical name (e.g., '02-upload'). If `attempt` is provided, a numeric suffix is appended so retries don't overwrite each other.""" name = step if attempt is None else f"{step}-{attempt}" if base is None: return Path(name + ".png") return base / (name + ".png") ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_screenshots.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/screenshots.py tests/test_screenshots.py git commit -m "feat(sp22): ScreenshotWriter + stable filename helper" ``` --- ## Task 10: Selectors module **Goal:** Centralized Playwright selectors with fallbacks. The selectors module owns the mapping from "logical action" (e.g., "click Parse button") to actual CSS/XPath/role selectors. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/selectors.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_selectors.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_selectors.py`: ```python """Centralized Playwright selectors. The selectors module owns the mapping from "logical action" to the actual CSS / role / text selectors. We try the most specific selector first and fall back to broader ones — this keeps the agent working even when the cyclone UI tweaks a class name or restructures a component, as long as the user-visible text is stable. """ import pytest from cyclone_pipeline.selectors import ( upload_drop_zone, upload_file_input, parse_button, parse_progress_bar, parse_success_toast, ) def test_upload_drop_zone_is_role_based(): """The drop zone is a role=button region (it's clickable but also a drop target). Selector should target the data-testid if present, else fall back to role + text.""" selectors = upload_drop_zone() assert any("role=button" in s or "[data-testid" in s for s in selectors) def test_upload_file_input_is_the_hidden_input(): """The file input is the hidden `` behind the drop zone. We select by type, not by class — classnames are unstable.""" selectors = upload_file_input() assert 'input[type="file"]' in selectors def test_parse_button_targets_text(): """The Parse button text is stable: 'Parse' or 'Parsing…' while running. We accept both.""" selectors = parse_button() assert any("Parse" in s for s in selectors) def test_progress_bar_targets_text(): """The progress bar's text is `${n}%`. We wait for that exact text.""" selectors = parse_progress_bar() assert any("100%" in s for s in selectors) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_selectors.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/selectors.py`: ```python """Centralized Playwright selectors with fallbacks. Each helper returns a list of selectors ordered from most specific to least. The browser wrapper tries them in order and uses the first that matches. """ from __future__ import annotations def upload_drop_zone() -> list[str]: """The drop zone / click-to-pick region on the Upload page.""" return [ '[data-testid="upload-dropzone"]', 'role=button[name="Drop a file here, or click to choose"]', 'role=button[name="Release to upload"]', ] def upload_file_input() -> list[str]: """The hidden file input. We select by type because the classname is unstable across React/Vite versions.""" return ['input[type="file"]'] def parse_button() -> list[str]: """The 'Parse' button. We accept both 'Parse' (idle) and 'Parsing…' (running) so we can match regardless of state.""" return [ 'button:has-text("Parse"):not([disabled])', 'role=button[name="Parse"]:not([disabled])', ] def parse_progress_bar() -> list[str]: """The progress bar's text is `100%` when complete.""" return [ 'text="100%"', '[data-testid="upload-progress"] >> text="100%"', ] def parse_success_toast() -> list[str]: """The success toast that appears after parse completes. Format: `Parsed N claims · M passed · K failed`.""" return [ 'text=/Parsed \\d+ claim(s|payments)/', ] ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_selectors.py -v ``` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/selectors.py tests/test_selectors.py git commit -m "feat(sp22): centralized Playwright selectors with fallbacks" ``` --- ## Task 11: Waiters module **Goal:** Reusable wait primitives: `wait_for(predicate, timeout, poll_interval)`, `wait_for_ta1`, `wait_for_999`. Used by the orchestrator to poll for ACKs. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/waiters.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_waiters.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_waiters.py`: ```python """Reusable wait primitives — backoff + timeout + predicate resolution. The orchestrator uses these to poll the API for ACKs. The `wait_for` primitive is the building block; the higher-level helpers (wait_for_ta1, wait_for_999, wait_for_claim_state) are thin wrappers. """ import asyncio import pytest from cyclone_pipeline.waiters import wait_for from cyclone_pipeline.exceptions import AckTimeoutError @pytest.mark.asyncio async def test_wait_for_resolves_when_predicate_true(): """If the predicate returns truthy on the first poll, wait_for returns immediately.""" calls = [] def pred() -> int | None: calls.append(1) return 42 result = await wait_for( pred, timeout_s=5.0, poll_interval_s=0.1, label="test" ) assert result == 42 assert len(calls) == 1 @pytest.mark.asyncio async def test_wait_for_resolves_on_later_poll(): """If the predicate returns None the first two times, then truthy, wait_for returns the truthy value.""" calls = [] def pred() -> int | None: calls.append(1) return 42 if len(calls) >= 3 else None result = await wait_for( pred, timeout_s=5.0, poll_interval_s=0.01, label="test" ) assert result == 42 assert len(calls) == 3 @pytest.mark.asyncio async def test_wait_for_raises_after_timeout(): """If the predicate never returns truthy, wait_for raises AckTimeoutError after the configured timeout.""" def pred() -> int | None: return None with pytest.raises(AckTimeoutError) as exc_info: await wait_for( pred, timeout_s=0.2, poll_interval_s=0.05, label="ta1" ) assert exc_info.value.ack_kind == "ta1" assert exc_info.value.timeout_s == pytest.approx(0.2, abs=0.1) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_waiters.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/waiters.py`: ```python """Reusable async wait primitives. The core is `wait_for(predicate, timeout_s, poll_interval_s, label)`. It polls a sync predicate (no await) at the configured interval and returns the first truthy value. On timeout, raises `AckTimeoutError` with the label + timeout for the report. """ from __future__ import annotations import asyncio import time from typing import Callable, TypeVar from cyclone_pipeline.exceptions import AckTimeoutError T = TypeVar("T") async def wait_for( predicate: Callable[[], T | None], *, timeout_s: float, poll_interval_s: float, label: str, ) -> T: """Poll `predicate` every `poll_interval_s` until it returns truthy or `timeout_s` elapses. Raises `AckTimeoutError` on timeout. The predicate is sync (it usually reads a cached API result). If you need an async predicate, wrap it in `asyncio.iscoroutine`. """ deadline = time.monotonic() + timeout_s while True: result = predicate() if result is not None: return result if time.monotonic() >= deadline: raise AckTimeoutError(label, int(timeout_s)) await asyncio.sleep(poll_interval_s) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_waiters.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/waiters.py tests/test_waiters.py git commit -m "feat(sp22): wait_for primitive + AckTimeoutError on timeout" ``` --- ## Task 12: Report module (JSON + Markdown) **Goal:** Two writers: `write_report_json()` and `write_report_md()`. The orchestrator calls both at the end of phase 7. Output shape matches §5 of the spec. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/report.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_report.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_report.py`: ```python """Report writers — JSON for agents, Markdown for humans.""" import json from pathlib import Path import pytest from cyclone_pipeline.report import ( RunReport, PhaseResult, write_report_json, write_report_md, ) @pytest.fixture def sample_report() -> RunReport: return RunReport( run_id="2026-06-21-1430-001", input_path="/tmp/foo.edi", input_size_bytes=12700, result="pass", result_detail="TA1 + 999 received, 835 deferred", started_at="2026-06-21T14:30:00-06:00", finished_at="2026-06-21T14:32:14-06:00", duration_s=134.0, phases=[ PhaseResult(n=1, name="preflight", status="ok", duration_s=0.4), PhaseResult(n=2, name="upload", status="ok", duration_s=2.1), PhaseResult(n=3, name="verify_parse", status="ok", duration_s=0.8, detail={"claim_ids": ["CLM-001"]}), PhaseResult(n=4, name="submit", status="ok", duration_s=4.3, detail={"filename": "11525703-837P-…-1of1.txt"}), PhaseResult(n=5, name="wait_ta1", status="ok", duration_s=12.0, detail={"ta1_id": "TA1-42", "accepted": True}), PhaseResult(n=6, name="wait_999", status="ok", duration_s=74.0, detail={"ack_999_id": "999-43", "status": "A"}), PhaseResult(n=7, name="scan_and_report", status="warn", duration_s=0.2, detail={"html_files": ["gainwell_status.htm"]}), ], expected_835_by="2026-06-28", next_steps={ "check_835_cmd": "cyclone-pipeline check-835 2026-06-21-1430-001", }, screenshots=["01-preflight.png", "02-upload-1.png", "99-final-inbox.png"], ) def test_json_round_trip(sample_report, tmp_path): p = tmp_path / "report.json" write_report_json(sample_report, p) assert p.exists() data = json.loads(p.read_text()) assert data["run_id"] == "2026-06-21-1430-001" assert data["result"] == "pass" assert len(data["phases"]) == 7 assert data["phases"][2]["detail"]["claim_ids"] == ["CLM-001"] def test_markdown_contains_phase_table(sample_report, tmp_path): p = tmp_path / "report.md" write_report_md(sample_report, p) text = p.read_text() 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 "check-835" in text assert "Monday" in text assert "01-preflight.png" in text def test_markdown_soft_fail_renders_remediation(sample_report, tmp_path): sample_report.result = "soft_fail" sample_report.phases[4].status = "timeout" # phase 5 (TA1) p = tmp_path / "report.md" write_report_md(sample_report, p) text = p.read_text() assert "SOFT_FAIL" in text or "soft_fail" in text assert "Gainwell" in text or "in flight" in text ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_report.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/report.py`: ```python """Report writers — JSON for agents, Markdown for humans. Both writers take a `RunReport` Pydantic model and write to a path. The shapes match §5 of the design spec exactly. """ from __future__ import annotations import json from pathlib import Path from typing import Any from pydantic import BaseModel, Field class PhaseResult(BaseModel): n: int name: str status: str duration_s: float attempts: int = 1 detail: dict[str, Any] = Field(default_factory=dict) class RunReport(BaseModel): run_id: str input_path: str input_size_bytes: int = 0 result: str # "pass" | "soft_fail" | "hard_fail" | "usage" result_detail: str started_at: str finished_at: str duration_s: float phases: list[PhaseResult] expected_835_by: str | None = None next_steps: dict[str, str] = Field(default_factory=dict) screenshots: list[str] = Field(default_factory=list) def write_report_json(report: RunReport, path: Path) -> None: path.write_text(report.model_dump_json(indent=2)) def write_report_md(report: RunReport, path: Path) -> None: """Render the report as Markdown. The shape is intentionally close to the spec example so a human reader gets a consistent layout.""" lines: list[str] = [] lines.append( f"# Cyclone pipeline run — {report.run_id}" ) lines.append("") lines.append(f"- **Input:** `{report.input_path}` " f"({report.input_size_bytes:,} bytes)") lines.append(f"- **Result:** {report.result.upper()} — " f"{report.result_detail}") lines.append(f"- **Started:** {report.started_at}") lines.append(f"- **Finished:** {report.finished_at}") lines.append(f"- **Duration:** {report.duration_s:.1f}s") lines.append("") lines.append("## Phase summary") lines.append("") lines.append("| # | Phase | Status | Duration | Detail |") lines.append("|---|-------|--------|----------|--------|") for p in report.phases: detail_str = _format_phase_detail(p) lines.append( f"| {p.n} | {p.name} | {p.status} | {p.duration_s:.1f}s " f"| {detail_str} |" ) lines.append("") if report.expected_835_by: lines.append("## Expected next steps") lines.append("") lines.append( f"- **835 expected by {report.expected_835_by}** " f"(CO Medicaid payment cycle)." ) cmd = report.next_steps.get("check_835_cmd") if cmd: lines.append(f" - Re-run: `{cmd}`") lines.append("") lines.append("## Artifacts") lines.append("") for s in report.screenshots: lines.append(f"- screenshots/{s}") path.write_text("\n".join(lines) + "\n") def _format_phase_detail(p: PhaseResult) -> str: d = p.detail if p.name == "preflight": return d.get("scheduler_state", "ok") if p.name == "upload": return f"{d.get('attempts', 1)} attempt(s)" if p.name == "verify_parse": ids = d.get("claim_ids", []) return f"claim_ids: {', '.join(ids[:3])}{'…' if len(ids) > 3 else ''}" if p.name == "submit": return d.get("filename", "—") if p.name == "wait_ta1": if p.status == "timeout": return "no TA1 within timeout" return f"ack: {d.get('ta1_id', '?')}, accepted={d.get('accepted', '?')}" if p.name == "wait_999": if p.status == "timeout": return "no 999 within timeout" return f"ack: {d.get('ack_999_id', '?')}, status={d.get('status', '?')}" if p.name == "scan_and_report": html = d.get("html_files", []) if not html: return "no unrecognized inbound" return f"warn: {len(html)} HTML file(s) in inbound" return "—" ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_report.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/report.py tests/test_report.py git commit -m "feat(sp22): RunReport + JSON + Markdown writers matching spec §5" ``` --- ## Task 13: Browser module — UploadPage (Playwright wrapper) **Goal:** A thin async Playwright wrapper exposing `attach_file`, `click_parse`, `wait_for_progress_complete`. The orchestrator drives this in phase 2. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/browser.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_browser.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_browser.py`: ```python """Browser wrapper — Playwright async, scoped to the Upload page. These tests are marked `browser` and require a real Chromium binary installed via `playwright install chromium`. In CI they run on pushes to main; PRs skip them with `-m "not browser"`. """ import pytest from pathlib import Path from cyclone_pipeline.browser import UploadPage @pytest.mark.browser @pytest.mark.asyncio async def test_attach_file_and_click_parse( sample_837p_path: Path, tmp_path: Path, ): """The full happy path against a real Playwright + Vite dev server. Skipped in environments without a running Vite (CI without the cyclone frontend).""" pytest.skip( "Requires a running Vite dev server on http://127.0.0.1:5173; " "run manually with: cyclone-pipeline run " ) ``` Note: a full happy-path browser test needs a Vite dev server. For the unit-level coverage we mark the test as `browser` and provide a smoke assertion; the real coverage comes from the end-to-end manual run. - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_browser.py -v ``` Expected: 1 skipped (the test has a `pytest.skip` so it doesn't fail the run; this confirms the test infrastructure is wired up). - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/browser.py`: ```python """Playwright wrapper for the cyclone Upload page. This module is intentionally small. The browser is used only for phase 2 (upload + click Parse + wait for progress). All other phases go through the API. """ from __future__ import annotations from pathlib import Path from typing import Awaitable, Callable from playwright.async_api import ( Page, Locator, expect, TimeoutError as PlaywrightTimeout, ) from cyclone_pipeline.logging_setup import get_logger from cyclone_pipeline.selectors import ( upload_drop_zone, upload_file_input, parse_button, parse_progress_bar, parse_success_toast, ) from cyclone_pipeline.exceptions import UploadError log = get_logger("cyclone_pipeline.browser") async def _first_visible(page: Page, selectors: list[str]) -> Locator: """Return the first selector in the list that matches a visible element on the page. Raises UploadError if none match.""" for s in selectors: loc = page.locator(s).first try: await expect(loc).to_be_visible(timeout=2000) return loc except (PlaywrightTimeout, AssertionError): continue raise UploadError( f"No selector matched on the page. Tried: {selectors}" ) class UploadPage: """Wraps the cyclone /upload route.""" URL_PATH = "/upload" def __init__(self, page: Page, browser_base: str): self.page = page self.browser_base = browser_base.rstrip("/") async def goto(self) -> None: await self.page.goto(f"{self.browser_base}{self.URL_PATH}") # Wait for at least one selector from the drop zone to be visible await _first_visible(self.page, upload_drop_zone()) async def attach_file(self, file_path: Path) -> None: loc = self.page.locator(upload_file_input()[0]).first await loc.set_input_files(str(file_path)) log.info("file_attached", filename=file_path.name, size_bytes=file_path.stat().st_size) async def click_parse(self) -> None: loc = await _first_visible(self.page, parse_button()) await loc.click() log.info("parse_clicked") async def wait_for_progress_complete( self, timeout_s: float = 300.0, ) -> None: """Wait for the progress bar to read '100%' or the success toast to appear, whichever comes first. Raises UploadError on timeout.""" try: await expect( self.page.locator(parse_progress_bar()[0]) ).to_be_visible(timeout=timeout_s * 1000) except PlaywrightTimeout as e: raise UploadError( f"Progress bar did not reach 100% within {timeout_s}s. " f"Last page state: {self.page.url!r}" ) from e log.info("parse_complete") ``` - [ ] **Step 4: Run test to verify it skips (it always skips, no real browser in unit test env)** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_browser.py -v ``` Expected: 1 skipped. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/browser.py tests/test_browser.py git commit -m "feat(sp22): UploadPage Playwright wrapper (attach + click + progress wait)" ``` --- ## Task 14: Pipeline orchestrator — class skeleton + phase 1 (preflight) **Goal:** The `CyclonePipeline` class skeleton with `__init__` and the first phase (preflight). Subsequent tasks add phases 2–7. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py`: ```python """Pipeline orchestrator — drives the 7-phase state machine. Each phase is a method on `CyclonePipeline` that returns a `PhaseResult` and (on success) appends to `RunState.completed_phases`. Tests mock both the API client and the browser wrapper. """ import asyncio import pytest import respx from datetime import datetime, timezone from httpx import Response 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 ) def test_constructor_builds_run_dir_and_state(tmp_path: Path): cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", headless=True, ) p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") assert p.run_state.input_path == str(tmp_path / "x.edi") assert p.run_state.next_phase_n() == 1 @pytest.mark.asyncio @respx.mock async def test_phase1_preflight_passes_when_backend_healthy(tmp_path: Path): respx.get("http://127.0.0.1:8000/api/health").mock( return_value=Response(200, json={ "status": "ok", "version": "0.1.0", "db": {"ok": True}, "scheduler": {"running": True, "interval_s": 60}, "pubsub": {}, "batch": {}, }) ) respx.get("http://127.0.0.1:8000/api/admin/scheduler/status").mock( return_value=Response(200, json={ "running": True, "interval_s": 60, "sftp_block": "co_medicaid", }) ) respx.head("http://127.0.0.1:5173/").mock(return_value=Response(200)) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", 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() assert result.status == "ok" assert "scheduler_state" in result.detail @pytest.mark.asyncio @respx.mock async def test_phase1_preflight_fails_fast_if_browser_down(tmp_path: Path): """If the Vite dev server is not reachable, phase 1 fails-fast with BrowserNotReachableError before phase 2 even tries.""" from cyclone_pipeline.exceptions import BrowserNotReachableError respx.get("http://127.0.0.1:8000/api/health").mock( return_value=Response(200, json={ "status": "ok", "version": "0.1.0", "db": {"ok": True}, "scheduler": {"running": True, "interval_s": 60}, "pubsub": {}, "batch": {}, }) ) respx.get("http://127.0.0.1:8000/api/admin/scheduler/status").mock( return_value=Response(200, json={ "running": True, "interval_s": 60, "sftp_block": "co_medicaid", }) ) # Make the head request fail with a connection error respx.head("http://127.0.0.1:9999/").mock( side_effect=httpx.ConnectError("Connection refused") ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:9999", 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() ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation (skeleton + phase 1)** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py`: ```python """CyclonePipeline — the orchestrator. Drives the 7-phase state machine: preflight → upload → verify parse → submit → wait TA1 → wait 999 → scan + report. Every phase returns a `PhaseResult`, writes `RunState`, and takes a screenshot on success or failure. The class is the public API for OpenClaw / Nora embedding. The CLI is a thin Click wrapper. """ from __future__ import annotations import time import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any import httpx import structlog from cyclone_pipeline.api_client import CycloneClient from cyclone_pipeline.exceptions import ( BrowserNotReachableError, CyclonePipelineError, UploadError, ParseError, SubmitError, AckTimeoutError, IdempotencyError, ) 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 log = get_logger("cyclone_pipeline.pipeline") @dataclass class PipelineConfig: api_base: str browser_base: str run_dir: Path headless: bool = True ta1_timeout_s: int = 300 ack_999_timeout_s: int = 900 poll_interval_s: float = 5.0 dedup_window_hours: int = 24 def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _new_run_id() -> str: return datetime.now().strftime("%Y-%m-%d-%H%M-") + uuid.uuid4().hex[:3] class CyclonePipeline: def __init__(self, cfg: PipelineConfig, input_path: Path): self.cfg = cfg self.input_path = Path(input_path) self._run_id = _new_run_id() self._client: CycloneClient | None = None self._writer: ScreenshotWriter | None = None self.run_state = RunState( run_id=self._run_id, input_path=str(self.input_path), started_at=_now_iso(), api_base=cfg.api_base, browser_base=cfg.browser_base, ) self._report: RunReport | None = None # -- lifecycle ---------------------------------------------------------- def __enter__(self) -> "CyclonePipeline": self._client = CycloneClient(self.cfg.api_base) return self async def __aenter__(self) -> "CyclonePipeline": self._client = CycloneClient(self.cfg.api_base) await self._client.__aenter__() self._writer = ScreenshotWriter(self.cfg.run_dir, self._run_id) return self async def __aexit__(self, *exc) -> None: if self._client is not None: await self._client.__aexit__(*exc) def attach_state(self, state: RunState) -> None: """Resume: load a RunState from disk and continue from its next phase.""" self.run_state = state self._run_id = state.run_id self._writer = ScreenshotWriter(self.cfg.run_dir, self._run_id) # -- phase 1: preflight ------------------------------------------------- async def phase1_preflight(self) -> PhaseResult: t0 = time.monotonic() assert self._client is not None snap = await self._client.health() if snap.status != "ok" or not snap.db_ok: raise CyclonePipelineError( f"Backend unhealthy: {snap.model_dump()}" ) sched = await self._client.scheduler_status() # Verify the browser-side URL is reachable. We use the same # httpx.AsyncClient as the API calls so tests can mock the # head request via `respx`. try: assert self._client._http is not None # type: ignore[attr-defined] r = await self._client._http.head( # type: ignore[attr-defined] self.cfg.browser_base + "/" ) r.raise_for_status() except httpx.HTTPError as e: raise BrowserNotReachableError(self.cfg.browser_base) from e result = PhaseResult( n=1, name="preflight", status="ok", duration_s=time.monotonic() - t0, detail={ "scheduler_state": "running" if sched.running else "stopped", "interval_s": sched.interval_s, }, ) self.run_state.completed_phases.append(result) self.run_state.pending_data["scheduler_running"] = sched.running log.info("phase1_preflight_ok", **result.detail) return result ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/pipeline.py tests/test_pipeline.py git commit -m "feat(sp22): CyclonePipeline skeleton + phase 1 (preflight)" ``` --- ## Task 15: Pipeline — phase 2 (upload via browser) **Goal:** Drive Playwright to open the upload page, attach the file, click Parse, and wait for progress to complete. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py` - [ ] **Step 1: Append failing test to `test_pipeline.py`** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py`: ```python from unittest.mock import AsyncMock, patch from cyclone_pipeline.report import PhaseResult as _PR # noqa @pytest.mark.asyncio async def test_phase2_upload_retries_on_failure(tmp_path: Path): """If the first upload attempt raises, phase 2 retries up to 3× and surfaces a screenshot per attempt.""" cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", headless=True, ) p = CyclonePipeline(cfg, input_path=tmp_path / "x.edi") 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 ) mock_browser.new_page = AsyncMock(return_value=fake_page) # 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** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py::test_phase2_upload_retries_on_failure -v ``` Expected: AttributeError (phase2_upload doesn't exist). - [ ] **Step 3: Extend `pipeline.py`** Append to `cyclone_pipeline/pipeline.py`: ```python # -- phase 2: upload (browser) ----------------------------------------- async def phase2_upload(self, max_attempts: int = 3) -> PhaseResult: t0 = time.monotonic() assert self._writer is not None 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 ) page = await browser.new_page() try: up = UploadPage(page, self.cfg.browser_base) await up.goto() await up.attach_file(self.input_path) await up.click_parse() await up.wait_for_progress_complete( timeout_s=300.0 ) finally: await browser.close() # On success, take a parse-complete screenshot await self._writer.full_path( "03-parse-complete" ).parent.mkdir(parents=True, exist_ok=True) # (The browser is closed at this point; a separate # "go back and screenshot" pass would be ideal but is # deferred. The 02-upload-{n} screenshots during # retries are still useful.) result = PhaseResult( n=2, name="upload", status="ok", duration_s=time.monotonic() - t0, attempts=attempt, detail={"attempts": attempt}, ) self.run_state.completed_phases.append(result) log.info("phase2_upload_ok", attempts=attempt) return result except (UploadError, Exception) as e: last_exc = e log.warning( "phase2_upload_attempt_failed", attempt=attempt, error=str(e), ) # Screenshot per attempt — for non-final failures if attempt < max_attempts: shot = self._writer.full_path( "02-upload", attempt=attempt ) shot.parent.mkdir(parents=True, exist_ok=True) shot.write_bytes(b"") await asyncio.sleep(2 ** (attempt - 1)) # All attempts failed assert last_exc is not None raise UploadError( f"Upload failed after {max_attempts} attempts: {last_exc}" ) ``` You also need `import asyncio` at the top of the file (add it next to the other stdlib imports). - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/pipeline.py tests/test_pipeline.py git commit -m "feat(sp22): pipeline phase 2 (browser upload with retry)" ``` --- ## Task 16: Pipeline — phase 3 (verify parse) **Goal:** After upload, query `/api/claims?since=…` to collect the new `claim_id[]` set. Fail with `ParseError` if 0 claims appear within 30s. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py` - [ ] **Step 1: Append failing test** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py`: ```python @pytest.mark.asyncio @respx.mock async def test_phase3_verify_parse_collects_new_claim_ids(tmp_path: Path): respx.get("http://127.0.0.1:8000/api/claims").mock( return_value=Response(200, json=[ {"id": "CLM-1", "submission_date": "2026-06-21T15:00:00Z"}, {"id": "CLM-2", "submission_date": "2026-06-21T15:00:01Z"}, ]) ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", 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() assert result.status == "ok" assert p.run_state.pending_data["claim_ids"] == ["CLM-1", "CLM-2"] @pytest.mark.asyncio @respx.mock async def test_phase3_verify_parse_fails_on_zero_claims(tmp_path: Path): from cyclone_pipeline.exceptions import ParseError respx.get("http://127.0.0.1:8000/api/claims").mock( return_value=Response(200, json=[]) ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", 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) ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py::test_phase3_verify_parse_collects_new_claim_ids -v ``` Expected: AttributeError. - [ ] **Step 3: Extend `pipeline.py`** Append to `cyclone_pipeline/pipeline.py`: ```python # -- phase 3: verify parse --------------------------------------------- async def phase3_verify_parse( self, timeout_s: float = 30.0 ) -> PhaseResult: t0 = time.monotonic() assert self._client is not None from cyclone_pipeline.waiters import wait_for # `since` is the moment phase 1 started. since = self.run_state.started_at def _collect() -> list[str] | None: # Synchronous wrapper — we use the underlying httpx client # directly because wait_for takes a sync predicate. raise NotImplementedError # replaced below # Simpler: do an async poll loop with a short backoff deadline = time.monotonic() + timeout_s last_ids: list[str] = [] while time.monotonic() < deadline: claims = await self._client.list_claims(since=since, limit=500) last_ids = [c.id for c in claims] if last_ids: break import asyncio as _aio await _aio.sleep(1.0) if not last_ids: raise ParseError( f"No new claims appeared within {timeout_s}s. " f"Check the browser upload completed successfully." ) result = PhaseResult( n=3, name="verify_parse", status="ok", duration_s=time.monotonic() - t0, detail={"claim_ids": last_ids, "count": len(last_ids)}, ) self.run_state.completed_phases.append(result) self.run_state.pending_data["claim_ids"] = last_ids log.info("phase3_verify_parse_ok", count=len(last_ids)) return result ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: 6 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/pipeline.py tests/test_pipeline.py git commit -m "feat(sp22): pipeline phase 3 (verify parse — collect new claim_ids)" ``` --- ## Task 17: Pipeline — phase 4 (SFTP submit) **Goal:** Call `client.clearhouse_submit()` and store the submission id + filename in `pending_data`. Idempotency conflicts raise `IdempotencyError`. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py` - [ ] **Step 1: Append failing test** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py`: ```python @pytest.mark.asyncio @respx.mock async def test_phase4_submit_stores_submission_data(tmp_path: Path): respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock( return_value=Response(200, json={ "submission_id": "SUB-42", "filename": "11525703-837P-20260621143012345-1of1.txt", "claim_count": 2, }) ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", 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() assert result.status == "ok" assert p.run_state.pending_data["submission_id"] == "SUB-42" assert p.run_state.pending_data["submission_filename"].endswith(".txt") @pytest.mark.asyncio @respx.mock async def test_phase4_submit_propagates_idempotency_error(tmp_path: Path): from cyclone_pipeline.exceptions import IdempotencyError respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock( return_value=Response(409, json={ "error": "duplicate", "collisions": ["11525703-837P-…-1of1.txt"], }) ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", 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() ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py::test_phase4_submit_stores_submission_data -v ``` Expected: AttributeError. - [ ] **Step 3: Extend `pipeline.py`** Append to `cyclone_pipeline/pipeline.py`: ```python # -- phase 4: SFTP submit ---------------------------------------------- async def phase4_submit(self) -> PhaseResult: t0 = time.monotonic() assert self._client is not None result_obj = await self._client.clearhouse_submit( payer_id="CO_TXIX", tx="837P" ) result = PhaseResult( n=4, name="submit", status="ok", duration_s=time.monotonic() - t0, detail={ "submission_id": result_obj.submission_id, "filename": result_obj.filename, "claim_count": result_obj.claim_count, }, ) self.run_state.completed_phases.append(result) self.run_state.pending_data["submission_id"] = ( result_obj.submission_id ) self.run_state.pending_data["submission_filename"] = ( result_obj.filename ) # Record the moment phase 4 completed; phase 7 uses this as # the `since` for `processed-files` so we only see inbound # files from the MFT scheduler *after* our submit. self.run_state.pending_data["phase4_at"] = _now_iso() log.info( "phase4_submit_ok", submission_id=result_obj.submission_id, filename=result_obj.filename, ) return result ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: 8 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/pipeline.py tests/test_pipeline.py git commit -m "feat(sp22): pipeline phase 4 (SFTP submit via API)" ``` --- ## Task 18: Pipeline — phase 5 (wait TA1) **Goal:** Poll for a TA1 ACK matching the submitted file's `isa13` (or, as a fallback, any TA1 since phase 4). Soft-fail on timeout. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py` - [ ] **Step 1: Append failing test** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py`: ```python @pytest.mark.asyncio @respx.mock async def test_phase5_wait_ta1_returns_matching_ack(tmp_path: Path): # First poll: empty. Second poll: matching TA1. respx.get("http://127.0.0.1:8000/api/ta1-acks").mock( side_effect=[ Response(200, json=[]), Response(200, json=[{ "id": "TA1-42", "isa13": "000000001", "accepted": True, "received_at": "2026-06-21T15:30:00Z", }]), ] ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", 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() assert result.status == "ok" assert result.detail["ta1_id"] == "TA1-42" @pytest.mark.asyncio @respx.mock async def test_phase5_wait_ta1_timeout_returns_status_timeout(tmp_path: Path): respx.get("http://127.0.0.1:8000/api/ta1-acks").mock( return_value=Response(200, json=[]) ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", 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() assert result.status == "timeout" # Pipeline continues; this is soft-fail. ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py::test_phase5_wait_ta1_returns_matching_ack -v ``` Expected: AttributeError. - [ ] **Step 3: Extend `pipeline.py`** Append to `cyclone_pipeline/pipeline.py`: ```python # -- phase 5: wait TA1 -------------------------------------------------- async def phase5_wait_ta1(self) -> PhaseResult: from cyclone_pipeline.exceptions import AckTimeoutError t0 = time.monotonic() assert self._client is not None since = self.run_state.started_at try: while True: acks = await self._client.list_ta1_acks(since=since) if acks: a = acks[0] result = PhaseResult( n=5, name="wait_ta1", status="ok", duration_s=time.monotonic() - t0, detail={"ta1_id": a.id, "accepted": a.accepted}, ) self.run_state.completed_phases.append(result) self.run_state.pending_data["ta1_id"] = a.id return result import asyncio as _aio await _aio.sleep(self.cfg.poll_interval_s) if (time.monotonic() - t0) >= self.cfg.ta1_timeout_s: raise AckTimeoutError("ta1", self.cfg.ta1_timeout_s) except AckTimeoutError: result = PhaseResult( n=5, name="wait_ta1", status="timeout", duration_s=time.monotonic() - t0, detail={"timeout_s": self.cfg.ta1_timeout_s}, ) self.run_state.completed_phases.append(result) return result ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: 10 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/pipeline.py tests/test_pipeline.py git commit -m "feat(sp22): pipeline phase 5 (wait TA1, soft-fail on timeout)" ``` --- ## Task 19: Pipeline — phase 6 (wait 999) **Goal:** Poll for a 999 ACK. Soft-fail on timeout, hard-fail on `R` (rejected). **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py` - [ ] **Step 1: Append failing test** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py`: ```python @pytest.mark.asyncio @respx.mock async def test_phase6_wait_999_returns_matching_ack(tmp_path: Path): respx.get("http://127.0.0.1:8000/api/acks").mock( side_effect=[ Response(200, json=[]), Response(200, json=[{ "id": "999-43", "status": "A", "original_filename": "11525703-837P-…-1of1.txt", "received_at": "2026-06-21T15:30:00Z", }]), ] ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", 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() assert result.status == "ok" assert result.detail["ack_999_id"] == "999-43" @pytest.mark.asyncio @respx.mock async def test_phase6_wait_999_rejected_is_hard_fail(tmp_path: Path): from cyclone_pipeline.exceptions import CyclonePipelineError respx.get("http://127.0.0.1:8000/api/acks").mock( return_value=Response(200, json=[{ "id": "999-44", "status": "R", "original_filename": "11525703-837P-…-1of1.txt", "received_at": "2026-06-21T15:30:00Z", }]) ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", 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() assert "rejected" in str(exc_info.value).lower() ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py::test_phase6_wait_999_returns_matching_ack -v ``` Expected: AttributeError. - [ ] **Step 3: Extend `pipeline.py`** Append to `cyclone_pipeline/pipeline.py`: ```python # -- phase 6: wait 999 -------------------------------------------------- async def phase6_wait_999(self) -> PhaseResult: from cyclone_pipeline.exceptions import ( AckTimeoutError, CyclonePipelineError, ) t0 = time.monotonic() assert self._client is not None since = self.run_state.started_at try: while True: acks = await self._client.list_acks(since=since) if acks: a = acks[0] if a.status == "R": # Hard fail result = PhaseResult( n=6, name="wait_999", status="rejected", duration_s=time.monotonic() - t0, detail={"ack_999_id": a.id, "status": a.status}, ) self.run_state.completed_phases.append(result) raise CyclonePipelineError( f"999 ACK rejected: {a.id} status={a.status}. " f"Check the file's syntax and re-run." ) result = PhaseResult( n=6, name="wait_999", status="ok", duration_s=time.monotonic() - t0, detail={"ack_999_id": a.id, "status": a.status}, ) self.run_state.completed_phases.append(result) self.run_state.pending_data["ack_999_id"] = a.id return result import asyncio as _aio await _aio.sleep(self.cfg.poll_interval_s) if (time.monotonic() - t0) >= self.cfg.ack_999_timeout_s: raise AckTimeoutError("999", self.cfg.ack_999_timeout_s) except AckTimeoutError: result = PhaseResult( n=6, name="wait_999", status="timeout", duration_s=time.monotonic() - t0, detail={"timeout_s": self.cfg.ack_999_timeout_s}, ) self.run_state.completed_phases.append(result) return result ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: 12 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/pipeline.py tests/test_pipeline.py git commit -m "feat(sp22): pipeline phase 6 (wait 999, hard-fail on reject)" ``` --- ## Task 20: Pipeline — phase 7 (scan + report) + main `run()` orchestrator **Goal:** Phase 7 scans for HTML files, writes `report.md` + `report.json`, and the main `run()` method drives phases 1→7 in order. Resume logic is also wired here. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py` - [ ] **Step 1: Append failing test** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py`: ```python @pytest.mark.asyncio @respx.mock async def test_phase7_scan_html_and_write_reports(tmp_path: Path): respx.get("http://127.0.0.1:8000/api/admin/scheduler/processed-files").mock( return_value=Response(200, json=[ {"filename": "gainwell_status.htm", "kind": "html", "processed_at": "2026-06-21T15:30:00Z", "size_bytes": 1024}, ]) ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", 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() assert result.status == "warn" # The two report files were written run_dir = tmp_path / "runs" / "test-run-007" assert (run_dir / "report.md").exists() assert (run_dir / "report.json").exists() text = (run_dir / "report.md").read_text() assert "warn" in text.lower() or "HTML" in text @pytest.mark.asyncio @respx.mock async def test_run_drives_all_phases(tmp_path: Path, sample_837p_path): """The end-to-end `run()` method executes phases 1→7 in order and returns a `RunReport`.""" # Phase 1: health + scheduler + browser respx.get("http://127.0.0.1:8000/api/health").mock( return_value=Response(200, json={ "status": "ok", "version": "0.1.0", "db": {"ok": True}, "scheduler": {"running": True, "interval_s": 60}, "pubsub": {}, "batch": {}, }) ) respx.get("http://127.0.0.1:8000/api/admin/scheduler/status").mock( return_value=Response(200, json={ "running": True, "interval_s": 60, "sftp_block": "co_medicaid", }) ) respx.head("http://127.0.0.1:5173/").mock(return_value=Response(200)) # Phase 3: claims list respx.get("http://127.0.0.1:8000/api/claims").mock( return_value=Response(200, json=[ {"id": "CLM-1", "submission_date": "2026-06-21T15:00:00Z"}, ]) ) # Phase 4: submit respx.post("http://127.0.0.1:8000/api/clearhouse/submit").mock( return_value=Response(200, json={ "submission_id": "SUB-42", "filename": "11525703-837P-…-1of1.txt", "claim_count": 1, }) ) # Phase 5: TA1 respx.get("http://127.0.0.1:8000/api/ta1-acks").mock( return_value=Response(200, json=[{ "id": "TA1-42", "isa13": "000000001", "accepted": True, "received_at": "2026-06-21T15:30:00Z", }]) ) # Phase 6: 999 respx.get("http://127.0.0.1:8000/api/acks").mock( return_value=Response(200, json=[{ "id": "999-43", "status": "A", "original_filename": "11525703-837P-…-1of1.txt", "received_at": "2026-06-21T15:30:00Z", }]) ) # Phase 7: processed files respx.get("http://127.0.0.1:8000/api/admin/scheduler/processed-files").mock( return_value=Response(200, json=[]) ) cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", run_dir=tmp_path / "runs", headless=True, ta1_timeout_s=2.0, ack_999_timeout_s=2.0, poll_interval_s=0.05, ) # Patch the browser wrapper so we don't need a real Playwright from unittest.mock import patch, AsyncMock fake_upload = AsyncMock() fake_upload.goto = AsyncMock() fake_upload.attach_file = AsyncMock() fake_upload.click_parse = AsyncMock() fake_upload.wait_for_progress_complete = AsyncMock() with patch("cyclone_pipeline.pipeline.UploadPage") as UP, \ patch("cyclone_pipeline.pipeline.async_playwright") as PW: UP.return_value = fake_upload PW.return_value.__aenter__.return_value.chromium.launch = AsyncMock( return_value=AsyncMock() ) PW.return_value.__aenter__.return_value.chromium.launch.return_value.new_page = AsyncMock( return_value=AsyncMock() ) async with CyclonePipeline(cfg, input_path=sample_837p_path) as p: p._run_id = "e2e-run-001" report = await p.run() assert report.result == "pass" assert len(report.phases) == 7 ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py::test_phase7_scan_html_and_write_reports -v ``` Expected: AttributeError. - [ ] **Step 3: Extend `pipeline.py`** Append to `cyclone_pipeline/pipeline.py`: ```python # -- phase 7: scan + report --------------------------------------------- async def phase7_scan_and_report(self) -> PhaseResult: from cyclone_pipeline.report import ( write_report_json, write_report_md, RunReport, ) t0 = time.monotonic() assert self._client is not None since = self.run_state.pending_data.get( "phase4_at", self.run_state.started_at ) files = await self._client.processed_files(since=since) html_files = [f for f in files if f.kind == "html"] status = "warn" if html_files else "ok" result = PhaseResult( n=7, name="scan_and_report", status=status, duration_s=time.monotonic() - t0, detail={"html_files": [f.filename for f in html_files]}, ) self.run_state.completed_phases.append(result) # Build + write the reports expected_835 = _next_monday_iso() report = RunReport( run_id=self._run_id, input_path=str(self.input_path), input_size_bytes=( self.input_path.stat().st_size if self.input_path.exists() else 0 ), result=self._compute_overall_result(), result_detail=self._result_detail(), started_at=self.run_state.started_at, finished_at=_now_iso(), duration_s=sum( p.duration_s for p in self.run_state.completed_phases ), phases=list(self.run_state.completed_phases), expected_835_by=expected_835, next_steps={ "check_835_cmd": ( f"cyclone-pipeline check-835 {self._run_id}" ), }, screenshots=self._list_screenshots(), ) self._report = report run_dir = self.cfg.run_dir / self._run_id run_dir.mkdir(parents=True, exist_ok=True) write_report_json(report, run_dir / "report.json") write_report_md(report, run_dir / "report.md") # Persist state self.run_state.write(run_dir / "run.state.json") log.info("phase7_report_written", path=str(run_dir)) return result def _compute_overall_result(self) -> str: statuses = {p.status for p in self.run_state.completed_phases} if "rejected" in statuses or "hard_fail" in statuses: return "hard_fail" if "timeout" in statuses: return "soft_fail" return "pass" def _result_detail(self) -> str: r = self._compute_overall_result() if r == "pass": return "TA1 + 999 received, 835 deferred" if r == "soft_fail": return "One or more ACKs timed out; submission in flight" if r == "hard_fail": return "999 rejected or unrecoverable error" return r def _list_screenshots(self) -> list[str]: if self._writer is None: return [] d = self._writer._root # type: ignore[attr-defined] if not d.exists(): return [] return sorted(p.name for p in d.iterdir() if p.suffix == ".png") # -- main entrypoint ---------------------------------------------------- async def run(self) -> "RunReport": """Drive phases 1→7 in order. The first phase to raise is surfaced as a CyclonePipelineError; partial progress is written to run.state.json so `resume` can continue. """ phases = [ self.phase1_preflight, self.phase2_upload, self.phase3_verify_parse, self.phase4_submit, self.phase5_wait_ta1, self.phase6_wait_999, self.phase7_scan_and_report, ] start_n = self.run_state.next_phase_n() for i, fn in enumerate(phases, start=1): if self.run_state.is_phase_done(i): log.info("phase_skipping", n=i, name=fn.__name__) continue if i < start_n: continue try: await fn() except CyclonePipelineError as e: # Persist state and re-raise run_dir = self.cfg.run_dir / self._run_id run_dir.mkdir(parents=True, exist_ok=True) self.run_state.write(run_dir / "run.state.json") log.error( "phase_failed", n=i, name=fn.__name__, error=str(e), ) raise assert self._report is not None return self._report # --- helpers --------------------------------------------------------------- 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 noon, expected same day; otherwise next Monday.""" from datetime import datetime, timedelta, timezone # Use Mountain Time (UTC-7/-6). We don't have tzdata deps; use UTC-7 # as a fixed offset (DST handling is operator-eyeball friendly). mt_offset = timedelta(hours=-6) # approximate Mountain time now_utc = datetime.now(timezone.utc) now_mt = now_utc + mt_offset weekday = now_mt.weekday() # 0=Mon … 6=Sun hour = now_mt.hour if weekday == 0 and hour < 12: return now_mt.date().isoformat() days_ahead = (7 - weekday) % 7 or 7 return (now_mt + timedelta(days=days_ahead)).date().isoformat() ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: 14 passed (the 12 from previous tasks + the 2 new). - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/pipeline.py tests/test_pipeline.py git commit -m "feat(sp22): pipeline phase 7 (scan + report) + main run() orchestrator" ``` --- ## Task 21: Pipeline — resume() **Goal:** `pipeline.resume(run_id)` reads `run.state.json` from a previous run, finds the lowest unfinished phase, and continues from there. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/pipeline.py` - Modify: `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py` - [ ] **Step 1: Append failing test** Append to `/Users/openclaw/dev/cyclone-pipeline/tests/test_pipeline.py`: ```python @pytest.mark.asyncio @respx.mock async def test_resume_skips_completed_phases(tmp_path: Path): cfg = PipelineConfig( api_base="http://127.0.0.1:8000", 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 )) # 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 ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py::test_resume_skips_completed_phases -v ``` Expected: AttributeError (no `resume` method). - [ ] **Step 3: Extend `pipeline.py`** Append to `cyclone_pipeline/pipeline.py`: ```python async def resume(self, run_id: str) -> "RunReport": """Load `run.state.json` for `run_id` and continue from the first unfinished phase.""" run_dir = self.cfg.run_dir / run_id state_path = run_dir / "run.state.json" if not state_path.exists(): raise FileNotFoundError( f"No run.state.json at {state_path}. " f"Check that --run-dir is correct." ) loaded = RunState.read(state_path) self.attach_state(loaded) log.info( "resume_starting", run_id=run_id, next_phase=loaded.next_phase_n(), ) return await self.run() ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_pipeline.py -v ``` Expected: 15 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/pipeline.py tests/test_pipeline.py git commit -m "feat(sp22): pipeline.resume() — continue from run.state.json" ``` --- ## Task 22: check-835 module **Goal:** A standalone helper that, given a `run_id`, queries `GET /api/remittances?since=…` and checks whether any 835 remittance covers the original claim_ids. Pure API, no browser. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/check_835.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_check_835.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_check_835.py`: ```python """check-835 — query the API for remittances matching a run's claim_ids.""" import pytest import respx from httpx import Response from pathlib import Path from cyclone_pipeline.check_835 import check_835_for_run from cyclone_pipeline.api_client import CycloneClient @pytest.mark.asyncio @respx.mock async def test_check_835_finds_matching_remittance(tmp_path: Path): run_dir = tmp_path / "runs" / "test-835-001" run_dir.mkdir(parents=True) # Write a run.state.json with claim_ids from cyclone_pipeline.state import RunState state = RunState( run_id="test-835-001", input_path="/tmp/x.edi", started_at="2026-06-21T14:30:00-06:00", api_base="http://test:8000", browser_base="http://test:5173", pending_data={ "claim_ids": ["CLM-001", "CLM-002"], "submission_filename": "11525703-837P-…-1of1.txt", }, ) state.write(run_dir / "run.state.json") respx.get("http://test:8000/api/remittances").mock( return_value=Response(200, json=[ {"id": "REM-1", "received_date": "2026-06-28T08:00:00Z", "payer_claim_control_numbers": ["CLM-001"]}, ]) ) async with CycloneClient("http://test:8000") as client: result = await check_835_for_run(client, "test-835-001", run_dir) assert result.found is True assert result.remittance_id == "REM-1" assert "CLM-001" in result.matched_claim_ids @pytest.mark.asyncio @respx.mock async def test_check_835_returns_not_found(tmp_path: Path): run_dir = tmp_path / "runs" / "test-835-002" run_dir.mkdir(parents=True) from cyclone_pipeline.state import RunState state = RunState( run_id="test-835-002", input_path="/tmp/x.edi", started_at="2026-06-21T14:30:00-06:00", api_base="http://test:8000", browser_base="http://test:5173", pending_data={"claim_ids": ["CLM-999"]}, ) state.write(run_dir / "run.state.json") respx.get("http://test:8000/api/remittances").mock( return_value=Response(200, json=[]) ) async with CycloneClient("http://test:8000") as client: result = await check_835_for_run(client, "test-835-002", run_dir) assert result.found is False assert result.matched_claim_ids == [] ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_check_835.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/check_835.py`: ```python """check-835 — query the API for remittances matching a run's claim_ids. This is a pure-API helper (no browser). The operator runs it on/after Monday to verify the 835 remit landed. Returns a `Check835Result` with `found`, `remittance_id`, and `matched_claim_ids`. """ from __future__ import annotations from pathlib import Path from pydantic import BaseModel from cyclone_pipeline.api_client import CycloneClient from cyclone_pipeline.state import RunState class Check835Result(BaseModel): found: bool remittance_id: str | None = None received_date: str | None = None matched_claim_ids: list[str] = [] async def check_835_for_run( client: CycloneClient, run_id: str, run_dir: Path, ) -> Check835Result: state_path = run_dir / "run.state.json" if not state_path.exists(): raise FileNotFoundError( f"No run.state.json at {state_path}" ) state = RunState.read(state_path) target_ids = set(state.pending_data.get("claim_ids", [])) if not target_ids: return Check835Result(found=False) remits = await client.list_remittances(since=state.started_at) for r in remits: matched = target_ids.intersection(r.payer_claim_control_numbers) if matched: return Check835Result( found=True, remittance_id=r.id, received_date=r.received_date, matched_claim_ids=sorted(matched), ) return Check835Result(found=False, matched_claim_ids=[]) ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_check_835.py -v ``` Expected: 2 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/check_835.py tests/test_check_835.py git commit -m "feat(sp22): check-835 helper (pure API, matches by claim_id)" ``` --- ## Task 23: CLI — Click commands **Goal:** The `cyclone-pipeline` script: `run`, `run-batch`, `status`, `resume`, `check-835`. Mirrors the library API; thin Click wrapper. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/cli.py` - Create: `/Users/openclaw/dev/cyclone-pipeline/tests/test_cli.py` - [ ] **Step 1: Write the failing test** Write `/Users/openclaw/dev/cyclone-pipeline/tests/test_cli.py`: ```python """CLI surface — run, run-batch, status, resume, check-835. These tests use Click's CliRunner and mock the underlying pipeline methods. The full end-to-end CLI behavior is exercised by running against a live cyclone backend. """ import json import pytest from pathlib import Path from click.testing import CliRunner from unittest.mock import AsyncMock, patch from cyclone_pipeline.cli import main from cyclone_pipeline.report import RunReport, PhaseResult @pytest.fixture def runner(): return CliRunner() def test_run_help(runner): result = runner.invoke(main, ["run", "--help"]) assert result.exit_code == 0 assert "FILE" in result.output assert "--api-base" in result.output def test_status_lists_runs(runner, tmp_path: Path): (tmp_path / "runs").mkdir() (tmp_path / "runs" / "2026-06-21-1430-001").mkdir() (tmp_path / "runs" / "2026-06-21-1430-001" / "report.json").write_text( json.dumps({ "run_id": "2026-06-21-1430-001", "result": "pass", }) ) result = runner.invoke( main, ["status", "--run-dir", str(tmp_path / "runs")] ) assert result.exit_code == 0 assert "2026-06-21-1430-001" in result.output assert "pass" in result.output def test_check_835_subcommand_dispatches_to_helper(runner, tmp_path: Path): from cyclone_pipeline.check_835 import Check835Result fake = Check835Result(found=False) with patch( "cyclone_pipeline.cli.check_835_for_run", new=AsyncMock(return_value=fake), ): # Set up a run dir with a state file run_dir = tmp_path / "runs" / "fake-001" run_dir.mkdir(parents=True) (run_dir / "run.state.json").write_text("{}") result = runner.invoke( main, ["check-835", "fake-001", "--run-dir", str(tmp_path / "runs"), "--api-base", "http://127.0.0.1:8000"], ) # We don't assert exit code; the helper is mocked and the # underlying API call won't happen, but the command should # at least dispatch and produce output. assert "fake-001" in result.output or "not found" in result.output.lower() ``` - [ ] **Step 2: Run test to verify it fails** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_cli.py -v ``` Expected: ImportError. - [ ] **Step 3: Write the implementation** Write `/Users/openclaw/dev/cyclone-pipeline/src/cyclone_pipeline/cli.py`: ```python """CLI surface for cyclone-pipeline. Subcommands: - run FILE Run a single file through the pipeline. - run-batch DIR Run every .edi/.txt in a directory. - status [--run-dir DIR] List recent runs. - resume RUN_ID Resume a crashed run. - check-835 RUN_ID Check whether the 835 has arrived. The CLI is a thin wrapper around the library API. Real work lives in `pipeline.py` and `check_835.py`. """ from __future__ import annotations import asyncio import json import sys from pathlib import Path from typing import Iterable import click from cyclone_pipeline.api_client import CycloneClient from cyclone_pipeline.check_835 import check_835_for_run from cyclone_pipeline.logging_setup import setup_logging from cyclone_pipeline.pipeline import CyclonePipeline, PipelineConfig from cyclone_pipeline.exceptions import ( CyclonePipelineError, BrowserNotReachableError, IdempotencyError, UploadError, ) def _run_dir_default() -> Path: return Path.cwd() / "runs" @click.group() @click.option( "--api-base", default="http://127.0.0.1:8000", envvar="CYCLONE_API_BASE", show_default=True, help="Cyclone FastAPI base URL.", ) @click.option( "--browser-base", default="http://127.0.0.1:5173", envvar="CYCLONE_BROWSER_BASE", show_default=True, help="Cyclone Vite dev server base URL.", ) @click.option( "--run-dir", default=None, type=click.Path(file_okay=False), help="Where to write run artifacts. Default: ./runs", ) @click.option( "--headless/--no-headless", default=False, help="Run Playwright headless. Default: visible (so screenshots are useful).", ) @click.option( "--log-level", default="INFO", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]), ) @click.pass_context def main(ctx, api_base, browser_base, run_dir, headless, log_level): """cyclone-pipeline — automate 837P upload, SFTP submit, ACK wait.""" setup_logging(log_level) ctx.ensure_object(dict) ctx.obj["api_base"] = api_base ctx.obj["browser_base"] = browser_base ctx.obj["run_dir"] = Path(run_dir) if run_dir else _run_dir_default() ctx.obj["headless"] = headless @main.command() @click.argument("file", type=click.Path(exists=True, dir_okay=False)) @click.option("--force", is_flag=True, help="Override idempotency dedup if it trips.") @click.option("--ta1-timeout", default=300, type=int) @click.option("--ack-999-timeout", default=900, type=int) @click.pass_context def run(ctx, file, force, ta1_timeout, ack_999_timeout): """Run a single FILE through the pipeline.""" cfg = PipelineConfig( api_base=ctx.obj["api_base"], browser_base=ctx.obj["browser_base"], run_dir=ctx.obj["run_dir"], headless=ctx.obj["headless"], ta1_timeout_s=ta1_timeout, ack_999_timeout_s=ack_999_timeout, ) rc = asyncio.run(_run_async(cfg, Path(file), force=force)) sys.exit(rc) @main.command("run-batch") @click.argument("dir", type=click.Path(exists=True, file_okay=False)) @click.option("--strict", is_flag=True, help="Abort on first failure. Default: continue.") @click.pass_context def run_batch(ctx, dir, strict): """Run every .edi/.txt in DIR sequentially.""" files = sorted( p for p in Path(dir).iterdir() if p.is_file() and p.suffix in (".edi", ".txt") ) if not files: click.echo(f"No .edi/.txt files in {dir}", err=True) sys.exit(4) rc = 0 for f in files: click.echo(f"--- {f.name} ---") cfg = PipelineConfig( api_base=ctx.obj["api_base"], browser_base=ctx.obj["browser_base"], run_dir=ctx.obj["run_dir"], headless=ctx.obj["headless"], ) r = asyncio.run(_run_async(cfg, f, force=False)) if r != 0 and strict: sys.exit(r) rc = max(rc, r) sys.exit(rc) @main.command() @click.pass_context def status(ctx): """List recent runs (newest first).""" run_dir: Path = ctx.obj["run_dir"] if not run_dir.exists(): click.echo(f"No run directory at {run_dir}") return runs = sorted(p for p in run_dir.iterdir() if p.is_dir()) if not runs: click.echo("(no runs yet)") return click.echo(f"{'Run ID':<30} {'Result':<12} Started") for r in runs: report_path = r / "report.json" if not report_path.exists(): click.echo(f"{r.name:<30} (no report yet)") continue data = json.loads(report_path.read_text()) click.echo( f"{r.name:<30} " f"{data.get('result', '?'):<12} " f"{data.get('started_at', '?')}" ) @main.command() @click.argument("run_id") @click.pass_context def resume(ctx, run_id): """Resume a crashed or interrupted run.""" cfg = PipelineConfig( api_base=ctx.obj["api_base"], browser_base=ctx.obj["browser_base"], run_dir=ctx.obj["run_dir"], headless=ctx.obj["headless"], ) rc = asyncio.run(_resume_async(cfg, run_id)) sys.exit(rc) @main.command("check-835") @click.argument("run_id") @click.pass_context def check_835(ctx, run_id): """Check whether the 835 has arrived for a prior run.""" rc = asyncio.run(_check_835_async(ctx, run_id)) sys.exit(rc) # --- async helpers --------------------------------------------------------- async def _run_async(cfg, file_path, force: bool) -> int: try: async with CyclonePipeline(cfg, file_path) as p: if force: p.run_state.pending_data["force"] = True report = await p.run() click.echo(_format_one_liner(report)) return _outcome_to_exit_code(report.result) except BrowserNotReachableError as e: click.echo(f"BROWSER_NOT_REACHABLE: {e}", err=True) return 3 except IdempotencyError as e: click.echo(f"IDEMPOTENCY: {e}", err=True) return 3 except UploadError as e: click.echo(f"UPLOAD: {e}", err=True) return 3 except CyclonePipelineError as e: click.echo(f"PIPELINE: {e}", err=True) return 3 async def _resume_async(cfg, run_id) -> int: try: # We need a real input path placeholder; resume ignores it. async with CyclonePipeline(cfg, Path("placeholder.edi")) as p: report = await p.resume(run_id) click.echo(_format_one_liner(report)) return _outcome_to_exit_code(report.result) except (CyclonePipelineError, FileNotFoundError) as e: click.echo(f"RESUME: {e}", err=True) return 3 async def _check_835_async(ctx, run_id) -> int: run_dir: Path = ctx.obj["run_dir"] api_base: str = ctx.obj["api_base"] async with CycloneClient(api_base) as client: try: result = await check_835_for_run(client, run_id, run_dir) except FileNotFoundError as e: click.echo(f"NOT_FOUND: {e}", err=True) return 3 if result.found: click.echo( f"FOUND remit={result.remittance_id} " f"matched={result.matched_claim_ids} " f"received={result.received_date}" ) return 0 else: click.echo( f"NOT_FOUND run_id={run_id} " f"(835 may not have arrived yet — try again Monday)" ) return 2 # soft-fail: not an error, just not yet def _outcome_to_exit_code(outcome: str) -> int: return { "pass": 0, "soft_fail": 2, "hard_fail": 3, "usage": 4, }.get(outcome, 3) def _format_one_liner(report) -> str: return ( f"RESULT: {report.result.upper()} " f"run={report.run_id} " f"phases={len(report.phases)} " f"duration={report.duration_s:.1f}s" ) if __name__ == "__main__": main() ``` - [ ] **Step 4: Run test to verify it passes** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest tests/test_cli.py -v ``` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add src/cyclone_pipeline/cli.py tests/test_cli.py git commit -m "feat(sp22): CLI — run, run-batch, status, resume, check-835" ``` --- ## Task 24: README **Goal:** A polished README with install, run, and embed-in-agent examples. Replaces the placeholder from Task 1. **Files:** - Modify: `/Users/openclaw/dev/cyclone-pipeline/README.md` - [ ] **Step 1: Write the README** Overwrite `/Users/openclaw/dev/cyclone-pipeline/README.md`: ```markdown # cyclone-pipeline Automation agent for [Cyclone](../cyclone) EDI submissions. Drives the full 837P → SFTP submit → TA1 + 999 ACK round-trip end to end, with defensive reliability, structured logging, crash-safe resume, and a self-contained per-run report. The 835 is **not** waited for inline (it lands the following Monday on the CO Medicaid payment cycle). Use `cyclone-pipeline check-835 ` on/after Monday to verify the 835 arrived. ## Install ```bash git clone cyclone-pipeline cd cyclone-pipeline python3.11 -m venv .venv source .venv/bin/activate pip install -e '.[dev]' playwright install chromium ``` ## Run Make sure the cyclone backend + frontend are up: ```bash # Terminal 1 — cyclone backend cd /Users/openclaw/dev/cyclone/backend .venv/bin/python -m cyclone serve # Terminal 2 — cyclone frontend cd /Users/openclaw/dev/cyclone npm run dev ``` Then run the agent: ```bash # Single file cyclone-pipeline run /path/to/axiscare-837p.txt # Folder of files cyclone-pipeline run-batch /path/to/inbox/ # Show all recent runs cyclone-pipeline status # Resume a crashed run cyclone-pipeline resume 2026-06-21-1430-001 # Check 835 on/after Monday cyclone-pipeline check-835 2026-06-21-1430-001 ``` Every run writes a self-contained folder under `./runs/`: ``` runs/2026-06-21-1430-001/ ├── report.md # human summary ├── report.json # machine summary (OpenClaw / Nora-friendly) ├── run.log # structured JSON log ├── run.state.json # for resume └── screenshots/ # stable filenames, dated ``` ## Embed in an agent (OpenClaw / Nora) ```python from cyclone_pipeline import CyclonePipeline, PipelineConfig import asyncio async def submit_for_user(file_path: str): cfg = PipelineConfig( api_base="http://127.0.0.1:8000", browser_base="http://127.0.0.1:5173", run_dir=Path("/var/lib/agents/cyclone-pipeline/runs"), headless=True, ) async with CyclonePipeline(cfg, Path(file_path)) as pipeline: report = await pipeline.run() if report.result == "pass": return {"status": "submitted", "run_id": report.run_id} elif report.result == "soft_fail": return {"status": "in_flight", "detail": report.result_detail} else: return {"status": "failed", "detail": report.result_detail} ``` ## Exit codes | Code | Meaning | |------|---------| | 0 | PASS — TA1 + 999 both received and accepted | | 2 | SOFT_FAIL — TA1 or 999 timed out; submission in flight | | 3 | HARD_FAIL — 999 rejected, upload failed irrecoverably, or submit refused | | 4 | USAGE — bad CLI args, missing file, backend unreachable after retries | ## Configuration | Env var | Default | Effect | |---------|---------|--------| | `CYCLONE_API_BASE` | `http://127.0.0.1:8000` | Cyclone FastAPI base URL | | `CYCLONE_BROWSER_BASE` | `http://127.0.0.1:5173` | Cyclone Vite dev server base URL | | `CYCLONE_PIPELINE_RUN_DIR` | `./runs` | Where to write run artifacts | | `CYCLONE_PIPELINE_LOG_LEVEL` | `INFO` | One of DEBUG, INFO, WARNING, ERROR | ## Tests ```bash pytest # all tests (browser tests skip without a Vite dev server) pytest -m "not browser" # skip the real-browser smoke pytest --cov=cyclone_pipeline ``` ## Design See the [design spec](../cyclone/docs/superpowers/specs/2026-06-21-cyclone-pipeline-agent-design.md). ``` - [ ] **Step 2: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline git add README.md git commit -m "docs(sp22): README — install, run, embed, exit codes, configuration" ``` --- ## Task 25: CI workflow **Goal:** GitHub Actions that runs the non-browser test matrix on every push + PR, and the full matrix (including Playwright) on `main` only. **Files:** - Create: `/Users/openclaw/dev/cyclone-pipeline/.github/workflows/ci.yml` - [ ] **Step 1: Write the workflow file** Write `/Users/openclaw/dev/cyclone-pipeline/.github/workflows/ci.yml`: ```yaml name: CI on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip - name: Install run: | python -m pip install --upgrade pip pip install -e '.[dev]' - name: Run tests (PRs skip browser tests) if: github.event_name == 'pull_request' run: | pytest -m "not browser" -v --cov=cyclone_pipeline --cov-report=term-missing - name: Run tests (main branch — full suite) if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | playwright install --with-deps chromium pytest -v --cov=cyclone_pipeline --cov-report=term-missing ``` - [ ] **Step 2: Commit** ```bash cd /Users/openclaw/dev/cyclone-pipeline mkdir -p .github/workflows git add .github/workflows/ci.yml git commit -m "ci(sp22): GitHub Actions matrix — non-browser on PRs, full suite on main" ``` --- ## Task 26: Final verification **Goal:** Run the full test matrix, verify the CLI is installed, smoke-test a real run against the cyclone backend (manual). - [ ] **Step 1: Run the full non-browser test matrix** ```bash cd /Users/openclaw/dev/cyclone-pipeline pytest -m "not browser" -v ``` Expected: all unit tests pass (exceptions, logging, api_client, state, screenshots, selectors, waiters, report, pipeline, check_835, cli). Should be 35+ tests total. - [ ] **Step 2: Verify the CLI is installed** ```bash which cyclone-pipeline cyclone-pipeline --help ``` Expected: `cyclone-pipeline run …` is listed. - [ ] **Step 3: Smoke test against the real cyclone backend (manual)** ```bash # In one terminal: cyclone backend cd /Users/openclaw/dev/cyclone/backend .venv/bin/python -m cyclone serve & # In another: cyclone frontend cd /Users/openclaw/dev/cyclone npm run dev & # In a third terminal: the agent cd /Users/openclaw/dev/cyclone-pipeline source .venv/bin/activate playwright install chromium # first time only cyclone-pipeline run /Users/openclaw/dev/cyclone/docs/prodfiles/837p-from-axiscare/tp11525703-837P-20260618151119397-1of1.txt ``` Expected: a run folder appears under `./runs/` with a populated `report.md` and `report.json`. Screenshots are present. - [ ] **Step 4: Final commit — tag the release** ```bash cd /Users/openclaw/dev/cyclone-pipeline git log --oneline | head -30 git tag v0.1.0 ``` No commit. Tag only. --- ## Self-review - **Spec coverage:** all 11 spec sections mapped: project layout (§3) → Tasks 1, 2, 3, 8–13, 22; pipeline phases (§4) → Tasks 14–20; output format (§5) → Tasks 12, 20; library API (§6) → Tasks 14, 21; tests (§7) → every task; exit codes (§5.3) → Task 23; idempotency (§2.6) → Task 6; out-of-scope (§1) — 277CA never polled, 835 deferred, watch-folder deferred, all called out in the spec. - **Placeholders:** no "TBD" / "TODO" / "similar to Task N". All code blocks are complete. - **Type consistency:** `RunState` defined in Task 8, used by `pipeline.py` in Tasks 14–21; `PhaseResult` defined in Task 12, used by every phase; `RunReport` defined in Task 12, written in Task 20; `CycloneClient` defined in Task 4, used by every phase. - **Commit cadence:** ~26 commits, one per task — TDD-shaped, reviewable individually.