Files
cyclone/docs/superpowers/plans/2026-07-02-cyclone-ack-live-tail.md
T
2026-07-02 08:34:54 -06:00

1857 lines
59 KiB
Markdown

# SP25 — Ack Live-Tail 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:** Make the Acks page reflect newly-downloaded 999 / TA1 acks within one heartbeat, by moving the `ack_received` pubsub publish from the handlers into the store (mirroring the existing `claim_written` / `remittance_written` / `activity_recorded` pattern), adding two NDJSON stream endpoints, and wiring the Acks page into the live-tail hook triplet.
**Architecture:** The store's `add_999_ack` / `add_ta1_ack` / `add_277ca_ack` gain an `event_bus` parameter and call `_sync_publish` after the row commits, publishing `ack_received` / `ta1_ack_received` / `two77ca_ack_received` with the full `to_ui_*` row shape. Two new stream endpoints (`/api/acks/stream`, `/api/ta1-acks/stream`) subscribe to those events and emit NDJSON snapshots + live items. The Acks page mounts `useTailStream("acks")` and `useMergedTail("acks", data?.items ?? [])` for the 999 register, and the same triplet for `"ta1_acks"` in the TA1 section. The `<TailStatusPill>` is dropped into the hero next to the existing "Envelope accepted" badge.
**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy, asyncio, Pydantic; React 18, TypeScript, TanStack Query, Zustand. Tests: pytest (backend), vitest + happy-dom + @testing-library/react (frontend).
**Spec:** [`docs/superpowers/specs/2026-07-02-cyclone-ack-live-tail-design.md`](../specs/2026-07-02-cyclone-ack-live-tail-design.md)
---
## File structure
```
backend/src/cyclone/
store/
ui.py [MODIFY] add to_ui_ack, to_ui_ta1_ack, to_ui_two77ca_ack
acks.py [MODIFY] add_999_ack / add_ta1_ack / add_277ca_ack take event_bus, _sync_publish
__init__.py [MODIFY] re-export to_ui_ack, to_ui_ta1_ack, to_ui_two77ca_ack
api_routers/
acks.py [MODIFY] import to_ui_ack from store; drop _ack_to_ui
ta1_acks.py [MODIFY] import to_ui_ta1_ack from store; drop _ta1_to_ui
handlers/
handle_999.py [MODIFY] drop event_bus kwarg + publish block + TODO docstring
handle_ta1.py [MODIFY] drop event_bus kwarg + publish block + TODO docstring
handle_277ca.py [MODIFY] drop event_bus kwarg + publish block + TODO docstring
handle_835.py [MODIFY] drop event_bus kwarg + publish block + TODO docstring
api.py [MODIFY] /api/acks/stream + /api/ta1-acks/stream; thread event_bus into /api/parse-999, /api/parse-ta1, /api/parse-277ca; move 277CA serializer to store/ui.py
backend/tests/
test_store_acks_publish.py [NEW] publish-from-store tests
test_api_ack_stream.py [NEW] /api/acks/stream + /api/ta1-acks/stream integration
src/
lib/tail-stream.ts [MODIFY] TailResource += "acks" | "ta1_acks"
store/tail-store.ts [MODIFY] add acks/ta1Acks slices + addAck/addTa1Ack/reset cases
hooks/useTailStream.ts [MODIFY] dispatch += acks/ta1_acks cases
hooks/useMergedTail.ts [MODIFY] slice selectors for the new resources
pages/Acks.tsx [MODIFY] useTailStream + useMergedTail + <TailStatusPill>
src/store/tail-store.test.ts [NEW] slice tests
src/hooks/useTailStream.test.ts [NEW or EXTEND] dispatch tests
src/hooks/useMergedTail.test.ts [NEW or EXTEND] slice-selector tests
src/pages/Acks.test.tsx [EXTEND] live-tail test cases
```
---
## Task 0: Worktree + clean baseline
**Files:**
- Create: `.worktrees/sp25-ack-live-tail/` (worktree)
- Create: branch `sp25-ack-live-tail` off `main`
- [ ] **Step 1: Create the worktree**
```bash
cd /home/tyler/dev/cyclone
git worktree add .worktrees/sp25-ack-live-tail -b sp25-ack-live-tail
```
- [ ] **Step 2: Symlink the existing venv + node_modules into the worktree**
The worktree only checks out tracked files. The untracked `.venv/` and `node_modules/` would otherwise need a full reinstall. Symlink the existing copies so the test runs use the same packages:
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
ln -s ../../backend/.venv backend/.venv
ln -s ../../node_modules node_modules
```
- [ ] **Step 3: Run the backend baseline test suite**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail/backend
.venv/bin/pytest -q 2>&1 | tail -10
```
Expected: all tests pass (the merge gate; should be ~89 files). If anything fails, stop and report — don't proceed with a broken baseline.
- [ ] **Step 4: Run the frontend baseline test suite**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npm test 2>&1 | tail -20
```
Expected: all vitest tests pass. If anything fails, stop and report.
- [ ] **Step 5: Run the typecheck + lint**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npm run typecheck 2>&1 | tail -5
npm run lint 2>&1 | tail -5
```
Expected: clean.
---
## Task 1: Move `_ack_to_ui` to `store/ui.py` as `to_ui_ack`
**Files:**
- Modify: `backend/src/cyclone/store/ui.py`
- Modify: `backend/src/cyclone/store/__init__.py`
- Modify: `backend/src/cyclone/api_routers/acks.py`
- Test: `backend/tests/test_store_ui_acks.py` (new)
- [ ] **Step 1: Write the failing test**
Create `backend/tests/test_store_ui_acks.py`:
```python
"""Tests for the moved-to-store UI serializers for 999/TA1/277CA acks."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from sqlalchemy.orm import Session
from cyclone import db
from cyclone.store.ui import to_ui_ack, to_ui_ta1_ack, to_ui_two77ca_ack
@pytest.fixture
def ack_row() -> db.Ack:
"""Insert a 999 ack row and return it."""
with db.SessionLocal()() as s:
row = db.Ack(
source_batch_id="999-TEST-1",
accepted_count=3,
rejected_count=1,
received_count=4,
ack_code="P",
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
raw_json={
"envelope": {"control_number": "000000001"},
"set_responses": [
{"set_control_number": "PCN-42"},
],
},
)
s.add(row)
s.commit()
s.refresh(row)
return row
def test_to_ui_ack_round_trips_999_fields(ack_row):
body = to_ui_ack(ack_row)
assert body["id"] == ack_row.id
assert body["source_batch_id"] == "999-TEST-1"
assert body["accepted_count"] == 3
assert body["rejected_count"] == 1
assert body["received_count"] == 4
assert body["ack_code"] == "P"
assert body["parsed_at"] == "2026-07-02T12:00:00Z"
# patient_control_number comes from raw_json.set_responses[0]
assert body["patient_control_number"] == "PCN-42"
```
- [ ] **Step 2: Run the test, see it fail**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_ui_acks.py -v 2>&1 | tail -15
```
Expected: `ImportError: cannot import name 'to_ui_ack' from 'cyclone.store.ui'`.
- [ ] **Step 3: Implement `to_ui_ack` in `store/ui.py`**
Append to `backend/src/cyclone/store/ui.py`:
```python
def to_ui_ack(row: db.Ack) -> dict:
"""Map a 999 ``Ack`` ORM row to the wire shape used by ``/api/acks``.
Field names match the rest of the Cyclone API (snake_case). The
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
interface in ``src/types/index.ts``.
Adds ``patient_control_number`` pulled from ``raw_json`` so the
operator can correlate each 999 back to the original claim
batch. Mirrors the previous in-router ``_ack_to_ui`` shape
byte-for-byte so the live-tail event payload matches the list
endpoint.
"""
body = {
"id": row.id,
"source_batch_id": row.source_batch_id,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"received_count": row.received_count,
"ack_code": row.ack_code,
"parsed_at": _iso_z(row.parsed_at),
"patient_control_number": None,
}
raw = row.raw_json or {}
try:
set_responses = raw.get("set_responses") or []
if set_responses:
body["patient_control_number"] = set_responses[0].get("set_control_number")
except (AttributeError, TypeError):
pass
return body
def to_ui_ta1_ack(row: db.Ta1Ack) -> dict:
"""Map a TA1 ``Ta1Ack`` ORM row to the wire shape used by ``/api/ta1-acks``.
Mirrors the previous in-router ``_ta1_to_ui`` shape byte-for-byte
so the live-tail event payload matches the list endpoint.
"""
return {
"id": row.id,
"control_number": row.control_number,
"ack_code": row.ack_code,
"note_code": row.note_code,
"interchange_date": row.interchange_date.isoformat()
if row.interchange_date else None,
"interchange_time": row.interchange_time,
"sender_id": row.sender_id,
"receiver_id": row.receiver_id,
"source_batch_id": row.source_batch_id,
"parsed_at": _iso_z(row.parsed_at),
}
def to_ui_two77ca_ack(row: db.Two77caAck) -> dict:
"""Map a 277CA ``Two77caAck`` ORM row to the wire shape used by
``/api/277ca-acks``.
Mirrors the previous in-``api.py`` serializer byte-for-byte so the
live-tail event payload matches the list endpoint.
"""
return {
"id": row.id,
"source_batch_id": row.source_batch_id,
"control_number": row.control_number,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"paid_count": row.paid_count,
"pended_count": row.pended_count,
"parsed_at": _iso_z(row.parsed_at),
}
```
- [ ] **Step 4: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_ui_acks.py -v 2>&1 | tail -10
```
Expected: PASS.
- [ ] **Step 5: Re-export from `store/__init__.py`**
Add to the `from .ui import (...)` block in `backend/src/cyclone/store/__init__.py`:
```python
from .ui import (
to_ui_ack,
to_ui_claim_detail,
to_ui_claim_from_orm,
to_ui_provider,
to_ui_remittance_from_orm,
to_ui_remittance_with_adjustments,
to_ui_ta1_ack,
to_ui_two77ca_ack,
)
```
- [ ] **Step 6: Update `api_routers/acks.py` to import from the store**
Replace the local `_ack_to_ui` definition with an import. In `backend/src/cyclone/api_routers/acks.py`:
```python
from cyclone.store import store, to_ui_ack
```
Then update the two call sites (`list_acks_endpoint` and `get_ack_endpoint`) from `_ack_to_ui(r)` to `to_ui_ack(r)`, and remove the `def _ack_to_ui(row)` block entirely. Also remove the now-unused `from cyclone.parsers.serialize_999 import serialize_999` import is still needed for the detail endpoint, so leave it. Remove the unused `from cyclone.parsers.models_999 import ParseResult999` IF nothing else in the file uses it (it does, for the regenerated text — keep).
Verify by grepping: `grep -n "_ack_to_ui" backend/src/cyclone/api_routers/acks.py` should return zero matches after the edit.
- [ ] **Step 7: Update `api_routers/ta1_acks.py` to import from the store**
Same pattern. Replace `def _ta1_to_ui(row)` and `def _serialize_ta1_from_row(row)` usages with the store imports:
```python
from cyclone.store import store, to_ui_ta1_ack
```
Replace `_ta1_to_ui(row)` calls with `to_ui_ta1_ack(row)`. Keep the `_serialize_ta1_from_row` local helper (it reconstructs a TA1 segment, which isn't a UI row shape — different concern, stays here).
- [ ] **Step 8: Find and move the 277CA serializer from `api.py`**
In `backend/src/cyclone/api.py`, find the 277CA list endpoint (around line 1033) and its local serializer function. Move the local serializer body to `store/ui.py` as `to_ui_two77ca_ack` (already done in Step 3), and replace the local function with an import.
Search:
```bash
grep -n "def _two77ca_to_ui\|def _277ca_to_ui\|/api/277ca-acks" backend/src/cyclone/api.py
```
Replace any `def _xxx_to_ui(row)` body in `api.py` for the 277CA list endpoint with:
```python
items = [to_ui_two77ca_ack(r) for r in rows[:limit]]
```
And add the import at the top of `api.py`:
```python
from cyclone.store import store, to_ui_ack, to_ui_ta1_ack, to_ui_two77ca_ack
```
(merge with the existing `from cyclone.store import store` line — single import is fine).
- [ ] **Step 9: Run the existing acks + ta1-acks tests**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_api_acks_persists.py backend/tests/test_api_acks.py backend/tests/test_api_ta1_acks.py -v 2>&1 | tail -15
```
(Adjust filenames to match what's on disk — find them with `ls backend/tests/test_api_acks*` and `ls backend/tests/test_api_ta1*`.)
Expected: all pass (the move was byte-for-byte).
- [ ] **Step 10: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add backend/src/cyclone/store/ui.py \
backend/src/cyclone/store/__init__.py \
backend/src/cyclone/api_routers/acks.py \
backend/src/cyclone/api_routers/ta1_acks.py \
backend/src/cyclone/api.py \
backend/tests/test_store_ui_acks.py
git commit -m "feat(sp25): move ack UI serializers to store/ui.py"
```
---
## Task 2: `add_999_ack` publishes `ack_received`
**Files:**
- Modify: `backend/src/cyclone/store/acks.py`
- Modify: `backend/src/cyclone/store/__init__.py` (re-export `publish` is not needed; the `add_ack` facade already exists)
- Test: `backend/tests/test_store_acks_publish.py` (new)
- [ ] **Step 1: Write the failing test**
Create `backend/tests/test_store_acks_publish.py`:
```python
"""Tests that the store's ACK add methods publish on the event bus.
Per SP25, the store is the single write surface; the handlers and
parse endpoints no longer need to publish. The publish is best-effort
— a failed subscriber MUST NOT roll back the persisted row.
"""
from __future__ import annotations
import asyncio
import json
from datetime import date, datetime, timezone
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.pubsub import EventBus
from cyclone.store import store
from cyclone.store.ui import to_ui_ack
@pytest.fixture
def bus() -> EventBus:
"""Fresh EventBus on the app for the duration of one test."""
new_bus = EventBus(max_queue_size=64)
app.state.event_bus = new_bus
return new_bus
def _drain(bus: EventBus, kind: str) -> list[dict]:
"""Pull every queued event for ``kind`` off the bus and return them."""
events: list[dict] = []
for queue in bus._subscribers.get(kind, []):
while not queue.empty():
events.append(queue.get_nowait())
return events
def test_add_999_ack_publishes_ack_received(bus: EventBus):
with db.SessionLocal()() as s:
row = store.add_999_ack(
source_batch_id="999-SP25-1",
accepted_count=2,
rejected_count=0,
received_count=2,
ack_code="A",
raw_json={
"envelope": {"control_number": "000000001"},
"set_responses": [],
},
event_bus=bus,
)
events = _drain(bus, "ack_received")
assert len(events) == 1
payload = events[0]
# The event payload MUST match the list-endpoint shape so a
# live-tail consumer sees the same row shape as a JSON fetch.
assert payload["_kind"] == "ack_received"
assert payload["id"] == row.id
assert payload["source_batch_id"] == "999-SP25-1"
assert payload["accepted_count"] == 2
assert payload["rejected_count"] == 0
assert payload["received_count"] == 2
assert payload["ack_code"] == "A"
def test_add_999_ack_publish_failure_does_not_rollback(bus: EventBus):
"""Best-effort publish: a throwing subscriber MUST NOT roll back the row."""
class _Boom:
def __init__(self) -> None:
self.published: list[tuple[str, dict]] = []
def publish(self, kind: str, payload: dict) -> None:
# Mimic a misbehaving subscriber that raises. The store
# catches the error and continues.
raise RuntimeError("subscriber exploded")
boom = _Boom()
with db.SessionLocal()() as s:
row = store.add_999_ack(
source_batch_id="999-SP25-BOOM",
accepted_count=1,
rejected_count=0,
received_count=1,
ack_code="A",
raw_json={},
event_bus=boom,
)
# Row still landed despite the publish failure.
assert store.get_ack(row.id) is not None
```
- [ ] **Step 2: Run the test, see it fail**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_acks_publish.py -v 2>&1 | tail -15
```
Expected: both tests fail (the `event_bus=` kwarg doesn't exist yet on `add_999_ack`).
- [ ] **Step 3: Modify `add_999_ack` to accept `event_bus` and publish**
In `backend/src/cyclone/store/acks.py`, update the signature and body:
```python
def add_999_ack(
*,
source_batch_id: str,
accepted_count: int,
rejected_count: int,
received_count: int,
ack_code: str,
raw_json: dict,
event_bus: "EventBus | None" = None,
) -> db.Ack:
"""Persist a 999 ACK row and return it.
SP25: when ``event_bus`` is provided, publishes one
``ack_received`` event with the ``to_ui_ack`` row shape after the
row commits. Best-effort — a publish failure never rolls back the
persisted row. Mirrors the existing ``claim_written`` publish in
``store.write.add_record``.
"""
with db.SessionLocal()() as s:
row = Ack(
source_batch_id=source_batch_id,
accepted_count=accepted_count,
rejected_count=rejected_count,
received_count=received_count,
ack_code=ack_code,
parsed_at=utcnow(),
raw_json=raw_json,
)
s.add(row)
s.commit()
s.refresh(row)
row_id = row.id
if event_bus is not None:
try:
# Re-fetch via a fresh session so the payload reflects the
# committed row shape, not the in-session row.
with db.SessionLocal()() as s:
payload = to_ui_ack(s.get(Ack, row_id))
_safe_publish(event_bus, "ack_received", payload)
except Exception: # noqa: BLE001
import logging
logging.getLogger(__name__).exception(
"add_999_ack: event publish failed for ack %s", row_id,
)
return row
```
Add the helper at the top of `acks.py`:
```python
def _safe_publish(event_bus, kind: str, payload: dict) -> None:
"""Best-effort sync publish. Tolerates async-only event buses.
Real ``EventBus`` instances expose both an async ``publish`` and a
private ``_enqueue_or_drop_oldest`` we can call without awaiting.
Tests sometimes pass a stub object with only a sync ``publish`` —
handle both, and never let a publish failure roll back the row.
"""
# Async real bus: schedule the coroutine without awaiting. The
# event loop is on the same thread as the FastAPI request, so the
# coroutine runs to completion on the next loop tick.
publish = getattr(event_bus, "publish", None)
if publish is not None and callable(publish) and not _is_coroutine_func(publish):
try:
publish(kind, payload)
return
except Exception:
raise
# Fallback: directly enqueue. Mirrors ``EventBus._enqueue_or_drop_oldest``.
queue_getter = getattr(event_bus, "_subscribers", None)
if isinstance(queue_getter, dict):
for queue in list(queue_getter.get(kind, ())):
try:
queue.put_nowait({**payload, "_kind": kind})
except Exception: # noqa: BLE001
pass
def _is_coroutine_func(fn) -> bool:
import asyncio
return asyncio.iscoroutinefunction(fn)
```
Wait — the existing `EventBus.publish` is async. We need a sync wrapper. The store's existing `_sync_publish` (in `store/write.py:235`) does exactly this — it reaches into the private `_subscribers` and `_enqueue_or_drop_oldest`. Reuse it.
Replace the helper above with an import:
```python
from .write import _sync_publish
```
And call `_sync_publish(event_bus, "ack_received", payload)` directly. If the bus is a stub, `_sync_publish` will silently no-op (no `_subscribers` attr). Update the `_safe_publish` to be a thin wrapper that catches and logs:
```python
def _safe_publish(event_bus, kind: str, payload: dict) -> None:
"""Best-effort publish. Never raises. Mirrors write._publish_events_sync."""
try:
_sync_publish(event_bus, kind, payload)
except Exception: # noqa: BLE001
import logging
logging.getLogger(__name__).exception(
"%s: event publish failed", kind,
)
```
- [ ] **Step 4: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_acks_publish.py::test_add_999_ack_publishes_ack_received -v 2>&1 | tail -10
```
Expected: PASS. The second test (boom) will still fail at this point because we haven't added the try/except in `_safe_publish` correctly — re-check after running.
- [ ] **Step 5: Run both publish tests**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_acks_publish.py -v 2>&1 | tail -10
```
Expected: both pass.
- [ ] **Step 6: Update the `CycloneStore.add_ack` facade**
In `backend/src/cyclone/store/__init__.py`, the `add_ack` facade currently is `def add_ack(self, **kwargs): return add_999_ack(**kwargs)`. The new signature is forward-compatible — `**kwargs` carries `event_bus` through. No change required; verify:
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_api_acks_persists.py -v 2>&1 | tail -10
```
Expected: existing tests still pass.
- [ ] **Step 7: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add backend/src/cyclone/store/acks.py \
backend/tests/test_store_acks_publish.py
git commit -m "feat(sp25): add_999_ack publishes ack_received"
```
---
## Task 3: `add_ta1_ack` publishes `ta1_ack_received`
**Files:**
- Modify: `backend/src/cyclone/store/acks.py`
- Test: extend `backend/tests/test_store_acks_publish.py`
- [ ] **Step 1: Write the failing test**
Add to `backend/tests/test_store_acks_publish.py`:
```python
def test_add_ta1_ack_publishes_ta1_ack_received(bus: EventBus):
with db.SessionLocal()() as s:
row = store.add_ta1_ack(
source_batch_id="TA1-SP25-1",
control_number="000000001",
interchange_date=date(2026, 7, 2),
interchange_time="1200",
ack_code="A",
note_code="000",
ack_generated_date=None,
sender_id="SENDER",
receiver_id="RECEIVER",
raw_json={"envelope": {"control_number": "000000001"}},
event_bus=bus,
)
events = _drain(bus, "ta1_ack_received")
assert len(events) == 1
payload = events[0]
assert payload["_kind"] == "ta1_ack_received"
assert payload["id"] == row.id
assert payload["control_number"] == "000000001"
assert payload["ack_code"] == "A"
assert payload["sender_id"] == "SENDER"
assert payload["receiver_id"] == "RECEIVER"
assert payload["interchange_date"] == "2026-07-02"
```
- [ ] **Step 2: Run the test, see it fail**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_acks_publish.py::test_add_ta1_ack_publishes_ta1_ack_received -v 2>&1 | tail -10
```
Expected: fail (event_bus kwarg missing on add_ta1_ack).
- [ ] **Step 3: Modify `add_ta1_ack` to accept `event_bus` and publish**
In `backend/src/cyclone/store/acks.py`, update:
```python
def add_ta1_ack(
*,
source_batch_id: str,
control_number: str,
interchange_date: date | None,
interchange_time: str | None,
ack_code: str,
note_code: str | None,
ack_generated_date: date | None,
sender_id: str,
receiver_id: str,
raw_json: dict,
event_bus: "EventBus | None" = None,
) -> db.Ta1Ack:
"""Persist a TA1 row and return it. SP25: publishes ``ta1_ack_received``."""
with db.SessionLocal()() as s:
row = db.Ta1Ack(
source_batch_id=source_batch_id,
control_number=control_number,
interchange_date=interchange_date,
interchange_time=interchange_time,
ack_code=ack_code,
note_code=note_code,
ack_generated_date=ack_generated_date,
sender_id=sender_id,
receiver_id=receiver_id,
parsed_at=utcnow(),
raw_json=raw_json,
)
s.add(row)
s.commit()
s.refresh(row)
row_id = row.id
if event_bus is not None:
try:
with db.SessionLocal()() as s:
payload = to_ui_ta1_ack(s.get(db.Ta1Ack, row_id))
_safe_publish(event_bus, "ta1_ack_received", payload)
except Exception: # noqa: BLE001
import logging
logging.getLogger(__name__).exception(
"add_ta1_ack: event publish failed for ta1 %s", row_id,
)
return row
```
Add the import at the top of `acks.py` (next to the existing `from . import utcnow`):
```python
from .ui import to_ui_ack, to_ui_ta1_ack, to_ui_two77ca_ack
```
- [ ] **Step 4: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_acks_publish.py -v 2>&1 | tail -10
```
Expected: all three pass (the 999 test, the boom test, and the new TA1 test).
- [ ] **Step 5: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add backend/src/cyclone/store/acks.py \
backend/tests/test_store_acks_publish.py
git commit -m "feat(sp25): add_ta1_ack publishes ta1_ack_received"
```
---
## Task 4: `add_277ca_ack` publishes `two77ca_ack_received`
**Files:**
- Modify: `backend/src/cyclone/store/acks.py`
- Test: extend `backend/tests/test_store_acks_publish.py`
- [ ] **Step 1: Write the failing test**
```python
def test_add_277ca_ack_publishes_two77ca_ack_received(bus: EventBus):
with db.SessionLocal()() as s:
row = store.add_277ca_ack(
source_batch_id="277CA-SP25-1",
control_number="000000001",
accepted_count=2,
rejected_count=1,
paid_count=0,
pended_count=0,
raw_json={"envelope": {"control_number": "000000001"}, "claim_statuses": []},
event_bus=bus,
)
events = _drain(bus, "two77ca_ack_received")
assert len(events) == 1
payload = events[0]
assert payload["_kind"] == "two77ca_ack_received"
assert payload["id"] == row.id
assert payload["accepted_count"] == 2
assert payload["rejected_count"] == 1
```
- [ ] **Step 2: Run the test, see it fail**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_acks_publish.py::test_add_277ca_ack_publishes_two77ca_ack_received -v 2>&1 | tail -10
```
Expected: fail.
- [ ] **Step 3: Modify `add_277ca_ack` to accept `event_bus` and publish**
In `backend/src/cyclone/store/acks.py`, update:
```python
def add_277ca_ack(
*,
source_batch_id: str,
control_number: str,
accepted_count: int,
rejected_count: int,
paid_count: int,
pended_count: int,
raw_json: dict,
event_bus: "EventBus | None" = None,
) -> db.Two77caAck:
"""Persist a 277CA row. SP25: publishes ``two77ca_ack_received``."""
with db.SessionLocal()() as s:
row = db.Two77caAck(
source_batch_id=source_batch_id,
control_number=control_number,
accepted_count=accepted_count,
rejected_count=rejected_count,
paid_count=paid_count,
pended_count=pended_count,
parsed_at=utcnow(),
raw_json=raw_json,
)
s.add(row)
s.commit()
s.refresh(row)
row_id = row.id
if event_bus is not None:
try:
with db.SessionLocal()() as s:
payload = to_ui_two77ca_ack(s.get(db.Two77caAck, row_id))
_safe_publish(event_bus, "two77ca_ack_received", payload)
except Exception: # noqa: BLE001
import logging
logging.getLogger(__name__).exception(
"add_277ca_ack: event publish failed for 277ca %s", row_id,
)
return row
```
- [ ] **Step 4: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_acks_publish.py -v 2>&1 | tail -10
```
Expected: all 5 tests pass.
- [ ] **Step 5: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add backend/src/cyclone/store/acks.py \
backend/tests/test_store_acks_publish.py
git commit -m "feat(sp25): add_277ca_ack publishes two77ca_ack_received"
```
---
## Task 5: Drop `event_bus` kwarg from the handlers
**Files:**
- Modify: `backend/src/cyclone/handlers/handle_999.py`
- Modify: `backend/src/cyclone/handlers/handle_ta1.py`
- Modify: `backend/src/cyclone/handlers/handle_277ca.py`
- Modify: `backend/src/cyclone/handlers/handle_835.py`
The store now owns the publish. The handlers stop accepting `event_bus`. Remove the `event_bus: Optional[object] = None` parameter, the `if event_bus is not None: ...` block, and the `TODO(sp27-task-6)` docstring text from all four handlers.
- [ ] **Step 1: Update `handle_999.py`**
Remove:
- The `event_bus: Optional[object] = None` parameter from the signature.
- The `if event_bus is not None:` publish block.
- The `TODO(sp27-task-6): bridge async EventBus.publish → sync caller` comment from the docstring.
- The "Reserved for the FastAPI-endpoint migration in Task 6" sentence.
- The `Optional` import if it's now unused.
Update the docstring to say: "Parse a 999, apply rejections, persist ack row. The store publishes the ``ack_received`` event (SP25); the handler no longer accepts an event_bus."
- [ ] **Step 2: Update `handle_ta1.py`**
Remove the `event_bus: Optional[object] = None` parameter, the `if event_bus is not None: ...` block, the `TODO(sp27-task-6)` comment, the "Reserved for the FastAPI-endpoint migration in Task 6" sentence, and the `Optional` import if unused after the edit.
The docstring gains this final sentence: "The store publishes the ``ta1_ack_received`` event (SP25); the handler no longer accepts an event_bus."
- [ ] **Step 3: Update `handle_277ca.py`**
Same set of removals as Step 1 (the `event_bus` parameter, the publish block, the `TODO(sp27-task-6)` comment, the migration sentence). Replace the docstring's final sentence with: "The store publishes the ``two77ca_ack_received`` event (SP25); the handler no longer accepts an event_bus."
- [ ] **Step 4: Update `handle_835.py`**
Same set of removals as Step 1. The 835 handler publishes a `remittance_written` event (via the store's 835 write path), NOT a 277CA-style ack event — but its `event_bus` kwarg was still being accepted and never used, so remove it for consistency with the other three handlers. The docstring's final sentence becomes: "The 835 write path publishes the ``remittance_written`` event through the store (SP21); this handler no longer accepts an event_bus."
- [ ] **Step 5: Run the handler tests + scheduler tests**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/ -k "handle or scheduler" -v 2>&1 | tail -20
```
Expected: all pass. The `HANDLERS` typing signature (`Callable[[str, str], tuple[str, int]]`) doesn't change because the handlers still take 2 args.
- [ ] **Step 6: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add backend/src/cyclone/handlers/handle_999.py \
backend/src/cyclone/handlers/handle_ta1.py \
backend/src/cyclone/handlers/handle_277ca.py \
backend/src/cyclone/handlers/handle_835.py
git commit -m "feat(sp25): drop event_bus kwarg from handlers (store owns publish)"
```
---
## Task 6: Thread `event_bus` from the parse endpoints to the store
**Files:**
- Modify: `backend/src/cyclone/api.py` (3 endpoints: `/api/parse-999`, `/api/parse-ta1`, `/api/parse-277ca`)
- [ ] **Step 1: Update `/api/parse-999`**
In `backend/src/cyclone/api.py`, the existing endpoint at line ~697 calls `store.add_ack(...)` without `event_bus=`. Update the call to:
```python
row = store.add_ack(
source_batch_id=synthetic_id,
accepted_count=accepted,
rejected_count=rejected,
received_count=received,
ack_code=ack_code,
raw_json=json.loads(result.model_dump_json()),
event_bus=request.app.state.event_bus,
)
```
- [ ] **Step 2: Update `/api/parse-ta1`**
Same pattern: add `event_bus=request.app.state.event_bus` to the `store.add_ta1_ack(...)` call. The TA1 endpoint also needs a `request: Request` parameter if it doesn't have one (it does for `/api/parse-999`; check `/api/parse-ta1` at line 824 — it does NOT currently have `request`. Add it as the first parameter.)
- [ ] **Step 3: Update `/api/parse-277ca`**
Same pattern: add `request: Request` parameter if missing, thread `event_bus=request.app.state.event_bus` to the `store.add_277ca_ack(...)` call.
- [ ] **Step 4: Add a test asserting the parse endpoint fires the event**
Add to `backend/tests/test_store_acks_publish.py`:
```python
def test_parse_999_endpoint_fires_ack_received_event(bus: EventBus):
client = TestClient(app)
text = (Path(__file__).parent / "fixtures" / "minimal_999.txt").read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999.txt", text, "text/plain")},
)
assert resp.status_code == 200, resp.text
events = _drain(bus, "ack_received")
assert len(events) == 1
assert events[0]["_kind"] == "ack_received"
```
(Requires `minimal_999.txt` to exist in `backend/tests/fixtures/`. If it doesn't, copy one from the prodfiles archive first per the `cyclone-tests` skill — drop-in rule.)
- [ ] **Step 5: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_store_acks_publish.py -v 2>&1 | tail -10
```
Expected: all pass.
- [ ] **Step 6: Run the full existing acks test suite**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_api_999.py backend/tests/test_api_parse_persists.py -v 2>&1 | tail -15
```
Expected: all pass (regression check).
- [ ] **Step 7: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add backend/src/cyclone/api.py \
backend/tests/test_store_acks_publish.py
git commit -m "feat(sp25): thread event_bus from /api/parse-999, parse-ta1, parse-277ca"
```
---
## Task 7: `/api/acks/stream` endpoint
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Test: `backend/tests/test_api_ack_stream.py` (new)
- [ ] **Step 1: Write the failing test**
Create `backend/tests/test_api_ack_stream.py`:
```python
"""Integration tests for /api/acks/stream and /api/ta1-acks/stream."""
from __future__ import annotations
import asyncio
import json
from datetime import date, datetime, timezone
import pytest
from fastapi.testclient import TestClient
from cyclone import db
from cyclone.api import app
from cyclone.pubsub import EventBus
from cyclone.store import store
@pytest.fixture
def bus() -> EventBus:
new_bus = EventBus(max_queue_size=64)
app.state.event_bus = new_bus
return new_bus
def _read_ndjson(client: TestClient, path: str, n_lines: int) -> list[dict]:
"""Read ``n_lines`` NDJSON lines from a streaming endpoint."""
with client.stream("GET", path, headers={"Accept": "application/x-ndjson"}) as r:
assert r.status_code == 200, r.text
assert r.headers["content-type"] == "application/x-ndjson"
lines: list[dict] = []
for raw in r.iter_lines():
if not raw:
continue
lines.append(json.loads(raw))
if len(lines) >= n_lines:
break
return lines
def test_acks_stream_snapshots_existing_rows(bus: EventBus):
# Seed one row.
with db.SessionLocal()() as s:
store.add_999_ack(
source_batch_id="999-SEED-1",
accepted_count=1, rejected_count=0, received_count=1,
ack_code="A", raw_json={}, event_bus=bus,
)
client = TestClient(app)
lines = _read_ndjson(client, "/api/acks/stream", 2)
# First: the seeded row. Second: snapshot_end.
assert lines[0]["type"] == "item"
assert lines[0]["data"]["source_batch_id"] == "999-SEED-1"
assert lines[1] == {"type": "snapshot_end", "data": {"count": 1}}
def test_ta1_acks_stream_snapshots_existing_rows(bus: EventBus):
with db.SessionLocal()() as s:
store.add_ta1_ack(
source_batch_id="TA1-SEED-1",
control_number="000000001",
interchange_date=date(2026, 7, 2),
interchange_time="1200",
ack_code="A", note_code="000",
ack_generated_date=None,
sender_id="S", receiver_id="R",
raw_json={}, event_bus=bus,
)
client = TestClient(app)
lines = _read_ndjson(client, "/api/ta1-acks/stream", 2)
assert lines[0]["type"] == "item"
assert lines[0]["data"]["control_number"] == "000000001"
assert lines[1] == {"type": "snapshot_end", "data": {"count": 1}}
```
- [ ] **Step 2: Run the test, see it fail**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_api_ack_stream.py -v 2>&1 | tail -10
```
Expected: 404 (the routes don't exist yet).
- [ ] **Step 3: Implement the stream endpoints**
In `backend/src/cyclone/api.py`, add the two new endpoints right next to the existing `claims_stream` endpoint (line ~1741) so the route order stays correct. Mirror the `claims_stream` shape — register BEFORE the `/{ack_id}` detail route:
```python
@app.get("/api/acks/stream", dependencies=[Depends(matrix_gate)])
async def acks_stream(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream 999 ACKs as NDJSON: snapshot first, then live events.
Subscribes to ``ack_received``. Default sort is newest-first
(auto-increment id desc), matching the list endpoint.
Wire format matches the other three live-tail endpoints
(``/api/claims/stream`` / ``/api/remittances/stream`` /
``/api/activity/stream``): one ``item`` per row, ``snapshot_end``
marker, then live items, with a 15s heartbeat cadence.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_acks()[:limit]
for row in rows:
yield _ndjson_line({"type": "item", "data": to_ui_ack(row)})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in _tail_events(request, bus, ["ack_received"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@app.get("/api/ta1-acks/stream", dependencies=[Depends(matrix_gate)])
async def ta1_acks_stream(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream TA1 envelope acks as NDJSON. Subscribes to ``ta1_ack_received``."""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_ta1_acks()[:limit]
for row in rows:
yield _ndjson_line({"type": "item", "data": to_ui_ta1_ack(row)})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in _tail_events(request, bus, ["ta1_ack_received"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
```
- [ ] **Step 4: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_api_ack_stream.py -v 2>&1 | tail -10
```
Expected: both pass.
- [ ] **Step 5: Add a live-event test (snapshot then live event)**
Add to `backend/tests/test_api_ack_stream.py`:
```python
def test_acks_stream_emits_live_event(bus: EventBus):
client = TestClient(app)
with client.stream("GET", "/api/acks/stream", headers={"Accept": "application/x-ndjson"}) as r:
assert r.status_code == 200
# Read snapshot_end first.
first = json.loads(next(r.iter_lines()))
assert first["type"] == "snapshot_end"
# Publish a live event by calling the store.
with db.SessionLocal()() as s:
store.add_999_ack(
source_batch_id="999-LIVE-1",
accepted_count=1, rejected_count=0, received_count=1,
ack_code="A", raw_json={}, event_bus=bus,
)
# The next line is the live event item.
second = json.loads(next(r.iter_lines()))
assert second["type"] == "item"
assert second["data"]["source_batch_id"] == "999-LIVE-1"
```
- [ ] **Step 6: Run the live-event test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest backend/tests/test_api_ack_stream.py::test_acks_stream_emits_live_event -v 2>&1 | tail -10
```
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add backend/src/cyclone/api.py \
backend/tests/test_api_ack_stream.py
git commit -m "feat(sp25): /api/acks/stream + /api/ta1-acks/stream NDJSON endpoints"
```
---
## Task 8: Frontend — extend `TailResource` type
**Files:**
- Modify: `src/lib/tail-stream.ts`
- [ ] **Step 1: Extend the `TailResource` union**
In `src/lib/tail-stream.ts:29`, change:
```ts
export type TailResource = "claims" | "remittances" | "activity" | "acks" | "ta1_acks";
```
- [ ] **Step 2: Run the typecheck**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npm run typecheck 2>&1 | tail -10
```
Expected: TS errors in `useTailStream.ts` and `useMergedTail.ts` (the dispatch and slice selectors don't cover the new cases yet). That's fine — Tasks 9-10 fix them.
- [ ] **Step 3: Commit (as part of the TailResource extension — separate commit for clarity)**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add src/lib/tail-stream.ts
git commit -m "feat(sp25): TailResource += acks, ta1_acks"
```
---
## Task 9: Frontend — extend `useTailStore` with `acks` and `ta1_acks` slices
**Files:**
- Modify: `src/store/tail-store.ts`
- Test: `src/store/tail-store.test.ts` (new)
- [ ] **Step 1: Write the failing test**
Create `src/store/tail-store.test.ts`:
```ts
// @vitest-environment happy-dom
import { describe, expect, it, beforeEach } from "vitest";
import { useTailStore } from "./tail-store";
import type { Ack, Ta1Ack } from "@/types";
const ack: Ack = {
id: 1,
sourceBatchId: "999-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-07-02T12:00:00Z",
};
const ta1: Ta1Ack = {
id: 2,
controlNumber: "000000001",
ackCode: "A",
noteCode: null,
interchangeDate: "2026-07-02",
interchangeTime: "1200",
senderId: "S",
receiverId: "R",
sourceBatchId: "TA1-1",
parsedAt: "2026-07-02T12:00:00Z",
};
describe("useTailStore — acks slice", () => {
beforeEach(() => {
useTailStore.getState().reset("acks");
useTailStore.getState().reset("ta1_acks");
});
it("addAck appends in arrival order", () => {
useTailStore.getState().addAck(ack);
expect(useTailStore.getState().acks[1]).toEqual(ack);
expect(useTailStore.getState().ackOrder).toEqual(["1"]);
});
it("addAck dedups by id (first write wins)", () => {
useTailStore.getState().addAck(ack);
useTailStore.getState().addAck({ ...ack, ackCode: "R" });
expect(useTailStore.getState().acks[1]?.ackCode).toBe("A");
expect(useTailStore.getState().ackOrder).toEqual(["1"]);
});
it("addTa1Ack appends in arrival order", () => {
useTailStore.getState().addTa1Ack(ta1);
expect(useTailStore.getState().ta1Acks[2]).toEqual(ta1);
expect(useTailStore.getState().ta1AckOrder).toEqual(["2"]);
});
it("reset(acks) clears the slice", () => {
useTailStore.getState().addAck(ack);
useTailStore.getState().reset("acks");
expect(useTailStore.getState().acks).toEqual({});
expect(useTailStore.getState().ackOrder).toEqual([]);
});
});
```
- [ ] **Step 2: Run the test, see it fail**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npx vitest run src/store/tail-store.test.ts 2>&1 | tail -10
```
Expected: TS / runtime errors on the missing slice.
- [ ] **Step 3: Implement the slices**
In `src/store/tail-store.ts`:
1. Import `Ack` and `Ta1Ack` from `@/types` (replace the existing `Activity, Claim, Remittance` import line):
```ts
import type { Activity, Ack, Claim, Remittance, Ta1Ack } from "@/types";
```
2. Add to the `TailStore` interface:
```ts
// --- ACKs (999) -------------------------------------------------------
acks: Record<string, Ack>;
ackOrder: string[];
// --- TA1 ACKs --------------------------------------------------------
ta1Acks: Record<string, Ta1Ack>;
ta1AckOrder: string[];
addAck: (a: Ack) => void;
addTa1Ack: (t: Ta1Ack) => void;
```
3. Add to the initial state (after `remitOrder: []`):
```ts
acks: {},
ackOrder: [],
ta1Acks: {},
ta1AckOrder: [],
```
4. Add `addAck` / `addTa1Ack` setters and the matching `reset` cases. Mirror the existing `addClaim` / `addRemittance` / `reset` patterns exactly.
- [ ] **Step 4: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npx vitest run src/store/tail-store.test.ts 2>&1 | tail -10
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add src/store/tail-store.ts \
src/store/tail-store.test.ts
git commit -m "feat(sp25): useTailStore adds acks + ta1_acks slices"
```
---
## Task 10: Frontend — extend `useTailStream` dispatch
**Files:**
- Modify: `src/hooks/useTailStream.ts`
- Test: `src/hooks/useTailStream.test.ts` (new)
- [ ] **Step 1: Write the failing test**
Create `src/hooks/useTailStream.test.ts`:
```ts
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/tail-stream", async () => {
const actual = await vi.importActual<typeof import("@/lib/tail-stream")>(
"@/lib/tail-stream",
);
return {
...actual,
streamTail: vi.fn(),
};
});
import { useTailStore } from "@/store/tail-store";
import { useTailStream } from "./useTailStream";
import type { Ack, Ta1Ack } from "@/types";
const ack: Ack = {
id: "1",
sourceBatchId: "999-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-07-02T12:00:00Z",
};
const ta1: Ta1Ack = {
id: "2",
controlNumber: "000000001",
ackCode: "A",
noteCode: null,
interchangeDate: "2026-07-02",
interchangeTime: "1200",
senderId: "S",
receiverId: "R",
sourceBatchId: "TA1-1",
parsedAt: "2026-07-02T12:00:00Z",
};
describe("useTailStream dispatch", () => {
beforeEach(() => {
useTailStore.getState().reset("acks");
useTailStore.getState().reset("ta1_acks");
});
afterEach(() => {
vi.useRealTimers();
});
it("dispatches acks items into the acks slice", () => {
// Simulate by directly calling the store's addAck — the dispatch
// logic is what we're testing. The hook just wires streamTail to
// the right store setter.
useTailStore.getState().addAck(ack);
expect(useTailStore.getState().acks["1"]).toEqual(ack);
});
it("dispatches ta1_acks items into the ta1Acks slice", () => {
useTailStore.getState().addTa1Ack(ta1);
expect(useTailStore.getState().ta1Acks["2"]).toEqual(ta1);
});
});
```
This is a "smoke" test that exercises the new slice setters. The full hook coverage lives in the integration test on the Acks page (Task 12).
- [ ] **Step 2: Run the test, see it fail**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npx vitest run src/hooks/useTailStream.test.ts 2>&1 | tail -10
```
Expected: PASS (because the store slices from Task 9 are already there). The point of this task is to wire `useTailStream` to call them. The real test of that wiring is the Acks page integration in Task 12.
- [ ] **Step 3: Update `useTailStream` dispatch**
In `src/hooks/useTailStream.ts:69-79`, extend the `dispatch` switch:
```ts
function dispatch(resource: TailResource, data: unknown): void {
const store = useTailStore.getState();
switch (resource) {
case "claims":
store.addClaim(data as Claim);
break;
case "remittances":
store.addRemittance(data as Remittance);
break;
case "activity":
store.addActivity(data as Activity);
break;
case "acks":
store.addAck(data as Ack);
break;
case "ta1_acks":
store.addTa1Ack(data as Ta1Ack);
break;
}
}
```
Add `Ack` and `Ta1Ack` to the `@/types` import.
- [ ] **Step 4: Run the typecheck**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npm run typecheck 2>&1 | tail -10
```
Expected: clean.
- [ ] **Step 5: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add src/hooks/useTailStream.ts \
src/hooks/useTailStream.test.ts
git commit -m "feat(sp25): useTailStream dispatch for acks + ta1_acks"
```
---
## Task 11: Frontend — extend `useMergedTail` slice selectors
**Files:**
- Modify: `src/hooks/useMergedTail.ts`
- Test: `src/hooks/useMergedTail.test.ts` (new)
- [ ] **Step 1: Write the failing test**
Create `src/hooks/useMergedTail.test.ts`:
```ts
// @vitest-environment happy-dom
import { renderHook } from "@testing-library/react";
import { describe, expect, it, beforeEach } from "vitest";
import { useTailStore } from "@/store/tail-store";
import { useMergedTail } from "./useMergedTail";
import type { Ack, Ta1Ack } from "@/types";
const base: Ack[] = [
{
id: "1",
sourceBatchId: "999-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-07-02T12:00:00Z",
},
];
const live: Ack = {
id: "2",
sourceBatchId: "999-2",
acceptedCount: 2,
rejectedCount: 0,
receivedCount: 2,
ackCode: "A",
parsedAt: "2026-07-02T12:01:00Z",
};
describe("useMergedTail — acks slice", () => {
beforeEach(() => {
useTailStore.getState().reset("acks");
useTailStore.getState().reset("ta1_acks");
});
it("merges base + tail with dedup", () => {
useTailStore.getState().addAck(live);
const { result } = renderHook(() => useMergedTail("acks", base));
expect(result.current).toEqual([...base, live]);
});
it("dedups tail items already in base", () => {
useTailStore.getState().addAck(base[0]!);
const { result } = renderHook(() => useMergedTail("acks", base));
expect(result.current).toEqual(base);
});
});
```
- [ ] **Step 2: Run the test, see it fail**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npx vitest run src/hooks/useMergedTail.test.ts 2>&1 | tail -10
```
Expected: TS error on the missing `case "acks"` in the slice switch.
- [ ] **Step 3: Extend the slice switch in `useMergedTail`**
In `src/hooks/useMergedTail.ts:30-60`, add two cases:
```ts
case "acks": {
const out: unknown[] = [];
for (const id of s.ackOrder) {
const v = s.acks[id];
if (v !== undefined) out.push(v);
}
return out;
}
case "ta1_acks": {
const out: unknown[] = [];
for (const id of s.ta1AckOrder) {
const v = s.ta1Acks[id];
if (v !== undefined) out.push(v);
}
return out;
}
```
- [ ] **Step 4: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npx vitest run src/hooks/useMergedTail.test.ts 2>&1 | tail -10
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add src/hooks/useMergedTail.ts \
src/hooks/useMergedTail.test.ts
git commit -m "feat(sp25): useMergedTail slice selectors for acks + ta1_acks"
```
---
## Task 12: Frontend — wire `useTailStream` + `useMergedTail` + `<TailStatusPill>` into the Acks page
**Files:**
- Modify: `src/pages/Acks.tsx`
- [ ] **Step 1: Add the imports**
In `src/pages/Acks.tsx`, add at the top:
```ts
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
```
- [ ] **Step 2: Wire the 999 stream into the page body**
In the `Acks` function, after the `useAcks(...)` call:
```ts
const { status: acksStatus, lastEventAt: acksLastEventAt, forceReconnect: acksReconnect } =
useTailStream("acks");
const items = useMergedTail("acks", data?.items ?? []);
```
Replace the existing `const items = data?.items ?? [];` line with the `useMergedTail` call (the new items array now includes the live tail).
- [ ] **Step 3: Drop a `<TailStatusPill>` into the hero next to the "Envelope accepted" badge**
Find the existing `<div className={cn("inline-flex items-center gap-2 rounded-full ...">}` block in the hero (around line 188) and add the pill next to it, inside the same flex container:
```tsx
<div className="flex items-center gap-2">
<TailStatusPill
status={acksStatus}
lastEventAt={acksLastEventAt}
onReconnect={acksReconnect}
/>
<div className={cn(/* existing badge className */)}>
{/* existing badge content */}
</div>
</div>
```
The pill follows the existing TailStatusPill contract: status ∈ {connecting, live, reconnecting, stalled, error, closed}; lastEventAt is a Date | null; onReconnect is a no-arg callback.
- [ ] **Step 4: Wire the TA1 stream into `Ta1AcksSection`**
Find the `Ta1AcksSection` function and add (next to its existing `useTa1Acks` call):
```ts
const { status: ta1Status, lastEventAt: ta1LastEventAt, forceReconnect: ta1Reconnect } =
useTailStream("ta1_acks");
const items = useMergedTail("ta1_acks", data?.items ?? []);
```
Replace `const items = data?.items ?? [];` with the new `useMergedTail` call.
Drop a `<TailStatusPill>` into the section header (next to the "Envelope acks" eyebrow), inside the same flex container:
```tsx
<div className="flex items-center gap-2">
<TailStatusPill
status={ta1Status}
lastEventAt={ta1LastEventAt}
onReconnect={ta1Reconnect}
/>
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
Envelope acks
</div>
</div>
```
- [ ] **Step 5: Run the existing `Acks.test.tsx`**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npx vitest run src/pages/Acks.test.tsx 2>&1 | tail -20
```
Expected: all 7 existing tests still pass. The mock for `useTailStream` isn't required for these tests because they don't assert on the pill — but the new code calls the hook, which will try to open a stream. The tests will need a mock for `@/hooks/useTailStream` to prevent the hook from making a real fetch.
Add at the top of `Acks.test.tsx`:
```ts
vi.mock("@/hooks/useTailStream", () => ({
useTailStream: () => ({
status: "live" as const,
lastEventAt: new Date(),
error: null,
forceReconnect: () => {},
}),
}));
```
This stubs the hook to a stable "live" state for the existing tests, so they pass without trying to open a real stream.
- [ ] **Step 6: Run the test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npx vitest run src/pages/Acks.test.tsx 2>&1 | tail -10
```
Expected: all 7 pass.
- [ ] **Step 7: Add a live-tail test case to `Acks.test.tsx`**
Add a new test that mocks `useTailStore` directly to simulate a live event landing after the page renders:
```ts
it("test_live_ack_event_appears_in_register", async () => {
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
items: [],
total: 0,
returned: 0,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Acks));
await waitForText("Awaiting first 837 with ack=true");
// Simulate a live event landing in the tail store.
const { useTailStore } = await import("@/store/tail-store");
act(() => {
useTailStore.getState().addAck({
id: 99,
sourceBatchId: "b-live-1",
acceptedCount: 1,
rejectedCount: 0,
receivedCount: 1,
ackCode: "A",
parsedAt: "2026-07-02T12:00:00Z",
});
});
await waitForText("b-live-1");
expect(document.body.textContent).toContain("99");
unmount();
});
```
- [ ] **Step 8: Run the new test, see it pass**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npx vitest run src/pages/Acks.test.tsx -v 2>&1 | tail -20
```
Expected: all 8 tests pass.
- [ ] **Step 9: Commit**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git add src/pages/Acks.tsx \
src/pages/Acks.test.tsx
git commit -m "feat(sp25): Acks page live-tail subscription (999 + TA1)"
```
---
## Task 13: Full verification
- [ ] **Step 1: Run the full backend test suite**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
.venv/bin/pytest -q 2>&1 | tail -10
```
Expected: all tests pass (the merge gate).
- [ ] **Step 2: Run the full frontend test suite**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npm test 2>&1 | tail -10
```
Expected: all tests pass.
- [ ] **Step 3: Run typecheck + lint + build**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
npm run typecheck 2>&1 | tail -5
npm run lint 2>&1 | tail -5
npm run build 2>&1 | tail -10
```
Expected: clean across the board.
- [ ] **Step 4: Sanity check the wire format manually (optional but recommended)**
Boot the backend and hit the new stream endpoint with `curl`:
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
CYCLONE_AUTH_DISABLED=1 .venv/bin/python -m cyclone serve &
sleep 2
curl -N -H "Accept: application/x-ndjson" http://127.0.0.1:8000/api/acks/stream | head -3
kill %1
```
Expected: NDJSON lines with `{"type":"item",...}` and `{"type":"snapshot_end",...}`.
---
## Task 14: Final review and merge prep
- [ ] **Step 1: Self-review the diff**
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git log --oneline main..HEAD
git diff main...HEAD --stat
```
Expected: a list of `feat(sp25): ...` commits and a clean diff stat.
- [ ] **Step 2: Run the `review` skill (or `pr-reviewer` subagent) against the branch**
Per the project workflow, dispatch a code-review subagent on the branch:
```bash
cd /home/tyler/dev/cyclone/.worktrees/sp25-ack-live-tail
git push origin sp25-ack-live-tail # if remote exists
```
If a remote exists, open a PR titled `SP25 Ack live-tail` and run the `review` skill on the PR. If no remote, dispatch a `general-purpose` subagent with the prompt: "Review the git diff from main to HEAD on this branch for the SP25 ack live-tail increment. Look for: (a) any place that broke the existing `claim_written` / `remittance_written` / `activity_recorded` publish path; (b) any unsynced event payload vs list-endpoint shape; (c) any auth-gate regression on the new endpoints; (d) any TODO comments that survived."
- [ ] **Step 3: Address review findings**
If the reviewer returns issues, fix them as separate `fix(sp25): ...` commits on the same branch. Re-run the affected test suites.
- [ ] **Step 4: Final merge to main (per the SP-N flow)**
```bash
cd /home/tyler/dev/cyclone
git checkout main
git merge --no-ff sp25-ack-live-tail -m "merge: SP25 ack live-tail into main"
```
No squash, no rebase. The SP-N merge commit IS the audit trail.