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:
12 files changed
+1320
-129
No files matched your search
@@ -3,3 +3,4 @@
|
||||
*.crows.md
|
||||
node_modules/
|
||||
dist/
|
||||
/somanycrows
|
||||
@@ -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.
|
||||
@@ -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/`.
|
||||
|
||||
## 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
|
||||
|
||||
1. **Sentry** classifies it: `solo` (one crow), `split` (2–4 subtasks in parallel), or `murder` (3 crows answer independently).
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -58,12 +58,24 @@ func (b *Budget) Check() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Budget) Spent() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.spent
|
||||
}
|
||||
|
||||
func (b *Budget) Add(n int) {
|
||||
b.mu.Lock()
|
||||
b.spent += n
|
||||
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 {
|
||||
brain Brain
|
||||
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.
|
||||
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 {
|
||||
if err := b.Check(); err != nil {
|
||||
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{}{}
|
||||
defer func() { <-f.sem }()
|
||||
|
||||
if w != nil {
|
||||
w.Flying(crow)
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, f.g.CrowTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
@@ -116,11 +131,14 @@ func (f *Flock) ask(ctx context.Context, crow, system, prompt string, budgets ..
|
||||
e.Error = err.Error()
|
||||
}
|
||||
f.log.Write(e)
|
||||
if w != nil {
|
||||
w.Landed(crow, tokens, err)
|
||||
}
|
||||
return text, err
|
||||
}
|
||||
|
||||
func (f *Flock) Triage(ctx context.Context, item string, budgets ...*Budget) (Triage, error) {
|
||||
text, err := f.ask(ctx, "Sentry", sentrySystem, "ITEM: "+item, budgets...)
|
||||
func (f *Flock) Triage(ctx context.Context, w Watcher, item string, budgets ...*Budget) (Triage, error) {
|
||||
text, err := f.ask(ctx, w, "Sentry", sentrySystem, "ITEM: "+item, budgets...)
|
||||
if err != nil {
|
||||
return Triage{}, err
|
||||
}
|
||||
@@ -151,16 +169,16 @@ func (f *Flock) Triage(ctx context.Context, item string, budgets ...*Budget) (Tr
|
||||
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 {
|
||||
case "split":
|
||||
// Split depth is 1: subtasks go straight to crows, never back to the Sentry.
|
||||
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 {
|
||||
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":
|
||||
n := min(f.g.MurderSize, f.g.MaxCrowsInFlight)
|
||||
crows := f.pick(n)
|
||||
@@ -168,19 +186,19 @@ func (f *Flock) Execute(ctx context.Context, item string, t Triage, budgets ...*
|
||||
for i := range tasks {
|
||||
tasks[i] = item
|
||||
}
|
||||
reports, err := f.fanOut(ctx, crows, tasks, budgets...)
|
||||
reports, err := f.fanOut(ctx, w, crows, tasks, budgets...)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
return f.reconcile(ctx, item, crows, tasks, reports, budgets...)
|
||||
return f.reconcile(ctx, w, item, crows, tasks, reports, budgets...)
|
||||
default:
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
errs := make([]error, len(tasks))
|
||||
var wg sync.WaitGroup
|
||||
@@ -188,7 +206,7 @@ func (f *Flock) fanOut(ctx context.Context, crows, tasks []string, budgets ...*B
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
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()
|
||||
@@ -206,12 +224,12 @@ func (f *Flock) fanOut(ctx context.Context, crows, tasks []string, budgets ...*B
|
||||
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
|
||||
fmt.Fprintf(&sb, "ORIGINAL TASK: %s\n\n", item)
|
||||
for i := range reports {
|
||||
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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
// 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.
|
||||
// The flock needs twice the streak to level up.
|
||||
func (l *Ledger) Judge(crows []string, taskType string, ok bool, promoteAfter int) {
|
||||
// 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) []TrustChange {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for _, c := range crows {
|
||||
get(l.Crows, c+"|"+taskType).apply(ok, promoteAfter)
|
||||
var changes []TrustChange
|
||||
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 {
|
||||
@@ -144,7 +166,8 @@ func (l *Ledger) requester(id string) *Requester {
|
||||
}
|
||||
|
||||
// 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()
|
||||
defer l.mu.Unlock()
|
||||
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) {
|
||||
return false, prank(r.CooldownUntil.Sub(now))
|
||||
wait := r.CooldownUntil.Sub(now)
|
||||
return false, prank(wait), wait
|
||||
}
|
||||
kept := r.Recent[:0]
|
||||
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
|
||||
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)
|
||||
return true, ""
|
||||
return true, "", 0
|
||||
}
|
||||
|
||||
func strike(r *Requester, now time.Time, g Guardrails) time.Duration {
|
||||
|
||||
@@ -22,6 +22,8 @@ func main() {
|
||||
switch os.Args[1] {
|
||||
case "run":
|
||||
cmdRun(os.Args[2:])
|
||||
case "serve":
|
||||
cmdServe(os.Args[2:])
|
||||
case "trust":
|
||||
cmdTrust()
|
||||
case "log":
|
||||
@@ -35,16 +37,12 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, `So Many Crows
|
||||
|
||||
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 log [-n 20] show recent flight log entries`)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
type itemResult struct {
|
||||
Item, Mode, TaskType, Answer, Verdict, Skipped string
|
||||
Crows []string
|
||||
}
|
||||
|
||||
func cmdRun(args []string) {
|
||||
fs := flag.NewFlagSet("run", flag.ExitOnError)
|
||||
as := fs.String("as", defaultRequester(), "requester id")
|
||||
@@ -64,111 +62,73 @@ func cmdRun(args []string) {
|
||||
flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl"))
|
||||
must(err)
|
||||
defer flog.Close()
|
||||
save := func() {
|
||||
if err := ledger.Save(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "could not save ledger:", err)
|
||||
}
|
||||
}
|
||||
p := &Pipeline{Ledger: ledger, Log: flog, G: g, Approver: &StdinApprover{in: bufio.NewReader(os.Stdin)}}
|
||||
|
||||
if ok, msg := ledger.Admit(*as, time.Now(), g); !ok {
|
||||
if ok, msg, _ := ledger.Admit(*as, time.Now(), g); !ok {
|
||||
fmt.Println(msg)
|
||||
flog.Write(Event{Kind: "denied", Requester: *as, Note: msg})
|
||||
save()
|
||||
p.save()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
items, err := readTodo(todoPath, g)
|
||||
data, err := os.ReadFile(todoPath)
|
||||
must(err)
|
||||
items, err := parseTodo(string(data), g)
|
||||
must(err)
|
||||
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)
|
||||
save()
|
||||
p.save()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
brain := NewBrain()
|
||||
flock := NewFlock(brain, g, flog)
|
||||
p.Flock = NewFlock(brain, g, flog)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
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))
|
||||
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"
|
||||
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) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// cliEmitter prints the events a person at a terminal cares about.
|
||||
type cliEmitter struct {
|
||||
items []string
|
||||
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
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
for _, p := range []string{"- [ ]", "- [x]", "-", "*"} {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, p))
|
||||
@@ -184,24 +144,6 @@ func readTodo(path string, g Guardrails) ([]string, error) {
|
||||
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 {
|
||||
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"))
|
||||
@@ -211,7 +153,7 @@ func writeResults(path, source string, results []itemResult) error {
|
||||
continue
|
||||
}
|
||||
box := "x"
|
||||
if r.Verdict == "rejected" {
|
||||
if r.Verdict == "rejected" || r.Verdict == "timeout" {
|
||||
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, " > "))
|
||||
@@ -222,7 +164,6 @@ func writeResults(path, source string, results []itemResult) error {
|
||||
func cmdTrust() {
|
||||
l, err := LoadLedger(filepath.Join(dataDir, "ledger.json"))
|
||||
must(err)
|
||||
names := []string{"stranger", "regular", "trusted"}
|
||||
show := func(title string, m map[string]*Record) {
|
||||
fmt.Println(title)
|
||||
keys := make([]string, 0, len(m))
|
||||
@@ -235,7 +176,7 @@ func cmdTrust() {
|
||||
}
|
||||
for _, k := range keys {
|
||||
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)
|
||||
|
||||
+195
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user