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
|
*.crows.md
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
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/`.
|
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).
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
}
|
}
|
||||||
@@ -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)))
|
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 {
|
||||||
|
|||||||
@@ -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
@@ -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