// Package registry contains the static project, machine, and herdr topology. package registry import ( "encoding/json" "errors" "fmt" "net" "os" "sort" "strings" "time" ) var ( ErrUnknownProject = errors.New("unknown project") ErrUnknownMachine = errors.New("unknown machine") ErrNoAffinity = errors.New("project has no machine affinity") ) type Project struct { ID string `json:"id"` MachineAffinity []string `json:"machine_affinity"` // Repo and WorktreeRoot let each project resolve its own git checkout // (spec §2.2 — a project is first-class; nothing about the model implies // a single shared repo across all projects). Both optional: a project // that omits them falls back to whatever global default the deployment // wires (single-repo deployments keep working unchanged). Repo string `json:"repo,omitempty"` WorktreeRoot string `json:"worktree_root,omitempty"` QualityGate string `json:"quality_gate,omitempty"` // SafeOperations is an audited, deliberately small allow-list for work // inside this project's task worktree. It documents what workers may // perform without an operator grant; network, secrets, destructive Git, // and paths outside the worktree are never represented here. SafeOperations []string `json:"safe_operations,omitempty"` } type Machine struct { ID string `json:"id"` Address string `json:"address"` } type Herdr struct { ID string `json:"id"` MachineID string `json:"machine_id"` Address string `json:"address,omitempty"` Harness string `json:"harness,omitempty"` Protocol string `json:"protocol,omitempty"` Capabilities []string `json:"capabilities"` Concurrency int `json:"concurrency"` // QuotaLimit is deprecated in favor of QuotaLimit5h/QuotaLimitWeekly; if // set and QuotaLimit5h is not, it is treated as the weekly limit only // (its historical meaning), to avoid silently inventing a 5h cap for // existing configuration. QuotaLimit float64 `json:"quota_limit,omitempty"` QuotaLimit5h float64 `json:"quota_limit_5h,omitempty"` QuotaLimitWeekly float64 `json:"quota_limit_weekly,omitempty"` } const defaultHerdrPort = "9245" type Config struct { Projects []Project `json:"projects"` Machines []Machine `json:"machines"` Herdrs []Herdr `json:"herdrs"` } type Registry struct { projects map[string]Project machines map[string]Machine herdrs map[string]Herdr } func Load(path string) (Registry, error) { b, err := os.ReadFile(path) if err != nil { return Registry{}, err } var c Config if err = json.Unmarshal(stripJSONComments(b), &c); err != nil { return Registry{}, fmt.Errorf("registry config: %w", err) } return New(c) } // stripJSONComments removes // line comments and /* */ block comments from // JSONC input, leaving valid JSON. Comment markers inside string literals // (respecting backslash escapes) are left untouched. This lets deployments // annotate config.json in place instead of keeping a separate undocumented // copy (see deploy/config.example.jsonc). func stripJSONComments(b []byte) []byte { out := make([]byte, 0, len(b)) inString, escaped, inLineComment, inBlockComment := false, false, false, false for i := 0; i < len(b); i++ { c := b[i] switch { case inLineComment: if c == '\n' { inLineComment = false out = append(out, c) } case inBlockComment: if c == '*' && i+1 < len(b) && b[i+1] == '/' { inBlockComment = false i++ } case inString: out = append(out, c) if escaped { escaped = false } else if c == '\\' { escaped = true } else if c == '"' { inString = false } case c == '"': inString = true out = append(out, c) case c == '/' && i+1 < len(b) && b[i+1] == '/': inLineComment = true i++ case c == '/' && i+1 < len(b) && b[i+1] == '*': inBlockComment = true i++ default: out = append(out, c) } } return out } func New(c Config) (Registry, error) { r := Registry{map[string]Project{}, map[string]Machine{}, map[string]Herdr{}} for _, p := range c.Projects { if err := putID(r.projects, p.ID, "project"); err != nil { return Registry{}, err } if len(p.MachineAffinity) == 0 { return Registry{}, fmt.Errorf("project %q: %w", p.ID, ErrNoAffinity) } for _, op := range p.SafeOperations { switch op { case "read", "edit", "test", "git": default: return Registry{}, fmt.Errorf("project %q: unsafe operation %q is not policy-configurable", p.ID, op) } } r.projects[p.ID] = p } for _, m := range c.Machines { if err := putID(r.machines, m.ID, "machine"); err != nil { return Registry{}, err } if strings.TrimSpace(m.Address) == "" { return Registry{}, fmt.Errorf("machine %q: address required", m.ID) } r.machines[m.ID] = m } for _, h := range c.Herdrs { if err := putID(r.herdrs, h.ID, "herdr"); err != nil { return Registry{}, err } if _, ok := r.machines[h.MachineID]; !ok { return Registry{}, fmt.Errorf("herdr %q: %w %q", h.ID, ErrUnknownMachine, h.MachineID) } if h.Concurrency < 0 { return Registry{}, fmt.Errorf("herdr %q: negative concurrency", h.ID) } r.herdrs[h.ID] = h } for _, p := range r.projects { for _, m := range p.MachineAffinity { if _, ok := r.machines[m]; !ok { return Registry{}, fmt.Errorf("project %q: %w %q", p.ID, ErrUnknownMachine, m) } } } return r, nil } func putID[T any](m map[string]T, id, kind string) error { if strings.TrimSpace(id) == "" { return fmt.Errorf("%s id required", kind) } if _, ok := m[id]; ok { return fmt.Errorf("duplicate %s %q", kind, id) } return nil } func (r Registry) Project(id string) (Project, bool) { p, ok := r.projects[id]; return p, ok } func (r Registry) Machine(id string) (Machine, bool) { m, ok := r.machines[id]; return m, ok } func (r Registry) Herdr(id string) (Herdr, bool) { h, ok := r.herdrs[id]; return h, ok } func (r Registry) Machines() []Machine { out := make([]Machine, 0, len(r.machines)) for _, m := range r.machines { out = append(out, m) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } func (r Registry) Herdrs() []Herdr { out := make([]Herdr, 0, len(r.herdrs)) for _, h := range r.herdrs { out = append(out, h) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } func (r Registry) Projects() []Project { return projects(r.projects) } func projects(m map[string]Project) []Project { out := make([]Project, 0, len(m)) for _, v := range m { out = append(out, v) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } type Reachability interface { Reachable(address string, timeout time.Duration) bool } type TCPReachability struct{} func (TCPReachability) Reachable(address string, timeout time.Duration) bool { c, err := net.DialTimeout("tcp", address, timeout) if err != nil { return false } _ = c.Close() return true } func (r Registry) Candidates(project string, check Reachability, timeout time.Duration) ([]Herdr, error) { p, ok := r.projects[project] if !ok { return nil, ErrUnknownProject } allowed := map[string]bool{} for _, m := range p.MachineAffinity { allowed[m] = true } out := []Herdr{} for _, h := range r.herdrs { if !allowed[h.MachineID] { continue } addr := h.Address if addr == "" { addr = r.machines[h.MachineID].Address if host, _, err := net.SplitHostPort(addr); err == nil { addr = net.JoinHostPort(host, defaultHerdrPort) } } if check == nil || check.Reachable(addr, timeout) { out = append(out, h) } } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil }