Go 175 lines
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 <username>/.eeco/cockpit.json — the C0-envisioned selector, separate from
// the emission ledger at <workspace>/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
// (<username>/.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
// (<username>/.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
}