From 76ba35a3a99dcce4ae1685b9b14f027abf6ec42f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 1 Apr 2026 14:47:58 +0100 Subject: [PATCH] Cap AI patrol history and correlation reads --- .../v6/internal/subsystems/ai-runtime.md | 9 +++++ internal/ai/patrol_history_persistence.go | 35 ++++++++++++++----- .../ai/patrol_history_persistence_test.go | 22 ++++++++++++ internal/ai/proxmox/events.go | 23 ++++++++++-- internal/ai/proxmox/events_test.go | 17 +++++++++ 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index e7c1199fb..11c3a109e 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -87,6 +87,15 @@ governed floor is ready. `internal/ai/` is the live backend AI engine. It owns chat execution, Patrol orchestration, findings generation, investigation support, quickstart and provider selection, remediation flow, and cost persistence. +That same backend runtime ownership also includes bounded Patrol and +investigation read models. `internal/ai/patrol_history_persistence.go` and +`internal/ai/proxmox/events.go` must cap persisted-history loads and +caller-requested read limits at the canonical runtime maxima instead of +allocating directly from raw on-disk counts or transport-supplied limits. +Callers may request fewer records, but AI runtime storage and correlation +surfaces remain responsible for enforcing the governed ceilings that protect +memory and keep Patrol/history behavior stable under malformed or oversized +inputs. That same backend runtime ownership includes `internal/config/ai.go`, because provider auth, base URLs, provider-scoped model defaults, and other persisted runtime AI selection rules must stay canonical in the shared AI config model diff --git a/internal/ai/patrol_history_persistence.go b/internal/ai/patrol_history_persistence.go index 477981893..f7befaf93 100644 --- a/internal/ai/patrol_history_persistence.go +++ b/internal/ai/patrol_history_persistence.go @@ -87,8 +87,9 @@ func (a *PatrolHistoryPersistenceAdapter) LoadPatrolRunHistory() ([]PatrolRunRec } // Convert from config.PatrolRunRecord to ai.PatrolRunRecord - runs := make([]PatrolRunRecord, len(data.Runs)) - for i, r := range data.Runs { + runCount := clampHistoryCount(len(data.Runs), 0, MaxPatrolRunHistory) + runs := make([]PatrolRunRecord, runCount) + for i, r := range data.Runs[:runCount] { runs[i] = normalizePatrolRunRecord(PatrolRunRecord{ ID: r.ID, StartedAt: r.StartedAt, @@ -191,6 +192,25 @@ type PatrolRunHistoryStore struct { onSaveError func(err error) } +func clampHistoryCount(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func normalizeHistoryRequestCount(requested, storeMax, available int) int { + if requested <= 0 { + requested = MaxPatrolRunHistory + } + requested = clampHistoryCount(requested, 0, MaxPatrolRunHistory) + requested = clampHistoryCount(requested, 0, storeMax) + return clampHistoryCount(requested, 0, available) +} + // NewPatrolRunHistoryStore creates a new patrol run history store func NewPatrolRunHistoryStore(maxRuns int) *PatrolRunHistoryStore { if maxRuns <= 0 { @@ -216,12 +236,11 @@ func (s *PatrolRunHistoryStore) SetPersistence(p PatrolHistoryPersistence) error return err } if len(runs) > 0 { + if len(runs) > s.maxRuns { + runs = runs[:s.maxRuns] + } s.mu.Lock() s.runs = runs - // Trim to max if loaded more than maxRuns - if len(s.runs) > s.maxRuns { - s.runs = s.runs[:s.maxRuns] - } s.mu.Unlock() log.Info().Int("count", len(runs)).Msg("loaded patrol run history from disk") } @@ -268,9 +287,7 @@ func (s *PatrolRunHistoryStore) GetRecent(n int) []PatrolRunRecord { s.mu.RLock() defer s.mu.RUnlock() - if n <= 0 || n > len(s.runs) { - n = len(s.runs) - } + n = normalizeHistoryRequestCount(n, s.maxRuns, len(s.runs)) result := make([]PatrolRunRecord, n) copy(result, s.runs[:n]) diff --git a/internal/ai/patrol_history_persistence_test.go b/internal/ai/patrol_history_persistence_test.go index 4f7132294..3f8cde93b 100644 --- a/internal/ai/patrol_history_persistence_test.go +++ b/internal/ai/patrol_history_persistence_test.go @@ -2,6 +2,7 @@ package ai import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -266,6 +267,27 @@ func TestPatrolHistoryPersistenceAdapter_NormalizesAlertIdentity(t *testing.T) { } } +func TestPatrolHistoryPersistenceAdapter_LoadPatrolRunHistoryCapsRuns(t *testing.T) { + records := make([]config.PatrolRunRecord, MaxPatrolRunHistory+10) + for i := range records { + records[i] = config.PatrolRunRecord{ID: fmt.Sprintf("run-%d", i)} + } + + persistence := config.NewConfigPersistence(t.TempDir()) + if err := persistence.SavePatrolRunHistory(records); err != nil { + t.Fatalf("SavePatrolRunHistory failed: %v", err) + } + + adapter := NewPatrolHistoryPersistenceAdapter(persistence) + loaded, err := adapter.LoadPatrolRunHistory() + if err != nil { + t.Fatalf("LoadPatrolRunHistory failed: %v", err) + } + if len(loaded) != MaxPatrolRunHistory { + t.Fatalf("expected %d runs, got %d", MaxPatrolRunHistory, len(loaded)) + } +} + func TestPatrolRunHistoryStore_PersistenceStatusAndErrors(t *testing.T) { store := NewPatrolRunHistoryStore(5) store.saveDebounce = 0 diff --git a/internal/ai/proxmox/events.go b/internal/ai/proxmox/events.go index aea483755..e9ac70c65 100644 --- a/internal/ai/proxmox/events.go +++ b/internal/ai/proxmox/events.go @@ -86,6 +86,25 @@ type OperationWindow struct { ExpectedMetrics []string `json:"expected_metrics"` // Metrics expected to be affected } +func clampCorrelationCount(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func normalizeCorrelationLimit(requested, configMax, available int) int { + if requested <= 0 { + requested = DefaultEventCorrelatorConfig().MaxCorrelations + } + requested = clampCorrelationCount(requested, 0, DefaultEventCorrelatorConfig().MaxCorrelations) + requested = clampCorrelationCount(requested, 0, configMax) + return clampCorrelationCount(requested, 0, available) +} + // EventCorrelatorConfig configures the event correlator type EventCorrelatorConfig struct { DataDir string @@ -403,9 +422,7 @@ func (c *EventCorrelator) GetCorrelations(limit int) []EventCorrelation { c.mu.RLock() defer c.mu.RUnlock() - if limit <= 0 || limit > len(c.correlations) { - limit = len(c.correlations) - } + limit = normalizeCorrelationLimit(limit, c.config.MaxCorrelations, len(c.correlations)) // Return most recent start := len(c.correlations) - limit diff --git a/internal/ai/proxmox/events_test.go b/internal/ai/proxmox/events_test.go index 18a003183..6588d1f01 100644 --- a/internal/ai/proxmox/events_test.go +++ b/internal/ai/proxmox/events_test.go @@ -153,6 +153,23 @@ func TestEventCorrelator_GetCorrelations(t *testing.T) { } } +func TestEventCorrelator_GetCorrelationsCapsToConfiguredLimit(t *testing.T) { + cfg := DefaultEventCorrelatorConfig() + cfg.MaxCorrelations = 2 + correlator := NewEventCorrelator(cfg) + + correlator.correlations = []EventCorrelation{ + {ID: "one"}, + {ID: "two"}, + {ID: "three"}, + } + + correlations := correlator.GetCorrelations(1000) + if len(correlations) != 2 { + t.Fatalf("expected 2 correlations, got %d", len(correlations)) + } +} + func TestEventCorrelator_FormatForPatrol(t *testing.T) { correlator := NewEventCorrelator(DefaultEventCorrelatorConfig())