Cap AI patrol history and correlation reads

This commit is contained in:
rcourtman 2026-04-01 14:47:58 +01:00
parent 73597f8b1a
commit 76ba35a3a9
5 changed files with 94 additions and 12 deletions

View file

@ -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

View file

@ -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])

View file

@ -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

View file

@ -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

View file

@ -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())