package workflow import ( "fmt" "path/filepath" "strings" "time" "github.com/ajhahnde/eeco/internal/cockpit" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/playbooks" "github.com/ajhahnde/eeco/internal/queue" ) // cockpitSync flags drift between the generated cockpit artifacts and their // neutral playbook sources. It runs cockpit.Sync, which reports three // staleness classes: an artifact hand-edited or left behind by an eeco // upgrade (drifted), an active target/playbook never emitted (missing), and // a deselected target whose file remains (orphan). It is the cockpit slice // of the drift-detection family, sibling to doc-drift and memory-drift. // // It needs no git (pure render + disk + ledger), so it never blocks: only // CodeClean or CodeFinding. cockpit.Sync's empty-ledger gate makes it a // silent no-op on a repo where the cockpit was never generated, so it is // safe to wire into the post-merge default. // // Behavior depends on the automation level (locked C4 decision #3). At // `automation=auto` (Automation.ReconcilesCockpit) it RECONCILES: drifted and // missing artifacts are regenerated deterministically (a render→write into the // gitignored tree, the standing consent of `auto`); orphan and safety findings // still queue, since removing a file is destructive and a safety violation must // surface to the operator. At every lower level it stays detect-only: one queue // item per finding routed to the single decision channel (AppendUnique, so a // repeated run does not pile up duplicates), exactly as C3 shipped. type cockpitSync struct{} func (cockpitSync) Name() string { return "cockpit-sync" } func (cockpitSync) Summary() string { return "flag (or, at automation=auto, regenerate) drift between cockpit artifacts and their sources" } func (cockpitSync) Run(env Env) (Result, error) { cfg := env.Config report, err := cockpit.Sync(cfg, playbooks.All()) if err != nil { return Result{}, fmt.Errorf("cockpit-sync: %w", err) } if report.Clean { return Result{Code: CodeClean, Summary: "cockpit artifacts match their sources"}, nil } if cfg.Automation.ReconcilesCockpit() { return reconcileCockpit(cfg, report) } return queueCockpitFindings(cfg, report.Findings) } // queueCockpitFindings routes one AppendUnique-deduped queue item per finding // to the single decision channel and returns a CodeFinding result carrying the // same findings as workflow output (the C3 detect-only behavior, also the // auto-mode fallback for orphan/safety findings). func queueCockpitFindings(cfg *config.Config, fs []cockpit.SyncFinding) (Result, error) { if err := appendSyncQueue(cfg, fs); err != nil { return Result{}, err } findings := make([]Finding, 0, len(fs)) for _, f := range fs { findings = append(findings, syncFinding(f)) } return Result{ Code: CodeFinding, Summary: fmt.Sprintf("%d cockpit drift finding(s)", len(findings)), Findings: findings, }, nil } // reconcileCockpit regenerates the drifted and missing artifacts in report // (the `auto` standing consent) and queues the rest (orphans are destructive to // remove, safety violations must surface — both stay operator-in-the-loop). A // regeneration that fails (e.g. a safety refusal) falls back to a queue item // rather than wedging the merge. func reconcileCockpit(cfg *config.Config, report cockpit.SyncReport) (Result, error) { sel := cockpit.LoadSelection(cfg) resolved := resolveSelectedPlaybooks(playbooks.All(), sel.Playbooks) var toQueue []cockpit.SyncFinding var findings []Finding regenerated := 0 for _, f := range report.Findings { if (f.Kind == "drifted" || f.Kind == "missing") && regenerateFinding(cfg, f, resolved) == nil { regenerated++ findings = append(findings, Finding{Path: cockpitSyncLoc(f), Line: 0, Msg: "regenerated (" + f.Kind + ")"}) continue } toQueue = append(toQueue, f) } if err := appendSyncQueue(cfg, toQueue); err != nil { return Result{}, err } if len(toQueue) == 0 { return Result{Code: CodeClean, Summary: fmt.Sprintf("regenerated %d cockpit artifact(s)", regenerated)}, nil } for _, f := range toQueue { findings = append(findings, syncFinding(f)) } return Result{ Code: CodeFinding, Summary: fmt.Sprintf("%d regenerated, %d need a decision", regenerated, len(toQueue)), Findings: findings, }, nil } // regenerateFinding regenerates the artifact a drifted/missing finding points // at: the whole shared file for an aggregate target, or the single playbook for // a per-playbook target. resolved is the active playbook set (aggregate re-emit // always writes the whole set). It returns Generate's error unchanged so the // caller can fall back to queuing on a refusal. func regenerateFinding(cfg *config.Config, f cockpit.SyncFinding, resolved []cockpit.Playbook) error { if cockpit.IsAggregateTarget(f.Target) { _, err := cockpit.GenerateAll(cfg, resolved, f.Target) return err } pb, err := playbooks.Get(f.Playbook) if err != nil { return err } _, err = cockpit.Generate(cfg, pb, f.Target) return err } // resolveSelectedPlaybooks returns the playbook subset a selection emits: all // when the selection does not narrow them, else the members of all named in // narrow (Name-ordered, unknown names skipped). It mirrors the cmd layer's // resolvePlaybooks for the aggregate re-emit set. func resolveSelectedPlaybooks(all []cockpit.Playbook, narrow []string) []cockpit.Playbook { if len(narrow) == 0 { return all } want := make(map[string]bool, len(narrow)) for _, n := range narrow { want[n] = true } out := make([]cockpit.Playbook, 0, len(narrow)) for _, pb := range all { if want[pb.Name] { out = append(out, pb) } } return out } // appendSyncQueue routes one AppendUnique-deduped queue item per finding to the // single decision channel. A no-op for an empty list. func appendSyncQueue(cfg *config.Config, fs []cockpit.SyncFinding) error { if len(fs) == 0 { return nil } project := filepath.Base(cfg.RepoRoot) stateDir := filepath.Join(cfg.Workspace, "state") today := time.Now().UTC() for _, f := range fs { item := queue.Item{ Kind: "cockpit-sync", Title: cockpitSyncTitle(f), Project: project, Detail: f.Detail, Date: today, } if _, aerr := queue.AppendUnique(stateDir, item); aerr != nil { return fmt.Errorf("cockpit-sync: queue: %w", aerr) } } return nil } // syncFinding renders one SyncFinding as workflow output. The report line // carries the location in Path, so the matching prefix is stripped off Detail // to avoid "claude/handover: claude/handover: …"; the queued item keeps the // full self-contained Detail. func syncFinding(f cockpit.SyncFinding) Finding { loc := cockpitSyncLoc(f) return Finding{Path: loc, Line: 0, Msg: strings.TrimPrefix(f.Detail, loc+": ")} } // cockpitSyncLoc is the target[/playbook] label for a finding. func cockpitSyncLoc(f cockpit.SyncFinding) string { if f.Playbook != "" { return f.Target + "/" + f.Playbook } return f.Target } // cockpitSyncTitle is the queue row title. It must be stable across runs for // AppendUnique to dedup (the dedup key is Kind+Title), so it is derived only // from the finding's location and kind, never from a timestamp. func cockpitSyncTitle(f cockpit.SyncFinding) string { action := "run `eeco cockpit generate`" if f.Kind == "orphan" { action = "run `eeco cockpit off --target " + f.Target + "`" } return fmt.Sprintf("%s %s — %s", cockpitSyncLoc(f), f.Kind, action) }