Files
pulse/internal/ai/memory/changes.go
T
rcourtman 88d44099aa Stop tenant incident stores racing on shared ID counters
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.
2026-10-07 20:12:04 +01:00

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
}