package store import ( "sync" "time" "tower/internal/model" ) const defaultResolveAfter = 30 * time.Second // Store is an in-memory IssueStore. // // Responsibilities (per PLAN.md): // - Dedupe by Issue.ID // - Track FirstSeen/LastSeen // - Maintain State (Open/Acknowledged/Resolved) // - Resolve issues only after resolveAfter duration of continuous absence // - Acknowledgements are in-memory only (not persisted) // - Safe for concurrent use type Store struct { mu sync.RWMutex resolveAfter time.Duration // issues holds the latest known version of each issue keyed by stable ID. issues map[string]model.Issue // ack is an in-memory toggle keyed by issue ID. // If true and the issue is currently present, its state is Acknowledged. ack map[string]bool } // New returns a new Store. // If resolveAfter <= 0, a default of 30s is used. func New(resolveAfter time.Duration) *Store { if resolveAfter <= 0 { resolveAfter = defaultResolveAfter } return &Store{ resolveAfter: resolveAfter, issues: map[string]model.Issue{}, ack: map[string]bool{}, } } // Upsert merges "currently true" issues for this tick. // // Incoming is deduped by Issue.ID; the first instance wins for non-timestamp fields. // Timestamps/state are managed by the store. func (s *Store) Upsert(now time.Time, incoming []model.Issue) { // Pre-dedupe without locking to keep lock hold times small. seen := make(map[string]model.Issue, len(incoming)) for _, iss := range incoming { if iss.ID == "" { // Ignore invalid issues. ID is the stable dedupe key. continue } if _, ok := seen[iss.ID]; ok { continue } seen[iss.ID] = iss } s.mu.Lock() defer s.mu.Unlock() for id, in := range seen { existing, ok := s.issues[id] if !ok || existing.State == model.StateResolved { // New issue (or a previously resolved one reappearing): start a new "episode". in.FirstSeen = now in.LastSeen = now in.State = model.StateOpen if s.ack[id] { in.State = model.StateAcknowledged } s.issues[id] = in continue } // Existing open/acked issue: update all fields from incoming, but preserve FirstSeen. in.FirstSeen = existing.FirstSeen in.LastSeen = now in.State = model.StateOpen if s.ack[id] { in.State = model.StateAcknowledged } s.issues[id] = in } // Update resolved state for issues not present this tick. s.applyResolutionsLocked(now, seen) } // Snapshot returns a point-in-time copy of all known issues with their states updated // according to resolveAfter. func (s *Store) Snapshot(now time.Time) []model.Issue { s.mu.Lock() defer s.mu.Unlock() // Apply resolutions based on time. We don't know which IDs are present "this tick" // from Snapshot alone, so we only resolve by absence window (LastSeen age). s.applyResolutionsLocked(now, nil) out := make([]model.Issue, 0, len(s.issues)) for _, iss := range s.issues { out = append(out, deepCopyIssue(iss)) } return out } // Acknowledge marks an issue acknowledged (in-memory only). func (s *Store) Acknowledge(id string) { if id == "" { return } s.mu.Lock() defer s.mu.Unlock() s.ack[id] = true iss, ok := s.issues[id] if !ok { return } if iss.State != model.StateResolved { iss.State = model.StateAcknowledged s.issues[id] = iss } } // Unacknowledge clears the acknowledgement toggle (in-memory only). func (s *Store) Unacknowledge(id string) { if id == "" { return } s.mu.Lock() defer s.mu.Unlock() delete(s.ack, id) iss, ok := s.issues[id] if !ok { return } if iss.State != model.StateResolved { iss.State = model.StateOpen s.issues[id] = iss } } func (s *Store) applyResolutionsLocked(now time.Time, present map[string]model.Issue) { for id, iss := range s.issues { // If caller provided a present set and the ID is present, it cannot be resolved. if present != nil { if _, ok := present[id]; ok { continue } } if iss.State == model.StateResolved { continue } if s.resolveAfter > 0 && now.Sub(iss.LastSeen) >= s.resolveAfter { iss.State = model.StateResolved s.issues[id] = iss } } } func deepCopyIssue(in model.Issue) model.Issue { out := in if in.Evidence != nil { m := make(map[string]string, len(in.Evidence)) for k, v := range in.Evidence { m[k] = v } out.Evidence = m } return out }