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

+1
View File
@@ -3,3 +3,4 @@
*.crows.md *.crows.md
node_modules/ node_modules/
dist/ dist/
/somanycrows
+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.
+20
View File
@@ -16,6 +16,26 @@ ANTHROPIC_API_KEY=... ./crows run todo.txt # real crows (Claude)
Optional: `CROWS_MODEL` overrides the model. Results land in `todo.crows.md`; state lives in `.crows/`. Optional: `CROWS_MODEL` overrides the model. Results land in `todo.crows.md`; state lives in `.crows/`.
## Web server
```
./crows serve # localhost:8080; API + Server-Sent Events (SSE); Ctrl-C to stop
```
The server runs the same pipeline as `crows run`, but items fly in parallel and approvals come over HTTP. `EVENTS.md` is the contract for events and the API:
```
curl -XPOST localhost:8080/api/lists -d '{"text":"- [ ] What is a crow","requester":"me"}'
curl -N "localhost:8080/api/events?list_id=<id>" # replays the list's events so far, then streams
curl -XPOST localhost:8080/api/verdicts -d '{"item_id":"<id>-1","target":"triage","approve":true}'
curl localhost:8080/api/trust
```
- Item IDs are `<list_id>-<n>`, numbered from 1 in list order.
- Approvals left unanswered for 10 minutes count as rejected (`verdict: timeout`). `-approval-timeout` changes this for testing.
- A flooding requester gets 429 with `message` and `cooldown_s`; any list of theirs that is still flying also gets `requester.pranked`.
- POST bodies are capped at 64 KB. Static files are served from `./web` when that folder exists.
## How an item flies ## How an item flies
1. **Sentry** classifies it: `solo` (one crow), `split` (2–4 subtasks in parallel), or `murder` (3 crows answer independently). 1. **Sentry** classifies it: `solo` (one crow), `split` (2–4 subtasks in parallel), or `murder` (3 crows answer independently).
+162
View File
@@ -0,0 +1,162 @@
# Rive contract: the `crows.riv` file
The animator builds `crows.riv` against this file, and the web code drives it from the events in `EVENTS.md`. Both sides build against this file, so change it first and then the code. Each animation exists only because an `EVENTS.md` event fires it.
Style: Duolingo-like. Chunky, expressive crows with big squash-and-stretch reactions and readable silhouettes at 96 pixels. The approve and reject buttons stay in HTML for v1 (see "Why buttons stay in HTML").
## Rive approach: data binding, not inputs
Rive now marks state machine inputs as deprecated and recommends data binding for new work ([migration guide](https://rive.app/docs/editor/data-binding/migration-guide), [inputs](https://rive.app/docs/runtimes/inputs)). So this file uses **view models** in place of inputs:
- A **view model** defines the data shape. A **view model instance** holds the values, one per crow on screen ([view models](https://rive.app/docs/editor/data-binding/view-models.md)).
- Number, Boolean, Trigger, Enum, String and Color properties can drive state machine transition conditions or bind straight to scene elements ([property types](https://rive.app/docs/editor/data-binding/property-types.md), [transitions](https://rive.app/docs/editor/state-machine/transitions)).
- An enum can drive a Solo when its values match the Solo children's **names and order**, and those names must be exported ([enums](https://rive.app/docs/editor/data-binding/enums.md)).
- Layers play at the same time, one state per layer, and the lower layer wins when two layers animate the same property ([layers](https://rive.app/docs/editor/state-machine/layers.md)). Any State reaches a state from wherever the layer is. Exit State stops a layer ([states](https://rive.app/docs/editor/state-machine/states.md)).
## 1. Artboards
| Artboard | v1? | Purpose |
|---|---|---|
| `Crow` | yes | One reusable character. It covers all 8 crows plus the Sentry and Elder, using a `role` enum that switches the costume. |
| `Perch` | later | A scene with a branch of crows rendered as an Artboard List of `Crow` items ([lists](https://rive.app/docs/editor/data-binding/lists.md)), plus flock-wide celebrations. |
| `Splat` | later | A full-screen "gift on your windshield" overlay for the prank. |
In v1, the web page puts one `Crow` canvas per visible crow and lays them out with HTML and CSS. The Sentry and Elder are **variants** of `Crow`, not separate artboards, so the whole cast shares one rig.
Artboard spec: 256 × 256, transparent background, crow centered with its feet on a baseline 40 pixels from the bottom, so the crows line up in a row.
## 2. `Crow` artboard
State machine: `CrowMachine`. View model: `CrowModel`, set as the artboard's default view model.
### `CrowModel` properties
| Property | Type | Values / range | Drives |
|---|---|---|---|
| `role` | Enum `Role` | `crow`, `sentry`, `elder` | Solo `Costume` (children named `crow`, `sentry`, `elder`, in that order). The Sentry wears a lookout cap and the Elder wears spectacles and a gray ruff. |
| `name` | String | e.g. `Hugin`, `Sentry` | Text run `nameplate` under the feet |
| `pose` | Enum `Pose` | `perched`, `flying`, `waiting`, `grounded` | `Body` layer |
| `trust` | Number | integer 0–2 (0 stranger, 1 regular, 2 trusted) | `Badge` layer |
| `cheer` | Trigger | | `Reaction` layer: happy hop with a wing pump |
| `sulk` | Trigger | | `Reaction` layer: droop, single feather falls. Sad, never angry. |
| `levelUp` | Trigger | | `Reaction` layer: the level-up celebration (spin, confetti, badge shine). It's the biggest moment in the file. |
| `levelDown` | Trigger | | `Reaction` layer: badge dims, sheepish shrug |
| `prank` | Trigger | | `Reaction` layer: mischievous grin, flies off frame, a "plop" sound cue, returns smug |
The 8 crows tell themselves apart by `name` in v1. A `plumage` Color property for per-crow tints is later polish.
### Layers, states and transitions
**Layer `Body`**, a loop that's always running:
| State | Loop | Notes |
|---|---|---|
| `perched` | yes | Entry state. Idle breathing, blinking, a head tilt every few seconds. |
| `flying` | yes | Wings flapping, a slight bob. It reads as "working". |
| `waiting` | yes | Holds a rolled scroll up toward the viewer and taps a foot. It reads as "your turn". |
| `grounded` | yes | Sitting dazed with stars circling. It means failure or timeout. |
Transitions: Any State → each state when `pose` equals that value, with a duration of 200 milliseconds (ms). There's no Exit Time, so a pose change is instant to read.
**Layer `Reaction`**, one-shots on top of `Body`:
- Entry → `none`, an empty state.
- Any State → `cheer`, `sulk`, `levelUp`, `levelDown` or `prank` on the trigger of the same name.
- Each one-shot → `none` with Exit Time at 100%.
- Lengths: `cheer` and `sulk` at most 0.8 seconds, `levelUp` at most 2 seconds, `levelDown` at most 1 second, `prank` at most 2.5 seconds.
- Animate only what `Body` doesn't need to own (face, wings, effects), or put `Reaction` below `Body` in the layer list so the reaction wins while it plays.
**Layer `Badge`**:
- Any State → `badge0`, `badge1` or `badge2` when `trust` equals 0, 1 or 2, with a duration of 150 ms.
- `badge0` shows no badge, `badge1` a bronze chest feather, `badge2` a gold chest feather with a gentle shimmer loop.
## 3. Event → animation mapping
The web code keeps a map from `item_id` to `crows[]` (from `item.answered`) so that it can aim `verdict` at the right crows. The `crow` field values `Sentry` and `Elder` select the instances with `role` set to `sentry` and `elder`.
| `EVENTS.md` event | Condition | Instance(s) | Set |
|---|---|---|---|
| `list.started` | | Sentry | Create and show the Sentry. `pose = perched`. |
| `item.triaged` | `needs_approval` true | Sentry | `pose = waiting` |
| `item.triaged` | `needs_approval` false | | No animation. The HTML shows the mode and reason. |
| `crow.flying` | | `crow` (create on first sight) | `pose = flying` |
| `crow.landed` | `ok` true | `crow` | `pose = perched` |
| `crow.landed` | `ok` false | `crow` | `pose = grounded` |
| `item.answered` | `needs_approval` true | each of `crows[]` | `pose = waiting` |
| `item.answered` | `needs_approval` false | | No animation. A `verdict: auto` follows. |
| `verdict` | `target` triage, `approved` | Sentry | `cheer`, then `pose = perched` |
| `verdict` | `target` triage, `rejected` or `timeout` | Sentry | `sulk`, then `pose = perched` |
| `verdict` | `target` triage, `auto` | | No animation. A trusted Sentry just keeps going. |
| `verdict` | `target` output, `approved` or `auto` | each of the item's `crows[]` | `cheer`, then `pose = perched` |
| `verdict` | `target` output, `rejected` or `timeout` | each of the item's `crows[]` | `sulk`, then `pose = perched` |
| `trust.changed` | `holder` is a crow, `to` > `from` | `holder` | `trust = to`, then `levelUp` |
| `trust.changed` | `holder` is a crow, `to` < `from` | `holder` | `trust = to`, then `levelDown` |
| `trust.changed` | `holder` is `flock` | | No animation in v1 (HTML banner). Later: a `Perch` celebration. |
| `item.skipped` | | | No animation. The HTML strikes the item through, and any instance still `flying` for that item goes to `pose = perched`. |
| `requester.pranked` | | one random crow on screen, or the Sentry if it's alone | `prank`. Later: the `Splat` overlay. The HTML shows `message` and a `cooldown_s` countdown. |
| `list.finished` | | every crow on screen | `pose = perched`, then `cheer` |
Two web-side notes:
- **Pranks.** `POST /api/lists` answers a pranked requester with a 429 error and no `list_id`, so there may be no event stream to deliver `requester.pranked`. The web code treats that 429 response the same as the event and fires `prank` on the Sentry.
- **Badges.** `trust` is per crow *and* per task type, so a badge shows the level for the task type of the item the crow is working on now. The web code seeds `trust` from `GET /api/trust` when it creates an instance, and updates it on `trust.changed` only when `task_type` matches.
## 4. Naming conventions
The web code matches these strings exactly, so they're case-sensitive.
| Thing | Case | Names |
|---|---|---|
| File | lowercase | `crows.riv` |
| Artboards | PascalCase | `Crow`, `Perch`, `Splat` |
| State machines | PascalCase + `Machine` | `CrowMachine`, `PerchMachine` |
| View models | PascalCase + `Model` | `CrowModel` |
| Enum types | PascalCase | `Role`, `Pose` |
| Enum values | lowercase, one word, matching server strings where they exist | `crow`, `sentry`, `elder`, `perched`, `flying`, `waiting`, `grounded` |
| Properties (including triggers) | camelCase | `role`, `name`, `pose`, `trust`, `cheer`, `sulk`, `levelUp`, `levelDown`, `prank` |
| States and layers | camelCase states, PascalCase layers | `perched`, `badge2`, `Body`, `Reaction`, `Badge` |
| Exported objects | as listed | Solo `Costume` (children `crow`, `sentry`, `elder`), text run `nameplate` |
The web code binds one blank instance per crow, using API names from the [web data binding docs](https://rive.app/docs/runtimes/web/data-binding):
```js
const r = new rive.Rive({ src: "crows.riv", canvas, artboard: "Crow",
stateMachines: "CrowMachine", autoplay: true, onLoad: () => {
const vmi = r.viewModelByName("CrowModel").instance();
r.bindViewModelInstance(vmi);
vmi.enum("role").value = "crow";
vmi.string("name").value = "Hugin";
vmi.number("trust").value = 1;
vmi.enum("pose").value = "flying"; // crow.flying
vmi.trigger("cheer").trigger(); // verdict approved
} });
```
Keep `autoBind` off. Every crow needs its own instance, or setting one crow's `pose` would move them all.
## 5. v1 minimum vs. later polish
**v1 minimum**, the smallest set that makes the web UI work:
1. The `Crow` artboard, the `CrowMachine` state machine and the `CrowModel` view model, named exactly as in section 4.
2. The `role` Solo with all three costumes. The Sentry and Elder must read as different from the crows at a glance.
3. The `name` text run.
4. The `Body` layer with all four `pose` states.
5. The `Reaction` layer with `cheer`, `sulk` and `levelUp`.
6. The `Badge` layer with all three `trust` states.
If `levelDown` or `prank` doesn't exist yet, the web code falls back to `sulk` and `cheer`, so they can ship a beat later without breaking anything.
**Later polish**, in rough order:
1. `levelDown` and `prank` reactions.
2. A `plumage` Color property so each of the 8 crows has a distinct tint.
3. The `Splat` artboard for the windshield gift.
4. The `Perch` scene: an Artboard List of crows, murder huddles and split formations, and a flock `levelUp` on `trust.changed` with `holder` set to `flock`.
5. A streak ring, a Duolingo-style progress bar toward promotion at 10 clean approvals. `EVENTS.md` carries no streak count today, so this needs a `streak` field added to `verdict` or `trust.changed` first.
6. A Rive-drawn approve or reject button, if HTML buttons ever feel flat.
## Why buttons stay in HTML
Approval is the safety gate, and the server treats anything other than an explicit yes as a no. Real `<button>` elements give keyboard, screen reader and focus support for free, and an animation glitch can never swallow a click. Rive decorates the moment by having the crow hold up its scroll. The click itself belongs to the browser.
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"bufio"
"context"
"fmt"
"strings"
"sync"
"time"
)
// StdinApprover asks at the terminal. It fails safe: anything but y/yes,
// including no input at all, is a no.
type StdinApprover struct {
mu sync.Mutex
in *bufio.Reader
}
func (s *StdinApprover) Approve(ctx context.Context, a Approval) string {
s.mu.Lock()
defer s.mu.Unlock()
prompt := " Accept this answer?"
if a.Target == "triage" {
prompt = " Approve triage?"
}
if confirm(s.in, prompt) {
return "approved"
}
return "rejected"
}
// confirm fails safe: anything but y/yes, including no input at all, is a no.
func confirm(in *bufio.Reader, prompt string) bool {
fmt.Print(prompt + " [y/N] ")
line, _ := in.ReadString('\n')
a := strings.ToLower(strings.TrimSpace(line))
if line == "" {
fmt.Println()
}
return a == "y" || a == "yes"
}
// WebApprover blocks an item's goroutine until POST /api/verdicts answers it,
// or the timeout passes, which counts as a rejection.
type WebApprover struct {
Timeout time.Duration
mu sync.Mutex
pending map[string]chan bool // key: item_id|target
}
func NewWebApprover(timeout time.Duration) *WebApprover {
return &WebApprover{Timeout: timeout, pending: map[string]chan bool{}}
}
func approvalKey(itemID, target string) string { return itemID + "|" + target }
func (w *WebApprover) Approve(ctx context.Context, a Approval) string {
key := approvalKey(a.ItemID, a.Target)
ch := make(chan bool, 1)
w.mu.Lock()
w.pending[key] = ch
w.mu.Unlock()
defer func() {
w.mu.Lock()
delete(w.pending, key)
w.mu.Unlock()
}()
timer := time.NewTimer(w.Timeout)
defer timer.Stop()
select {
case ok := <-ch:
if ok {
return "approved"
}
return "rejected"
case <-timer.C:
return "timeout"
case <-ctx.Done():
return "rejected"
}
}
// Resolve delivers a human's verdict. It reports false if nothing is waiting.
func (w *WebApprover) Resolve(itemID, target string, approve bool) bool {
key := approvalKey(itemID, target)
w.mu.Lock()
defer w.mu.Unlock()
ch, ok := w.pending[key]
if !ok {
return false
}
delete(w.pending, key) // one verdict per question
ch <- approve
return true
}
+31 -13
View File
@@ -58,12 +58,24 @@ func (b *Budget) Check() error {
return nil return nil
} }
func (b *Budget) Spent() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.spent
}
func (b *Budget) Add(n int) { func (b *Budget) Add(n int) {
b.mu.Lock() b.mu.Lock()
b.spent += n b.spent += n
b.mu.Unlock() b.mu.Unlock()
} }
// Watcher hears every takeoff and landing (Sentry and Elder included). It may be nil.
type Watcher interface {
Flying(crow string)
Landed(crow string, tokens int, err error)
}
type Flock struct { type Flock struct {
brain Brain brain Brain
g Guardrails g Guardrails
@@ -91,7 +103,7 @@ func (f *Flock) pick(n int) []string {
} }
// ask is the only way any crow touches the model: budget, flock cap, timeout, log. // ask is the only way any crow touches the model: budget, flock cap, timeout, log.
func (f *Flock) ask(ctx context.Context, crow, system, prompt string, budgets ...*Budget) (string, error) { func (f *Flock) ask(ctx context.Context, w Watcher, crow, system, prompt string, budgets ...*Budget) (string, error) {
for _, b := range budgets { for _, b := range budgets {
if err := b.Check(); err != nil { if err := b.Check(); err != nil {
f.log.Write(Event{Kind: "grounded", Crow: crow, Error: err.Error()}) f.log.Write(Event{Kind: "grounded", Crow: crow, Error: err.Error()})
@@ -101,6 +113,9 @@ func (f *Flock) ask(ctx context.Context, crow, system, prompt string, budgets ..
f.sem <- struct{}{} f.sem <- struct{}{}
defer func() { <-f.sem }() defer func() { <-f.sem }()
if w != nil {
w.Flying(crow)
}
cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout) cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout)
defer cancel() defer cancel()
start := time.Now() start := time.Now()
@@ -116,11 +131,14 @@ func (f *Flock) ask(ctx context.Context, crow, system, prompt string, budgets ..
e.Error = err.Error() e.Error = err.Error()
} }
f.log.Write(e) f.log.Write(e)
if w != nil {
w.Landed(crow, tokens, err)
}
return text, err return text, err
} }
func (f *Flock) Triage(ctx context.Context, item string, budgets ...*Budget) (Triage, error) { func (f *Flock) Triage(ctx context.Context, w Watcher, item string, budgets ...*Budget) (Triage, error) {
text, err := f.ask(ctx, "Sentry", sentrySystem, "ITEM: "+item, budgets...) text, err := f.ask(ctx, w, "Sentry", sentrySystem, "ITEM: "+item, budgets...)
if err != nil { if err != nil {
return Triage{}, err return Triage{}, err
} }
@@ -151,16 +169,16 @@ func (f *Flock) Triage(ctx context.Context, item string, budgets ...*Budget) (Tr
return t, nil return t, nil
} }
func (f *Flock) Execute(ctx context.Context, item string, t Triage, budgets ...*Budget) (Outcome, error) { func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, budgets ...*Budget) (Outcome, error) {
switch t.Mode { switch t.Mode {
case "split": case "split":
// Split depth is 1: subtasks go straight to crows, never back to the Sentry. // Split depth is 1: subtasks go straight to crows, never back to the Sentry.
crows := f.pick(len(t.Subtasks)) crows := f.pick(len(t.Subtasks))
reports, err := f.fanOut(ctx, crows, t.Subtasks, budgets...) reports, err := f.fanOut(ctx, w, crows, t.Subtasks, budgets...)
if err != nil { if err != nil {
return Outcome{}, err return Outcome{}, err
} }
return f.reconcile(ctx, item, crows, t.Subtasks, reports, budgets...) return f.reconcile(ctx, w, item, crows, t.Subtasks, reports, budgets...)
case "murder": case "murder":
n := min(f.g.MurderSize, f.g.MaxCrowsInFlight) n := min(f.g.MurderSize, f.g.MaxCrowsInFlight)
crows := f.pick(n) crows := f.pick(n)
@@ -168,19 +186,19 @@ func (f *Flock) Execute(ctx context.Context, item string, t Triage, budgets ...*
for i := range tasks { for i := range tasks {
tasks[i] = item tasks[i] = item
} }
reports, err := f.fanOut(ctx, crows, tasks, budgets...) reports, err := f.fanOut(ctx, w, crows, tasks, budgets...)
if err != nil { if err != nil {
return Outcome{}, err return Outcome{}, err
} }
return f.reconcile(ctx, item, crows, tasks, reports, budgets...) return f.reconcile(ctx, w, item, crows, tasks, reports, budgets...)
default: default:
c := f.pick(1)[0] c := f.pick(1)[0]
ans, err := f.ask(ctx, c, crowSystem, "TASK: "+item, budgets...) ans, err := f.ask(ctx, w, c, crowSystem, "TASK: "+item, budgets...)
return Outcome{Answer: ans, Crows: []string{c}}, err return Outcome{Answer: ans, Crows: []string{c}}, err
} }
} }
func (f *Flock) fanOut(ctx context.Context, crows, tasks []string, budgets ...*Budget) ([]string, error) { func (f *Flock) fanOut(ctx context.Context, w Watcher, crows, tasks []string, budgets ...*Budget) ([]string, error) {
reports := make([]string, len(tasks)) reports := make([]string, len(tasks))
errs := make([]error, len(tasks)) errs := make([]error, len(tasks))
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -188,7 +206,7 @@ func (f *Flock) fanOut(ctx context.Context, crows, tasks []string, budgets ...*B
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
reports[i], errs[i] = f.ask(ctx, crows[i], crowSystem, "TASK: "+tasks[i], budgets...) reports[i], errs[i] = f.ask(ctx, w, crows[i], crowSystem, "TASK: "+tasks[i], budgets...)
}() }()
} }
wg.Wait() wg.Wait()
@@ -206,12 +224,12 @@ func (f *Flock) fanOut(ctx context.Context, crows, tasks []string, budgets ...*B
return reports, nil return reports, nil
} }
func (f *Flock) reconcile(ctx context.Context, item string, crows, tasks, reports []string, budgets ...*Budget) (Outcome, error) { func (f *Flock) reconcile(ctx context.Context, w Watcher, item string, crows, tasks, reports []string, budgets ...*Budget) (Outcome, error) {
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "ORIGINAL TASK: %s\n\n", item) fmt.Fprintf(&sb, "ORIGINAL TASK: %s\n\n", item)
for i := range reports { for i := range reports {
fmt.Fprintf(&sb, "--- %s on %q:\n%s\n\n", crows[i], tasks[i], reports[i]) fmt.Fprintf(&sb, "--- %s on %q:\n%s\n\n", crows[i], tasks[i], reports[i])
} }
ans, err := f.ask(ctx, "Elder", elderSystem, sb.String(), budgets...) ans, err := f.ask(ctx, w, "Elder", elderSystem, sb.String(), budgets...)
return Outcome{Answer: ans, Crows: append(append([]string{}, crows...), "Elder")}, err return Outcome{Answer: ans, Crows: append(append([]string{}, crows...), "Elder")}, err
} }
+156
View File
@@ -0,0 +1,156 @@
package main
import (
"encoding/json"
"sync"
"time"
)
// sseMsg is one event, already encoded, numbered within its list.
type sseMsg struct {
ID int
Name string
Data []byte
}
// listStream holds everything one list has said so far, plus who is listening.
type listStream struct {
requester string
created time.Time
finished bool
history []sseMsg
subs map[chan sseMsg]struct{}
}
// Hub fans events out to SSE subscribers per list_id and keeps each list's
// history in memory so late subscribers can catch up.
type Hub struct {
mu sync.Mutex
lists map[string]*listStream
}
func NewHub() *Hub {
return &Hub{lists: map[string]*listStream{}}
}
// subBuffer is how far a subscriber may fall behind before it is cut off.
// It reconnects with Last-Event-ID and catches up from history.
const subBuffer = 256
// keepLists caps how many lists the hub remembers; the oldest finished go first.
const keepLists = 200
func (h *Hub) Open(listID, requester string) {
h.mu.Lock()
defer h.mu.Unlock()
h.prune()
h.lists[listID] = &listStream{requester: requester, created: time.Now(), subs: map[chan sseMsg]struct{}{}}
}
func (h *Hub) prune() {
for len(h.lists) >= keepLists {
var oldest string
for id, ls := range h.lists {
if ls.finished && (oldest == "" || ls.created.Before(h.lists[oldest].created)) {
oldest = id
}
}
if oldest == "" {
return // every list is still flying; keep them all
}
h.drop(oldest)
}
}
func (h *Hub) drop(id string) {
for ch := range h.lists[id].subs {
close(ch)
}
delete(h.lists, id)
}
// ActiveLists returns the requester's lists that are still flying.
func (h *Hub) ActiveLists(requester string) []string {
h.mu.Lock()
defer h.mu.Unlock()
var ids []string
for id, ls := range h.lists {
if ls.requester == requester && !ls.finished {
ids = append(ids, id)
}
}
return ids
}
// Publish records an event for a list and sends it to current subscribers.
func (h *Hub) Publish(listID, name string, fields map[string]any) {
payload := map[string]any{}
for k, v := range fields {
payload[k] = v
}
payload["list_id"] = listID
payload["ts"] = time.Now().UTC().Format(time.RFC3339)
data, err := json.Marshal(payload)
if err != nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
ls := h.lists[listID]
if ls == nil {
return
}
m := sseMsg{ID: len(ls.history) + 1, Name: name, Data: data}
ls.history = append(ls.history, m)
if name == "list.finished" {
ls.finished = true
}
for ch := range ls.subs {
select {
case ch <- m:
default: // too slow: cut it off rather than stall the flock
delete(ls.subs, ch)
close(ch)
}
}
}
// Subscribe returns the history after lastID and a channel for what comes next.
// The channel closes if the subscriber falls too far behind.
func (h *Hub) Subscribe(listID string, lastID int) ([]sseMsg, chan sseMsg, bool) {
h.mu.Lock()
defer h.mu.Unlock()
ls := h.lists[listID]
if ls == nil {
return nil, nil, false
}
var past []sseMsg
if lastID < len(ls.history) {
past = append(past, ls.history[max(lastID, 0):]...)
}
ch := make(chan sseMsg, subBuffer)
ls.subs[ch] = struct{}{}
return past, ch, true
}
func (h *Hub) Unsubscribe(listID string, ch chan sseMsg) {
h.mu.Lock()
defer h.mu.Unlock()
if ls := h.lists[listID]; ls != nil {
if _, ok := ls.subs[ch]; ok {
delete(ls.subs, ch)
close(ch)
}
}
}
// Emitter binds the hub to one list.
func (h *Hub) Emitter(listID string) Emitter { return hubEmitter{h, listID} }
type hubEmitter struct {
h *Hub
id string
}
func (e hubEmitter) Emit(name string, fields map[string]any) { e.h.Publish(e.id, name, fields) }
+34 -9
View File
@@ -123,15 +123,37 @@ func (l *Ledger) Level(crow, taskType string) int {
return int(math.Round(w*float64(own.Level) + (1-w)*float64(flock.Level))) return int(math.Round(w*float64(own.Level) + (1-w)*float64(flock.Level)))
} }
// TrustChange is one level moving up or down. Holder is a crow name or "flock".
type TrustChange struct {
Holder, TaskType string
From, To int
}
// Judge records a verdict for every crow involved, and once for the flock. // Judge records a verdict for every crow involved, and once for the flock.
// The flock needs twice the streak to level up. // The flock needs twice the streak to level up. It reports any level changes.
func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter int) { func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter int) []TrustChange {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
for _, c := range crows { var changes []TrustChange
get(l.Crows, c+"|"+taskType).apply(ok, promoteAfter) judge := func(holder string, r *Record, need int) {
from := r.Level
r.apply(ok, need)
if r.Level != from {
changes = append(changes, TrustChange{Holder: holder, TaskType: taskType, From: from, To: r.Level})
}
} }
get(l.Flock, taskType).apply(ok, promoteAfter*2) for _, c := range crows {
judge(c, get(l.Crows, c+"|"+taskType), promoteAfter)
}
judge("flock", get(l.Flock, taskType), promoteAfter*2)
return changes
}
// MarshalSnapshot returns the ledger as JSON, safe to call while lists are flying.
func (l *Ledger) MarshalSnapshot() ([]byte, error) {
l.mu.Lock()
defer l.mu.Unlock()
return json.MarshalIndent(l, "", " ")
} }
func (l *Ledger) requester(id string) *Requester { func (l *Ledger) requester(id string) *Requester {
@@ -144,7 +166,8 @@ func (l *Ledger) requester(id string) *Requester {
} }
// Admit checks a requester at the door: cooldowns, rate cap, and fading grudges. // Admit checks a requester at the door: cooldowns, rate cap, and fading grudges.
func (l *Ledger) Admit(id string, now time.Time, g Guardrails) (bool, string) { // When it says no, it returns a prank message and how long the cooldown lasts.
func (l *Ledger) Admit(id string, now time.Time, g Guardrails) (bool, string, time.Duration) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()
r := l.requester(id) r := l.requester(id)
@@ -157,7 +180,8 @@ func (l *Ledger) Admit(id string, now time.Time, g Guardrails) (bool, string) {
} }
} }
if now.Before(r.CooldownUntil) { if now.Before(r.CooldownUntil) {
return false, prank(r.CooldownUntil.Sub(now)) wait := r.CooldownUntil.Sub(now)
return false, prank(wait), wait
} }
kept := r.Recent[:0] kept := r.Recent[:0]
for _, t := range r.Recent { for _, t := range r.Recent {
@@ -167,10 +191,11 @@ func (l *Ledger) Admit(id string, now time.Time, g Guardrails) (bool, string) {
} }
r.Recent = kept r.Recent = kept
if len(r.Recent) >= g.MaxRequestsPerMin { if len(r.Recent) >= g.MaxRequestsPerMin {
return false, prank(strike(r, now, g)) wait := strike(r, now, g)
return false, prank(wait), wait
} }
r.Recent = append(r.Recent, now) r.Recent = append(r.Recent, now)
return true, "" return true, "", 0
} }
func strike(r *Requester, now time.Time, g Guardrails) time.Duration { func strike(r *Requester, now time.Time, g Guardrails) time.Duration {
+48 -107
View File
@@ -22,6 +22,8 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "run": case "run":
cmdRun(os.Args[2:]) cmdRun(os.Args[2:])
case "serve":
cmdServe(os.Args[2:])
case "trust": case "trust":
cmdTrust() cmdTrust()
case "log": case "log":
@@ -35,16 +37,12 @@ func usage() {
fmt.Fprintln(os.Stderr, `So Many Crows fmt.Fprintln(os.Stderr, `So Many Crows
crows run <todo.txt> [-as name] hand the flock a todo list crows run <todo.txt> [-as name] hand the flock a todo list
crows serve [-addr 127.0.0.1:8080] run the web server (API + SSE)
crows trust show trust ledger crows trust show trust ledger
crows log [-n 20] show recent flight log entries`) crows log [-n 20] show recent flight log entries`)
os.Exit(2) os.Exit(2)
} }
type itemResult struct {
Item, Mode, TaskType, Answer, Verdict, Skipped string
Crows []string
}
func cmdRun(args []string) { func cmdRun(args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError) fs := flag.NewFlagSet("run", flag.ExitOnError)
as := fs.String("as", defaultRequester(), "requester id") as := fs.String("as", defaultRequester(), "requester id")
@@ -64,111 +62,73 @@ func cmdRun(args []string) {
flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl")) flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl"))
must(err) must(err)
defer flog.Close() defer flog.Close()
save := func() { p := &Pipeline{Ledger: ledger, Log: flog, G: g, Approver: &StdinApprover{in: bufio.NewReader(os.Stdin)}}
if err := ledger.Save(); err != nil {
fmt.Fprintln(os.Stderr, "could not save ledger:", err)
}
}
if ok, msg := ledger.Admit(*as, time.Now(), g); !ok { if ok, msg, _ := ledger.Admit(*as, time.Now(), g); !ok {
fmt.Println(msg) fmt.Println(msg)
flog.Write(Event{Kind: "denied", Requester: *as, Note: msg}) flog.Write(Event{Kind: "denied", Requester: *as, Note: msg})
save() p.save()
os.Exit(1) os.Exit(1)
} }
items, err := readTodo(todoPath, g) data, err := os.ReadFile(todoPath)
must(err)
items, err := parseTodo(string(data), g)
must(err) must(err)
if len(items) > g.MaxItemsPerList { if len(items) > g.MaxItemsPerList {
fmt.Printf("That list has %d items. The flock takes at most %d at a time.\n", len(items), g.MaxItemsPerList) fmt.Printf("That list has %d items. The flock takes at most %d at a time.\n", len(items), g.MaxItemsPerList)
save() p.save()
os.Exit(1) os.Exit(1)
} }
brain := NewBrain() brain := NewBrain()
flock := NewFlock(brain, g, flog) p.Flock = NewFlock(brain, g, flog)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop() defer stop()
in := bufio.NewReader(os.Stdin)
listBudget := &Budget{name: "list", limit: g.TokenBudgetPerList}
fmt.Printf("Brain: %s · requester: %s · %d items\n", brain.Name(), *as, len(items)) fmt.Printf("Brain: %s · requester: %s · %d items\n", brain.Name(), *as, len(items))
flog.Write(Event{Kind: "list", Requester: *as, Note: fmt.Sprintf("%s (%d items)", todoPath, len(items))}) l := List{ID: "cli", Requester: *as, Source: todoPath, Items: items}
results, listBudget := p.Run(ctx, l, &cliEmitter{items: items, seen: map[string]bool{}})
var results []itemResult
for i, item := range items {
fmt.Printf("\n[%d/%d] %s\n", i+1, len(items), item)
r := itemResult{Item: item}
itemBudget := &Budget{name: "item", limit: g.TokenBudgetPerItem}
t, err := flock.Triage(ctx, item, itemBudget, listBudget)
if err != nil {
r.Skipped = "triage failed: " + err.Error()
fmt.Println(" " + r.Skipped)
results = append(results, r)
continue
}
r.Mode, r.TaskType = t.Mode, t.TaskType
fmt.Printf(" Sentry: %s · %s · %s\n", t.Mode, t.TaskType, t.Reason)
for _, s := range t.Subtasks {
fmt.Printf(" - %s\n", s)
}
flog.Write(Event{Kind: "triage", Requester: *as, Item: item, Mode: t.Mode, TaskType: t.TaskType, Note: t.Reason})
// The Sentry earns autonomy on triage separately from the crows' work.
triageType := "triage/" + t.TaskType
if ledger.Level("Sentry", triageType) < Trusted {
ok := confirm(in, " Approve triage?")
ledger.Judge([]string{"Sentry"}, triageType, ok, g.PromoteAfter)
flog.Write(Event{Kind: "verdict", Item: item, Crow: "Sentry", TaskType: triageType, Verdict: verdict(ok)})
if !ok {
r.Skipped = "triage rejected"
results = append(results, r)
continue
}
}
out, err := flock.Execute(ctx, item, t, itemBudget, listBudget)
if err != nil {
r.Skipped = "flight failed: " + err.Error()
fmt.Println(" " + r.Skipped)
results = append(results, r)
continue
}
r.Answer, r.Crows = out.Answer, out.Crows
fmt.Printf(" Crows: %s\n%s\n", strings.Join(out.Crows, ", "), indent(out.Answer, " > "))
// The least-trusted crow involved sets the bar.
lowest := Trusted
for _, c := range out.Crows {
lowest = min(lowest, ledger.Level(c, t.TaskType))
}
if lowest < Regular {
ok := confirm(in, " Accept this answer?")
ledger.Judge(out.Crows, t.TaskType, ok, g.PromoteAfter)
r.Verdict = verdict(ok)
} else if lowest < Trusted {
r.Verdict = "accepted, unreviewed"
} else {
r.Verdict = "accepted, trusted"
}
flog.Write(Event{Kind: "verdict", Item: item, Crow: strings.Join(out.Crows, ","), TaskType: t.TaskType, Verdict: r.Verdict})
results = append(results, r)
}
save()
resultPath := strings.TrimSuffix(todoPath, filepath.Ext(todoPath)) + ".crows.md" resultPath := strings.TrimSuffix(todoPath, filepath.Ext(todoPath)) + ".crows.md"
must(writeResults(resultPath, todoPath, results)) must(writeResults(resultPath, todoPath, results))
fmt.Printf("\nResults: %s · tokens used: %d/%d\n", resultPath, listBudget.spent, listBudget.limit) fmt.Printf("\nResults: %s · tokens used: %d/%d\n", resultPath, listBudget.Spent(), listBudget.limit)
} }
func readTodo(path string, g Guardrails) ([]string, error) { // cliEmitter prints the events a person at a terminal cares about.
data, err := os.ReadFile(path) type cliEmitter struct {
if err != nil { items []string
return nil, err seen map[string]bool
}
func (c *cliEmitter) Emit(event string, f map[string]any) {
if id, ok := f["item_id"].(string); ok && !c.seen[id] {
c.seen[id] = true
var n int
fmt.Sscanf(id[strings.LastIndex(id, "-")+1:], "%d", &n)
fmt.Printf("\n[%d/%d] %s\n", n, len(c.items), c.items[n-1])
} }
switch event {
case "item.triaged":
fmt.Printf(" Sentry: %s · %s · %s\n", f["mode"], f["task_type"], f["reason"])
for _, s := range f["subtasks"].([]string) {
fmt.Printf(" - %s\n", s)
}
case "item.answered":
fmt.Printf(" Crows: %s\n%s\n", strings.Join(f["crows"].([]string), ", "), indent(f["answer"].(string), " > "))
case "trust.changed":
fmt.Printf(" Trust: %s on %s went %s → %s\n", f["holder"], f["task_type"], levelNames[f["from"].(int)], levelNames[f["to"].(int)])
case "item.skipped":
fmt.Printf(" %s\n", f["reason"])
}
}
var levelNames = []string{"stranger", "regular", "trusted"}
// parseTodo turns todo text (markdown checkboxes, bullets or plain lines) into items.
func parseTodo(text string, g Guardrails) ([]string, error) {
var items []string var items []string
for _, line := range strings.Split(string(data), "\n") { for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
for _, p := range []string{"- [ ]", "- [x]", "-", "*"} { for _, p := range []string{"- [ ]", "- [x]", "-", "*"} {
line = strings.TrimSpace(strings.TrimPrefix(line, p)) line = strings.TrimSpace(strings.TrimPrefix(line, p))
@@ -184,24 +144,6 @@ func readTodo(path string, g Guardrails) ([]string, error) {
return items, nil return items, nil
} }
// confirm fails safe: anything but y/yes, including no input at all, is a no.
func confirm(in *bufio.Reader, prompt string) bool {
fmt.Print(prompt + " [y/N] ")
line, _ := in.ReadString('\n')
a := strings.ToLower(strings.TrimSpace(line))
if line == "" {
fmt.Println()
}
return a == "y" || a == "yes"
}
func verdict(ok bool) string {
if ok {
return "approved"
}
return "rejected"
}
func writeResults(path, source string, results []itemResult) error { func writeResults(path, source string, results []itemResult) error {
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "# So Many Crows: %s\n\n_%s_\n\n", filepath.Base(source), time.Now().Format("2006-01-02 15:04")) fmt.Fprintf(&sb, "# So Many Crows: %s\n\n_%s_\n\n", filepath.Base(source), time.Now().Format("2006-01-02 15:04"))
@@ -211,7 +153,7 @@ func writeResults(path, source string, results []itemResult) error {
continue continue
} }
box := "x" box := "x"
if r.Verdict == "rejected" { if r.Verdict == "rejected" || r.Verdict == "timeout" {
box = " " box = " "
} }
fmt.Fprintf(&sb, "- [%s] %s\n - %s · %s · %s · %s\n%s\n\n", box, r.Item, r.Mode, r.TaskType, strings.Join(r.Crows, ", "), r.Verdict, indent(r.Answer, " > ")) fmt.Fprintf(&sb, "- [%s] %s\n - %s · %s · %s · %s\n%s\n\n", box, r.Item, r.Mode, r.TaskType, strings.Join(r.Crows, ", "), r.Verdict, indent(r.Answer, " > "))
@@ -222,7 +164,6 @@ func writeResults(path, source string, results []itemResult) error {
func cmdTrust() { func cmdTrust() {
l, err := LoadLedger(filepath.Join(dataDir, "ledger.json")) l, err := LoadLedger(filepath.Join(dataDir, "ledger.json"))
must(err) must(err)
names := []string{"stranger", "regular", "trusted"}
show := func(title string, m map[string]*Record) { show := func(title string, m map[string]*Record) {
fmt.Println(title) fmt.Println(title)
keys := make([]string, 0, len(m)) keys := make([]string, 0, len(m))
@@ -235,7 +176,7 @@ func cmdTrust() {
} }
for _, k := range keys { for _, k := range keys {
r := m[k] r := m[k]
fmt.Printf(" %-28s %-9s +%d -%d streak %d\n", k, names[r.Level], r.Approved, r.Rejected, r.Streak) fmt.Printf(" %-28s %-9s +%d -%d streak %d\n", k, levelNames[r.Level], r.Approved, r.Rejected, r.Streak)
} }
} }
show("Crows", l.Crows) show("Crows", l.Crows)
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"sync"
)
// Emitter receives the events in EVENTS.md. The web server streams them; the
// command line prints them. Fields exclude list_id and ts; the emitter adds those.
type Emitter interface {
Emit(event string, fields map[string]any)
}
// Approval is one question for a human: may this triage or answer stand?
type Approval struct {
ListID, ItemID, Item string
Target string // "triage" or "output"
}
// Approver asks a human. It returns "approved", "rejected" or "timeout".
// Anything other than a clear yes is a no.
type Approver interface {
Approve(ctx context.Context, a Approval) string
}
// List is one todo list handed to the flock.
type List struct {
ID, Requester, Source string
Items []string
Parallel bool // fly items at once (web); the command line goes one by one
}
type itemResult struct {
Item, Mode, TaskType, Answer, Verdict, Skipped string
Crows []string
}
// Pipeline is the one path every list takes, from the command line or the web.
type Pipeline struct {
Flock *Flock
Ledger *Ledger
Log *FlightLog
G Guardrails
Approver Approver
}
func itemID(listID string, i int) string { return fmt.Sprintf("%s-%d", listID, i+1) }
// Run flies a list and returns per-item results and the list budget.
func (p *Pipeline) Run(ctx context.Context, l List, em Emitter) ([]itemResult, *Budget) {
listBudget := &Budget{name: "list", limit: p.G.TokenBudgetPerList}
items := make([]map[string]any, len(l.Items))
for i, text := range l.Items {
items[i] = map[string]any{"item_id": itemID(l.ID, i), "text": text}
}
p.Log.Write(Event{Kind: "list", Requester: l.Requester, Note: fmt.Sprintf("%s (%d items)", l.Source, len(l.Items))})
em.Emit("list.started", map[string]any{"items": items, "requester": l.Requester})
results := make([]itemResult, len(l.Items))
if l.Parallel {
var wg sync.WaitGroup
for i := range l.Items {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = p.item(ctx, l, i, listBudget, em)
}()
}
wg.Wait()
} else {
for i := range l.Items {
results[i] = p.item(ctx, l, i, listBudget, em)
}
}
p.save()
em.Emit("list.finished", map[string]any{"tokens_used": listBudget.Spent(), "token_budget": listBudget.limit})
return results, listBudget
}
func (p *Pipeline) item(ctx context.Context, l List, i int, listBudget *Budget, em Emitter) itemResult {
id, item := itemID(l.ID, i), l.Items[i]
r := itemResult{Item: item}
skip := func(reason string) itemResult {
r.Skipped = reason
em.Emit("item.skipped", map[string]any{"item_id": id, "reason": reason})
return r
}
w := &itemWatcher{em: em, itemID: id}
itemBudget := &Budget{name: "item", limit: p.G.TokenBudgetPerItem}
t, err := p.Flock.Triage(ctx, w, item, itemBudget, listBudget)
if err != nil {
return skip("triage failed: " + err.Error())
}
r.Mode, r.TaskType = t.Mode, t.TaskType
p.Log.Write(Event{Kind: "triage", Requester: l.Requester, Item: item, Mode: t.Mode, TaskType: t.TaskType, Note: t.Reason})
// The Sentry earns autonomy on triage separately from the crows' work.
triageType := "triage/" + t.TaskType
needTriage := p.Ledger.Level("Sentry", triageType) < Trusted
em.Emit("item.triaged", map[string]any{
"item_id": id, "mode": t.Mode, "task_type": t.TaskType,
"subtasks": nonNil(t.Subtasks), "reason": t.Reason, "needs_approval": needTriage,
})
if needTriage {
v := p.judge(ctx, Approval{ListID: l.ID, ItemID: id, Item: item, Target: "triage"}, []string{"Sentry"}, triageType, em)
if v != "approved" {
return skip("triage " + v)
}
} else {
em.Emit("verdict", map[string]any{"item_id": id, "target": "triage", "verdict": "auto"})
}
out, err := p.Flock.Execute(ctx, w, item, t, itemBudget, listBudget)
if err != nil {
return skip("flight failed: " + err.Error())
}
r.Answer, r.Crows = out.Answer, out.Crows
// The least-trusted crow involved sets the bar.
lowest := Trusted
for _, c := range out.Crows {
lowest = min(lowest, p.Ledger.Level(c, t.TaskType))
}
needOutput := lowest < Regular
em.Emit("item.answered", map[string]any{"item_id": id, "answer": out.Answer, "crows": out.Crows, "needs_approval": needOutput})
switch {
case needOutput:
r.Verdict = p.judge(ctx, Approval{ListID: l.ID, ItemID: id, Item: item, Target: "output"}, out.Crows, t.TaskType, em)
case lowest < Trusted:
r.Verdict = "accepted, unreviewed"
em.Emit("verdict", map[string]any{"item_id": id, "target": "output", "verdict": "auto"})
default:
r.Verdict = "accepted, trusted"
em.Emit("verdict", map[string]any{"item_id": id, "target": "output", "verdict": "auto"})
}
p.Log.Write(Event{Kind: "verdict", Item: item, Crow: strings.Join(out.Crows, ","), TaskType: t.TaskType, Verdict: r.Verdict})
return r
}
// judge asks a human, records the verdict, reports trust changes, and saves.
// A timeout counts as a rejection. A shutdown records nothing.
func (p *Pipeline) judge(ctx context.Context, a Approval, holders []string, taskType string, em Emitter) string {
v := p.Approver.Approve(ctx, a)
if ctx.Err() != nil {
return "rejected" // shutting down: no human said anything, so record nothing
}
if v != "approved" && v != "timeout" {
v = "rejected"
}
em.Emit("verdict", map[string]any{"item_id": a.ItemID, "target": a.Target, "verdict": v})
for _, c := range p.Ledger.Judge(holders, taskType, v == "approved", p.G.PromoteAfter) {
em.Emit("trust.changed", map[string]any{"holder": c.Holder, "task_type": c.TaskType, "from": c.From, "to": c.To})
}
if a.Target == "triage" {
p.Log.Write(Event{Kind: "verdict", Item: a.Item, Crow: "Sentry", TaskType: taskType, Verdict: v})
}
p.save()
return v
}
func (p *Pipeline) save() {
if err := p.Ledger.Save(); err != nil {
fmt.Fprintln(os.Stderr, "could not save ledger:", err)
}
}
// itemWatcher turns flock takeoffs and landings into crow events for one item.
type itemWatcher struct {
em Emitter
itemID string
}
func (w *itemWatcher) Flying(crow string) {
w.em.Emit("crow.flying", map[string]any{"item_id": w.itemID, "crow": crow})
}
func (w *itemWatcher) Landed(crow string, tokens int, err error) {
f := map[string]any{"item_id": w.itemID, "crow": crow, "ok": err == nil, "tokens": tokens}
if err != nil {
f["error"] = err.Error()
}
w.em.Emit("crow.landed", f)
}
func nonNil(s []string) []string {
if s == nil {
return []string{}
}
return s
}
+255
View File
@@ -0,0 +1,255 @@
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"math"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"time"
)
const (
maxBody = 64 << 10 // POST bodies
heartbeat = 15 * time.Second
webDir = "web"
)
// Server is the web front door: the same pipeline as the command line,
// with web approvals and an SSE hub instead of stdin and stdout.
type Server struct {
p *Pipeline
approver *WebApprover
hub *Hub
ctx context.Context // cancelled on shutdown; lists and streams end with it
beat time.Duration
}
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover) *Server {
return &Server{p: p, approver: approver, hub: NewHub(), ctx: ctx, beat: heartbeat}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /api/lists", s.postList)
mux.HandleFunc("GET /api/events", s.events)
mux.HandleFunc("POST /api/verdicts", s.postVerdict)
mux.HandleFunc("GET /api/trust", s.trust)
if st, err := os.Stat(webDir); err == nil && st.IsDir() {
mux.Handle("GET /", http.FileServer(http.Dir(webDir)))
} else {
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "So Many Crows is listening. The web front end lands in ./web later.")
})
}
return mux
}
func cmdServe(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on (localhost only by default: there is no login yet)")
timeout := fs.Duration("approval-timeout", 10*time.Minute, "unanswered approvals count as rejected after this long")
fs.Parse(args)
g := DefaultGuardrails()
ledger, err := LoadLedger(filepath.Join(dataDir, "ledger.json"))
must(err)
flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl"))
must(err)
defer flog.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
brain := NewBrain()
approver := NewWebApprover(*timeout)
p := &Pipeline{Flock: NewFlock(brain, g, flog), Ledger: ledger, Log: flog, G: g, Approver: approver}
s := NewServer(ctx, p, approver)
srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() {
<-ctx.Done()
shut, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shut)
}()
fmt.Printf("Brain: %s · listening on %s\n", brain.Name(), *addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
must(err)
}
if err := ledger.Save(); err != nil {
fmt.Fprintln(os.Stderr, "could not save ledger:", err)
}
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func httpError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"message": msg})
}
// decode reads a size-capped JSON body.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
httpError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("body over %d bytes", maxBody))
} else {
httpError(w, http.StatusBadRequest, "body must be JSON: "+err.Error())
}
return false
}
return true
}
func (s *Server) postList(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
Requester string `json:"requester"`
}
if !decode(w, r, &body) {
return
}
if body.Requester == "" {
body.Requester, _, _ = net.SplitHostPort(r.RemoteAddr)
}
ok, msg, wait := s.p.Ledger.Admit(body.Requester, time.Now(), s.p.G)
if !ok {
cooldown := int(math.Ceil(wait.Seconds()))
s.p.Log.Write(Event{Kind: "denied", Requester: body.Requester, Note: msg})
s.p.save()
// Only a list that is still flying can hear about it; otherwise the 429 says it all.
for _, id := range s.hub.ActiveLists(body.Requester) {
s.hub.Publish(id, "requester.pranked", map[string]any{"message": msg, "cooldown_s": cooldown})
}
writeJSON(w, http.StatusTooManyRequests, map[string]any{"message": msg, "cooldown_s": cooldown})
return
}
s.p.save()
items, err := parseTodo(body.Text, s.p.G)
switch {
case err != nil:
httpError(w, http.StatusBadRequest, err.Error())
return
case len(items) == 0:
httpError(w, http.StatusBadRequest, "That list is empty.")
return
case len(items) > s.p.G.MaxItemsPerList:
httpError(w, http.StatusBadRequest, fmt.Sprintf("That list has %d items. The flock takes at most %d at a time.", len(items), s.p.G.MaxItemsPerList))
return
}
id := newListID()
s.hub.Open(id, body.Requester)
l := List{ID: id, Requester: body.Requester, Source: "web", Items: items, Parallel: true}
go s.p.Run(s.ctx, l, s.hub.Emitter(id))
writeJSON(w, http.StatusAccepted, map[string]string{"list_id": id})
}
func newListID() string {
b := make([]byte, 6)
rand.Read(b)
return hex.EncodeToString(b)
}
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
listID := r.URL.Query().Get("list_id")
lastID, _ := strconv.Atoi(r.Header.Get("Last-Event-ID"))
past, ch, ok := s.hub.Subscribe(listID, lastID)
if !ok {
httpError(w, http.StatusNotFound, "no such list_id")
return
}
defer s.hub.Unsubscribe(listID, ch)
flusher, canFlush := w.(http.Flusher)
if !canFlush {
httpError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
send := func(m sseMsg) bool {
_, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", m.ID, m.Name, m.Data)
flusher.Flush()
return err == nil
}
io.WriteString(w, ": caw\n\n")
flusher.Flush()
for _, m := range past {
if !send(m) {
return
}
}
tick := time.NewTicker(s.beat)
defer tick.Stop()
for {
select {
case m, open := <-ch:
if !open || !send(m) {
return
}
case <-tick.C:
if _, err := io.WriteString(w, ": heartbeat\n\n"); err != nil {
return
}
flusher.Flush()
case <-r.Context().Done():
return
case <-s.ctx.Done():
return
}
}
}
func (s *Server) postVerdict(w http.ResponseWriter, r *http.Request) {
var body struct {
ItemID string `json:"item_id"`
Target string `json:"target"`
Approve *bool `json:"approve"`
}
if !decode(w, r, &body) {
return
}
if body.ItemID == "" || (body.Target != "triage" && body.Target != "output") || body.Approve == nil {
httpError(w, http.StatusBadRequest, `need item_id, target ("triage" or "output") and approve (bool)`)
return
}
if !s.approver.Resolve(body.ItemID, body.Target, *body.Approve) {
httpError(w, http.StatusNotFound, "nothing is waiting on that verdict")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) trust(w http.ResponseWriter, r *http.Request) {
data, err := s.p.Ledger.MarshalSnapshot()
if err != nil {
httpError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
+281
View File
@@ -0,0 +1,281 @@
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
func TestJudgeReportsLevelChanges(t *testing.T) {
l, _ := LoadLedger(filepath.Join(t.TempDir(), "ledger.json"))
if ch := l.Judge([]string{"Hugin"}, "writing", true, 2); len(ch) != 0 {
t.Fatalf("first approval should not change levels: %v", ch)
}
ch := l.Judge([]string{"Hugin"}, "writing", true, 2)
if len(ch) != 1 || ch[0] != (TrustChange{"Hugin", "writing", Stranger, Regular}) {
t.Fatalf("want Hugin promoted, got %v", ch)
}
l.Judge([]string{"Hugin"}, "writing", true, 2) // flock needs 4
ch = l.Judge([]string{"Hugin"}, "writing", true, 2)
if !hasChange(ch, "flock", Stranger, Regular) {
t.Fatalf("want flock promoted, got %v", ch)
}
ch = l.Judge([]string{"Hugin"}, "writing", false, 2)
if !hasChange(ch, "Hugin", Trusted, Regular) || !hasChange(ch, "flock", Regular, Stranger) {
t.Fatalf("want both demoted, got %v", ch)
}
}
func hasChange(ch []TrustChange, holder string, from, to int) bool {
for _, c := range ch {
if c.Holder == holder && c.From == from && c.To == to {
return true
}
}
return false
}
func TestWebApprover(t *testing.T) {
w := NewWebApprover(50 * time.Millisecond)
if w.Resolve("x-1", "triage", true) {
t.Fatal("nothing pending; Resolve should report false")
}
done := make(chan string)
go func() { done <- w.Approve(context.Background(), Approval{ItemID: "x-1", Target: "triage"}) }()
for !w.Resolve("x-1", "triage", true) {
time.Sleep(time.Millisecond)
}
if v := <-done; v != "approved" {
t.Fatalf("want approved, got %s", v)
}
if v := w.Approve(context.Background(), Approval{ItemID: "x-2", Target: "output"}); v != "timeout" {
t.Fatalf("want timeout, got %s", v)
}
}
func TestHubReplaysHistory(t *testing.T) {
h := NewHub()
h.Open("L", "r")
h.Publish("L", "list.started", map[string]any{"requester": "r"})
h.Publish("L", "list.finished", nil)
past, ch, ok := h.Subscribe("L", 0)
if !ok || len(past) != 2 || past[1].Name != "list.finished" {
t.Fatalf("want 2 past events, got %v", past)
}
past, _, _ = h.Subscribe("L", 1)
if len(past) != 1 {
t.Fatalf("Last-Event-ID 1 should skip one event, got %d", len(past))
}
h.Unsubscribe("L", ch)
if len(h.ActiveLists("r")) != 0 {
t.Fatal("finished list should not be active")
}
}
// sseEvent is one parsed SSE message.
type sseEvent struct {
Name string
Data map[string]any
}
func newTestServer(t *testing.T, g Guardrails, timeout time.Duration) (*httptest.Server, *Server) {
dir := t.TempDir()
ledger, _ := LoadLedger(filepath.Join(dir, "ledger.json"))
flog, err := OpenFlightLog(filepath.Join(dir, "flight.jsonl"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { flog.Close() })
ctx, cancel := context.WithCancel(context.Background())
approver := NewWebApprover(timeout)
p := &Pipeline{Flock: NewFlock(MockBrain{}, g, flog), Ledger: ledger, Log: flog, G: g, Approver: approver}
s := NewServer(ctx, p, approver)
s.beat = 20 * time.Millisecond
ts := httptest.NewServer(s.Handler())
t.Cleanup(func() { cancel(); ts.Close() })
return ts, s
}
func post(t *testing.T, url, body string) (int, map[string]any) {
resp, err := http.Post(url, "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]any
json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
// stream reads events until list.finished, answering approvals with approve.
// It also reports whether a heartbeat comment arrived.
func stream(t *testing.T, ts *httptest.Server, listID string, approve *bool) ([]sseEvent, bool) {
resp, err := http.Get(ts.URL + "/api/events?list_id=" + listID)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Fatalf("content type %q", ct)
}
var events []sseEvent
var name string
beat := false
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
line := sc.Text()
switch {
case line == ": heartbeat":
beat = true
case strings.HasPrefix(line, "event: "):
name = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
var d map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
events = append(events, sseEvent{name, d})
if d["list_id"] != listID || d["ts"] == nil {
t.Fatalf("event missing list_id or ts: %v", d)
}
needs, _ := d["needs_approval"].(bool)
if approve != nil && needs && (name == "item.triaged" || name == "item.answered") {
target := "triage"
if name == "item.answered" {
target = "output"
}
body := fmt.Sprintf(`{"item_id":%q,"target":%q,"approve":%v}`, d["item_id"], target, *approve)
// The approval is registered just after the event; retry briefly.
for i := 0; ; i++ {
resp, err := http.Post(ts.URL+"/api/verdicts", "application/json", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
break
}
if i > 200 {
t.Fatalf("verdict never accepted: %d", resp.StatusCode)
}
time.Sleep(5 * time.Millisecond)
}
}
if name == "list.finished" {
return events, beat
}
}
}
t.Fatal("stream ended before list.finished")
return nil, false
}
func count(events []sseEvent, name string) int {
n := 0
for _, e := range events {
if e.Name == name {
n++
}
}
return n
}
func TestServerApproveAndPromote(t *testing.T) {
g := DefaultGuardrails()
g.PromoteAfter = 2
ts, _ := newTestServer(t, g, time.Minute)
code, out := post(t, ts.URL+"/api/lists", `{"text":"- [ ] What is a crow\n- [ ] What is a rook","requester":"t"}`)
if code != http.StatusAccepted {
t.Fatalf("POST /api/lists: %d %v", code, out)
}
yes := true
events, _ := stream(t, ts, out["list_id"].(string), &yes)
if events[0].Name != "list.started" || count(events, "item.answered") != 2 || count(events, "verdict") != 4 {
t.Fatalf("unexpected events: %v", events)
}
promoted := false
for _, e := range events {
if e.Name == "trust.changed" && e.Data["holder"] == "Sentry" && e.Data["to"] == float64(Regular) {
promoted = true
}
}
if !promoted {
t.Fatalf("want Sentry promoted; events: %v", events)
}
resp, _ := http.Get(ts.URL + "/api/trust")
var ledger Ledger
json.NewDecoder(resp.Body).Decode(&ledger)
resp.Body.Close()
if ledger.Crows["Sentry|triage/retrieval"].Level != Regular {
t.Fatalf("ledger not updated: %+v", ledger.Crows)
}
}
func TestServerTimeoutAndHeartbeat(t *testing.T) {
ts, _ := newTestServer(t, DefaultGuardrails(), 100*time.Millisecond)
_, out := post(t, ts.URL+"/api/lists", `{"text":"Write a crow poem","requester":"t"}`)
events, beat := stream(t, ts, out["list_id"].(string), nil)
var v, skipped sseEvent
for _, e := range events {
switch e.Name {
case "verdict":
v = e
case "item.skipped":
skipped = e
}
}
if v.Data["verdict"] != "timeout" || skipped.Data["reason"] != "triage timeout" {
t.Fatalf("want timeout verdict and skip, got %v", events)
}
if !beat {
t.Fatal("no heartbeat during a 100ms wait with a 20ms beat")
}
}
func TestServerPrankAndLimits(t *testing.T) {
g := DefaultGuardrails()
ts, s := newTestServer(t, g, time.Minute)
var first string
for i := 0; i < g.MaxRequestsPerMin; i++ {
code, out := post(t, ts.URL+"/api/lists", `{"text":"Write a poem","requester":"flood"}`)
if code != http.StatusAccepted {
t.Fatalf("request %d: %d", i, code)
}
if first == "" {
first = out["list_id"].(string)
}
}
code, out := post(t, ts.URL+"/api/lists", `{"text":"Write a poem","requester":"flood"}`)
if code != http.StatusTooManyRequests || out["message"] == "" || out["cooldown_s"] != float64(30) {
t.Fatalf("want 429 with message and cooldown_s 30, got %d %v", code, out)
}
past, _, _ := s.hub.Subscribe(first, 0)
found := false
for _, m := range past {
found = found || m.Name == "requester.pranked"
}
if !found {
t.Fatal("requester.pranked not emitted on the active list")
}
// A requester with no list gets only the 429 (nothing to emit on).
s.p.Ledger.Requesters["ghost"] = &Requester{CooldownUntil: time.Now().Add(time.Minute)}
if code, _ := post(t, ts.URL+"/api/lists", `{"text":"hi","requester":"ghost"}`); code != http.StatusTooManyRequests {
t.Fatalf("cooldown should 429, got %d", code)
}
big := `{"text":"` + strings.Repeat("a", maxBody) + `"}`
if code, _ := post(t, ts.URL+"/api/lists", big); code != http.StatusRequestEntityTooLarge {
t.Fatalf("want 413, got %d", code)
}
if code, _ := post(t, ts.URL+"/api/verdicts", `{"item_id":"none-1","target":"triage","approve":true}`); code != http.StatusNotFound {
t.Fatalf("want 404 for unknown verdict, got %d", code)
}
}