ajhahn.de
← eeco
Go 126 lines
package workflow

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

	"github.com/ajhahnde/eeco/internal/queue"
)

// HistoryFilename is the evolve-history ledger filename inside
// <workspace>/state/. Frozen surface; renaming or removing it is a
// breaking change.
const HistoryFilename = "evolve-history.json"

// HistoryRecord is one entry the evolve workflow has surfaced.
//
// SignalKind and SignalKey identify the deterministic signal that
// produced the proposal (e.g. SignalCommitType + "fix") and form the
// suppression key: a recurring signal already in the ledger does not
// re-trigger a proposal.
//
// QueueKind and QueueTitle pin the proposal back to the queue row the
// workflow filed, so reconciliation can ask the queue whether the
// operator has resolved the item.
//
// Resolved and ResolvedAt are additive: omitted from the wire when
// false/empty so older ledgers without the fields still round-trip.
// More fields may be added in later slices following the same
// additive discipline (e.g. accepted-vs-rejected disambiguation).
type HistoryRecord struct {
	SignalKind      string `json:"signal_kind"`
	SignalKey       string `json:"signal_key"`
	CountAtProposal int    `json:"count_at_proposal"`
	QueueKind       string `json:"queue_kind"`
	QueueTitle      string `json:"queue_title"`
	ProposedAt      string `json:"proposed_at"`
	Resolved        bool   `json:"resolved,omitempty"`
	ResolvedAt      string `json:"resolved_at,omitempty"`
}

// History is the on-disk shape of the evolve repetition ledger.
type History struct {
	Records []HistoryRecord `json:"records"`
}

// LoadHistory reads <stateDir>/evolve-history.json. A missing file is
// the empty ledger; a corrupt file degrades to the empty ledger so
// evolve is never wedged by a broken ledger — the next save rewrites
// it from scratch.
func LoadHistory(stateDir string) (History, error) {
	var h History
	b, err := os.ReadFile(filepath.Join(stateDir, HistoryFilename))
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return h, nil
		}
		return h, fmt.Errorf("evolve history: read: %w", err)
	}
	if len(b) == 0 {
		return h, nil
	}
	if jerr := json.Unmarshal(b, &h); jerr != nil {
		return History{}, nil
	}
	return h, nil
}

// SaveHistory writes h to disk, creating the state dir if missing.
// Marshalled with indentation and a trailing newline so the file is
// human-inspectable (mirrors hooks.json).
func SaveHistory(stateDir string, h History) error {
	if err := os.MkdirAll(stateDir, 0o755); err != nil {
		return fmt.Errorf("evolve history: state dir: %w", err)
	}
	b, err := json.MarshalIndent(h, "", "  ")
	if err != nil {
		return fmt.Errorf("evolve history: encode: %w", err)
	}
	return os.WriteFile(filepath.Join(stateDir, HistoryFilename), append(b, '\n'), 0o644)
}

// HasProposed reports whether a candidate of the given (signalKind,
// signalKey) has already been proposed. Suppression is unconditional:
// once proposed, never re-proposed, regardless of the
// record's resolved state. A re-propose-on-signal-recurrence knob is
// reserved for a follow-on slice.
func (h History) HasProposed(signalKind, signalKey string) bool {
	for _, r := range h.Records {
		if r.SignalKind == signalKind && r.SignalKey == signalKey {
			return true
		}
	}
	return false
}

// ReconcileHistory walks h's unresolved records and, for each, asks
// the queue whether the recorded (QueueKind, QueueTitle) row has been
// ticked. A ticked row flips the record to Resolved=true with
// ResolvedAt=now. Resolution is one-way: a record that is already
// Resolved is left untouched. Returns the updated ledger and a
// changed flag the caller uses to decide whether to write.
//
// Latency: reconciliation runs once per evolve invocation; there is
// no live event stream. An operator who ticks a queue item between
// runs sees the ledger update on the next `eeco run evolve`.
func ReconcileHistory(stateDir string, h History, now time.Time) (History, bool) {
	changed := false
	for i := range h.Records {
		if h.Records[i].Resolved {
			continue
		}
		ok, err := queue.Resolved(stateDir, h.Records[i].QueueKind, h.Records[i].QueueTitle)
		if err != nil || !ok {
			continue
		}
		h.Records[i].Resolved = true
		h.Records[i].ResolvedAt = now.UTC().Format(time.RFC3339)
		changed = true
	}
	return h, changed
}