ajhahn.de
← eeco
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)
}