diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 31a32302e4..8c14451813 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -6633,15 +6633,17 @@ The patrol-local `memory.ChangeDetector.GetChangesSummary` path now also delegates to the shared memory recent-change presentation helper, so any future fallback summary entry point inherits the same heading, resource prefixing, and change-type labels without re-implementing the markdown shape. -That detector's on-disk history (`ai_changes.json`) is legacy and read-only. -Nothing in the runtime has called `DetectChanges` since patrol moved to -agentic execution, so the detector only loads that file at construction and -never writes it. Detected changes stay in memory, and there is no background -save to join before a data directory is removed. The unjoined save goroutine -that `DetectChanges` used to launch was reachable only from tests, and could -outlive a `t.TempDir` cleanup; it was removed rather than hardened. -`TestChangeDetector_LegacyHistoryIsReadOnly` proves detection leaves the file -and directory untouched. +That detector is a reader of legacy history, not a detector. Its on-disk +history (`ai_changes.json`) was written by releases that still ran change +detection, which patrol dropped when it moved to agentic execution. The +detector loads that file once at construction and never modifies it, so it +holds no lock and has no background save to join before a data directory is +removed. `DetectChanges`, its snapshot diffing and `ResourceSnapshot` were +reachable only from tests and are retired; new change history belongs in the +unified-resource timeline, not in this fallback. Tests seed the fallback +through a legacy `ai_changes.json`, the same path production reads, and +`TestChangeDetector_LegacyHistoryIsReadOnly` proves reads leave the file and +directory untouched. Those unified-resource action and export audit records are now also exposed through the enterprise audit read surface so operators can inspect the execution trail without reaching into storage internals. diff --git a/internal/ai/coverage_increase_test.go b/internal/ai/coverage_increase_test.go index 38a9632555..4d2b58749a 100644 --- a/internal/ai/coverage_increase_test.go +++ b/internal/ai/coverage_increase_test.go @@ -338,18 +338,7 @@ func TestService_BuildEnrichedResourceContext(t *testing.T) { s.mu.Unlock() // Case 8: With Change Logic - cd := NewChangeDetector(memory.ChangeDetectorConfig{}) - // Simulate a "created" change - cd.DetectChanges([]memory.ResourceSnapshot{ - { - ID: "res1", - Name: "res1", - Type: "vm", - Status: "running", - SnapshotTime: now, - }, - }) - // Force persistence flush/processing if needed (DetectChanges does it async but returns changes immediately) + cd := newLegacyChangeDetector(t, legacyCreatedChange("res1", "res1", "vm")) ps.SetChangeDetector(cd) @@ -607,16 +596,13 @@ func TestService_BuildRelationshipContext_UsesCanonicalReadState(t *testing.T) { func TestService_BuildRecentResourceChangesContext_FallsBackToMemoryFormatter(t *testing.T) { s := &Service{} ps := NewPatrolService(nil, nil) - cd := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10}) - cd.DetectChanges([]ResourceSnapshot{ - {ID: "res-fallback", Name: "fallback-resource", Type: "vm", Status: "running", SnapshotTime: time.Now()}, - }) + cd := newLegacyChangeDetector(t, legacyCreatedChange("res-fallback", "fallback-resource", "vm")) ps.SetChangeDetector(cd) s.patrolService = ps got := s.buildRecentResourceChangesContext("res-fallback") want := memory.FormatRecentChangesContext(cd.GetChangesForResource("res-fallback", 5), false, "###") - if got != want { + if got == "" || got != want { t.Fatalf("expected fallback recent-resource-changes context to use shared memory formatter:\nwant %q\n got %q", want, got) } } diff --git a/internal/ai/intelligence_coverage_test.go b/internal/ai/intelligence_coverage_test.go index 3abfbe2d7c..e2d5a2d218 100644 --- a/internal/ai/intelligence_coverage_test.go +++ b/internal/ai/intelligence_coverage_test.go @@ -740,8 +740,7 @@ func TestIntelligence_GetSummary_WithSubsystems(t *testing.T) { patternDetector.RecordEvent(patterns.HistoricalEvent{ResourceID: "vm-sum", EventType: patterns.EventHighCPU, Timestamp: patternStart}) patternDetector.RecordEvent(patterns.HistoricalEvent{ResourceID: "vm-sum", EventType: patterns.EventHighCPU, Timestamp: patternStart.Add(60 * time.Minute)}) - changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) - changes.DetectChanges([]memory.ResourceSnapshot{{ID: "vm-sum", Name: "vm-sum", Type: "vm", Status: "running", SnapshotTime: time.Now()}}) + changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-sum", "vm-sum", "vm")) remediations := memory.NewRemediationLog(memory.RemediationLogConfig{MaxRecords: 10}) _ = remediations.Log(memory.RemediationRecord{ResourceID: "vm-sum", Problem: "cpu", Action: "restart", Outcome: memory.OutcomeResolved}) @@ -762,7 +761,7 @@ func TestIntelligence_GetSummary_WithSubsystems(t *testing.T) { t.Error("expected recent changes in summary") } if len(summary.RecentChanges) == 0 { - t.Error("expected recent change entries in summary") + t.Fatal("expected recent change entries in summary") } if summary.RecentChanges[0].SourceType != ur.SourceHeuristic { t.Fatalf("expected heuristic source type for fallback summary, got %s", summary.RecentChanges[0].SourceType) diff --git a/internal/ai/intelligence_test.go b/internal/ai/intelligence_test.go index f6f2a1a64b..19309b54d4 100644 --- a/internal/ai/intelligence_test.go +++ b/internal/ai/intelligence_test.go @@ -1,6 +1,9 @@ package ai import ( + "encoding/json" + "os" + "path/filepath" "strings" "testing" "time" @@ -13,6 +16,49 @@ import ( ur "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) +// newLegacyChangeDetector loads changes the only way the detector still gets +// them in production: from a legacy ai_changes.json in its data directory. +func newLegacyChangeDetector(t *testing.T, changes ...memory.Change) *memory.ChangeDetector { + t.Helper() + dir := t.TempDir() + data, err := json.Marshal(changes) + if err != nil { + t.Fatalf("marshal legacy change history: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "ai_changes.json"), data, 0o600); err != nil { + t.Fatalf("write legacy change history: %v", err) + } + return memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10, DataDir: dir}) +} + +// legacyCreatedChange is a "created" entry as the retired detector recorded it. +func legacyCreatedChange(resourceID, name, resourceType string) memory.Change { + return memory.Change{ + ID: "legacy-created-" + resourceID, + ResourceID: resourceID, + ResourceType: resourceType, + ResourceName: name, + ChangeType: memory.ChangeCreated, + DetectedAt: time.Now(), + Description: resourceType + " '" + name + "' created", + } +} + +// legacyStatusChange is a status transition as the retired detector recorded it. +func legacyStatusChange(resourceID, name, resourceType, before, after string) memory.Change { + return memory.Change{ + ID: "legacy-status-" + resourceID, + ResourceID: resourceID, + ResourceType: resourceType, + ResourceName: name, + ChangeType: memory.ChangeStatus, + Before: before, + After: after, + DetectedAt: time.Now(), + Description: "'" + name + "' status changed: " + before + " → " + after, + } +} + func TestNewIntelligence(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{DataDir: "/tmp/test"}) if intel == nil { @@ -329,10 +375,7 @@ func TestIntelligence_GetResourceIntelligence_WithSubsystems(t *testing.T) { func TestIntelligence_GetResourceIntelligence_FallsBackToChangeDetector(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) - changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) - changes.DetectChanges([]memory.ResourceSnapshot{ - {ID: "vm-rc", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()}, - }) + changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-rc", "resource-vm", "vm")) intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) @@ -374,10 +417,7 @@ func TestIntelligence_GetRecentChanges_UsesCanonicalTimeline(t *testing.T) { func TestIntelligence_GetRecentChanges_FallsBackToMemoryDetector(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) - changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) - changes.DetectChanges([]memory.ResourceSnapshot{ - {ID: "vm-fallback", Name: "resource-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()}, - }) + changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-fallback", "resource-vm", "vm")) intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) recent := intel.GetRecentChanges(time.Now().Add(-time.Hour), 100) @@ -483,10 +523,7 @@ func TestIntelligence_FormatContext_WithKnowledge(t *testing.T) { func TestIntelligence_FormatContext_IncludesCanonicalRecentChanges(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) - changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) - changes.DetectChanges([]memory.ResourceSnapshot{ - {ID: "vm-300", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()}, - }) + changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-300", "fallback-vm", "vm")) intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) canonicalStore := ur.NewMemoryStore() @@ -514,10 +551,7 @@ func TestIntelligence_FormatContext_IncludesCanonicalRecentChanges(t *testing.T) func TestIntelligence_FormatContext_FallsBackToChangeDetector(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) - changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) - changes.DetectChanges([]memory.ResourceSnapshot{ - {ID: "vm-301", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()}, - }) + changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-301", "fallback-vm", "vm")) intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) ctx := intel.FormatContext("vm-301") @@ -559,10 +593,7 @@ func TestIntelligence_FormatGlobalContext_IncludesCanonicalRecentChanges(t *test func TestIntelligence_FormatGlobalContext_FallsBackToChangeDetector(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) - changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) - changes.DetectChanges([]memory.ResourceSnapshot{ - {ID: "node-fallback", Name: "fallback-node", Type: "node", Status: "running", SnapshotTime: time.Now()}, - }) + changes := newLegacyChangeDetector(t, legacyCreatedChange("node-fallback", "fallback-node", "node")) intel.SetSubsystems(nil, nil, nil, nil, nil, nil, changes, nil) ctx := intel.FormatGlobalContext() @@ -603,14 +634,11 @@ func TestIntelligence_BuildRecentChangesContext_UsesCanonicalSectionFormatter(t func TestIntelligence_BuildRecentChangesContext_FallsBackToMemoryFormatter(t *testing.T) { intel := NewIntelligence(IntelligenceConfig{}) - changes := memory.NewChangeDetector(memory.ChangeDetectorConfig{MaxChanges: 10}) - changes.DetectChanges([]memory.ResourceSnapshot{ - {ID: "vm-302", Name: "fallback-vm", Type: "vm", Status: "running", SnapshotTime: time.Now()}, - }) + changes := newLegacyChangeDetector(t, legacyCreatedChange("vm-302", "fallback-vm", "vm")) ctx := intel.buildRecentChangesContext("vm-302", nil, changes, true, 5) want := memory.FormatRecentChangesContext(changes.GetChangesForResource("vm-302", 5), true, "##") - if ctx != want { + if ctx == "" || ctx != want { t.Fatalf("expected fallback recent-changes context to use shared memory formatter:\nwant %q\n got %q", want, ctx) } } diff --git a/internal/ai/memory/change_detector_memory_regression_test.go b/internal/ai/memory/change_detector_memory_regression_test.go deleted file mode 100644 index 0c6fb6d490..0000000000 --- a/internal/ai/memory/change_detector_memory_regression_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package memory - -import ( - "fmt" - "runtime" - "runtime/debug" - "testing" - "time" -) - -func TestChangeDetectorMemoryStability(t *testing.T) { - if testing.Short() { - t.Skip("skipping memory regression in short mode") - } - - detector := NewChangeDetector(ChangeDetectorConfig{ - MaxChanges: 50, - }) - - resourcesPerCycle := 120 - warmupCycles := 4 - measureCycles := 12 - - buildSnapshots := func(offset int, status string) []ResourceSnapshot { - now := time.Now() - snapshots := make([]ResourceSnapshot, 0, resourcesPerCycle) - for i := 0; i < resourcesPerCycle; i++ { - snapshots = append(snapshots, ResourceSnapshot{ - ID: fmt.Sprintf("vm-%03d", i), - Name: fmt.Sprintf("vm-%03d", i), - Type: "vm", - Status: status, - Node: fmt.Sprintf("node-%d", i%3), - CPUCores: 2 + ((i + offset) % 4), - MemoryBytes: int64(2+(i+offset)%4) * 1024 * 1024 * 1024, - DiskBytes: int64(20+i%10) * 1024 * 1024 * 1024, - LastBackup: now.Add(-time.Duration((i+offset)%5) * time.Hour), - SnapshotTime: now, - }) - } - return snapshots - } - - for i := 0; i < warmupCycles; i++ { - status := "running" - if i%2 == 1 { - status = "stopped" - } - detector.DetectChanges(buildSnapshots(i*resourcesPerCycle, status)) - } - - runtime.GC() - debug.FreeOSMemory() - var baseline runtime.MemStats - runtime.ReadMemStats(&baseline) - - for i := 0; i < measureCycles; i++ { - status := "running" - if i%2 == 1 { - status = "stopped" - } - detector.DetectChanges(buildSnapshots(100000+i*resourcesPerCycle, status)) - } - - runtime.GC() - debug.FreeOSMemory() - var after runtime.MemStats - runtime.ReadMemStats(&after) - - if baseline.HeapAlloc > 0 { - allowed := baseline.HeapAlloc + 5*1024*1024 - growthRatio := float64(after.HeapAlloc) / float64(baseline.HeapAlloc) - if after.HeapAlloc > allowed && growthRatio > 1.25 { - t.Fatalf("heap allocation grew too much: baseline=%d final=%d ratio=%.2f", baseline.HeapAlloc, after.HeapAlloc, growthRatio) - } - } -} diff --git a/internal/ai/memory/changes.go b/internal/ai/memory/changes.go index 17627d3d22..0771e9cd71 100644 --- a/internal/ai/memory/changes.go +++ b/internal/ai/memory/changes.go @@ -1,11 +1,10 @@ // Package memory provides operational memory for AI context. -// It tracks changes to infrastructure, remediation actions, and their outcomes -// to enable the AI to provide more informed, experience-based recommendations. +// 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 ( - "sync" - "sync/atomic" "time" "github.com/rs/zerolog/log" @@ -37,31 +36,16 @@ type Change struct { Description string `json:"description"` } -// ResourceSnapshot captures key attributes for change detection -type ResourceSnapshot struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Node string `json:"node,omitempty"` - CPUCores int `json:"cpu_cores,omitempty"` - MemoryBytes int64 `json:"memory_bytes,omitempty"` - DiskBytes int64 `json:"disk_bytes,omitempty"` - LastBackup time.Time `json:"last_backup,omitempty"` - SnapshotTime time.Time `json:"snapshot_time"` -} - -// ChangeDetector tracks infrastructure changes over time. +// ChangeDetector serves legacy infrastructure change history. // // Recent-change context comes from the unified-resource timeline; the detector -// is only its fallback. Its on-disk history (ai_changes.json) is legacy data -// from before patrol stopped calling DetectChanges: it is loaded once at -// construction and never written, so changes detected now stay in memory. +// 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 { - mu sync.RWMutex - previousState map[string]ResourceSnapshot // resourceID -> snapshot - changes []Change - maxChanges int + changes []Change + maxChanges int // dataDir holds the legacy history read by loadFromDisk. dataDir string @@ -80,10 +64,9 @@ func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector { } d := &ChangeDetector{ - previousState: make(map[string]ResourceSnapshot), - changes: make([]Change, 0), - maxChanges: cfg.MaxChanges, - dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir), + changes: make([]Change, 0), + maxChanges: cfg.MaxChanges, + dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir), } // Load existing changes from disk @@ -98,167 +81,8 @@ func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector { return d } -// DetectChanges compares current snapshots to previous state and detects changes -func (d *ChangeDetector) DetectChanges(currentSnapshots []ResourceSnapshot) []Change { - d.mu.Lock() - defer d.mu.Unlock() - - now := time.Now() - var newChanges []Change - - // Track which resources we've seen in current snapshot - currentIDs := make(map[string]bool) - - for _, current := range currentSnapshots { - currentIDs[current.ID] = true - - prev, exists := d.previousState[current.ID] - if !exists { - // New resource - change := Change{ - ID: generateChangeID(), - ResourceID: current.ID, - ResourceType: current.Type, - ResourceName: current.Name, - ChangeType: ChangeCreated, - After: current, - DetectedAt: now, - Description: formatCreateDescription(current), - } - newChanges = append(newChanges, change) - } else { - // Check for changes - changes := d.detectResourceChanges(prev, current, now) - newChanges = append(newChanges, changes...) - } - - // Update previous state - d.previousState[current.ID] = current - } - - // Check for deleted resources - for id, prev := range d.previousState { - if !currentIDs[id] { - change := Change{ - ID: generateChangeID(), - ResourceID: id, - ResourceType: prev.Type, - ResourceName: prev.Name, - ChangeType: ChangeDeleted, - Before: prev, - DetectedAt: now, - Description: formatDeleteDescription(prev), - } - newChanges = append(newChanges, change) - delete(d.previousState, id) - } - } - - // Store new changes in memory only; the legacy history is read-only. - if len(newChanges) > 0 { - d.changes = append(d.changes, newChanges...) - d.trimChanges() - } - - return newChanges -} - -// detectResourceChanges checks for changes between two snapshots of the same resource -func (d *ChangeDetector) detectResourceChanges(prev, current ResourceSnapshot, now time.Time) []Change { - var changes []Change - - // Status change - if prev.Status != current.Status { - change := Change{ - ID: generateChangeID(), - ResourceID: current.ID, - ResourceType: current.Type, - ResourceName: current.Name, - ChangeType: ChangeStatus, - Before: prev.Status, - After: current.Status, - DetectedAt: now, - Description: formatStatusDescription(current.Name, prev.Status, current.Status), - } - changes = append(changes, change) - } - - // Node change (migration) - if prev.Node != "" && current.Node != "" && prev.Node != current.Node { - change := Change{ - ID: generateChangeID(), - ResourceID: current.ID, - ResourceType: current.Type, - ResourceName: current.Name, - ChangeType: ChangeMigrated, - Before: prev.Node, - After: current.Node, - DetectedAt: now, - Description: formatMigrationDescription(current.Name, prev.Node, current.Node), - } - changes = append(changes, change) - } - - // CPU change - if prev.CPUCores > 0 && current.CPUCores > 0 && prev.CPUCores != current.CPUCores { - change := Change{ - ID: generateChangeID(), - ResourceID: current.ID, - ResourceType: current.Type, - ResourceName: current.Name, - ChangeType: ChangeConfig, - Before: map[string]int{"cpu_cores": prev.CPUCores}, - After: map[string]int{"cpu_cores": current.CPUCores}, - DetectedAt: now, - Description: formatCPUChangeDescription(current.Name, prev.CPUCores, current.CPUCores), - } - changes = append(changes, change) - } - - // Memory change (significant change > 5%) - if prev.MemoryBytes > 0 && current.MemoryBytes > 0 { - pctChange := float64(current.MemoryBytes-prev.MemoryBytes) / float64(prev.MemoryBytes) - if pctChange > 0.05 || pctChange < -0.05 { - change := Change{ - ID: generateChangeID(), - ResourceID: current.ID, - ResourceType: current.Type, - ResourceName: current.Name, - ChangeType: ChangeConfig, - Before: map[string]int64{"memory_bytes": prev.MemoryBytes}, - After: map[string]int64{"memory_bytes": current.MemoryBytes}, - DetectedAt: now, - Description: formatMemoryChangeDescription(current.Name, prev.MemoryBytes, current.MemoryBytes), - } - changes = append(changes, change) - } - } - - // Backup completed - if !prev.LastBackup.IsZero() && !current.LastBackup.IsZero() && - current.LastBackup.After(prev.LastBackup) { - change := Change{ - ID: generateChangeID(), - ResourceID: current.ID, - ResourceType: current.Type, - ResourceName: current.Name, - ChangeType: ChangeBackedUp, - Before: prev.LastBackup, - After: current.LastBackup, - DetectedAt: now, - Description: formatBackupDescription(current.Name, current.LastBackup), - } - changes = append(changes, change) - } - - return changes -} - // GetChangesForResource returns recent changes for a specific resource func (d *ChangeDetector) GetChangesForResource(resourceID string, limit int) []Change { - d.mu.RLock() - defer d.mu.RUnlock() - var result []Change // Iterate in reverse to get most recent first for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- { @@ -271,9 +95,6 @@ func (d *ChangeDetector) GetChangesForResource(resourceID string, limit int) []C // GetRecentChanges returns the most recent changes across all resources func (d *ChangeDetector) GetRecentChanges(limit int, since time.Time) []Change { - d.mu.RLock() - defer d.mu.RUnlock() - var result []Change for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- { if d.changes[i].DetectedAt.After(since) { @@ -313,41 +134,6 @@ func (d *ChangeDetector) loadFromDisk() error { // Helper functions -var changeCounter atomic.Uint64 - -func generateChangeID() string { - count := changeCounter.Add(1) - return time.Now().Format("20060102150405") + "-" + intToString(int(count%1000)) -} - -func formatBytes(bytes int64) string { - const ( - KB = 1024 - MB = KB * 1024 - GB = MB * 1024 - ) - switch { - case bytes >= GB: - return formatFloat(float64(bytes)/GB) + " GB" - case bytes >= MB: - return formatFloat(float64(bytes)/MB) + " MB" - case bytes >= KB: - return formatFloat(float64(bytes)/KB) + " KB" - default: - return intToString(int(bytes)) + " B" - } -} - -func formatFloat(v float64) string { - // Simple formatting without fmt dependency - whole := int(v) - frac := int((v - float64(whole)) * 10) - if frac == 0 { - return intToString(whole) - } - return intToString(whole) + "." + string(rune('0'+frac)) -} - func intToString(n int) string { if n == 0 { return "0" @@ -359,39 +145,3 @@ func intToString(n int) string { } return result } - -func formatCreateDescription(r ResourceSnapshot) string { - return r.Type + " '" + r.Name + "' created" -} - -func formatDeleteDescription(r ResourceSnapshot) string { - return r.Type + " '" + r.Name + "' deleted" -} - -func formatStatusDescription(name, before, after string) string { - return "'" + name + "' status changed: " + before + " → " + after -} - -func formatMigrationDescription(name, fromNode, toNode string) string { - return "'" + name + "' migrated from " + fromNode + " to " + toNode -} - -func formatCPUChangeDescription(name string, before, after int) string { - direction := "increased" - if after < before { - direction = "decreased" - } - return "'" + name + "' CPU " + direction + ": " + intToString(before) + " → " + intToString(after) + " cores" -} - -func formatMemoryChangeDescription(name string, before, after int64) string { - direction := "increased" - if after < before { - direction = "decreased" - } - return "'" + name + "' memory " + direction + ": " + formatBytes(before) + " → " + formatBytes(after) -} - -func formatBackupDescription(name string, backupTime time.Time) string { - return "'" + name + "' backup completed at " + backupTime.Format("2006-01-02 15:04") -} diff --git a/internal/ai/memory/changes_concurrency_test.go b/internal/ai/memory/changes_concurrency_test.go deleted file mode 100644 index 5ae2007bb5..0000000000 --- a/internal/ai/memory/changes_concurrency_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package memory - -import ( - "sync" - "testing" -) - -func TestGenerateChangeID_ConcurrentUnique(t *testing.T) { - changeCounter.Store(0) - - const workers = 500 - start := make(chan struct{}) - ids := make(chan string, workers) - - var wg sync.WaitGroup - wg.Add(workers) - for i := 0; i < workers; i++ { - go func() { - defer wg.Done() - <-start - ids <- generateChangeID() - }() - } - - close(start) - wg.Wait() - close(ids) - - seen := make(map[string]struct{}, workers) - for id := range ids { - if _, exists := seen[id]; exists { - t.Fatalf("duplicate change ID generated under concurrency: %s", id) - } - seen[id] = struct{}{} - } - - if len(seen) != workers { - t.Fatalf("expected %d IDs, got %d", workers, len(seen)) - } -} diff --git a/internal/ai/memory/memory_coverage_test.go b/internal/ai/memory/memory_coverage_test.go index 50b9cba439..653f05796d 100644 --- a/internal/ai/memory/memory_coverage_test.go +++ b/internal/ai/memory/memory_coverage_test.go @@ -23,21 +23,6 @@ func TestChangeDetector_DefaultsAndHelpers(t *testing.T) { if got := intToString(42); got != "42" { t.Errorf("intToString(42) = %q", got) } - if got := formatFloat(2.0); got != "2" { - t.Errorf("formatFloat(2.0) = %q", got) - } - if got := formatFloat(2.5); got != "2.5" { - t.Errorf("formatFloat(2.5) = %q", got) - } - - cpu := formatCPUChangeDescription("vm-1", 4, 2) - if !strings.Contains(cpu, "decreased") { - t.Errorf("expected cpu decrease description, got %q", cpu) - } - mem := formatMemoryChangeDescription("vm-1", 8<<30, 4<<30) - if !strings.Contains(mem, "decreased") { - t.Errorf("expected memory decrease description, got %q", mem) - } } func TestNewChangeDetector_LoadsFromDisk(t *testing.T) { diff --git a/internal/ai/memory/memory_extended_test.go b/internal/ai/memory/memory_extended_test.go index fcaafede8e..9c8067d8ab 100644 --- a/internal/ai/memory/memory_extended_test.go +++ b/internal/ai/memory/memory_extended_test.go @@ -12,104 +12,16 @@ import ( // Additional tests to improve coverage -func TestChangeDetector_ConfigChange(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // Initial state with memory - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 4 * 1024 * 1024 * 1024}, - }) - - // Memory increased - should detect config change - changes := d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 8 * 1024 * 1024 * 1024}, - }) - - if len(changes) != 1 { - t.Fatalf("Expected 1 config change, got %d", len(changes)) - } - if changes[0].ChangeType != ChangeConfig { - t.Errorf("Expected ChangeConfig, got %s", changes[0].ChangeType) - } -} - -func TestChangeDetector_DiskChange(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // Initial state with disk - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", DiskBytes: 100 * 1024 * 1024 * 1024}, - }) - - // Disk increased significantly (>5%) - should detect config change - // Note: The implementation may not track disk changes, so this test documents behavior - changes := d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", DiskBytes: 200 * 1024 * 1024 * 1024}, - }) - - // Disk changes may not be tracked by the implementation - adjust expectation - // This test documents the current behavior - _ = changes -} - -func TestChangeDetector_CPUChange(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // Initial state with CPUCores - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", CPUCores: 2}, - }) - - // CPUCores increased - should detect config change - changes := d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", CPUCores: 4}, - }) - - if len(changes) != 1 { - t.Fatalf("Expected 1 config change, got %d", len(changes)) - } - if changes[0].ChangeType != ChangeConfig { - t.Errorf("Expected ChangeConfig, got %s", changes[0].ChangeType) - } -} - -func TestChangeDetector_BackupChange(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - oldBackup := time.Now().Add(-24 * time.Hour) - newBackup := time.Now() - - // Initial state with old backup - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", LastBackup: oldBackup}, - }) - - // New backup completed - changes := d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", LastBackup: newBackup}, - }) - - if len(changes) != 1 { - t.Fatalf("Expected 1 backup change, got %d", len(changes)) - } - if changes[0].ChangeType != ChangeBackedUp { - t.Errorf("Expected ChangeBackedUp, got %s", changes[0].ChangeType) - } -} - func TestChangeDetector_GetChangesSummary(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // Create some changes - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"}, - }) + d := &ChangeDetector{maxChanges: 100, changes: []Change{ + {ID: "c1", ResourceID: "vm-100", ResourceType: "vm", ResourceName: "web-server", ChangeType: ChangeCreated, DetectedAt: time.Now(), Description: "vm 'web-server' created"}, + }} since := time.Now().Add(-1 * time.Hour) summary := d.GetChangesSummary(since, 5) want := FormatRecentChangesContext(d.GetRecentChanges(5, since), false, "##") - if summary != want { + if summary == "" || summary != want { t.Fatalf("expected shared recent-changes formatter, got %q want %q", summary, want) } } @@ -125,29 +37,10 @@ func TestChangeDetector_GetChangesSummary_NoChanges(t *testing.T) { } } -func TestChangeDetector_MultipleChangesAtOnce(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // Initial state - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1", MemoryBytes: 4 * 1024 * 1024 * 1024, CPUCores: 2}, - }) - - // Multiple changes at once: status, memory, CPUCores, and migration - changes := d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "stopped", Node: "node2", MemoryBytes: 8 * 1024 * 1024 * 1024, CPUCores: 4}, - }) - - // Should detect multiple changes - if len(changes) < 2 { - t.Errorf("Expected multiple changes, got %d", len(changes)) - } -} - func TestChangeDetector_LegacyHistoryIsReadOnly(t *testing.T) { tmpDir := t.TempDir() path := filepath.Join(tmpDir, "ai_changes.json") - legacy := []byte(`[{"id":"legacy-1","resource_id":"vm-100","resource_type":"vm","change_type":"created","detected_at":"2026-01-01T00:00:00Z"}]`) + legacy := []byte(`[{"id":"legacy-1","resource_id":"vm-100","resource_type":"vm","resource_name":"web-server","change_type":"created","detected_at":"2026-01-01T00:00:00Z","description":"vm 'web-server' created"}]`) if err := os.WriteFile(path, legacy, 0600); err != nil { t.Fatalf("write legacy history: %v", err) } @@ -156,17 +49,16 @@ func TestChangeDetector_LegacyHistoryIsReadOnly(t *testing.T) { MaxChanges: 100, DataDir: tmpDir, }) - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"}, - }) - // Detected changes join the loaded legacy history in memory. - if got := d.GetChangesForResource("vm-100", 10); len(got) != 2 { - t.Fatalf("expected legacy and detected change, got %d", len(got)) + // The legacy history is served through the read APIs. + if got := d.GetChangesForResource("vm-100", 10); len(got) != 1 || got[0].ID != "legacy-1" { + t.Fatalf("expected the legacy change, got %v", got) + } + if summary := d.GetChangesSummary(time.Time{}, 10); !containsStr(summary, "web-server") { + t.Fatalf("expected the legacy change in the summary, got %q", summary) } - // Nothing writes the data directory, so no background save can outlive - // the test and race TempDir cleanup. + // Nothing rewrites the file or adds to the data directory. entries, err := os.ReadDir(tmpDir) if err != nil { t.Fatalf("read data dir: %v", err) @@ -362,27 +254,6 @@ func TestFormatDuration(t *testing.T) { } } -func TestFormatBytes(t *testing.T) { - tests := []struct { - input int64 - contains string - }{ - {500, "B"}, - {1024, "KB"}, - {1024 * 1024, "MB"}, - {1024 * 1024 * 1024, "GB"}, - // Note: formatBytes doesn't handle TB, it shows as GB - {1024 * 1024 * 1024 * 1024, "GB"}, - } - - for _, tt := range tests { - result := formatBytes(tt.input) - if !containsStr(result, tt.contains) { - t.Errorf("formatBytes(%d) = %q, expected to contain %q", tt.input, result, tt.contains) - } - } -} - func TestTruncateOutput(t *testing.T) { tests := []struct { input string @@ -402,23 +273,6 @@ func TestTruncateOutput(t *testing.T) { } } -func TestChangeDetector_TrimChanges(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 3}) - - // Add 5 changes (exceeds max of 3) - for i := 0; i < 5; i++ { - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1", CPUCores: i + 1}, - }) - } - - // Should only have 3 changes after trimming - changes := d.GetRecentChanges(100, time.Time{}) - if len(changes) > 3 { - t.Errorf("Expected at most 3 changes after trimming, got %d", len(changes)) - } -} - func TestRemediationLog_TrimRecords(t *testing.T) { r := NewRemediationLog(RemediationLogConfig{MaxRecords: 3}) diff --git a/internal/ai/memory/memory_test.go b/internal/ai/memory/memory_test.go index d1c66f4c14..97b43f6932 100644 --- a/internal/ai/memory/memory_test.go +++ b/internal/ai/memory/memory_test.go @@ -5,123 +5,24 @@ import ( "time" ) -func TestChangeDetector_DetectNew(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // First detection - should see new resource - snapshots := []ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"}, - } - - changes := d.DetectChanges(snapshots) - if len(changes) != 1 { - t.Errorf("Expected 1 change (creation), got %d", len(changes)) - } - if changes[0].ChangeType != ChangeCreated { - t.Errorf("Expected ChangeCreated, got %s", changes[0].ChangeType) - } -} - -func TestChangeDetector_DetectStatusChange(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // Initial state - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"}, - }) - - // Status change - changes := d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "stopped", Node: "node1"}, - }) - - if len(changes) != 1 { - t.Errorf("Expected 1 status change, got %d", len(changes)) - } - if changes[0].ChangeType != ChangeStatus { - t.Errorf("Expected ChangeStatus, got %s", changes[0].ChangeType) - } - if changes[0].Before != "running" || changes[0].After != "stopped" { - t.Errorf("Expected running->stopped, got %v->%v", changes[0].Before, changes[0].After) - } -} - -func TestChangeDetector_DetectMigration(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // Initial state - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"}, - }) - - // Migration - changes := d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node2"}, - }) - - if len(changes) != 1 { - t.Errorf("Expected 1 migration change, got %d", len(changes)) - } - if changes[0].ChangeType != ChangeMigrated { - t.Errorf("Expected ChangeMigrated, got %s", changes[0].ChangeType) - } -} - -func TestChangeDetector_DetectDeleted(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - // Initial state - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"}, - }) - - // Delete (empty snapshot) - changes := d.DetectChanges([]ResourceSnapshot{}) - - if len(changes) != 1 { - t.Errorf("Expected 1 deletion change, got %d", len(changes)) - } - if changes[0].ChangeType != ChangeDeleted { - t.Errorf("Expected ChangeDeleted, got %s", changes[0].ChangeType) - } -} - -func TestChangeDetector_NoChanges(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) - - snapshot := []ResourceSnapshot{ - {ID: "vm-100", Name: "web-server", Type: "vm", Status: "running", Node: "node1"}, - } - - // First time - creates - d.DetectChanges(snapshot) - - // Second time - no changes - changes := d.DetectChanges(snapshot) - if len(changes) != 0 { - t.Errorf("Expected 0 changes, got %d", len(changes)) - } -} - func TestChangeDetector_GetChangesForResource(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) + now := time.Now() + d := &ChangeDetector{maxChanges: 100, changes: []Change{ + {ID: "c1", ResourceID: "vm-100", ChangeType: ChangeCreated, DetectedAt: now.Add(-3 * time.Minute)}, + {ID: "c2", ResourceID: "vm-200", ChangeType: ChangeCreated, DetectedAt: now.Add(-2 * time.Minute)}, + {ID: "c3", ResourceID: "vm-100", ChangeType: ChangeStatus, DetectedAt: now.Add(-time.Minute)}, + }} - // Create and change a few times - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web", Type: "vm", Status: "stopped", Node: "node1"}, - {ID: "vm-200", Name: "db", Type: "vm", Status: "running", Node: "node1"}, - }) - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"}, - {ID: "vm-200", Name: "db", Type: "vm", Status: "running", Node: "node1"}, - }) - - // Get changes for vm-100 only + // Get changes for vm-100 only, most recent first changes := d.GetChangesForResource("vm-100", 10) - for _, c := range changes { - if c.ResourceID != "vm-100" { - t.Errorf("Got change for wrong resource: %s", c.ResourceID) - } + if len(changes) != 2 { + t.Fatalf("Expected 2 changes for vm-100, got %d", len(changes)) + } + if changes[0].ID != "c3" || changes[1].ID != "c1" { + t.Errorf("Expected vm-100 changes newest first, got %s, %s", changes[0].ID, changes[1].ID) + } + if limited := d.GetChangesForResource("vm-100", 1); len(limited) != 1 || limited[0].ID != "c3" { + t.Errorf("Expected the limit to keep only the newest change, got %v", limited) } } @@ -164,18 +65,16 @@ func TestRemediationLog_Stats(t *testing.T) { } func TestChangeDetector_GetRecentChanges(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 100}) + now := time.Now() + d := &ChangeDetector{maxChanges: 100, changes: []Change{ + {ID: "old", ResourceID: "vm-100", DetectedAt: now.Add(-2 * time.Hour)}, + {ID: "new", ResourceID: "vm-100", DetectedAt: now.Add(-10 * time.Minute)}, + }} - // Create some changes - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"}, - }) - - // Get recent changes - since := time.Now().Add(-1 * time.Hour) - changes := d.GetRecentChanges(10, since) - if len(changes) == 0 { - t.Error("Expected at least 1 recent change") + // Only changes inside the window are recent + changes := d.GetRecentChanges(10, now.Add(-1*time.Hour)) + if len(changes) != 1 || changes[0].ID != "new" { + t.Errorf("Expected only the change inside the window, got %v", changes) } } @@ -260,24 +159,3 @@ func TestRemediationLog_AutomaticVsManual(t *testing.T) { t.Errorf("Expected 2 total, got %d", stats["total"]) } } - -func TestChangeDetector_Limit(t *testing.T) { - d := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 5}) - - // Create many changes to exceed limit - for i := 0; i < 10; i++ { - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web", Type: "vm", Status: "running", Node: "node1"}, - }) - // Alternate status to create changes - d.DetectChanges([]ResourceSnapshot{ - {ID: "vm-100", Name: "web", Type: "vm", Status: "stopped", Node: "node1"}, - }) - } - - // Should have limited records - allChanges := d.GetRecentChanges(100, time.Time{}) - if len(allChanges) > 5 { - t.Errorf("Expected max 5 changes due to limit, got %d", len(allChanges)) - } -} diff --git a/internal/ai/memory_exports.go b/internal/ai/memory_exports.go index ebd0113a63..6da92ce465 100644 --- a/internal/ai/memory_exports.go +++ b/internal/ai/memory_exports.go @@ -13,9 +13,6 @@ type ChangeDetectorConfig = memory.ChangeDetectorConfig // Change is an alias for memory.Change type Change = memory.Change -// ResourceSnapshot is an alias for memory.ResourceSnapshot -type ResourceSnapshot = memory.ResourceSnapshot - // ChangeType is an alias for memory.ChangeType type ChangeType = memory.ChangeType diff --git a/internal/ai/patrol_ai_additional_test.go b/internal/ai/patrol_ai_additional_test.go index 549d9756f3..a21b2ee8d2 100644 --- a/internal/ai/patrol_ai_additional_test.go +++ b/internal/ai/patrol_ai_additional_test.go @@ -193,21 +193,10 @@ func TestPatrolService_BuildScopedSetForRuntime_TypeOnlyScopeWithCorrelation(t * func TestBuildSeedContextState_TypeOnlyScopeUsesRuntimeScopedSet(t *testing.T) { ps := NewPatrolService(nil, nil) - changeDetector := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10}) - changeDetector.DetectChanges([]ResourceSnapshot{{ - ID: "qemu/101", - Name: "vm-1", - Type: "vm", - Status: "running", - SnapshotTime: time.Now().Add(-2 * time.Hour), - }}) - changeDetector.DetectChanges([]ResourceSnapshot{{ - ID: "qemu/101", - Name: "vm-1", - Type: "vm", - Status: "stopped", - SnapshotTime: time.Now().Add(-time.Hour), - }}) + changeDetector := newLegacyChangeDetector(t, + legacyCreatedChange("qemu/101", "vm-1", "vm"), + legacyStatusChange("qemu/101", "vm-1", "vm", "running", "stopped"), + ) ps.SetChangeDetector(changeDetector) node := ur.NewNodeView(&ur.Resource{ID: "node-1", Name: "node-1", Type: ur.ResourceTypeAgent}) @@ -230,21 +219,10 @@ func TestBuildSeedContextState_TypeOnlyScopeUsesRuntimeScopedSet(t *testing.T) { func TestBuildSeedContextState_TypeOnlyScopeIncludesCorrelatedRuntimeChange(t *testing.T) { ps := NewPatrolService(nil, nil) - changeDetector := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10}) - changeDetector.DetectChanges([]ResourceSnapshot{{ - ID: "qemu/101", - Name: "vm-1", - Type: "vm", - Status: "running", - SnapshotTime: time.Now().Add(-2 * time.Hour), - }}) - changeDetector.DetectChanges([]ResourceSnapshot{{ - ID: "qemu/101", - Name: "vm-1", - Type: "vm", - Status: "stopped", - SnapshotTime: time.Now().Add(-time.Hour), - }}) + changeDetector := newLegacyChangeDetector(t, + legacyCreatedChange("qemu/101", "vm-1", "vm"), + legacyStatusChange("qemu/101", "vm-1", "vm", "running", "stopped"), + ) ps.SetChangeDetector(changeDetector) cfg := correlation.DefaultConfig() diff --git a/internal/ai/patrol_ai_intelligence_test.go b/internal/ai/patrol_ai_intelligence_test.go index 1de5c92866..e83464c271 100644 --- a/internal/ai/patrol_ai_intelligence_test.go +++ b/internal/ai/patrol_ai_intelligence_test.go @@ -143,9 +143,10 @@ func TestSeedPrecomputeIntelligence_PopulatesSignals(t *testing.T) { pd.RecordEvent(HistoricalEvent{ResourceID: "vm-1", EventType: EventHighCPU, Timestamp: now.Add(-12 * time.Hour)}) ps.SetPatternDetector(pd) - cd := NewChangeDetector(ChangeDetectorConfig{MaxChanges: 10}) - cd.DetectChanges([]ResourceSnapshot{{ID: "vm-1", Name: "vm-1", Type: "vm", Status: "running", SnapshotTime: now.Add(-2 * time.Hour)}}) - cd.DetectChanges([]ResourceSnapshot{{ID: "vm-1", Name: "vm-1", Type: "vm", Status: "stopped", SnapshotTime: now.Add(-1 * time.Hour)}}) + cd := newLegacyChangeDetector(t, + legacyCreatedChange("vm-1", "vm-1", "vm"), + legacyStatusChange("vm-1", "vm-1", "vm", "running", "stopped"), + ) ps.SetChangeDetector(cd) corrCfg := DefaultCorrelationConfig()