mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
88d44099aa
Each tenant monitor owns its own IncidentStore, but incident and incident-event IDs came from package-level int64 counters bumped with ++ under nothing but the calling store's mutex. Two tenants recording alerts at once raced on them: go test -race failed TestLeavingMockModeClearsEveryRunningTenantMonitor on PR 2698 at incidents.go:1753. The remediation record counter had the same shape, and remediation logs are created per org. All six internal/ai/memory ID generators now mint through one atomic helper. The numeric suffix also stops wrapping at 1000: even with atomic counters, one generator minting more than a thousand IDs in a wall-clock second repeated an ID, and incident notes find their incident by ID. The prefix and timestamp layout is unchanged, and intToString lost its last callers. ids_test.go mints 2,400 IDs from every generator on 16 goroutines and records alerts into two tenant stores concurrently. Against the old files it reports DATA RACE at incidents.go:1753/1760 and remediation.go:355, and every generator mints duplicate or wrapped IDs.
134 lines
4.4 KiB
Go
134 lines
4.4 KiB
Go
// Package memory provides operational memory for AI context.
|
|
// It keeps legacy infrastructure change history, remediation actions, and their
|
|
// outcomes to enable the AI to provide more informed, experience-based
|
|
// recommendations.
|
|
package memory
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ChangeType represents the type of infrastructure change detected
|
|
type ChangeType string
|
|
|
|
const (
|
|
ChangeCreated ChangeType = "created" // New resource appeared
|
|
ChangeDeleted ChangeType = "deleted" // Resource removed
|
|
ChangeConfig ChangeType = "config" // Configuration changed (RAM, CPU, etc)
|
|
ChangeStatus ChangeType = "status" // Status changed (started, stopped, paused)
|
|
ChangeMigrated ChangeType = "migrated" // Moved to different node
|
|
ChangeRestarted ChangeType = "restarted" // Resource was restarted
|
|
ChangeBackedUp ChangeType = "backed_up" // Backup completed
|
|
)
|
|
|
|
// Change represents a detected change to infrastructure
|
|
type Change struct {
|
|
ID string `json:"id"`
|
|
ResourceID string `json:"resource_id"`
|
|
ResourceType string `json:"resource_type"` // vm, container, node, storage
|
|
ResourceName string `json:"resource_name"`
|
|
ChangeType ChangeType `json:"change_type"`
|
|
Before interface{} `json:"before,omitempty"`
|
|
After interface{} `json:"after,omitempty"`
|
|
DetectedAt time.Time `json:"detected_at"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// ChangeDetector serves legacy infrastructure change history.
|
|
//
|
|
// Recent-change context comes from the unified-resource timeline; the detector
|
|
// is only its fallback. Its history (ai_changes.json) was written by releases
|
|
// that still ran change detection, which patrol dropped when it moved to
|
|
// agentic execution. The history is loaded once at construction and never
|
|
// modified afterwards, so reads need no lock.
|
|
type ChangeDetector struct {
|
|
changes []Change
|
|
maxChanges int
|
|
|
|
// dataDir holds the legacy history read by loadFromDisk.
|
|
dataDir string
|
|
}
|
|
|
|
// ChangeDetectorConfig configures the change detector
|
|
type ChangeDetectorConfig struct {
|
|
MaxChanges int // Maximum changes to retain (default: 1000)
|
|
DataDir string // Directory for persistence
|
|
}
|
|
|
|
// NewChangeDetector creates a new change detector
|
|
func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector {
|
|
if cfg.MaxChanges <= 0 {
|
|
cfg.MaxChanges = 1000
|
|
}
|
|
|
|
d := &ChangeDetector{
|
|
changes: make([]Change, 0),
|
|
maxChanges: cfg.MaxChanges,
|
|
dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir),
|
|
}
|
|
|
|
// Load existing changes from disk
|
|
if cfg.DataDir != "" {
|
|
if err := d.loadFromDisk(); err != nil {
|
|
log.Warn().Err(err).Msg("failed to load change history from disk")
|
|
} else if len(d.changes) > 0 {
|
|
log.Info().Int("count", len(d.changes)).Msg("loaded change history from disk")
|
|
}
|
|
}
|
|
|
|
return d
|
|
}
|
|
|
|
// GetChangesForResource returns recent changes for a specific resource
|
|
func (d *ChangeDetector) GetChangesForResource(resourceID string, limit int) []Change {
|
|
var result []Change
|
|
// Iterate in reverse to get most recent first
|
|
for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- {
|
|
if d.changes[i].ResourceID == resourceID {
|
|
result = append(result, d.changes[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetRecentChanges returns the most recent changes across all resources
|
|
func (d *ChangeDetector) GetRecentChanges(limit int, since time.Time) []Change {
|
|
var result []Change
|
|
for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- {
|
|
if d.changes[i].DetectedAt.After(since) {
|
|
result = append(result, d.changes[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetChangesSummary returns a formatted summary of recent changes for AI context
|
|
func (d *ChangeDetector) GetChangesSummary(since time.Time, maxChanges int) string {
|
|
changes := d.GetRecentChanges(maxChanges, since)
|
|
return FormatRecentChangesContext(changes, false, "##")
|
|
}
|
|
|
|
// trimChanges removes old changes beyond maxChanges
|
|
func (d *ChangeDetector) trimChanges() {
|
|
if len(d.changes) > d.maxChanges {
|
|
// Keep most recent
|
|
d.changes = d.changes[len(d.changes)-d.maxChanges:]
|
|
}
|
|
}
|
|
|
|
// loadFromDisk loads the legacy change history from its JSON file
|
|
func (d *ChangeDetector) loadFromDisk() error {
|
|
changes, ok, err := loadMemoryHistory(d.dataDir, changeHistoryFileName, "change history", func(a, b Change) bool {
|
|
return a.DetectedAt.Before(b.DetectedAt)
|
|
})
|
|
if err != nil || !ok {
|
|
return err
|
|
}
|
|
|
|
d.changes = changes
|
|
d.trimChanges()
|
|
return nil
|
|
}
|