package cockpit import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "github.com/ajhahnde/eeco/internal/config" ) // Selection is the operator-managed set of active cockpit targets, persisted // at /.eeco/cockpit.json — the C0-envisioned selector, separate from // the emission ledger at /state/cockpit.json. Targets is the active // harness set `eeco cockpit generate` renders by default. Playbooks is // reserved for future narrowing: absent (or empty) means every registered // playbook (C2 keeps it implicit-all; the field is present so a later slice // can narrow without a schema change). type Selection struct { Targets []string `json:"targets"` Playbooks []string `json:"playbooks,omitempty"` } // selectionName is the selection store's filename inside the workspace dir // (/.eeco/). It is intentionally the same base name as the emission // ledger but at a different path (the ledger is under state/), so the two // never collide. const selectionName = "cockpit.json" func selectionPath(cfg *config.Config) string { return filepath.Join(cfg.Workspace, selectionName) } // SelectionPath returns the absolute path of the selection store // (/.eeco/cockpit.json). Exported so the contract-watch hook can // recognize an edit to it without duplicating the path construction. func SelectionPath(cfg *config.Config) string { return selectionPath(cfg) } // HasSelection reports whether a selection store already exists, so init can // record the operator's harness choice once without clobbering it on re-run. func HasSelection(cfg *config.Config) bool { _, err := os.Stat(selectionPath(cfg)) return err == nil } // DefaultSelection is the fail-safe active set: Claude alone, the one enforced // target. Used when no selection is configured or the stored one is unusable. func DefaultSelection() Selection { return Selection{Targets: []string{"claude"}} } // globalSelectionPath is the user-global cockpit selection, parallel to the // global config.local layer: a project with no workspace selection inherits // these targets. Empty when no global config dir resolves. func globalSelectionPath() string { dir := config.GlobalConfigDir() if dir == "" { return "" } return filepath.Join(dir, selectionName) } // GlobalSelectionPath is the exported user-global cockpit selection path // (or "" when no global config dir resolves), for command messaging. func GlobalSelectionPath() string { return globalSelectionPath() } // LoadGlobalSelection reads the user-global cockpit selection, degrading to // DefaultSelection when it is absent, empty, corrupt, or all-unknown. func LoadGlobalSelection() Selection { if s, ok := loadSelectionFile(globalSelectionPath()); ok { return s } return DefaultSelection() } // SaveGlobalSelection writes the user-global cockpit selection, creating the // global config dir if absent. Same sanitize semantics as SaveSelection. func SaveGlobalSelection(s Selection) error { path := globalSelectionPath() if path == "" { return fmt.Errorf("cannot resolve a global config directory (set EECO_CONFIG_HOME or HOME)") } s.Targets = sanitizeTargets(s.Targets) if len(s.Targets) == 0 { s.Targets = DefaultSelection().Targets } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("selection dir: %w", err) } out, err := json.MarshalIndent(s, "", " ") if err != nil { return err } return os.WriteFile(path, append(out, '\n'), 0o644) } // LoadSelection reads the active target set, resolving in layers: workspace // cockpit.json → user-global cockpit.json → DefaultSelection. A missing, // empty, corrupt, or all-unknown file at one layer falls through to the next // rather than wedging the tool (mirrors loadLedger). Unknown target names are // dropped so a stale entry from a newer binary can't break an older one. func LoadSelection(cfg *config.Config) Selection { if s, ok := loadSelectionFile(selectionPath(cfg)); ok { return s } if s, ok := loadSelectionFile(globalSelectionPath()); ok { return s } return DefaultSelection() } // loadSelectionFile parses a cockpit.json at path. ok is false when the file // is absent, empty, unparseable, or declares no known targets. func loadSelectionFile(path string) (Selection, bool) { if path == "" { return Selection{}, false } b, err := os.ReadFile(path) if err != nil || len(b) == 0 { return Selection{}, false } var s Selection if err := json.Unmarshal(b, &s); err != nil { return Selection{}, false } s.Targets = sanitizeTargets(s.Targets) if len(s.Targets) == 0 { return Selection{}, false } return s, true } // SaveSelection writes the active target set under the workspace dir, creating // it if absent. Targets are sanitized (deduped, known-only, order-preserving); // an empty result falls back to the default so the store is never wedged. func SaveSelection(cfg *config.Config, s Selection) error { s.Targets = sanitizeTargets(s.Targets) if len(s.Targets) == 0 { s.Targets = DefaultSelection().Targets } dir := filepath.Dir(selectionPath(cfg)) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("selection dir: %w", err) } out, err := json.MarshalIndent(s, "", " ") if err != nil { return err } return os.WriteFile(selectionPath(cfg), append(out, '\n'), 0o644) } // sanitizeTargets returns the known targets in in, deduplicated and in their // first-seen order. Unknown or blank names are dropped. func sanitizeTargets(in []string) []string { seen := make(map[string]bool, len(in)) var out []string for _, t := range in { t = strings.TrimSpace(t) if t == "" || seen[t] { continue } if _, ok := rendererFor(t); !ok { continue } seen[t] = true out = append(out, t) } return out }