mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
e0fa401fb2
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.
148 lines
4.5 KiB
Go
148 lines
4.5 KiB
Go
// Package memory provides operational memory for AI context.
|
|
// It keeps legacy infrastructure change history, remediation actions, and their
|
|
// outcomes to enable the AI to provide more informed, experience-based
|
|
// recommendations.
|
|
package memory
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// ChangeType represents the type of infrastructure change detected
|
|
type ChangeType string
|
|
|
|
const (
|
|
ChangeCreated ChangeType = "created" // New resource appeared
|
|
ChangeDeleted ChangeType = "deleted" // Resource removed
|
|
ChangeConfig ChangeType = "config" // Configuration changed (RAM, CPU, etc)
|
|
ChangeStatus ChangeType = "status" // Status changed (started, stopped, paused)
|
|
ChangeMigrated ChangeType = "migrated" // Moved to different node
|
|
ChangeRestarted ChangeType = "restarted" // Resource was restarted
|
|
ChangeBackedUp ChangeType = "backed_up" // Backup completed
|
|
)
|
|
|
|
// Change represents a detected change to infrastructure
|
|
type Change struct {
|
|
ID string `json:"id"`
|
|
ResourceID string `json:"resource_id"`
|
|
ResourceType string `json:"resource_type"` // vm, container, node, storage
|
|
ResourceName string `json:"resource_name"`
|
|
ChangeType ChangeType `json:"change_type"`
|
|
Before interface{} `json:"before,omitempty"`
|
|
After interface{} `json:"after,omitempty"`
|
|
DetectedAt time.Time `json:"detected_at"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// ChangeDetector serves legacy infrastructure change history.
|
|
//
|
|
// Recent-change context comes from the unified-resource timeline; the detector
|
|
// is only its fallback. Its history (ai_changes.json) was written by releases
|
|
// that still ran change detection, which patrol dropped when it moved to
|
|
// agentic execution. The history is loaded once at construction and never
|
|
// modified afterwards, so reads need no lock.
|
|
type ChangeDetector struct {
|
|
changes []Change
|
|
maxChanges int
|
|
|
|
// dataDir holds the legacy history read by loadFromDisk.
|
|
dataDir string
|
|
}
|
|
|
|
// ChangeDetectorConfig configures the change detector
|
|
type ChangeDetectorConfig struct {
|
|
MaxChanges int // Maximum changes to retain (default: 1000)
|
|
DataDir string // Directory for persistence
|
|
}
|
|
|
|
// NewChangeDetector creates a new change detector
|
|
func NewChangeDetector(cfg ChangeDetectorConfig) *ChangeDetector {
|
|
if cfg.MaxChanges <= 0 {
|
|
cfg.MaxChanges = 1000
|
|
}
|
|
|
|
d := &ChangeDetector{
|
|
changes: make([]Change, 0),
|
|
maxChanges: cfg.MaxChanges,
|
|
dataDir: normalizeOptionalMemoryDataDir(cfg.DataDir),
|
|
}
|
|
|
|
// Load existing changes from disk
|
|
if cfg.DataDir != "" {
|
|
if err := d.loadFromDisk(); err != nil {
|
|
log.Warn().Err(err).Msg("failed to load change history from disk")
|
|
} else if len(d.changes) > 0 {
|
|
log.Info().Int("count", len(d.changes)).Msg("loaded change history from disk")
|
|
}
|
|
}
|
|
|
|
return d
|
|
}
|
|
|
|
// GetChangesForResource returns recent changes for a specific resource
|
|
func (d *ChangeDetector) GetChangesForResource(resourceID string, limit int) []Change {
|
|
var result []Change
|
|
// Iterate in reverse to get most recent first
|
|
for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- {
|
|
if d.changes[i].ResourceID == resourceID {
|
|
result = append(result, d.changes[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetRecentChanges returns the most recent changes across all resources
|
|
func (d *ChangeDetector) GetRecentChanges(limit int, since time.Time) []Change {
|
|
var result []Change
|
|
for i := len(d.changes) - 1; i >= 0 && len(result) < limit; i-- {
|
|
if d.changes[i].DetectedAt.After(since) {
|
|
result = append(result, d.changes[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetChangesSummary returns a formatted summary of recent changes for AI context
|
|
func (d *ChangeDetector) GetChangesSummary(since time.Time, maxChanges int) string {
|
|
changes := d.GetRecentChanges(maxChanges, since)
|
|
return FormatRecentChangesContext(changes, false, "##")
|
|
}
|
|
|
|
// trimChanges removes old changes beyond maxChanges
|
|
func (d *ChangeDetector) trimChanges() {
|
|
if len(d.changes) > d.maxChanges {
|
|
// Keep most recent
|
|
d.changes = d.changes[len(d.changes)-d.maxChanges:]
|
|
}
|
|
}
|
|
|
|
// loadFromDisk loads the legacy change history from its JSON file
|
|
func (d *ChangeDetector) loadFromDisk() error {
|
|
changes, ok, err := loadMemoryHistory(d.dataDir, changeHistoryFileName, "change history", func(a, b Change) bool {
|
|
return a.DetectedAt.Before(b.DetectedAt)
|
|
})
|
|
if err != nil || !ok {
|
|
return err
|
|
}
|
|
|
|
d.changes = changes
|
|
d.trimChanges()
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func intToString(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var result string
|
|
for n > 0 {
|
|
result = string(rune('0'+n%10)) + result
|
|
n /= 10
|
|
}
|
|
return result
|
|
}
|