Nests: team rooms with core crows and borrowing
- Server: nests API, persistence, core-first picking, borrowed flag (built by the backend agent against EVENTS.md; tests pass with -race) - UI: nests in the sidebar, one feed per nest, header shows the core team, New nest dialog, borrowed crows labeled, per-nest waiting count - start.command: one-click local server in controlled mode (mock crows, localhost only, state in .crows/) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQnSw4RRWp8uqpj3nsnnXF
This commit is contained in:
14 files changed
+782
-62
No files matched your search
@@ -5,3 +5,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
/somanycrows
|
/somanycrows
|
||||||
/web/
|
/web/
|
||||||
|
/dist/
|
||||||
@@ -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.
|
- 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.
|
- 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
|
## 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).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -82,22 +83,46 @@ type Flock struct {
|
|||||||
log *FlightLog
|
log *FlightLog
|
||||||
sem chan struct{}
|
sem chan struct{}
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
next int
|
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 {
|
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.
|
// pick chooses n crows for a job in a nest. A nest with no core (todo) rotates
|
||||||
func (f *Flock) pick(n int) []string {
|
// 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()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
out := make([]string, n)
|
out := make([]string, 0, n)
|
||||||
for i := range out {
|
if len(nest.Crows) == 0 {
|
||||||
out[i] = roster[f.next%len(roster)]
|
for range n {
|
||||||
f.next++
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -169,11 +194,11 @@ func (f *Flock) Triage(ctx context.Context, w Watcher, item string, budgets ...*
|
|||||||
return t, nil
|
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 {
|
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(nest, len(t.Subtasks))
|
||||||
reports, err := f.fanOut(ctx, w, 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
|
||||||
@@ -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...)
|
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(nest, n)
|
||||||
tasks := make([]string, n)
|
tasks := make([]string, n)
|
||||||
for i := range tasks {
|
for i := range tasks {
|
||||||
tasks[i] = item
|
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...)
|
return f.reconcile(ctx, w, item, crows, tasks, reports, budgets...)
|
||||||
default:
|
default:
|
||||||
c := f.pick(1)[0]
|
c := f.pick(nest, 1)[0]
|
||||||
ans, err := f.ask(ctx, w, 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func main() {
|
|||||||
func usage() {
|
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] [-nest id] hand the flock a todo list
|
||||||
crows serve [-addr 127.0.0.1:8080] run the web server (API + SSE)
|
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`)
|
||||||
@@ -46,6 +46,7 @@ func usage() {
|
|||||||
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")
|
||||||
|
nestID := fs.String("nest", defaultNestID, "nest whose core crows fly first")
|
||||||
var pos []string
|
var pos []string
|
||||||
for fs.Parse(args); fs.NArg() > 0; fs.Parse(args) {
|
for fs.Parse(args); fs.NArg() > 0; fs.Parse(args) {
|
||||||
pos = append(pos, fs.Arg(0))
|
pos = append(pos, fs.Arg(0))
|
||||||
@@ -62,6 +63,12 @@ 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()
|
||||||
|
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)}}
|
p := &Pipeline{Ledger: ledger, Log: flog, G: g, Approver: &StdinApprover{in: bufio.NewReader(os.Stdin)}}
|
||||||
|
|
||||||
if ok, msg, _ := ledger.Admit(*as, time.Now(), g); !ok {
|
if ok, msg, _ := ledger.Admit(*as, time.Now(), g); !ok {
|
||||||
@@ -86,8 +93,8 @@ func cmdRun(args []string) {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
fmt.Printf("Brain: %s · requester: %s · %d items\n", brain.Name(), *as, len(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}
|
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{}})
|
results, listBudget := p.Run(ctx, l, &cliEmitter{items: items, seen: map[string]bool{}})
|
||||||
|
|
||||||
resultPath := strings.TrimSuffix(todoPath, filepath.Ext(todoPath)) + ".crows.md"
|
resultPath := strings.TrimSuffix(todoPath, filepath.Ext(todoPath)) + ".crows.md"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+214
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-4
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,7 @@ type Approver interface {
|
|||||||
type List struct {
|
type List struct {
|
||||||
ID, Requester, Source string
|
ID, Requester, Source string
|
||||||
Items []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
|
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.
|
// 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) {
|
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}
|
listBudget := &Budget{name: "list", limit: p.G.TokenBudgetPerList}
|
||||||
items := make([]map[string]any, len(l.Items))
|
items := make([]map[string]any, len(l.Items))
|
||||||
for i, text := range l.Items {
|
for i, text := range l.Items {
|
||||||
items[i] = map[string]any{"item_id": itemID(l.ID, i), "text": text}
|
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))})
|
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))
|
results := make([]itemResult, len(l.Items))
|
||||||
if l.Parallel {
|
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})
|
em.Emit("item.skipped", map[string]any{"item_id": id, "reason": reason})
|
||||||
return r
|
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}
|
itemBudget := &Budget{name: "item", limit: p.G.TokenBudgetPerItem}
|
||||||
|
|
||||||
t, err := p.Flock.Triage(ctx, w, item, itemBudget, listBudget)
|
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"})
|
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 {
|
if err != nil {
|
||||||
return skip("flight failed: " + err.Error())
|
return skip("flight failed: " + err.Error())
|
||||||
}
|
}
|
||||||
@@ -173,10 +178,14 @@ func (p *Pipeline) save() {
|
|||||||
type itemWatcher struct {
|
type itemWatcher struct {
|
||||||
em Emitter
|
em Emitter
|
||||||
itemID string
|
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) {
|
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) {
|
func (w *itemWatcher) Landed(crow string, tokens int, err error) {
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ type Server struct {
|
|||||||
p *Pipeline
|
p *Pipeline
|
||||||
approver *WebApprover
|
approver *WebApprover
|
||||||
hub *Hub
|
hub *Hub
|
||||||
|
nests *NestStore
|
||||||
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
ctx context.Context // cancelled on shutdown; lists and streams end with it
|
||||||
beat time.Duration
|
beat time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover) *Server {
|
func NewServer(ctx context.Context, p *Pipeline, approver *WebApprover, nests *NestStore) *Server {
|
||||||
return &Server{p: p, approver: approver, hub: NewHub(), ctx: ctx, beat: heartbeat}
|
return &Server{p: p, approver: approver, hub: NewHub(), nests: nests, ctx: ctx, beat: heartbeat}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Handler() http.Handler {
|
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("GET /api/events", s.events)
|
||||||
mux.HandleFunc("POST /api/verdicts", s.postVerdict)
|
mux.HandleFunc("POST /api/verdicts", s.postVerdict)
|
||||||
mux.HandleFunc("GET /api/trust", s.trust)
|
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() {
|
if st, err := os.Stat(webDir); err == nil && st.IsDir() {
|
||||||
mux.Handle("GET /", http.FileServer(http.Dir(webDir)))
|
mux.Handle("GET /", http.FileServer(http.Dir(webDir)))
|
||||||
} else {
|
} else {
|
||||||
@@ -67,13 +70,15 @@ func cmdServe(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()
|
||||||
|
nests, err := LoadNests(filepath.Join(dataDir, "nests.json"))
|
||||||
|
must(err)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
defer stop()
|
defer stop()
|
||||||
brain := NewBrain()
|
brain := NewBrain()
|
||||||
approver := NewWebApprover(*timeout)
|
approver := NewWebApprover(*timeout)
|
||||||
p := &Pipeline{Flock: NewFlock(brain, g, flog), Ledger: ledger, Log: flog, G: g, Approver: approver}
|
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}
|
srv := &http.Server{Addr: *addr, Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -120,10 +125,19 @@ func (s *Server) postList(w http.ResponseWriter, r *http.Request) {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
Requester string `json:"requester"`
|
Requester string `json:"requester"`
|
||||||
|
NestID string `json:"nest_id"`
|
||||||
}
|
}
|
||||||
if !decode(w, r, &body) {
|
if !decode(w, r, &body) {
|
||||||
return
|
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 == "" {
|
if body.Requester == "" {
|
||||||
body.Requester, _, _ = net.SplitHostPort(r.RemoteAddr)
|
body.Requester, _, _ = net.SplitHostPort(r.RemoteAddr)
|
||||||
}
|
}
|
||||||
@@ -157,7 +171,7 @@ func (s *Server) postList(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
id := newListID()
|
id := newListID()
|
||||||
s.hub.Open(id, body.Requester)
|
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))
|
go s.p.Run(s.ctx, l, s.hub.Emitter(id))
|
||||||
writeJSON(w, http.StatusAccepted, map[string]string{"list_id": 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.Header().Set("Content-Type", "application/json")
|
||||||
w.Write(data)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -93,10 +93,14 @@ func newTestServer(t *testing.T, g Guardrails, timeout time.Duration) (*httptest
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { flog.Close() })
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
approver := NewWebApprover(timeout)
|
approver := NewWebApprover(timeout)
|
||||||
p := &Pipeline{Flock: NewFlock(MockBrain{}, g, flog), Ledger: ledger, Log: flog, G: g, Approver: approver}
|
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
|
s.beat = 20 * time.Millisecond
|
||||||
ts := httptest.NewServer(s.Handler())
|
ts := httptest.NewServer(s.Handler())
|
||||||
t.Cleanup(func() { cancel(); ts.Close() })
|
t.Cleanup(func() { cancel(); ts.Close() })
|
||||||
|
|||||||
Executable
+22
@@ -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
|
||||||
+22
-3
@@ -17,6 +17,9 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<aside class="side" aria-label="The flock">
|
<aside class="side" aria-label="The flock">
|
||||||
<div class="brand">So Many Crows</div>
|
<div class="brand">So Many Crows</div>
|
||||||
|
<h2>Nests</h2>
|
||||||
|
<nav class="nests" id="nests" aria-label="Nests"></nav>
|
||||||
|
<button class="new-nest" id="new-nest" type="button">+ New nest</button>
|
||||||
<h2>The flock</h2>
|
<h2>The flock</h2>
|
||||||
<div class="roster" id="roster"></div>
|
<div class="roster" id="roster"></div>
|
||||||
<label class="who">You are <input id="requester" maxlength="40" autocomplete="off" /></label>
|
<label class="who">You are <input id="requester" maxlength="40" autocomplete="off" /></label>
|
||||||
@@ -24,11 +27,11 @@
|
|||||||
|
|
||||||
<section class="room">
|
<section class="room">
|
||||||
<header class="room-head">
|
<header class="room-head">
|
||||||
<h1># todo</h1>
|
<h1 id="room-title"># todo</h1>
|
||||||
<p class="room-sub">Hand the flock a list. The Sentry sorts it, crows fly, you judge.</p>
|
<p class="room-sub" id="room-sub">The whole flock roosts here. Hand it a list: the Sentry sorts it, crows fly, you judge.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ol class="feed" id="feed" aria-live="polite"></ol>
|
<div class="feeds" id="feeds"></div>
|
||||||
<p class="typing" id="typing" aria-live="polite"></p>
|
<p class="typing" id="typing" aria-live="polite"></p>
|
||||||
|
|
||||||
<form class="composer" id="composer">
|
<form class="composer" id="composer">
|
||||||
@@ -39,6 +42,22 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<dialog class="nest-dialog inked" id="nest-dialog">
|
||||||
|
<form method="dialog" id="nest-form">
|
||||||
|
<h2>New nest</h2>
|
||||||
|
<p class="dialog-sub">A room where a team of crows works together. The Sentry can borrow other crows when a job needs more hands.</p>
|
||||||
|
<label class="dialog-field">Name <input id="nest-name" maxlength="40" required autocomplete="off" placeholder="Launch copy" /></label>
|
||||||
|
<fieldset class="pick">
|
||||||
|
<legend>Core crows</legend>
|
||||||
|
<div class="pick-grid" id="nest-crows"></div>
|
||||||
|
</fieldset>
|
||||||
|
<p class="err" id="nest-err"></p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn btn-no inked" value="cancel" formnovalidate>Cancel</button>
|
||||||
|
<button class="btn btn-go inked" id="nest-create" value="create">Build the nest</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+34
-2
@@ -5,11 +5,11 @@ export type PostListResult =
|
|||||||
| { ok: false; pranked: true; message: string; cooldownS: number }
|
| { ok: false; pranked: true; message: string; cooldownS: number }
|
||||||
| { ok: false; pranked: false; message: string };
|
| { ok: false; pranked: false; message: string };
|
||||||
|
|
||||||
export async function postList(text: string, requester: string): Promise<PostListResult> {
|
export async function postList(text: string, requester: string, nestId: string): Promise<PostListResult> {
|
||||||
const res = await fetch("/api/lists", {
|
const res = await fetch("/api/lists", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ text, requester }),
|
body: JSON.stringify({ text, requester, nest_id: nestId }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (res.ok) return { ok: true, listId: String(body.list_id) };
|
if (res.ok) return { ok: true, listId: String(body.list_id) };
|
||||||
@@ -43,3 +43,35 @@ export async function getTrust(): Promise<TrustLevels> {
|
|||||||
export function listEvents(listId: string): EventSource {
|
export function listEvents(listId: string): EventSource {
|
||||||
return new EventSource(`/api/events?list_id=${encodeURIComponent(listId)}`);
|
return new EventSource(`/api/events?list_id=${encodeURIComponent(listId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- nests ----------
|
||||||
|
export type Nest = { nestId: string; name: string; crows: string[] };
|
||||||
|
export type Nests = { roster: string[]; nests: Nest[] };
|
||||||
|
|
||||||
|
const DEFAULT_ROSTER = ["Hugin", "Munin", "Corvina", "Soot", "Pepper", "Inky", "Edgar", "Jet"];
|
||||||
|
|
||||||
|
export async function getNests(): Promise<Nests> {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/nests");
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
const body = await res.json();
|
||||||
|
return {
|
||||||
|
roster: (body.roster ?? DEFAULT_ROSTER).map(String),
|
||||||
|
nests: (body.nests ?? []).map((n: any) => ({ nestId: String(n.nest_id), name: String(n.name), crows: (n.crows ?? []).map(String) })),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// A server without nests still has the default room.
|
||||||
|
return { roster: DEFAULT_ROSTER, nests: [{ nestId: "todo", name: "todo", crows: [] }] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNest(name: string, crows: string[]): Promise<Nest> {
|
||||||
|
const res = await fetch("/api/nests", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, crows }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(String(body.message ?? `Couldn't build that nest (${res.status}).`));
|
||||||
|
return { nestId: String(body.nest_id), name: String(body.name), crows: (body.crows ?? []).map(String) };
|
||||||
|
}
|
||||||
+151
-32
@@ -1,11 +1,12 @@
|
|||||||
import "./style.css";
|
import "./style.css";
|
||||||
import { getTrust, listEvents, postList, postVerdict, type TrustLevels } from "./api";
|
import { createNest, getNests, getTrust, listEvents, postList, postVerdict, type Nest, type TrustLevels } from "./api";
|
||||||
import { crowAvatar, svgCrow, type CrowView, type Role } from "./crow";
|
import { crowAvatar, svgCrow, type CrowView, type Role } from "./crow";
|
||||||
|
|
||||||
// ---------- page ----------
|
// ---------- page ----------
|
||||||
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||||
const roster = $<HTMLDivElement>("roster");
|
const roster = $<HTMLDivElement>("roster");
|
||||||
const feed = $<HTMLOListElement>("feed");
|
const feedsBox = $<HTMLDivElement>("feeds");
|
||||||
|
const nestNav = $<HTMLElement>("nests");
|
||||||
const typing = $<HTMLParagraphElement>("typing");
|
const typing = $<HTMLParagraphElement>("typing");
|
||||||
const form = $<HTMLFormElement>("composer");
|
const form = $<HTMLFormElement>("composer");
|
||||||
const listInput = $<HTMLTextAreaElement>("list");
|
const listInput = $<HTMLTextAreaElement>("list");
|
||||||
@@ -110,16 +111,70 @@ function fmtTime(ts?: string) {
|
|||||||
return isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
return isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function append(li: HTMLLIElement) {
|
// ---------- nests: one room (and one feed) per team ----------
|
||||||
|
type NestRoom = Nest & { feed: HTMLOListElement; link: HTMLButtonElement; count: HTMLSpanElement };
|
||||||
|
const nests = new Map<string, NestRoom>();
|
||||||
|
let flockNames: string[] = [];
|
||||||
|
let current = "todo";
|
||||||
|
const roomTitle = $<HTMLHeadingElement>("room-title");
|
||||||
|
const roomSub = $<HTMLParagraphElement>("room-sub");
|
||||||
|
const roomTab = $<HTMLButtonElement>("tab-room");
|
||||||
|
|
||||||
|
function addNest(n: Nest): NestRoom {
|
||||||
|
const feed = el("ol", "feed");
|
||||||
|
feed.setAttribute("aria-live", "polite");
|
||||||
|
feed.hidden = true;
|
||||||
|
feedsBox.append(feed);
|
||||||
|
const link = el("button", "nest-link");
|
||||||
|
link.type = "button";
|
||||||
|
const count = el("span", "nest-count");
|
||||||
|
link.append(el("span", "nest-name", `# ${n.name}`), count);
|
||||||
|
link.addEventListener("click", () => { selectNest(n.nestId); showView("room"); });
|
||||||
|
nestNav.append(link);
|
||||||
|
const room = { ...n, feed, link, count };
|
||||||
|
nests.set(n.nestId, room);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nest(id: string): NestRoom {
|
||||||
|
return nests.get(id) ?? nests.get("todo")!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNest(id: string) {
|
||||||
|
current = nest(id).nestId;
|
||||||
|
for (const r of nests.values()) {
|
||||||
|
r.feed.hidden = r.nestId !== current;
|
||||||
|
r.link.setAttribute("aria-current", String(r.nestId === current));
|
||||||
|
}
|
||||||
|
const r = nest(current);
|
||||||
|
roomTitle.textContent = `# ${r.name}`;
|
||||||
|
roomSub.textContent = r.crows.length
|
||||||
|
? `Core crows: ${r.crows.join(", ")}. The Sentry borrows others when a job needs more hands.`
|
||||||
|
: "The whole flock roosts here. Hand it a list: the Sentry sorts it, crows fly, you judge.";
|
||||||
|
roomTab.textContent = `# ${r.name}`;
|
||||||
|
r.feed.scrollTop = r.feed.scrollHeight;
|
||||||
|
renderTyping();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red count on each nest: questions waiting on you in that room.
|
||||||
|
function renderNestCounts() {
|
||||||
|
for (const r of nests.values()) {
|
||||||
|
const n = r.feed.querySelectorAll(".msg.asking").length;
|
||||||
|
r.count.textContent = n ? String(n) : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function append(li: HTMLLIElement, nestId: string) {
|
||||||
|
const feed = nest(nestId).feed;
|
||||||
const stick = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
|
const stick = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120;
|
||||||
feed.append(li);
|
feed.append(li);
|
||||||
if (stick) feed.scrollTop = feed.scrollHeight;
|
if (stick) feed.scrollTop = feed.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function post(author: string, parts: Node[], ts?: string): Msg {
|
function post(author: string, parts: Node[], ts: string | undefined, nestId: string): Msg {
|
||||||
const li = el("li", "msg");
|
const li = el("li", "msg");
|
||||||
li.dataset.author = author;
|
li.dataset.author = author;
|
||||||
const last = feed.lastElementChild as HTMLElement | null;
|
const last = nest(nestId).feed.lastElementChild as HTMLElement | null;
|
||||||
if (last?.dataset.author === author) li.classList.add("cont");
|
if (last?.dataset.author === author) li.classList.add("cont");
|
||||||
let avatar: Element;
|
let avatar: Element;
|
||||||
if (author === "you") {
|
if (author === "you") {
|
||||||
@@ -141,14 +196,14 @@ function post(author: string, parts: Node[], ts?: string): Msg {
|
|||||||
const main = el("div", "msg-main");
|
const main = el("div", "msg-main");
|
||||||
main.append(head, body, actions, reactions);
|
main.append(head, body, actions, reactions);
|
||||||
li.append(avatar, main);
|
li.append(avatar, main);
|
||||||
append(li);
|
append(li, nestId);
|
||||||
return { li, body, actions, reactions };
|
return { li, body, actions, reactions };
|
||||||
}
|
}
|
||||||
|
|
||||||
function note(text: string, kind = "") {
|
function note(text: string, nestId: string, kind = "") {
|
||||||
const li = el("li", `msg system ${kind}`.trim());
|
const li = el("li", `msg system ${kind}`.trim());
|
||||||
li.append(el("p", "", text));
|
li.append(el("p", "", text));
|
||||||
append(li);
|
append(li, nestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function re(text: string) {
|
function re(text: string) {
|
||||||
@@ -160,7 +215,7 @@ function react(m: Msg, text: string, kind: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- per-item state ----------
|
// ---------- per-item state ----------
|
||||||
type Item = { text: string; crows: string[]; taskType: string; triage?: Msg; answer?: Msg };
|
type Item = { text: string; nest: string; crows: string[]; borrowed: Set<string>; taskType: string; triage?: Msg; answer?: Msg };
|
||||||
const items = new Map<string, Item>();
|
const items = new Map<string, Item>();
|
||||||
|
|
||||||
function ask(m: Msg, itemId: string, target: "triage" | "output") {
|
function ask(m: Msg, itemId: string, target: "triage" | "output") {
|
||||||
@@ -183,12 +238,14 @@ function ask(m: Msg, itemId: string, target: "triage" | "output") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
m.actions.append(yes, no, err);
|
m.actions.append(yes, no, err);
|
||||||
|
renderNestCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- who's flying ----------
|
// ---------- who's flying ----------
|
||||||
const flying = new Set<string>();
|
const flying = new Set<string>();
|
||||||
|
let flyingNest = "";
|
||||||
function renderTyping() {
|
function renderTyping() {
|
||||||
const names = [...flying];
|
const names = flyingNest === current ? [...flying] : [];
|
||||||
if (names.length === 0) { typing.textContent = ""; return; }
|
if (names.length === 0) { typing.textContent = ""; return; }
|
||||||
const who = names.length === 1 ? names[0]
|
const who = names.length === 1 ? names[0]
|
||||||
: names.length === 2 ? `${names[0]} and ${names[1]}`
|
: names.length === 2 ? `${names[0]} and ${names[1]}`
|
||||||
@@ -203,12 +260,14 @@ const LEVELS = ["stranger", "regular", "trusted"];
|
|||||||
|
|
||||||
const handlers: Record<string, (d: Payload) => void> = {
|
const handlers: Record<string, (d: Payload) => void> = {
|
||||||
"list.started": (d) => {
|
"list.started": (d) => {
|
||||||
|
const nestId = nests.has(d.nest_id) ? d.nest_id : sendingNest;
|
||||||
|
flyingNest = nestId;
|
||||||
const ol = el("ol", "list");
|
const ol = el("ol", "list");
|
||||||
for (const it of d.items ?? []) {
|
for (const it of d.items ?? []) {
|
||||||
items.set(it.item_id, { text: it.text, crows: [], taskType: "other" });
|
items.set(it.item_id, { text: it.text, nest: nestId, crows: [], borrowed: new Set(), taskType: "other" });
|
||||||
ol.append(el("li", "", it.text));
|
ol.append(el("li", "", it.text));
|
||||||
}
|
}
|
||||||
post("you", [ol], d.ts);
|
post("you", [ol], d.ts, nestId);
|
||||||
crow("Sentry").setPose("perched");
|
crow("Sentry").setPose("perched");
|
||||||
const n = (d.items ?? []).length;
|
const n = (d.items ?? []).length;
|
||||||
update("Sentry", `Got a list of ${n} item${n === 1 ? "" : "s"}`, d.ts);
|
update("Sentry", `Got a list of ${n} item${n === 1 ? "" : "s"}`, d.ts);
|
||||||
@@ -225,7 +284,7 @@ const handlers: Record<string, (d: Payload) => void> = {
|
|||||||
for (const s of d.subtasks) ul.append(el("li", "", s));
|
for (const s of d.subtasks) ul.append(el("li", "", s));
|
||||||
parts.push(ul);
|
parts.push(ul);
|
||||||
}
|
}
|
||||||
it.triage = post("Sentry", parts, d.ts);
|
it.triage = post("Sentry", parts, d.ts, it.nest);
|
||||||
showTrust("Sentry", d.task_type);
|
showTrust("Sentry", d.task_type);
|
||||||
if (d.needs_approval) {
|
if (d.needs_approval) {
|
||||||
crow("Sentry").setPose("waiting");
|
crow("Sentry").setPose("waiting");
|
||||||
@@ -242,8 +301,10 @@ const handlers: Record<string, (d: Payload) => void> = {
|
|||||||
if (it && d.crow !== "Sentry") showTrust(d.crow, it.taskType);
|
if (it && d.crow !== "Sentry") showTrust(d.crow, it.taskType);
|
||||||
crow(d.crow).setPose("flying");
|
crow(d.crow).setPose("flying");
|
||||||
if (it) {
|
if (it) {
|
||||||
|
if (d.borrowed) it.borrowed.add(d.crow);
|
||||||
const doing = d.crow === "Sentry" ? "Sorting" : d.crow === "Elder" ? "Merging reports on" : "Flying";
|
const doing = d.crow === "Sentry" ? "Sorting" : d.crow === "Elder" ? "Merging reports on" : "Flying";
|
||||||
update(d.crow, `${doing}${doing === "Flying" ? ":" : ""} ${q(it.text)}`, d.ts);
|
const where = d.borrowed ? ` (borrowed for # ${nest(it.nest).name})` : "";
|
||||||
|
update(d.crow, `${doing}${where}${doing === "Flying" ? ":" : ""} ${q(it.text)}`, d.ts);
|
||||||
}
|
}
|
||||||
flying.add(d.crow);
|
flying.add(d.crow);
|
||||||
renderTyping();
|
renderTyping();
|
||||||
@@ -265,8 +326,9 @@ const handlers: Record<string, (d: Payload) => void> = {
|
|||||||
const author = it.crows.includes("Elder") ? "Elder" : it.crows[0] ?? "Elder";
|
const author = it.crows.includes("Elder") ? "Elder" : it.crows[0] ?? "Elder";
|
||||||
const parts: Node[] = [re(it.text), el("p", "answer", d.answer)];
|
const parts: Node[] = [re(it.text), el("p", "answer", d.answer)];
|
||||||
const helpers = it.crows.filter((n) => n !== "Elder");
|
const helpers = it.crows.filter((n) => n !== "Elder");
|
||||||
if (author === "Elder" && helpers.length) parts.push(el("p", "with", `from ${helpers.join(", ")}`));
|
const named = helpers.map((n) => (it.borrowed.has(n) ? `${n} (borrowed)` : n));
|
||||||
it.answer = post(author, parts, d.ts);
|
if (author === "Elder" && helpers.length) parts.push(el("p", "with", `from ${named.join(", ")}`));
|
||||||
|
it.answer = post(author, parts, d.ts, it.nest);
|
||||||
if (d.needs_approval) {
|
if (d.needs_approval) {
|
||||||
for (const n of it.crows) {
|
for (const n of it.crows) {
|
||||||
crow(n).setPose("waiting");
|
crow(n).setPose("waiting");
|
||||||
@@ -292,6 +354,7 @@ const handlers: Record<string, (d: Payload) => void> = {
|
|||||||
};
|
};
|
||||||
const [text, kind] = label[d.verdict] ?? [d.verdict, ""];
|
const [text, kind] = label[d.verdict] ?? [d.verdict, ""];
|
||||||
react(m, text, kind);
|
react(m, text, kind);
|
||||||
|
renderNestCounts();
|
||||||
}
|
}
|
||||||
if (d.target === "triage" && d.verdict === "auto") return; // a trusted Sentry just keeps going
|
if (d.target === "triage" && d.verdict === "auto") return; // a trusted Sentry just keeps going
|
||||||
const word: Record<string, string> = { approved: "Approved", rejected: "Rejected", auto: "Accepted (trusted)", timeout: "Timed out" };
|
const word: Record<string, string> = { approved: "Approved", rejected: "Rejected", auto: "Accepted (trusted)", timeout: "Timed out" };
|
||||||
@@ -308,19 +371,20 @@ const handlers: Record<string, (d: Payload) => void> = {
|
|||||||
const what = String(d.task_type).replace("triage/", "sorting ");
|
const what = String(d.task_type).replace("triage/", "sorting ");
|
||||||
const up = d.to > d.from;
|
const up = d.to > d.from;
|
||||||
if (d.holder === "flock") {
|
if (d.holder === "flock") {
|
||||||
note(`The flock ${up ? "earned" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
|
note(`The flock ${up ? "earned" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, flyingNest || current, up ? "sparkle" : "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const c = crow(d.holder);
|
const c = crow(d.holder);
|
||||||
c.setTrust(d.to);
|
c.setTrust(d.to);
|
||||||
c.fire(up ? "levelUp" : "levelDown");
|
c.fire(up ? "levelUp" : "levelDown");
|
||||||
update(d.holder, `${up ? "Leveled up to" : "Dropped to"} ${LEVELS[d.to]} on ${what}`, d.ts, up ? "good" : "bad");
|
update(d.holder, `${up ? "Leveled up to" : "Dropped to"} ${LEVELS[d.to]} on ${what}`, d.ts, up ? "good" : "bad");
|
||||||
note(`${d.holder} ${up ? "leveled up to" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, up ? "sparkle" : "");
|
note(`${d.holder} ${up ? "leveled up to" : "dropped to"} “${LEVELS[d.to]}” on ${what}.`, flyingNest || current, up ? "sparkle" : "");
|
||||||
},
|
},
|
||||||
|
|
||||||
"item.skipped": (d) => {
|
"item.skipped": (d) => {
|
||||||
const it = items.get(d.item_id);
|
const it = items.get(d.item_id);
|
||||||
note(`Skipped “${it?.text ?? d.item_id}”: ${d.reason}.`);
|
note(`Skipped “${it?.text ?? d.item_id}”: ${d.reason}.`, it?.nest ?? flyingNest ?? current);
|
||||||
|
renderNestCounts();
|
||||||
for (const n of it?.crows ?? []) crow(n).setPose("perched");
|
for (const n of it?.crows ?? []) crow(n).setPose("perched");
|
||||||
update("Sentry", `Skipped ${q(it?.text ?? d.item_id)}`, d.ts, "bad");
|
update("Sentry", `Skipped ${q(it?.text ?? d.item_id)}`, d.ts, "bad");
|
||||||
},
|
},
|
||||||
@@ -331,7 +395,8 @@ const handlers: Record<string, (d: Payload) => void> = {
|
|||||||
for (const t of tiles.values()) { t.view.setPose("perched"); t.view.fire("cheer"); }
|
for (const t of tiles.values()) { t.view.setPose("perched"); t.view.fire("cheer"); }
|
||||||
flying.clear();
|
flying.clear();
|
||||||
renderTyping();
|
renderTyping();
|
||||||
note(`List done. The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`);
|
note(`List done. The flock used ${Number(d.tokens_used).toLocaleString()} of ${Number(d.token_budget).toLocaleString()} tokens.`, flyingNest || current);
|
||||||
|
flyingNest = "";
|
||||||
source?.close();
|
source?.close();
|
||||||
source = null;
|
source = null;
|
||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
@@ -343,7 +408,7 @@ let cooldownTimer = 0;
|
|||||||
const sendLabel = sendBtn.textContent ?? "Send";
|
const sendLabel = sendBtn.textContent ?? "Send";
|
||||||
function prank(message: string, cooldownS: number) {
|
function prank(message: string, cooldownS: number) {
|
||||||
crow("Sentry").fire("prank");
|
crow("Sentry").fire("prank");
|
||||||
post("Sentry", [el("p", "", message)]);
|
post("Sentry", [el("p", "", message)], undefined, current);
|
||||||
update("Sentry", "Left a gift on someone's windshield", undefined, "bad");
|
update("Sentry", "Left a gift on someone's windshield", undefined, "bad");
|
||||||
let left = Math.max(0, Math.round(cooldownS));
|
let left = Math.max(0, Math.round(cooldownS));
|
||||||
clearInterval(cooldownTimer);
|
clearInterval(cooldownTimer);
|
||||||
@@ -362,6 +427,7 @@ function prank(message: string, cooldownS: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- send a list ----------
|
// ---------- send a list ----------
|
||||||
|
let sendingNest = "todo";
|
||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = listInput.value.trim();
|
const text = listInput.value.trim();
|
||||||
@@ -369,13 +435,14 @@ form.addEventListener("submit", async (e) => {
|
|||||||
const requester = requesterInput.value.trim() || "anonymous";
|
const requester = requesterInput.value.trim() || "anonymous";
|
||||||
store.set("requester", requester);
|
store.set("requester", requester);
|
||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
|
sendingNest = current;
|
||||||
|
|
||||||
const res = await postList(text, requester).catch(() => ({ ok: false as const, pranked: false as const, message: "Can't reach the flock. Is `crows serve` running?" }));
|
const res = await postList(text, requester, current).catch(() => ({ ok: false as const, pranked: false as const, message: "Can't reach the flock. Is `crows serve` running?" }));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.pranked) {
|
if (res.pranked) {
|
||||||
prank(res.message, res.cooldownS); // re-enables the button when the cooldown ends
|
prank(res.message, res.cooldownS); // re-enables the button when the cooldown ends
|
||||||
} else {
|
} else {
|
||||||
note(res.message);
|
note(res.message, current);
|
||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -392,16 +459,68 @@ form.addEventListener("submit", async (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Phone tabs: the channel or the flock.
|
// ---------- phone tabs: the room or the roost (nests + flock) ----------
|
||||||
const app = document.getElementById("app")!;
|
const app = document.getElementById("app")!;
|
||||||
for (const [id, view] of [["tab-room", "room"], ["tab-flock", "flock"]] as const) {
|
function showView(view: "room" | "flock") {
|
||||||
document.getElementById(id)!.addEventListener("click", () => {
|
app.dataset.view = view;
|
||||||
app.dataset.view = view;
|
for (const t of document.querySelectorAll<HTMLButtonElement>(".tab")) {
|
||||||
for (const t of document.querySelectorAll<HTMLButtonElement>(".tab")) t.setAttribute("aria-pressed", String(t.id === id));
|
t.setAttribute("aria-pressed", String(t.id === (view === "room" ? "tab-room" : "tab-flock")));
|
||||||
if (view === "room") feed.scrollTop = feed.scrollHeight;
|
}
|
||||||
});
|
if (view === "room") nest(current).feed.scrollTop = nest(current).feed.scrollHeight;
|
||||||
}
|
}
|
||||||
|
roomTab.addEventListener("click", () => showView("room"));
|
||||||
|
$<HTMLButtonElement>("tab-flock").addEventListener("click", () => showView("flock"));
|
||||||
|
|
||||||
// The Sentry is always on watch, and says hello.
|
// ---------- new nest dialog ----------
|
||||||
|
const dialog = $<HTMLDialogElement>("nest-dialog");
|
||||||
|
const nestForm = $<HTMLFormElement>("nest-form");
|
||||||
|
const nestName = $<HTMLInputElement>("nest-name");
|
||||||
|
const nestCrows = $<HTMLDivElement>("nest-crows");
|
||||||
|
const nestErr = $<HTMLParagraphElement>("nest-err");
|
||||||
|
|
||||||
|
$<HTMLButtonElement>("new-nest").addEventListener("click", () => {
|
||||||
|
nestErr.textContent = "";
|
||||||
|
nestName.value = "";
|
||||||
|
nestCrows.replaceChildren();
|
||||||
|
for (const name of flockNames) {
|
||||||
|
const label = el("label", "pick-crow");
|
||||||
|
const box = el("input");
|
||||||
|
box.type = "checkbox";
|
||||||
|
box.value = name;
|
||||||
|
label.append(box, crowAvatar("crow"), el("span", "", name));
|
||||||
|
nestCrows.append(label);
|
||||||
|
}
|
||||||
|
dialog.showModal();
|
||||||
|
nestName.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
nestForm.addEventListener("submit", async (e) => {
|
||||||
|
const submitter = (e as SubmitEvent).submitter as HTMLButtonElement | null;
|
||||||
|
if (submitter?.value !== "create") return; // Cancel closes the dialog
|
||||||
|
e.preventDefault();
|
||||||
|
const name = nestName.value.trim();
|
||||||
|
const crows = [...nestCrows.querySelectorAll<HTMLInputElement>("input:checked")].map((b) => b.value);
|
||||||
|
if (!name) { nestErr.textContent = "Give the nest a name."; return; }
|
||||||
|
if (!crows.length) { nestErr.textContent = "Pick at least one crow for the core team."; return; }
|
||||||
|
try {
|
||||||
|
const n = await createNest(name, crows);
|
||||||
|
addNest(n);
|
||||||
|
dialog.close();
|
||||||
|
selectNest(n.nestId);
|
||||||
|
showView("room");
|
||||||
|
post("Sentry", [el("p", "", `New nest. Core crows: ${n.crows.join(", ")}. I'll borrow others when a job needs more hands.`)], undefined, n.nestId);
|
||||||
|
} catch (err) {
|
||||||
|
nestErr.textContent = (err as Error).message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- start: load the nests, open # todo, the Sentry says hello ----------
|
||||||
crow("Sentry");
|
crow("Sentry");
|
||||||
post("Sentry", [el("p", "", "Caw. Send me a todo list, one item per line. I'll sort it, the flock will fly it, and you judge the results.")]);
|
(async () => {
|
||||||
|
const data = await getNests();
|
||||||
|
flockNames = data.roster;
|
||||||
|
if (!data.nests.some((n) => n.nestId === "todo")) data.nests.unshift({ nestId: "todo", name: "todo", crows: [] });
|
||||||
|
for (const n of data.nests) addNest(n);
|
||||||
|
selectNest("todo");
|
||||||
|
post("Sentry", [el("p", "", "Caw. Send me a todo list, one item per line. I'll sort it, the flock will fly it, and you judge the results.")], undefined, "todo");
|
||||||
|
})();
|
||||||
@@ -73,6 +73,47 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
|
|||||||
}
|
}
|
||||||
.who input:focus { outline: none; border-bottom-width: 3px; }
|
.who input:focus { outline: none; border-bottom-width: 3px; }
|
||||||
|
|
||||||
|
/* ---------- nests: rooms, like channels ---------- */
|
||||||
|
.nests { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.nest-link {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%;
|
||||||
|
padding: 8px 10px; border: 0; border-radius: var(--corner); background: none; cursor: pointer;
|
||||||
|
font: 400 17px/1.2 var(--display); color: var(--muted); text-align: left;
|
||||||
|
}
|
||||||
|
.nest-link[aria-current="true"] { color: var(--ink); background: rgb(28 25 21 / 0.06); }
|
||||||
|
.nest-link:focus-visible { outline: none; text-decoration: underline 2px; text-underline-offset: 4px; }
|
||||||
|
.nest-count:not(:empty) {
|
||||||
|
padding: 0 7px; border-radius: 999px; background: var(--red); color: var(--paper); font: 700 12px/1.4 var(--body);
|
||||||
|
}
|
||||||
|
.new-nest {
|
||||||
|
align-self: flex-start; margin: 2px 4px 6px; padding: 4px 6px; border: 0; background: none; cursor: pointer;
|
||||||
|
font: italic 500 15px/1.2 var(--body); color: var(--muted);
|
||||||
|
}
|
||||||
|
.new-nest:hover { color: var(--ink); }
|
||||||
|
.new-nest:focus-visible { outline: none; text-decoration: underline 2px; text-underline-offset: 4px; }
|
||||||
|
|
||||||
|
/* ---------- new nest dialog: a card on the page, no outline ---------- */
|
||||||
|
.nest-dialog { width: min(440px, calc(100vw - 32px)); padding: 0; color: var(--ink); overflow: visible; }
|
||||||
|
.nest-dialog::backdrop { background: rgb(28 25 21 / 0.25); }
|
||||||
|
.nest-dialog form { padding: 22px 22px 18px; }
|
||||||
|
.nest-dialog h2 { margin: 0; font: 400 24px/1.1 var(--display); }
|
||||||
|
.dialog-sub { margin: 6px 0 16px; color: var(--muted); font-style: italic; font-size: 15px; }
|
||||||
|
.dialog-field { display: block; color: var(--muted); font-style: italic; font-size: 15px; }
|
||||||
|
.dialog-field input {
|
||||||
|
display: block; width: 100%; margin-top: 4px; padding: 2px 0; font: 500 18px var(--body); color: var(--ink);
|
||||||
|
background: transparent; border: 0; border-bottom: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
.dialog-field input:focus { outline: none; border-bottom-width: 3px; }
|
||||||
|
.pick { margin: 18px 0 0; padding: 0; border: 0; }
|
||||||
|
.pick legend { padding: 0; color: var(--muted); font-style: italic; font-size: 15px; }
|
||||||
|
.pick-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; margin-top: 8px; }
|
||||||
|
.pick-crow { display: flex; align-items: center; gap: 10px; padding: 6px 8px; border-radius: var(--corner); cursor: pointer; font: 400 16px var(--display); }
|
||||||
|
.pick-crow:has(input:checked) { background: rgb(28 25 21 / 0.07); }
|
||||||
|
.pick-crow input { accent-color: var(--ink); width: 16px; height: 16px; margin: 0; }
|
||||||
|
.pick-crow .avatar { width: 30px; height: 28px; margin: 0; }
|
||||||
|
.dialog-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 18px; }
|
||||||
|
.nest-dialog .err { margin: 10px 0 0; }
|
||||||
|
|
||||||
/* ---------- roster: one list tile per crow, newest update on top ---------- */
|
/* ---------- roster: one list tile per crow, newest update on top ---------- */
|
||||||
.roster { display: flex; flex-direction: column; gap: 6px; }
|
.roster { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.tile {
|
.tile {
|
||||||
@@ -146,7 +187,9 @@ body { margin: 0; background: var(--paper); color: var(--ink); font: 500 17px/1.
|
|||||||
-webkit-mask-image: linear-gradient(rgb(0 0 0 / 0.55), transparent); mask-image: linear-gradient(rgb(0 0 0 / 0.55), transparent);
|
-webkit-mask-image: linear-gradient(rgb(0 0 0 / 0.55), transparent); mask-image: linear-gradient(rgb(0 0 0 / 0.55), transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feeds { display: grid; min-height: 0; }
|
||||||
.feed { list-style: none; margin: 0; padding: 20px 0 10px; overflow-y: auto; min-height: 0; }
|
.feed { list-style: none; margin: 0; padding: 20px 0 10px; overflow-y: auto; min-height: 0; }
|
||||||
|
.feed[hidden] { display: none; }
|
||||||
.msg { display: grid; grid-template-columns: 40px 1fr; gap: 0 14px; padding: 14px 28px; }
|
.msg { display: grid; grid-template-columns: 40px 1fr; gap: 0 14px; padding: 14px 28px; }
|
||||||
@media (hover: hover) { .msg:hover { background: rgb(28 25 21 / 0.03); } }
|
@media (hover: hover) { .msg:hover { background: rgb(28 25 21 / 0.03); } }
|
||||||
/* Consecutive messages from one author group under a single name, Slack style. */
|
/* Consecutive messages from one author group under a single name, Slack style. */
|
||||||
|
|||||||
Reference in new issue
Block a user