Files
cyclone/docs/superpowers/specs/2026-06-21-cyclone-pipeline-agent-design.md
T

503 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sub-project 22 — Cyclone Pipeline Agent: Design Spec
**Date:** 2026-06-21
**Status:** Draft (awaiting user review)
**Branch:** `sp22-pipeline-agent` (not yet created; will branch from `main`)
**Aesthetic direction:** n/a (backend automation; no UI surface)
## 1. Scope
A production-grade agent (sibling Python package `cyclone-pipeline`) that
automates the full submission lifecycle of an EDI 837P file through Cyclone
to the Gainwell MFT, and watches for the inbound TA1 + 999 ACKs that
confirm acceptance. The agent exists so a single human operator can drop a
file in and walk away; the agent handles the browser upload, SFTP
submission, ACK polling, and report generation end to end, and writes a
self-contained run directory that OpenClaw / Nora / any other automation
can read.
**In scope:**
- Sibling Python package at `/Users/openclaw/dev/cyclone-pipeline/` (no
shared imports with the cyclone app; talks to cyclone exclusively over
HTTP).
- CLI surface: `run <file>`, `run-batch <dir>`, `status <run-id>`,
`resume <run-id>`, `check-835 <run-id>`.
- Library surface: `CyclonePipeline` class with `run()`, `run_batch()`,
`resume()`, `check_835()` for embedding in OpenClaw / Nora.
- 7-phase state machine: pre-flight → upload (browser) → verify parse →
SFTP submit → wait TA1 → wait 999 → scan HTML + report. The "scan
HTML" and "final report" steps are combined as phase 7 (the HTML
scan populates the report; the report write is phase 7's
postcondition).
- Per-run output folder with `report.{md,json}`, `run.log`,
`run.state.json`, and `screenshots/` with stable filenames.
- Crash-safe resume: `run.state.json` is written after each phase's
postcondition; `resume` skips completed phases.
- Headless + visible browser modes; default visible so screenshots are
useful for debugging.
- Defensive reliability: typed exceptions, 3× retry with backoff on
idempotent operations, timeouts per phase, screenshots on failure,
Keychain-failure guidance.
- Test suite: `pytest` + `pytest-asyncio` + `respx` (httpx mock) +
`pytest-playwright` for the small browser surface. Non-browser tests
run on every PR; Playwright tests gated to `main`.
**Out of scope (deferred):**
- **835 waiting within a single run.** CO Medicaid 835 remittances land
the following Monday, on the payment cycle. A run that blocks for up
to 7 days is impractical. The agent defers 835 detection to a separate
`check-835 <run-id>` subcommand that the operator runs on/after
Monday. The report always carries the `check-835` recipe.
- **277CA polling.** CO Medicaid does not use 277CA claim-level ACKs;
the agent never polls for them. (The `GET /api/277ca-acks` endpoint
exists in the API surface but is not exercised by this agent.)
- **Watch-folder / daemon mode.** The agent is on-demand. A cron job that
invokes `cyclone-pipeline run-batch /inbox/*.txt --strict` on a 5-min
schedule achieves the same effect with no daemon in the loop.
- **Multi-payer support.** The agent targets CO Medicaid (the only
configured payer in `config/payers.yaml`). A new payer means new
payer-specific regex / file-naming / SFTP block — out of scope for v1.
- **Patient peek or other drill-down features.** This is a backend
automation; no UI changes.
- **Editing the audit log or any existing cyclone data.** The agent
only reads + triggers; it never writes to cyclone's DB except through
the published APIs.
## 2. Locked decisions
### 2.1 Architecture
API-first + minimal Playwright shell. The browser is used only for the
upload page (drop zone, Parse button, progress bar). Everything else
(verification, SFTP submit, scheduler control, ACK polling, claim-state
checks) hits the FastAPI directly via `httpx.AsyncClient`.
Rationale: the upload page is the only surface in cyclone with a real
interactive flow. Clearinghouse submission is API-only; the MFT
scheduler is API-only; ACK retrieval is API-only. A pure-Playwright
approach would be 35x more code and considerably flakier on the
API-driven phases, with no benefit.
### 2.2 Sibling package, not an extra
The agent lives at `/Users/openclaw/dev/cyclone-pipeline/` with its own
`pyproject.toml`, not as a new optional extra on the existing cyclone
package. Rationale: the agent has Playwright as a hard dependency; we
do not want Playwright in cyclone's dev env for developers who never
invoke the agent. Separate package = clean separation of concerns.
### 2.3 Real round-trip, but only the parts that are time-bounded
The agent waits for **TA1 (envelope ACK)** and **999 (file-level ACK)**.
It does **not** wait for 277CA (CO Medicaid doesn't use it) or 835
(payment cycle is next Monday). 835 detection is a separate, fast
follow-up command that the operator runs on Monday.
Rationale: blocking a run for up to 7 days is impractical and ties up
operator attention. The TA1 + 999 receipt is the unambiguous "your file
was accepted by the payer at the file level" signal; the 835 is
eventually-consistent and doesn't need a tight loop.
### 2.4 CO Medicaid / Gainwell specifics
- **SFTP server:** `mft.gainwelltechnologies.com:22`
- **Inbound path:** `/CO XIX/PROD/coxix_prod_11525703/FromHPE`
- **Outbound file naming:** `11525703-837P-{yyyymmddhhmmssSSS}-1of1.txt`
(17-digit Mountain Time timestamp; matches the `clearhouse` config's
`outbound` template in `config/payers.yaml`).
- **Inbound ACKs (in observed order):**
1. TA1 (seconds to minutes) — envelope-level
2. 999 (minutes) — file-level
3. HTML (sometimes) — non-X12 artifact, lands in inbound, not parsed
4. 835 (next Monday) — remit
5. 277CA — *never used by CO Medicaid*
### 2.5 Failure semantics
- **Upload retries:** 3× attempts; each attempt is a fresh page load +
file re-attach + click. Screenshots on every attempt.
- **SFTP submit retries:** 3× attempts with `1s → 2s → 4s` backoff. The
submit endpoint is POST and is *not* auto-retried after a network
timeout (would double-submit); the retry covers `409 Conflict` /
`503 Service Unavailable` only.
- **TA1 timeout (5 min):** if no TA1 arrives, the run is **soft-fail**.
The submission is "in flight" — the report says so and recommends
checking Gainwell directly.
- **999 timeout (15 min):** same — soft-fail. Most production runs
complete this phase in < 2 min.
- **999 received with status `R` (rejected):** hard-fail. The 999's
rejection segments surface in the report with full context.
- **HTML detected in inbound:** warn in the report; do not fail.
- **835 not present at end of run:** expected; reported as
"deferred — expected next Monday" with the `check-835` recipe.
- **Process crash (SIGKILL, power loss, etc.):** the next `resume` skips
completed phases by reading `run.state.json`. No work is lost except
the in-flight phase.
### 2.6 Idempotency
- The MFT outbound filename embeds a millisecond Mountain Time
timestamp, so back-to-back submissions to the same input file are
unique on the SFTP server. No `Idempotency-Key` header is needed.
- The agent captures the new `claim_id[]` set from phase 3 (verify
parse) and refuses to SFTP-submit if any of those claim IDs already
appear in a prior `processed_inbound_files` row within a configurable
dedup window (default 24h). `--force` overrides the dedup.
- The `check-835` command is fully idempotent: querying
`GET /api/remittances?since=<date>` is read-only.
### 2.7 Output shape
Every run writes a dated folder under `./runs/`. The folder is
self-contained — anyone reading it has the full picture without needing
the live cyclone instance.
```
runs/2026-06-21-1430-001/
├── run.log # structured JSON, structlog
├── run.state.json # for resume
├── report.md # human-readable summary
├── report.json # machine-readable, OpenClaw/Nora-friendly
└── screenshots/
├── 01-preflight.png
├── 02-upload-1.png # attempts are numbered
├── 02-upload-2.png
├── 03-parse-complete.png
├── 04-submit.png
├── 05-ta1.png
├── 06-999.png
├── 07-inbound-scan.png # HTML detection screenshot
└── 99-final-inbox.png # final state of the Inbox page
```
On failure, the `finally` block adds `screenshots/FAIL-{phase}.png` and
a `failure.md` with the traceback + phase + remediation hint.
## 3. Module map
| File | Role |
|---|---|
| `pyproject.toml` | Project metadata; declares httpx, playwright, click, pydantic, structlog; dev deps: pytest, pytest-asyncio, respx, pytest-playwright |
| `README.md` | Install, run, embed-in-agent examples |
| `src/cyclone_pipeline/__init__.py` | Public API re-exports |
| `src/cyclone_pipeline/__main__.py` | `python -m cyclone_pipeline run …` |
| `src/cyclone_pipeline/cli.py` | Click CLI: `run`, `run-batch`, `status`, `resume`, `check-835` |
| `src/cyclone_pipeline/pipeline.py` | `CyclonePipeline` orchestrator; 7-phase state machine |
| `src/cyclone_pipeline/api_client.py` | `httpx.AsyncClient` wrapper with typed methods |
| `src/cyclone_pipeline/browser.py` | Playwright `UploadPage` class for the upload UI |
| `src/cyclone_pipeline/waiters.py` | Reusable wait primitives (claim state, ACK arrival) |
| `src/cyclone_pipeline/state.py` | `RunState` dataclass + `run.state.json` writer/reader |
| `src/cyclone_pipeline/screenshots.py` | Dated folder + stable filename helper |
| `src/cyclone_pipeline/report.py` | `report.md` + `report.json` writer |
| `src/cyclone_pipeline/selectors.py` | Centralized Playwright selectors with fallbacks |
| `src/cyclone_pipeline/exceptions.py` | Typed errors: `UploadError`, `ParseError`, `SubmitError`, `AckTimeoutError`, `SchedulerNotRunningError`, `IdempotencyError` |
| `src/cyclone_pipeline/logging_setup.py` | `structlog` config matching cyclone's SP18 JSON style |
| `src/cyclone_pipeline/check_835.py` | The deferred 835 detector (pure API, no browser) |
| `tests/conftest.py` | Shared fixtures: fake API server, mock browser, fake clock |
| `tests/test_api_client.py` | Typed responses, retry behavior, timeouts (respx) |
| `tests/test_waiters.py` | Backoff, timeout, predicate resolution (fake clock) |
| `tests/test_browser.py` | safe_click, attach_file, progress wait (pytest-playwright) |
| `tests/test_pipeline.py` | End-to-end orchestration, state machine, resume (mocks) |
| `tests/test_report.py` | Markdown + JSON shape, screenshot paths (golden files) |
| `tests/test_cli.py` | `run`, `run-batch`, `resume`, `check-835` (Click's CliRunner) |
| `tests/test_check_835.py` | `check-835` happy path + "835 not yet present" path |
| `tests/fixtures/sample_837p.edi` | A small valid 837P file for tests (copied from cyclone's `tests/fixtures/minimal_837p.txt`) |
## 4. Pipeline phases
The state machine in `pipeline.py` advances through 7 phases. Each phase
has: `precondition`, `execute`, `postcondition`, `retry_policy`, and a
side-effect of writing `run.state.json` + a screenshot at the end of a
successful attempt.
### Phase 1 — Pre-flight (API)
- `GET /api/health` → expect `status == "ok"` and `db.ok == true`.
- `GET /api/admin/scheduler/status` → record scheduler state
(`running` / `stopped`). If `stopped`, the agent will call
`POST /api/admin/scheduler/tick` per-iteration in phase 5/6 instead
of relying on the polling loop.
- Verify the browser-side URL is reachable: `HEAD {browser_base}/`
via `httpx` (cheap, no rendering). If the frontend dev server isn't
up, fail-fast with `BrowserNotReachableError` rather than discovering
this in phase 2.
- Capture the API base URL + browser base URL for the report.
- Screenshot the Upload page in its empty state →
`screenshots/01-preflight.png`.
### Phase 2 — Upload (Playwright)
- `chromium.launch(headless=VISIBLE)` (configurable).
- `page.goto(f"{browser_base}/upload")`.
- `expect(drop_zone).to_be_visible(timeout=10s)`.
- `set_input_files("input[type=file]", file_path)` (the hidden input
behind the drop zone; `set_input_files` works on hidden inputs).
- `expect(file_name_display).to_have_text(file.name)`.
- `safe_click("button:has-text('Parse')")`.
- `wait_for_progress_complete()` — polls the progress bar
(`text_content` containing `"100%"`) or the success toast
(`text_content` containing `"Parsed N claims"`), whichever comes
first, with a 5 min ceiling.
- Screenshots: `screenshots/02-upload-{attempt}.png` per attempt.
### Phase 3 — Verify parse (API)
- The browser-side toast gives the parsed claim count; capture it.
- `GET /api/claims?since=<phase1_start_ts>` → filter to claims whose
`submission_date >= phase1_start_ts`; these are the new ones.
- Collect the set of new `claim_id` values.
- If 0 new claims after a 30s poll, fail with `ParseError`.
### Phase 4 — SFTP submit (API)
- Dedup check: query `GET /api/admin/scheduler/processed-files` for
the past 24h; if any outbound filename pattern matches a recent
submission, fail with `IdempotencyError` unless `--force`.
- `POST /api/clearhouse/submit` with `{payer_id: "CO_TXIX",
transaction_type: "837P"}`. The body is small; the server picks
the batch from the newly-parsed claims.
- Expect 200; if 5xx with a "keychain" hint, raise `SubmitError` with
the macOS Keychain setup recipe inline.
- Screenshot: `screenshots/04-submit.png` (the Inbox page right after
submit, showing the batch in the lane).
### Phase 5 — Wait for TA1 (API poll)
- `GET /api/ta1-acks?since=<phase4_ts>` in a loop, every 5s, with a
5 min ceiling.
- Match by `isa13` (the control number in the submitted interchange
envelope; surfaced in the TA1 record).
- If scheduler is `stopped`, call `POST /api/admin/scheduler/tick`
between polls to manually drive the MFT fetch.
- On hit: capture `ta1_id`, `accepted` (vs `rejected`), screenshot the
TA1 detail in the Acks page → `screenshots/05-ta1.png`.
- On timeout: soft-fail; record `ta1_status: "timeout"`.
### Phase 6 — Wait for 999 (API poll)
- `GET /api/acks?since=<phase4_ts>` in a loop, every 10s, with a
15 min ceiling.
- Match by `original_batch_id` (or, if not available, by
`functional_group_control_number` from the submitted file's GS06).
- On hit: capture `ack_id`, `status` (`A` = accepted, `R` = rejected,
`E` = rejected with errors), the rejection segment details if
rejected.
- On `A`: screenshot the Acks page → `screenshots/06-999.png`.
- On `R`: hard-fail; record `ack.status == "rejected"`, the rejection
segments, and the recommended remediation in the report.
- On timeout: soft-fail; record `ack_999_status: "timeout"`.
### Phase 7 — Scan for HTML + final report (API + local)
- `GET /api/admin/scheduler/processed-files?since=<phase4_ts>` — list
inbound files the MFT scheduler saw during the run. If any has
`file_type == "html"` or an unrecognized extension, log a WARN,
capture the filename, and add it to the report's "Detected artifacts"
section.
- Generate `report.md` and `report.json` (see §5).
- Screenshot the Inbox page → `screenshots/99-final-inbox.png`.
- Emit a one-line summary on stdout for shell pipelines:
`RESULT: PASS run=2026-06-21-1430-001 ta1=TA1-42 999=999-43 835=deferred`.
## 5. Output format
### 5.1 `report.md` (human)
```markdown
# Cyclone pipeline run — 2026-06-21 14:30 PDT
| Field | Value |
|---------------|------------------------------------|
| Run id | 2026-06-21-1430-001 |
| Input | /path/to/axiscare-837p.txt (12.4 KB) |
| Result | PASS — TA1 + 999 received, 835 deferred |
| Duration | 2m 14s |
## Phase summary
| Phase | Status | Duration | Detail |
|------------------------|--------|----------|-----------------------------------------|
| 1. Pre-flight | OK | 0.4s | health.ok, scheduler running |
| 2. Upload | OK | 2.1s | 1 attempt, 17 claims parsed |
| 3. Verify parse | OK | 0.8s | claim_ids: CLM-001 … CLM-017 |
| 4. SFTP submit | OK | 4.3s | filename 11525703-837P-20260621143012345-1of1.txt |
| 5. Wait TA1 | OK | 12s | ack id: TA1-42, accepted |
| 6. Wait 999 | OK | 1m 14s | ack id: 999-43, status Accepted |
| 7. Scan HTML + report | WARN | 0.2s | 1 HTML file in inbound, skipped; report.md + report.json written |
## Inbound artifacts detected
- 1 HTML file (`gainwell_status_20260621.htm`) — not parsed, not a failure.
## Expected next steps
- **835 expected Monday** (2026-06-28; computed as next Monday in
Mountain Time at the moment of report generation; if today is
Monday before noon, expected same day, otherwise next Monday).
Re-run: `cyclone-pipeline check-835 2026-06-21-1430-001`
## Artifacts
- screenshots/01-preflight.png
- screenshots/02-upload-1.png
- screenshots/03-parse-complete.png
- screenshots/04-submit.png
- screenshots/05-ta1.png
- screenshots/06-999.png
- screenshots/99-final-inbox.png
```
### 5.2 `report.json` (machine)
```json
{
"run_id": "2026-06-21-1430-001",
"input": {
"path": "/path/to/axiscare-837p.txt",
"size_bytes": 12700,
"sha256": "…"
},
"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,
"phases": [
{"n": 1, "name": "preflight", "status": "ok", "duration_s": 0.4},
{"n": 2, "name": "upload", "status": "ok", "duration_s": 2.1, "attempts": 1},
{"n": 3, "name": "verify_parse", "status": "ok", "duration_s": 0.8, "claim_ids": ["CLM-001", "…"]},
{"n": 4, "name": "submit", "status": "ok", "duration_s": 4.3, "filename": "11525703-837P-…"},
{"n": 5, "name": "wait_ta1", "status": "ok", "duration_s": 12, "ta1_id": "TA1-42", "accepted": true},
{"n": 6, "name": "wait_999", "status": "ok", "duration_s": 74, "ack_999_id": "999-43", "status": "A"},
{"n": 7, "name": "scan_and_report", "status": "warn", "duration_s": 0.2, "html_files": ["gainwell_status_20260621.htm"], "report_paths": {"md": "report.md", "json": "report.json"}}
],
"artifacts": {
"screenshots": ["01-preflight.png", "…", "99-final-inbox.png"],
"report_md": "report.md",
"report_json": "report.json",
"run_log": "run.log"
},
"next_steps": {
"check_835": "cyclone-pipeline check-835 2026-06-21-1430-001",
"expected_835_by": "2026-06-28"
}
}
```
On soft-fail (TA1 or 999 timeout) or hard-fail (999 rejected), the
`result` is `"soft_fail"` or `"hard_fail"`, the offending phase gets
`status: "timeout"` / `"rejected"`, and a `failure` block is added
with the traceback, recommended remediation, and the `failure.md` path.
### 5.3 Exit codes
| Code | Meaning |
|------|---------|
| 0 | PASS — TA1 + 999 both received, both accepted |
| 2 | SOFT_FAIL — TA1 or 999 timed out; submission in flight, manual check needed |
| 3 | HARD_FAIL — 999 rejected, upload failed irrecoverably, or submit refused |
| 4 | USAGE — bad CLI args, missing file, backend unreachable after retries |
This mirrors common CI conventions so OpenClaw / Nora / shell scripts
can branch on `$?` without parsing JSON.
## 6. Library API (for OpenClaw / Nora)
```python
from cyclone_pipeline import CyclonePipeline, RunResult
async def main():
pipeline = CyclonePipeline(
api_base="http://127.0.0.1:8000",
browser_base="http://127.0.0.1:5173",
run_dir="./runs",
headless=False,
timeouts={"ta1_s": 300, "ack_999_s": 900},
)
result: RunResult = await pipeline.run(file_path="axiscare-837p.txt")
# result.outcome: "pass" | "soft_fail" | "hard_fail" | "usage"
# result.exit_code: int (0/2/3/4; mirrors CLI exit codes in §5.3)
# result.report: RunReport (Pydantic model; serializes to report.json)
# result.run_id: str
# Or batch:
results = await pipeline.run_batch(file_paths=[...], strict=False)
# Or resume:
result = await pipeline.resume(run_id="2026-06-21-1430-001")
# Or check 835 later:
ack = await pipeline.check_835(run_id="2026-06-21-1430-001")
# ack.found, ack.remittance_id, ack.total_paid, ...
```
`RunResult` is a Pydantic model so OpenClaw / Nora can serialize it
straight to JSON without any extra glue.
## 7. Tests
| Test file | Strategy |
|---|---|
| `tests/test_api_client.py` | `respx` mock; verify typed responses, retry-on-idempotent, no-retry-on-POST, timeout behavior |
| `tests/test_waiters.py` | Inject fake clock + fake state; verify backoff math, timeout, predicate resolution |
| `tests/test_browser.py` | `pytest-playwright` against a Vite dev server spun up in CI; covers `safe_click`, `set_input_files`, progress wait |
| `tests/test_pipeline.py` | Mock both `api_client` and `browser`; drive fake "happy path" through every phase + "phase 5 timeout" + "phase 3 parse error" + "phase 6 reject" |
| `tests/test_report.py` | Golden-file comparison for `report.md` and `report.json` |
| `tests/test_cli.py` | Click's `CliRunner`; covers all 5 subcommands + error paths |
| `tests/test_check_835.py` | Mock `GET /api/remittances?since=…`; happy path + "835 not yet present" path |
CI: GitHub Actions matrix on Python 3.11 + 3.12. Non-Playwright tests
run on every PR; Playwright tests gated to `main` push (the Vite
dev-server + chromium setup is too slow for every PR).
Coverage target: 90% line coverage on `pipeline.py`, 85% on the rest.
`browser.py` is exempt from the strict target (Playwright's own
assertion failures are the real test).
## 8. Files to add
- `pyproject.toml` — package metadata, deps, dev deps, scripts entry
- `README.md` — install, run, embed-in-agent examples
- 14 source files under `src/cyclone_pipeline/` listed in §3
- 8 test files under `tests/` listed in §3
- `tests/fixtures/sample_837p.edi` — small valid 837P for tests
(copied from cyclone's `tests/fixtures/minimal_837p.txt`)
Total: 24 files (1 `pyproject.toml`, 1 `README.md`, 14 src `.py`, 8
test `.py`, 1 fixture).
## 9. Files NOT touched
- No changes to the cyclone repo. The agent is a strict HTTP consumer
of cyclone's published API.
- No changes to cyclone's DB schema, API, CLI, or UI.
- No new dependencies added to cyclone's `pyproject.toml`.
## 10. Future work (explicit non-goals for v1)
- **Watch-folder daemon** — `cyclone-pipeline watch /inbox/*.txt` that
runs continuously and submits files as they appear. Achievable with
the current `run-batch` + a 1-line shell loop; the daemon is just UX.
- **Multi-payer support** — adding a new payer (e.g., a second
Medicaid MCO) means new payer-specific SFTP block, file naming
template, and ID-matching strategy. Worth doing once a second payer
is on the books.
- **Slack / email notification on completion** — `report.json` is the
integration point; a 50-LOC notifier could post a summary. v1 just
prints the one-line summary and writes the file.
- **Pushing reports to S3 / a share** — same as above; `report.json`
is the integration point.
- **Web UI for run history** — out of scope; OpenClaw / Nora can read
the `runs/` directory and surface the JSON.
- **277CA polling for payers that use it** — straightforward to add
behind a `--payer-config` flag once a second payer is configured.
277CA support in cyclone is already shipped; only the agent's wait
list changes.
## 11. Open questions (none for v1)
None. The design is locked pending user review of this spec.