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.
This commit is contained in:
rcourtman
2026-09-24 11:52:30 +01:00
parent 2ea416b6c2
commit e0fa401fb2
13 changed files with 130 additions and 789 deletions
@@ -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.
+3 -17
View File
@@ -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)
}
}
+2 -3
View File
@@ -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)
+53 -25
View File
@@ -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)
}
}
@@ -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)
}
}
}
+13 -263
View File
@@ -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")
}
@@ -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))
}
}
@@ -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) {
+12 -158
View File
@@ -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})
+24 -146
View File
@@ -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))
}
}
-3
View File
@@ -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
+8 -30
View File
@@ -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()
+4 -3
View File
@@ -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()