Go 140 lines
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 <workspace>/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 <workspace>/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[:])
}