Add web server, event stream, and Rive contract

- crows serve: HTTP API, Server-Sent Events per list, web approvals
  with a 10-minute fail-safe timeout; localhost only by default
- Shared pipeline for command line and server; trust.changed events
- EVENTS.md: server/UI contract; RIVE_CONTRACT.md: data-binding spec
  for the animator (one Crow artboard for v1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
This commit is contained in:
SmokeyandClaude Opus 5.5 committed 2026-10-27 08:31:27 -06:00
1 parent 7ae7f670ff
commit 940c74672a
12 files changed
+1320 -129

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
# Events: the shared contract
The Go server emits these over Server-Sent Events (SSE). The web UI turns them into Rive state machine inputs. Both sides build against this file; change it first, then the code.
Each SSE message: `event: <name>` and `data: <JSON>`. Every payload includes `list_id` and `ts` (RFC 3339).
| Event | Payload (besides list_id, ts) | Meaning |
|---|---|---|
| `list.started` | `items: [{item_id, text}]`, `requester` | Flock accepted a list |
| `item.triaged` | `item_id`, `mode` (solo/split/murder), `task_type`, `subtasks[]`, `reason`, `needs_approval` (bool) | Sentry made its call |
| `crow.flying` | `item_id`, `crow` | A crow (or Sentry, Elder) took off |
| `crow.landed` | `item_id`, `crow`, `ok` (bool), `tokens`, `error?` | A crow came back, or didn't |
| `item.answered` | `item_id`, `answer`, `crows[]`, `needs_approval` (bool) | Result ready |
| `verdict` | `item_id`, `target` (triage/output), `verdict` (approved/rejected/auto/timeout) | Judgment recorded |
| `trust.changed` | `holder` (crow name or "flock"), `task_type`, `from`, `to` (0 stranger, 1 regular, 2 trusted) | Level up or down |
| `item.skipped` | `item_id`, `reason` | Item dropped (rejected triage, budget, failure) |
| `requester.pranked` | `message`, `cooldown_s` | Abuse response |
| `list.finished` | `tokens_used`, `token_budget` | Done |
## HTTP API
| Method | Path | Body / query | Returns |
|---|---|---|---|
| POST | `/api/lists` | `{"text": "<todo list>", "requester": "<id>"}` | `{"list_id"}`, or 429 with `{"message","cooldown_s"}` when pranked |
| GET | `/api/events` | `?list_id=` | SSE stream |
| POST | `/api/verdicts` | `{"item_id", "target": "triage"\|"output", "approve": bool}` | 204 |
| GET | `/api/trust` | | ledger JSON |
Pending approvals that get no answer within 10 minutes count as rejected (`verdict: timeout`). Fail safe.
## Settled details
- `item_id` is `<list_id>-<n>`, numbered from 1 in list order.
- `POST /api/lists` returns 202. Bad input returns 400 with `{"message"}`.
- `POST /api/verdicts` returns 404 when nothing is waiting (including a second answer), 400 for a bad body. `approve` is required, so a missing value is never read as a no.
- When trust skips approval, the server still emits `verdict: "auto"`, so every target gets exactly one verdict.
- `crow.flying` and `crow.landed` fire for the Sentry and Elder too.
- `requester.pranked` goes to every list of that requester's still flying; if none, only the 429 is sent.
- Each SSE message carries an `id:` line, so clients can reconnect with `Last-Event-ID`. Heartbeat comment every 15 seconds.
- The server listens on localhost only by default. There is no login yet, and requester names are self-asserted.