Go 203 lines
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)
}