feat(sp-skill-catalog): add cyclone-tail skill (live-tail wire format)
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# Live-tail wire format — field reference
|
||||
|
||||
The Cyclone live-tail NDJSON contract is owned by the frontend parser
|
||||
(`src/lib/tail-stream.ts:22-44`) and the backend emitter
|
||||
(`backend/src/cyclone/api_helpers.py:tail_events()` + the three
|
||||
endpoints in `backend/src/cyclone/api.py:1357-2006`). This file is the
|
||||
quick reference; the canonical prose lives in the README and the
|
||||
source-of-truth type definitions.
|
||||
|
||||
## Source
|
||||
|
||||
Wire format excerpt copied verbatim from `README.md:76-94` (the
|
||||
"Live updates → Wire format" section). The README is the user-facing
|
||||
exposition; this reference adds the per-line field semantics and the
|
||||
parser-tolerance rules from the code.
|
||||
|
||||
> Each stream endpoint emits newline-delimited JSON. The first batch
|
||||
> is the **snapshot** of currently-known rows; after that comes
|
||||
> **`snapshot_end`** with the count, then the **live** events.
|
||||
>
|
||||
> ```json
|
||||
> {"type":"item","data":{"id":"CLM-1", "...":"..."}}
|
||||
> {"type":"item","data":{"id":"CLM-2", "...":"..."}}
|
||||
> {"type":"snapshot_end","data":{"count":2}}
|
||||
> {"type":"item","data":{"id":"CLM-3", "...":"..."}} ← live
|
||||
> {"type":"heartbeat","data":{"ts":"2026-06-20T23:17:09Z"}} ← idle keep-alive
|
||||
> ```
|
||||
>
|
||||
> Lines are `{"type": ..., "data": ...}`; known types are `item`,
|
||||
> `snapshot_end`, `heartbeat`, and (rare) `item_dropped` /
|
||||
> `error`. Heartbeats keep the connection alive when nothing is
|
||||
> happening — clients flip to `stalled` after 30s of total silence
|
||||
> (heartbeat or otherwise) and surface a **↻ Reconnect** button.
|
||||
|
||||
## Per-line field reference
|
||||
|
||||
| `type` | `data` shape | Required? | Emitted by | Parser behavior |
|
||||
| -------------- | ----------------------------------------- | --------- | ------------------------------------------- | -------------------------------------------- |
|
||||
| `item` | resource-specific row (`Claim` / `Remittance` / `Activity`) | yes, in `data` (the per-row envelope) | snapshot loop + `_tail_events` forwarding `claim_written` / `remittance_written` / `activity_recorded` | `dispatch(resource, ev.data)` → `useTailStore.addClaim` / `addRemittance` / `addActivity` (first-write-wins dedup on the id-keyed slices — `tail-store.ts:104,120`). |
|
||||
| `snapshot_end` | `{"count": N}` (integer ≥ 0) | yes, on every stream | `api.py:1395,1889,1999` (one per stream, after the snapshot loop) | Flips `<TailStatusPill>` from `connecting` to `live`, resets the reconnect backoff counter to 0 (`useTailStream.ts:174-180`). |
|
||||
| `heartbeat` | `{"ts": "<iso-8601 UTC>"}` | yes, but only when idle | `_tail_events` in `api_helpers.py:241-245` (cadence from `heartbeat_seconds()`, default 15s) | Re-arms the stall timer (`useTailStream.ts:124-140`); no state change. |
|
||||
| `item_dropped` | `{"id": "<string>"}` | optional (rare; queue overflow) | EventBus drop-oldest path (per the spec at `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md:299`) | Re-arms the stall timer; no state change. The `id` field is informational — the hook does not refetch on drop, the page does (see Spec §3.6). |
|
||||
| `error` | `{"message": "<string>"}` | optional (server-side failure) | Reserved for future server-emitted errors (currently only thrown client-side) | Hook promotes to a thrown `Error(message)` so the catch block runs the reconnect machinery (`useTailStream.ts:190-194`). |
|
||||
|
||||
## Parser tolerance
|
||||
|
||||
The shared parser at `src/lib/tail-stream.ts` is intentionally
|
||||
forgiving so a single bad frame doesn't kill the stream:
|
||||
|
||||
- **Trailing `\r`** is stripped per line (`tail-stream.ts:116`) so a
|
||||
CRLF-terminated stream still parses.
|
||||
- **Malformed JSON** (`JSON.parse` throws) → `console.warn` + skip the
|
||||
line; the iterator continues (`tail-stream.ts:122-131`).
|
||||
- **Unknown `type`** (not in `KNOWN_TYPES`) → `console.warn` + skip
|
||||
(`tail-stream.ts:141-148`). Adding a new event type is
|
||||
forward-compatible: old clients see warn lines, new clients see the
|
||||
typed event.
|
||||
- **Empty lines** (consecutive `\n`s) → silently skipped
|
||||
(`tail-stream.ts:118`).
|
||||
- **No trailing newline** → flushed as a final partial line on
|
||||
stream close (`tail-stream.ts:154-178`).
|
||||
- **Abort signal** → iterator exits cleanly without throwing
|
||||
(`tail-stream.ts:75,98,107`).
|
||||
|
||||
## Endpoint inventory
|
||||
|
||||
| Method | Path | Subscribes to | Default sort | Defined at |
|
||||
| ------ | ------------------------- | -------------------- | ------------------- | ----------------------------------- |
|
||||
| GET | `/api/claims/stream` | `claim_written` | `-submission_date` | `backend/src/cyclone/api.py:1357` |
|
||||
| GET | `/api/remittances/stream` | `remittance_written` | `-received_date` | `backend/src/cyclone/api.py:1858` |
|
||||
| GET | `/api/activity/stream` | `activity_recorded` | `-timestamp` (limit 50) | `backend/src/cyclone/api.py:1971` |
|
||||
|
||||
All three accept the same query params as their non-streaming
|
||||
counterparts (`status`, `payer`, `date_from`, …) so a frontend can
|
||||
swap a one-shot fetch for a tail with no URL surgery. Responses are
|
||||
`Content-Type: application/x-ndjson`.
|
||||
Reference in New Issue
Block a user