feat(boss): Boss creates nests and assigns registry crows (#43) #52

Merged
jacisjake merged 2 commits from task/43-boss-nests into main 2026-10-28 15:20:29 +00:00
Showing only changes of commit 3e4b591762 - Show all commits
+171
View File
@@ -3,9 +3,11 @@ package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
@@ -333,3 +335,172 @@ func TestCrowsRegistryEmptyWithoutStore(t *testing.T) {
t.Fatalf("want no crows, got %v", out.Crows)
}
}
// --- #43: Boss creates nests and assigns members ---
// listNests returns every nest from GET /api/nests.
func listNests(t *testing.T, ts *httptest.Server) []map[string]any {
t.Helper()
resp, err := http.Get(ts.URL + "/api/nests")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /api/nests: want 200, got %d", resp.StatusCode)
}
var out struct {
Nests []map[string]any `json:"nests"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out.Nests
}
func findNest(t *testing.T, ts *httptest.Server, name string) map[string]any {
t.Helper()
for _, n := range listNests(t, ts) {
if n["name"] == name {
return n
}
}
return nil
}
func nestCrowNames(nest map[string]any) []string {
raw, _ := nest["crows"].([]any)
out := make([]string, 0, len(raw))
for _, c := range raw {
out = append(out, c.(string))
}
slices.Sort(out)
return out
}
func TestBossCreatesNest(t *testing.T) {
s, bb := newBossServer(t,
`SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`,
`NEST {"name":"Launch","crows":["Hugin","Ash"]}`,
)
ts := serve(s)
defer ts.Close()
labID := newNest(t, ts, "Lab", `"Hugin"`)
// Ash must exist as a registry crow: spin it through Boss first.
askBoss(t, ts, labID, "spin up a crow named Ash")
bossSay(t, ts, labID, 2)
askBoss(t, ts, labID, "make a nest Launch with Hugin and Ash")
var launch map[string]any
deadline := time.Now().Add(2 * time.Second)
for launch == nil && time.Now().Before(deadline) {
launch = findNest(t, ts, "Launch")
if launch == nil {
time.Sleep(20 * time.Millisecond)
}
}
if launch == nil {
t.Fatalf("Boss must create the nest Launch, nests: %v", listNests(t, ts))
}
if got := nestCrowNames(launch); !slices.Equal(got, []string{"Ash", "Hugin"}) {
t.Fatalf("Launch crows must be exactly [Ash Hugin], got %v", got)
}
// Boss announces membership in the NEW nest: a say to the owner.
announced := false
for _, m := range awaitMessages(t, ts, launch["nest_id"].(string), 1) {
if m["author"] == "Boss" && m["kind"] == "say" && m["to"] == "owner" {
announced = true
}
}
if !announced {
t.Fatalf("Boss must post a say to the owner in Launch, got %v",
nestMessages(t, ts, launch["nest_id"].(string)))
}
// And confirms in the requesting nest.
confirmed := false
for _, m := range awaitMessages(t, ts, labID, 4) {
if m["author"] == "Boss" && m["kind"] == "say" && strings.Contains(m["text"].(string), "Launch") {
confirmed = true
}
}
if !confirmed {
t.Fatalf("Boss must confirm the new nest in Lab, got %v", nestMessages(t, ts, labID))
}
if bb.count() != 2 {
t.Fatalf("two orders are two model calls, got %d", bb.count())
}
}
func TestBossNestUnknownMemberRefused(t *testing.T) {
s, _ := newBossServer(t, `NEST {"name":"Nope","crows":["Gandalf"]}`)
ts := serve(s)
defer ts.Close()
labID := newNest(t, ts, "Lab", `"Hugin"`)
before := len(listNests(t, ts))
askBoss(t, ts, labID, "make a nest Nope with Gandalf")
say := bossSay(t, ts, labID, 2)
text, _ := say["text"].(string)
if strings.HasPrefix(text, "NEST") {
t.Fatalf("Boss must interpret the order, not echo it back: %q", text)
}
if !strings.Contains(text, "Gandalf") {
t.Fatalf("the refusal must name the unknown member, got %q", text)
}
if after := len(listNests(t, ts)); after != before {
t.Fatalf("an unknown member must refuse the whole create: %d before, %d after", before, after)
}
}
func TestBossNestCapRespected(t *testing.T) {
s, _ := newBossServer(t, `NEST {"name":"Overflow","crows":["Hugin"]}`)
ts := serve(s)
defer ts.Close()
labID := newNest(t, ts, "Lab", `"Hugin"`)
// Fill the store to its cap: todo + Lab + (maxNests-2) pads.
for i := 0; 2+i < maxNests; i++ {
if _, err := s.nests.Create(fmt.Sprintf("Pad %d", i), []string{"Hugin"}); err != nil {
t.Fatalf("padding nests: %v", err)
}
}
if n := len(listNests(t, ts)); n != maxNests {
t.Fatalf("test setup: want %d nests, got %d", maxNests, n)
}
askBoss(t, ts, labID, "make a nest Overflow with Hugin") // 201: no 500 at the front door
say := bossSay(t, ts, labID, 2)
text, _ := say["text"].(string)
if !strings.Contains(text, fmt.Sprintf("at most %d nests", maxNests)) {
t.Fatalf("Boss must report the store's cap refusal, got %q", text)
}
if findNest(t, ts, "Overflow") != nil {
t.Fatal("a capped create must not make a nest")
}
}
func TestBossNestGoesThroughFlock(t *testing.T) {
s, _ := newBossServer(t, `NEST {"name":"Launch","crows":["Hugin","Munin"]}`)
s.p.Flock.sem = make(chan struct{}, 0) // saturated flock: ask must wait, never act
ts := serve(s)
defer ts.Close()
labID := newNest(t, ts, "Lab", `"Hugin"`)
before := len(listNests(t, ts))
askBoss(t, ts, labID, "make a nest Launch with Hugin and Munin")
time.Sleep(300 * time.Millisecond)
if n := len(listNests(t, ts)); n != before {
t.Fatalf("a saturated flock must not create a nest, got %v", listNests(t, ts))
}
for _, m := range nestMessages(t, ts, labID) {
if m["author"] == "Boss" {
t.Fatalf("a saturated flock must not produce a Boss message, got %v", m)
}
}
}