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
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user