From e0fa401fb29ab2914463cecbf40ed3d6c01bd0f6 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:52:30 +0100 Subject: [PATCH] Retire ChangeDetector's dead change-detection path Patrol stopped calling DetectChanges when it moved to agentic execution (1b1c9bb2a3). Recent-change context comes from the unified-resource timeline, and the detector survives only as a fallback reader of the legacy ai_changes.json that older releases wrote. The previous change removed the detector's unjoined save; the detection itself was still there, kept alive only by tests. Remove DetectChanges, its snapshot diffing, ResourceSnapshot (and its internal/ai alias), change-ID generation, the description formatters and the detector's lock. The history is now loaded once and never modified, so reads need no lock. The read APIs, trimming and legacy loading are unchanged. Tests that used detection to seed the fallback now write a legacy ai_changes.json and load it, the same path production reads. With loading broken, 10 of the 12 re-seeded internal/ai tests fail; the other two assert that the fallback is absent or loses to the canonical timeline. Two formatter comparisons now reject empty output, which let them pass against an empty detector. Tests of the detection logic, its heap stability and ID uniqueness go with it. --- .../v6/internal/subsystems/ai-runtime.md | 20 +- internal/ai/coverage_increase_test.go | 20 +- internal/ai/intelligence_coverage_test.go | 5 +- internal/ai/intelligence_test.go | 78 +++-- .../change_detector_memory_regression_test.go | 77 ----- internal/ai/memory/changes.go | 276 +----------------- .../ai/memory/changes_concurrency_test.go | 40 --- internal/ai/memory/memory_coverage_test.go | 15 - internal/ai/memory/memory_extended_test.go | 170 +---------- internal/ai/memory/memory_test.go | 170 ++--------- internal/ai/memory_exports.go | 3 - internal/ai/patrol_ai_additional_test.go | 38 +-- internal/ai/patrol_ai_intelligence_test.go | 7 +- 13 files changed, 130 insertions(+), 789 deletions(-) delete mode 100644 internal/ai/memory/change_detector_memory_regression_test.go delete mode 100644 internal/ai/memory/changes_concurrency_test.go 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()