// Package registry contains the static project, machine, and herdr topology. package registry import ( "encoding/json" "errors" "fmt" "net" "os" "sort" "strings" "time" "orchestra/internal/domain" ) 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"` // WorkPhases is the phase path this project's tasks follow. Empty means // the default path. A phase not listed here is skipped, which is how a // trivial project runs frame, implement, review with no research or plan. WorkPhases []domain.WorkPhase `json:"work_phases,omitempty"` // TrajectoryGate names the phase transitions the human must confirm // before work continues, keyed "_to_" with value "required". // Anything else, including an absent key, is automatic. Explicit policy // beats a complexity classifier until there is evidence one is needed. TrajectoryGate map[string]string `json:"trajectory_gate,omitempty"` // HumanDecisions bounds how often one task may stop to ask. Zero uses the // default. HumanDecisions struct { MaxRequestsPerTask int `json:"max_requests_per_task,omitempty"` } `json:"human_decisions,omitempty"` } // MaxDecisionRequests is the per-task question budget. func (p Project) MaxDecisionRequests() int { if p.HumanDecisions.MaxRequestsPerTask > 0 { return p.HumanDecisions.MaxRequestsPerTask } return defaultMaxDecisionRequests } // GateRequired reports whether this transition needs human confirmation. func (p Project) GateRequired(from, to domain.WorkPhase) bool { if from == "" { from = domain.WorkPhaseFrame } return strings.EqualFold(p.TrajectoryGate[string(from)+"_to_"+string(to)], "required") } // defaultMaxDecisionRequests mirrors operations.DefaultMaxDecisionRequests, // duplicated to keep registry free of a dependency on operations. const defaultMaxDecisionRequests = 6 // DefaultWorkPhases is the path a project takes when it declares none. var DefaultWorkPhases = []domain.WorkPhase{domain.WorkPhaseFrame, domain.WorkPhaseResearch, domain.WorkPhasePlan, domain.WorkPhaseImplement, domain.WorkPhaseReview} // Phases returns the declared path, or the default. func (p Project) Phases() []domain.WorkPhase { if len(p.WorkPhases) == 0 { return DefaultWorkPhases } return p.WorkPhases } // NextPhase returns the phase that follows current on this project's path, // skipping any phase the project does not declare. It reports false at the // end of the path. The result is always a legal transition, so a project // cannot declare a path that moves backwards. func (p Project) NextPhase(current domain.WorkPhase) (domain.WorkPhase, bool) { if current == "" { current = domain.WorkPhaseFrame } phases := p.Phases() // Review's only legal move is back to implement, the one backwards edge // in the model. Review passing is not a phase change: it is completion, // which belongs to the task lifecycle. if current == domain.WorkPhaseReview { for _, phase := range phases { if phase == domain.WorkPhaseImplement { return domain.WorkPhaseImplement, true } } return "", false } for i, phase := range phases { if phase != current { continue } for _, candidate := range phases[i+1:] { if domain.CanTransitionPhase(current, candidate) { return candidate, true } } return "", false } return "", false } type Machine struct { ID string `json:"id"` Address string `json:"address"` } type Herdr struct { ID string `json:"id"` MachineID string `json:"machine_id"` // Backend selects the machine-local pane implementation. The empty value // preserves the existing herdr default. tmux is currently Claude-only. Backend string `json:"backend,omitempty"` 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) } switch h.Backend { case "", "herdr": case "tmux": if h.Harness != "claude" { return Registry{}, fmt.Errorf("herdr %q: tmux backend currently supports only claude, got %q", h.ID, h.Harness) } default: return Registry{}, fmt.Errorf("herdr %q: unsupported backend %q", h.ID, h.Backend) } 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) { return r.candidates(project, func(h Herdr) bool { address := r.Endpoint(h) return check == nil || check.Reachable(address, timeout) }) } // CandidatesWithHealth filters candidates using a health snapshot gathered by // the router. Keeping probe execution outside this method lets a scheduling // pass probe each herdr once, in parallel, instead of once per queued task. func (r Registry) CandidatesWithHealth(project string, healthy map[string]bool) ([]Herdr, error) { return r.candidates(project, func(h Herdr) bool { return healthy[h.ID] }) } func (r Registry) candidates(project string, include func(Herdr) bool) ([]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] || !include(h) { continue } out = append(out, h) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } // Endpoint resolves the backend-specific health key. Worker-owned tmux // backends use an identity-only pseudo endpoint so bypassing their legacy TCP // probe cannot accidentally bypass another local herdr sharing port 9245. func (r Registry) Endpoint(h Herdr) string { if h.Backend == "tmux" { return "tmux:" + h.ID } if h.Address != "" { return h.Address } address := r.machines[h.MachineID].Address if host, _, err := net.SplitHostPort(address); err == nil { return net.JoinHostPort(host, defaultHerdrPort) } return address }