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 // /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 /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 }