package cockpit import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "github.com/ajhahnde/eeco/internal/config" ) // ledgerName is the reversibility record for emitted cockpit artifacts, // inside /state. It mirrors internal/hooks/state/hooks.json: a // record per artifact, sha-stamped, with a backup pointer, so every emit is // cleanly undoable. const ledgerName = "cockpit.json" // record is one emitted artifact's reversibility state. Keyed by // (Target, Playbook). Created is true when generate created the leaf skill // directory (so off can prune it); Backup points at a pre-existing file's // saved copy under /state/backups. type record struct { Installed bool `json:"installed"` Target string `json:"target,omitempty"` Playbook string `json:"playbook,omitempty"` Path string `json:"path,omitempty"` SHA256 string `json:"sha256,omitempty"` Backup string `json:"backup,omitempty"` Created bool `json:"created,omitempty"` At string `json:"at,omitempty"` } // ledger is the persisted state. A slice so it grows per playbook/target in // C2 without a shape change. type ledger struct { Records []record `json:"records"` } // find returns the index of the record for (target, playbook), or -1. func (l *ledger) find(target, playbook string) int { for i := range l.Records { if l.Records[i].Target == target && l.Records[i].Playbook == playbook { return i } } return -1 } // upsert stores rec, replacing any existing (target, playbook) record. func (l *ledger) upsert(rec record) { if i := l.find(rec.Target, rec.Playbook); i >= 0 { l.Records[i] = rec return } l.Records = append(l.Records, rec) } // hasInstalled reports whether any record is still installed — the // "cockpit in use here" gate for Sync. init writes a default selection but // never the ledger, so an empty (or all-removed) ledger means generate // never produced an artifact and a drift scan has nothing to check. func (l *ledger) hasInstalled() bool { for i := range l.Records { if l.Records[i].Installed { return true } } return false } // clear drops the record for (target, playbook) if present. func (l *ledger) clear(target, playbook string) { i := l.find(target, playbook) if i < 0 { return } l.Records = append(l.Records[:i], l.Records[i+1:]...) } // findAgg / upsertAgg / clearAgg are the aggregate-target views of the ledger: // an aggregate artifact (AGENTS.md, GEMINI.md) is one shared file for the // whole set, so its record is keyed on the target alone (Playbook==""). Keying // on target alone is what stops an `off` of one playbook from deleting a file // shared by the rest (the orphan bug). They reuse the (target, playbook) // primitives with an empty playbook, so per-playbook and aggregate records // coexist under distinct keys. func (l *ledger) findAgg(target string) int { return l.find(target, "") } func (l *ledger) upsertAgg(rec record) { l.upsert(rec) } func (l *ledger) clearAgg(target string) { l.clear(target, "") } func ledgerPath(cfg *config.Config) string { return filepath.Join(cfg.Workspace, "state", ledgerName) } // loadLedger reads the cockpit ledger. A missing or empty file is empty // state; a corrupt file degrades to empty state rather than wedging the // tool (on-disk sha verification still guards every removal). func loadLedger(cfg *config.Config) (ledger, error) { var l ledger b, err := os.ReadFile(ledgerPath(cfg)) if err != nil { if errors.Is(err, os.ErrNotExist) { return l, nil } return l, fmt.Errorf("read cockpit ledger: %w", err) } if len(b) == 0 { return l, nil } if err := json.Unmarshal(b, &l); err != nil { return ledger{}, nil } return l, nil } // saveLedger writes the cockpit ledger with the indent + trailing-newline // discipline of the hooks ledger. func saveLedger(cfg *config.Config, l ledger) error { dir := filepath.Join(cfg.Workspace, "state") if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("cockpit ledger dir: %w", err) } b, err := json.MarshalIndent(l, "", " ") if err != nil { return err } return os.WriteFile(ledgerPath(cfg), append(b, '\n'), 0o644) } // sha256hex is the local 3-line dup of internal/hooks.sha256hex (the two // packages share no exported helper; duplicating it keeps cockpit free of a // hooks import for a one-liner). func sha256hex(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) }