diff --git a/.gitignore b/.gitignore index ae05b96..a2faa1a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules/ dist/ /somanycrows /web/ +/dist/ diff --git a/README.md b/README.md index 67157d1..4c1f83a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,21 @@ curl localhost:8080/api/trust - UI development: run `crows serve`, then `cd ui && npm run dev` (Vite proxies `/api`). The crow art is `art/crow.svg`; the UI's `CrowView` mirrors `RIVE_CONTRACT.md`, so a Rive-backed view replaces the SVG stand-in later. - POST bodies are capped at 64 KB. Static files are served from `./web` when that folder exists. +## Nests + +A nest is a room where a team of crows works together. Create one with its core crows; jobs in that nest use the core first and borrow from the rest of the flock when they need more (a murder of 3 in a 2-crow nest borrows one). The Sentry and Elder serve every nest. + +``` +curl localhost:8080/api/nests # {"roster": [...], "nests": [...]} +curl -XPOST localhost:8080/api/nests -d '{"name":"Launch copy","crows":["Pepper","Inky"]}' # 201, nest_id "launch-copy" +curl -XPOST localhost:8080/api/lists -d '{"text":"- [ ] Should we launch on a Tuesday","nest_id":"launch-copy"}' +./crows run todo.txt -nest launch-copy +``` + +- The default nest is `todo`, with an empty core: crows come from the whole flock and are never "borrowed". +- `crow.flying` carries `borrowed: true` for a crow from outside the nest's core. +- Names are 1–40 characters; `nest_id` is the name as a slug and must be unique (409 on a clash). 1–8 roster crows, no repeats, at most 20 nests. Nests are saved in `.crows/nests.json`. + ## How an item flies 1. **Sentry** classifies it: `solo` (one crow), `split` (2–4 subtasks in parallel), or `murder` (3 crows answer independently). diff --git a/flock.go b/flock.go index 53abc06..1535c91 100644 --- a/flock.go +++ b/flock.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "sync" "time" @@ -82,22 +83,46 @@ type Flock struct { log *FlightLog sem chan struct{} - mu sync.Mutex - next int + mu sync.Mutex + next int // rotation through the whole flock (the todo nest) + core map[string]int // per nest: rotation within its core + borrow map[string]int // per nest: rotation through crows outside its core } func NewFlock(brain Brain, g Guardrails, log *FlightLog) *Flock { - return &Flock{brain: brain, g: g, log: log, sem: make(chan struct{}, g.MaxCrowsInFlight)} + return &Flock{ + brain: brain, g: g, log: log, sem: make(chan struct{}, g.MaxCrowsInFlight), + core: map[string]int{}, borrow: map[string]int{}, + } } -// pick rotates through the roster so every crow gets a chance to build a record. -func (f *Flock) pick(n int) []string { +// pick chooses n crows for a job in a nest. A nest with no core (todo) rotates +// through the whole roster so every crow gets a chance to build a record. +// Otherwise core crows go first (rotating within the core), and any extra +// crows are borrowed from outside the core (rotating). +func (f *Flock) pick(nest Nest, n int) []string { f.mu.Lock() defer f.mu.Unlock() - out := make([]string, n) - for i := range out { - out[i] = roster[f.next%len(roster)] - f.next++ + out := make([]string, 0, n) + if len(nest.Crows) == 0 { + for range n { + out = append(out, roster[f.next%len(roster)]) + f.next++ + } + return out + } + core := nest.Crows + for range min(n, len(core)) { + out = append(out, core[f.core[nest.ID]%len(core)]) + f.core[nest.ID]++ + } + outside := slices.DeleteFunc(slices.Clone(roster), func(c string) bool { return slices.Contains(core, c) }) + if len(outside) == 0 { + outside = core // the whole flock is the core: nobody left to borrow + } + for len(out) < n { + out = append(out, outside[f.borrow[nest.ID]%len(outside)]) + f.borrow[nest.ID]++ } return out } @@ -169,11 +194,11 @@ func (f *Flock) Triage(ctx context.Context, w Watcher, item string, budgets ...* return t, nil } -func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, budgets ...*Budget) (Outcome, error) { +func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, nest Nest, 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)) + crows := f.pick(nest, len(t.Subtasks)) reports, err := f.fanOut(ctx, w, crows, t.Subtasks, budgets...) if err != nil { return Outcome{}, err @@ -181,7 +206,7 @@ func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, b 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) + crows := f.pick(nest, n) tasks := make([]string, n) for i := range tasks { tasks[i] = item @@ -192,7 +217,7 @@ func (f *Flock) Execute(ctx context.Context, w Watcher, item string, t Triage, b } return f.reconcile(ctx, w, item, crows, tasks, reports, budgets...) default: - c := f.pick(1)[0] + c := f.pick(nest, 1)[0] ans, err := f.ask(ctx, w, c, crowSystem, "TASK: "+item, budgets...) return Outcome{Answer: ans, Crows: []string{c}}, err } diff --git a/main.go b/main.go index 7e7ac6c..fa7e285 100644 --- a/main.go +++ b/main.go @@ -36,7 +36,7 @@ func main() { func usage() { fmt.Fprintln(os.Stderr, `So Many Crows - crows run [-as name] hand the flock a todo list + crows run [-as name] [-nest id] 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`) @@ -46,6 +46,7 @@ func usage() { func cmdRun(args []string) { fs := flag.NewFlagSet("run", flag.ExitOnError) as := fs.String("as", defaultRequester(), "requester id") + nestID := fs.String("nest", defaultNestID, "nest whose core crows fly first") var pos []string for fs.Parse(args); fs.NArg() > 0; fs.Parse(args) { pos = append(pos, fs.Arg(0)) @@ -62,6 +63,12 @@ func cmdRun(args []string) { flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl")) must(err) defer flog.Close() + nests, err := LoadNests(filepath.Join(dataDir, "nests.json")) + must(err) + nest, found := nests.Get(*nestID) + if !found { + must(fmt.Errorf("there is no nest called %q", *nestID)) + } 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 { @@ -86,8 +93,8 @@ func cmdRun(args []string) { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() - fmt.Printf("Brain: %s · requester: %s · %d items\n", brain.Name(), *as, len(items)) - l := List{ID: "cli", Requester: *as, Source: todoPath, Items: items} + fmt.Printf("Brain: %s · requester: %s · nest: %s · %d items\n", brain.Name(), *as, nest.ID, len(items)) + l := List{ID: "cli", Requester: *as, Source: todoPath, Items: items, Nest: nest} results, listBudget := p.Run(ctx, l, &cliEmitter{items: items, seen: map[string]bool{}}) resultPath := strings.TrimSuffix(todoPath, filepath.Ext(todoPath)) + ".crows.md" diff --git a/nests.go b/nests.go new file mode 100644 index 0000000..58dd183 --- /dev/null +++ b/nests.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "unicode/utf8" +) + +// A nest is a room where a team of crows works together. Its core crows are +// picked first; when a job needs more, the Sentry borrows from the rest of the +// flock. The Sentry and Elder serve every nest and are never core members. +type Nest struct { + ID string `json:"nest_id"` + Name string `json:"name"` + Crows []string `json:"crows"` +} + +const ( + defaultNestID = "todo" + maxNests = 20 // includes the default nest + maxNestName = 40 +) + +// defaultNest has an empty core: its crows come from the whole flock. +var defaultNest = Nest{ID: defaultNestID, Name: "To do", Crows: []string{}} + +// NestError is a rejected nest. Clash means the nest_id is already taken (409); +// anything else is bad input or too many nests (400). +type NestError struct { + Clash bool + Msg string +} + +func (e *NestError) Error() string { return e.Msg } + +func invalid(format string, a ...any) error { + return &NestError{Msg: fmt.Sprintf(format, a...)} +} + +// NestStore holds the nests and saves the ones people create to disk. +type NestStore struct { + path string + mu sync.Mutex + made []Nest // created nests, in creation order; the default nest is not stored +} + +func LoadNests(path string) (*NestStore, error) { + s := &NestStore{path: path} + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + if err == nil { + if err := json.Unmarshal(data, &s.made); err != nil { + return nil, fmt.Errorf("nests file is unreadable: %w", err) + } + } + return s, nil +} + +// All returns the default nest first, then the others in creation order. +func (s *NestStore) All() []Nest { + s.mu.Lock() + defer s.mu.Unlock() + out := []Nest{copyNest(defaultNest)} + for _, n := range s.made { + out = append(out, copyNest(n)) + } + return out +} + +// Get finds a nest by ID. +func (s *NestStore) Get(id string) (Nest, bool) { + if id == defaultNestID { + return copyNest(defaultNest), true + } + s.mu.Lock() + defer s.mu.Unlock() + for _, n := range s.made { + if n.ID == id { + return copyNest(n), true + } + } + return Nest{}, false +} + +// Create checks the guardrails, adds the nest, and saves. A rejection is a +// *NestError; any other error means the save failed. +func (s *NestStore) Create(name string, crows []string) (Nest, error) { + name = strings.TrimSpace(name) + if n := utf8.RuneCountInString(name); n < 1 || n > maxNestName { + return Nest{}, invalid("A nest name is 1 to %d characters.", maxNestName) + } + id := slugify(name) + if id == "" { + return Nest{}, invalid("A nest name needs at least one letter or digit (a-z, 0-9).") + } + if len(crows) < 1 || len(crows) > len(roster) { + return Nest{}, invalid("A nest has 1 to %d crows.", len(roster)) + } + for i, c := range crows { + if !slices.Contains(roster, c) { + return Nest{}, invalid("%q is not in the flock. Choose from %s.", c, strings.Join(roster, ", ")) + } + if slices.Contains(crows[:i], c) { + return Nest{}, invalid("%s is listed twice.", c) + } + } + + s.mu.Lock() + defer s.mu.Unlock() + if id == defaultNestID || slices.ContainsFunc(s.made, func(n Nest) bool { return n.ID == id }) { + return Nest{}, &NestError{Clash: true, Msg: fmt.Sprintf("A nest called %q already exists.", id)} + } + if 1+len(s.made) >= maxNests { + return Nest{}, invalid("The flock keeps at most %d nests.", maxNests) + } + n := Nest{ID: id, Name: name, Crows: slices.Clone(crows)} + s.made = append(s.made, n) + if err := s.save(); err != nil { + s.made = s.made[:len(s.made)-1] + return Nest{}, err + } + return copyNest(n), nil +} + +// save writes atomically, like the ledger. The caller holds s.mu. +func (s *NestStore) save() error { + data, err := json.MarshalIndent(s.made, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func copyNest(n Nest) Nest { + n.Crows = append([]string{}, n.Crows...) + return n +} + +// slugify lowercases and keeps a-z and 0-9; every other run of characters +// becomes one dash, and dashes at the ends are dropped. +func slugify(name string) string { + var sb strings.Builder + dash := false + for _, r := range strings.ToLower(name) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + if dash && sb.Len() > 0 { + sb.WriteByte('-') + } + sb.WriteRune(r) + dash = false + } else { + dash = true + } + } + return sb.String() +} diff --git a/nests_test.go b/nests_test.go new file mode 100644 index 0000000..0b10f1f --- /dev/null +++ b/nests_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +func TestPickCoreFirstThenBorrow(t *testing.T) { + f := NewFlock(MockBrain{}, DefaultGuardrails(), nil) + nest := Nest{ID: "launch", Crows: []string{"Pepper", "Inky"}} + + // Within the core, picks rotate. + if got := f.pick(nest, 1); !slices.Equal(got, []string{"Pepper"}) { + t.Fatalf("first solo pick: %v", got) + } + if got := f.pick(nest, 1); !slices.Equal(got, []string{"Inky"}) { + t.Fatalf("second solo pick should rotate within the core: %v", got) + } + + // A murder of 3 needs one more than the core: the whole core flies, plus one borrowed. + got := f.pick(nest, 3) + if !slices.Contains(got, "Pepper") || !slices.Contains(got, "Inky") { + t.Fatalf("core crows should fly first: %v", got) + } + if got[2] != "Hugin" { + t.Fatalf("first borrowed crow should be the first roster crow outside the core: %v", got) + } + // Borrowing rotates too, and never picks a core crow. + if got := f.pick(nest, 3); got[2] != "Munin" { + t.Fatalf("borrowing should rotate: %v", got) + } + + // The todo nest keeps the old whole-flock rotation, unaffected by other nests. + if got := f.pick(defaultNest, 3); !slices.Equal(got, roster[:3]) { + t.Fatalf("todo nest should rotate the whole roster: %v", got) + } +} + +func TestPickWholeFlockCore(t *testing.T) { + f := NewFlock(MockBrain{}, DefaultGuardrails(), nil) + nest := Nest{ID: "all", Crows: slices.Clone(roster)} + got := f.pick(nest, 3) + if !slices.Equal(got, roster[:3]) { + t.Fatalf("got %v", got) + } +} + +func TestNestValidation(t *testing.T) { + s, _ := LoadNests(filepath.Join(t.TempDir(), "nests.json")) + bad := []struct { + name string + crows []string + }{ + {" ", []string{"Pepper"}}, + {strings.Repeat("a", 41), []string{"Pepper"}}, + {"!!!", []string{"Pepper"}}, + {"No crows", nil}, + {"Too many", append(slices.Clone(roster), "Hugin")}, + {"Stranger", []string{"Pepper", "Tweety"}}, + {"Sentry inside", []string{"Sentry"}}, + {"Twice", []string{"Pepper", "Pepper"}}, + } + for _, b := range bad { + _, err := s.Create(b.name, b.crows) + var ne *NestError + if err == nil || !errors.As(err, &ne) || ne.Clash { + t.Errorf("Create(%q, %v): want invalid, got %v", b.name, b.crows, err) + } + } + + n, err := s.Create(" Launch -- Copy! ", []string{"Pepper", "Inky"}) + if err != nil || n.ID != "launch-copy" || n.Name != "Launch -- Copy!" { + t.Fatalf("got %+v, %v", n, err) + } + if _, err := s.Create(strings.Repeat("a", 40), []string{"Jet"}); err != nil { + t.Fatalf("40 characters should be fine: %v", err) + } + for _, name := range []string{"launch copy", "TODO"} { + _, err := s.Create(name, []string{"Jet"}) + var ne *NestError + if !errors.As(err, &ne) || !ne.Clash { + t.Errorf("Create(%q): want clash, got %v", name, err) + } + } +} + +func TestNestsPersistAndCap(t *testing.T) { + path := filepath.Join(t.TempDir(), "nests.json") + s, _ := LoadNests(path) + for i := 1; i < maxNests; i++ { // todo is nest number one + if _, err := s.Create(fmt.Sprintf("Nest %d", i), []string{"Soot"}); err != nil { + t.Fatalf("nest %d: %v", i, err) + } + } + if _, err := s.Create("One too many", []string{"Soot"}); err == nil { + t.Fatal("want the nest cap to refuse") + } + + reloaded, err := LoadNests(path) + if err != nil { + t.Fatal(err) + } + all := reloaded.All() + if len(all) != maxNests || all[0].ID != "todo" || all[1].ID != "nest-1" || all[maxNests-1].ID != "nest-19" { + t.Fatalf("reload lost order or nests: %d nests, first %v", len(all), all[:2]) + } + if n, ok := reloaded.Get("nest-7"); !ok || !slices.Equal(n.Crows, []string{"Soot"}) { + t.Fatalf("Get after reload: %+v %v", n, ok) + } +} + +func TestServerNests(t *testing.T) { + ts, _ := newTestServer(t, DefaultGuardrails(), time.Minute) + + var got struct { + Roster []string `json:"roster"` + Nests []Nest `json:"nests"` + } + getJSON(t, ts.URL+"/api/nests", &got) + if !slices.Equal(got.Roster, roster) || len(got.Nests) != 1 || got.Nests[0].ID != "todo" || got.Nests[0].Crows == nil { + t.Fatalf("GET /api/nests: %+v", got) + } + + code, out := post(t, ts.URL+"/api/nests", `{"name":"Launch copy","crows":["Pepper","Inky"]}`) + if code != http.StatusCreated || out["nest_id"] != "launch-copy" { + t.Fatalf("create: %d %v", code, out) + } + if code, out := post(t, ts.URL+"/api/nests", `{"name":"launch COPY","crows":["Jet"]}`); code != http.StatusConflict || out["message"] == nil { + t.Fatalf("clash: want 409 with message, got %d %v", code, out) + } + if code, out := post(t, ts.URL+"/api/nests", `{"name":"Bad","crows":["Elder"]}`); code != http.StatusBadRequest || out["message"] == nil { + t.Fatalf("bad crow: want 400 with message, got %d %v", code, out) + } + if code, _ := post(t, ts.URL+"/api/nests", `{"name":"x","crows":`+strings.Repeat(" ", maxBody)+`[]}`); code != http.StatusRequestEntityTooLarge { + t.Fatalf("want 413, got %d", code) + } + getJSON(t, ts.URL+"/api/nests", &got) + if len(got.Nests) != 2 || got.Nests[1].ID != "launch-copy" { + t.Fatalf("after create: %+v", got.Nests) + } + + if code, out := post(t, ts.URL+"/api/lists", `{"text":"Write a poem","requester":"t","nest_id":"nope"}`); code != http.StatusNotFound || out["message"] == nil { + t.Fatalf("unknown nest: want 404 with message, got %d %v", code, out) + } +} + +func TestServerBorrowedEvents(t *testing.T) { + ts, _ := newTestServer(t, DefaultGuardrails(), time.Minute) + post(t, ts.URL+"/api/nests", `{"name":"Launch copy","crows":["Pepper","Inky"]}`) + yes := true + + // "should" makes the mock Sentry call a murder of 3: the 2-crow core plus one borrowed. + _, out := post(t, ts.URL+"/api/lists", `{"text":"Should we launch on a Tuesday","requester":"t","nest_id":"launch-copy"}`) + events, _ := stream(t, ts, out["list_id"].(string), &yes) + if events[0].Data["nest_id"] != "launch-copy" { + t.Fatalf("list.started nest_id: %v", events[0].Data) + } + borrowed := flights(t, events) + if !slices.Equal(borrowed["Pepper"], []bool{false}) || !slices.Equal(borrowed["Inky"], []bool{false}) || + !slices.Equal(borrowed["Hugin"], []bool{true}) || !slices.Equal(borrowed["Sentry"], []bool{false}) || + !slices.Equal(borrowed["Elder"], []bool{false}) { + t.Fatalf("borrowed flags: %v", borrowed) + } + + // The todo nest (the default) never borrows. + _, out = post(t, ts.URL+"/api/lists", `{"text":"Should we launch on a Tuesday","requester":"t"}`) + events, _ = stream(t, ts, out["list_id"].(string), &yes) + if events[0].Data["nest_id"] != "todo" { + t.Fatalf("default nest_id: %v", events[0].Data) + } + for crow, flags := range flights(t, events) { + if slices.Contains(flags, true) { + t.Fatalf("%s borrowed in the todo nest", crow) + } + } +} + +// flights maps each crow to the borrowed flag of each of its crow.flying events. +func flights(t *testing.T, events []sseEvent) map[string][]bool { + m := map[string][]bool{} + for _, e := range events { + if e.Name != "crow.flying" { + continue + } + b, ok := e.Data["borrowed"].(bool) + if !ok { + t.Fatalf("crow.flying without a borrowed bool: %v", e.Data) + } + c := e.Data["crow"].(string) + m[c] = append(m[c], b) + } + return m +} + +func getJSON(t *testing.T, url string, v any) { + resp, err := http.Get(url) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET %s: %d", url, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + t.Fatal(err) + } +} diff --git a/pipeline.go b/pipeline.go index 9946364..c042d1d 100644 --- a/pipeline.go +++ b/pipeline.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "slices" "strings" "sync" ) @@ -30,6 +31,7 @@ type Approver interface { type List struct { ID, Requester, Source string Items []string + Nest Nest // zero value means the todo nest Parallel bool // fly items at once (web); the command line goes one by one } @@ -51,13 +53,16 @@ func itemID(listID string, i int) string { return fmt.Sprintf("%s-%d", listID, i // 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) { + if l.Nest.ID == "" { + l.Nest = defaultNest + } 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}) + em.Emit("list.started", map[string]any{"items": items, "requester": l.Requester, "nest_id": l.Nest.ID}) results := make([]itemResult, len(l.Items)) if l.Parallel { @@ -89,7 +94,7 @@ func (p *Pipeline) item(ctx context.Context, l List, i int, listBudget *Budget, em.Emit("item.skipped", map[string]any{"item_id": id, "reason": reason}) return r } - w := &itemWatcher{em: em, itemID: id} + w := &itemWatcher{em: em, itemID: id, core: l.Nest.Crows} itemBudget := &Budget{name: "item", limit: p.G.TokenBudgetPerItem} t, err := p.Flock.Triage(ctx, w, item, itemBudget, listBudget) @@ -115,7 +120,7 @@ func (p *Pipeline) item(ctx context.Context, l List, i int, listBudget *Budget, em.Emit("verdict", map[string]any{"item_id": id, "target": "triage", "verdict": "auto"}) } - out, err := p.Flock.Execute(ctx, w, item, t, itemBudget, listBudget) + out, err := p.Flock.Execute(ctx, w, item, t, l.Nest, itemBudget, listBudget) if err != nil { return skip("flight failed: " + err.Error()) } @@ -173,10 +178,14 @@ func (p *Pipeline) save() { type itemWatcher struct { em Emitter itemID string + core []string // the nest's core crows; empty for todo } +// Flying marks a crow borrowed when the nest has a core and the crow is a +// roster crow outside it. The Sentry and Elder are never borrowed. func (w *itemWatcher) Flying(crow string) { - w.em.Emit("crow.flying", map[string]any{"item_id": w.itemID, "crow": crow}) + borrowed := len(w.core) > 0 && slices.Contains(roster, crow) && !slices.Contains(w.core, crow) + w.em.Emit("crow.flying", map[string]any{"item_id": w.itemID, "crow": crow, "borrowed": borrowed}) } func (w *itemWatcher) Landed(crow string, tokens int, err error) { diff --git a/server.go b/server.go index 7870d43..a0fa84b 100644 --- a/server.go +++ b/server.go @@ -31,12 +31,13 @@ type Server struct { p *Pipeline approver *WebApprover hub *Hub + nests *NestStore 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 NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *NestStore) *Server { + return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat} } func (s *Server) Handler() http.Handler { @@ -45,6 +46,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/events", s.events) mux.HandleFunc("POST /api/verdicts", s.postVerdict) mux.HandleFunc("GET /api/trust", s.trust) + mux.HandleFunc("GET /api/nests", s.getNests) + mux.HandleFunc("POST /api/nests", s.postNest) if st, err := os.Stat(webDir); err == nil && st.IsDir() { mux.Handle("GET /", http.FileServer(http.Dir(webDir))) } else { @@ -67,13 +70,15 @@ func cmdServe(args []string) { flog, err := OpenFlightLog(filepath.Join(dataDir, "flight.jsonl")) must(err) defer flog.Close() + nests, err := LoadNests(filepath.Join(dataDir, "nests.json")) + must(err) 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) + s := NewServer(ctx, p, approver, nests) srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} go func() { @@ -120,10 +125,19 @@ func (s *Server) postList(w http.ResponseWriter, r *http.Request) { var body struct { Text string `json:"text"` Requester string `json:"requester"` + NestID string `json:"nest_id"` } if !decode(w, r, &body) { return } + if body.NestID == "" { + body.NestID = defaultNestID + } + nest, found := s.nests.Get(body.NestID) + if !found { + httpError(w, http.StatusNotFound, fmt.Sprintf("There is no nest called %q.", body.NestID)) + return + } if body.Requester == "" { body.Requester, _, _ = net.SplitHostPort(r.RemoteAddr) } @@ -157,7 +171,7 @@ func (s *Server) postList(w http.ResponseWriter, r *http.Request) { id := newListID() s.hub.Open(id, body.Requester) - l := List{ID: id, Requester: body.Requester, Source: "web", Items: items, Parallel: true} + l := List{ID: id, Requester: body.Requester, Source: "web", Items: items, Nest: nest, Parallel: true} go s.p.Run(s.ctx, l, s.hub.Emitter(id)) writeJSON(w, http.StatusAccepted, map[string]string{"list_id": id}) } @@ -253,3 +267,29 @@ func (s *Server) trust(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write(data) } + +func (s *Server) getNests(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"roster": roster, "nests": s.nests.All()}) +} + +func (s *Server) postNest(w http.ResponseWriter, r *http.Request) { + var body struct { + Name string `json:"name"` + Crows []string `json:"crows"` + } + if !decode(w, r, &body) { + return + } + n, err := s.nests.Create(body.Name, body.Crows) + var bad *NestError + switch { + case errors.As(err, &bad) && bad.Clash: + httpError(w, http.StatusConflict, bad.Msg) + case errors.As(err, &bad): + httpError(w, http.StatusBadRequest, bad.Msg) + case err != nil: + httpError(w, http.StatusInternalServerError, "could not save the nest: "+err.Error()) + default: + writeJSON(w, http.StatusCreated, n) + } +} diff --git a/server_test.go b/server_test.go index d7d9962..a1efb95 100644 --- a/server_test.go +++ b/server_test.go @@ -93,10 +93,14 @@ func newTestServer(t *testing.T, g Guardrails, timeout time.Duration) (*httptest t.Fatal(err) } t.Cleanup(func() { flog.Close() }) + nests, err := LoadNests(filepath.Join(dir, "nests.json")) + if err != nil { + t.Fatal(err) + } 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 := NewServer(ctx, p, approver, nests) s.beat = 20 * time.Millisecond ts := httptest.NewServer(s.Handler()) t.Cleanup(func() { cancel(); ts.Close() }) diff --git a/start.command b/start.command new file mode 100755 index 0000000..9cf8aeb --- /dev/null +++ b/start.command @@ -0,0 +1,22 @@ +#!/bin/bash +# So Many Crows: local test server. +# Double-click to start. Close this window (or Ctrl-C) to stop. +# +# Controlled by default: +# - offline mock crows (no API key used, no cost, no network calls) +# - listens on this Mac only (127.0.0.1:8080) +# - all state lives in this folder's .crows/ (delete it to start fresh) +# From Terminal, `./start.command --real` uses real Claude crows (needs ANTHROPIC_API_KEY). +cd "$(dirname "$0")" || exit 1 +xattr -d com.apple.quarantine ./crows 2>/dev/null +chmod +x ./crows +if [ "$1" != "--real" ]; then + unset ANTHROPIC_API_KEY + echo "Controlled mode: offline mock crows." +fi +if lsof -iTCP:8080 -sTCP:LISTEN >/dev/null 2>&1; then + echo "Something is already using port 8080. Stop it first." + exit 1 +fi +(sleep 1; open "http://127.0.0.1:8080") & +exec ./crows serve -addr 127.0.0.1:8080 diff --git a/ui/index.html b/ui/index.html index c76bed0..3423471 100644 --- a/ui/index.html +++ b/ui/index.html @@ -17,6 +17,9 @@