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
}