Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
single-tenant build is unchanged by default.
- **Helm** — `agent.tools.findRunbook.*` values + configmap wiring.

#### AI SRE Agent — catalog bounds + purge API
- **`max_patterns` / `retention` + sweep** (`pkg/agent/catalog.go`,
`pkg/agent/worker.go`) — the pattern catalog grew without limit. It is now
bounded on the persist tick: `agent.catalog.max_patterns` (default 5000)
evicts the oldest patterns over the cap, and `agent.catalog.retention`
(default `720h`) drops patterns idle longer than the window. Eviction only
ever touches **uncurated** patterns (no verdict, no tags) — operator-
labelled patterns are always kept. `0`/negative cap and `"0"` retention
disable each independently.
- **Purge endpoints** —
`DELETE /api/agent/patterns?service=&older_than=` bulk-removes patterns by
service and/or idle age, and `DELETE /api/agent/services/:name` removes a
service's tracking entry (both gated by `X-Gateway-Secret`).
- Config triple-touch (struct + clone_config + config.yaml). Not exposed in
the Helm chart, which surfaces no other `agent.catalog` tuning.

---

## [1.4.3] — 2026-05
Expand Down
4 changes: 4 additions & 0 deletions config/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ agent:
catalog:
persist_interval: 30s
auto_promote_after: 50 # in detect mode, this many sightings = "known"
# Catalog bounds, enforced on the persist tick. Only UNCURATED patterns
# (no verdict, no tags) are evicted; operator-curated patterns are kept.
max_patterns: 5000 # 0/negative = no cap
retention: 720h # drop uncurated patterns idle longer than this; "0" disables
# Spike detection: a known pattern is re-flagged when its tick-level
# frequency suddenly exceeds the EWMA (Exponentially Weighted Moving Average) baseline by `spike_multiplier`.
# Two safety floors keep noise out:
Expand Down
104 changes: 104 additions & 0 deletions pkg/agent/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,110 @@ func (c *Catalog) Delete(patternID string) bool {
return true
}

// protectedFromEviction reports whether a pattern must survive automatic
// cap / retention sweeps. Operator-curated patterns — those with an explicit
// verdict or any tag — are never evicted by the agent; only the operator can
// remove them (via PurgePatterns / Delete). Caller holds the lock.
func protectedFromEviction(p *Pattern) bool {
return p.Verdict != "" || len(p.Tags) > 0
}

// Sweep bounds the catalog by enforcing retention and a size cap on
// UNPROTECTED patterns (no verdict, no tags). Patterns idle longer than
// `retention` are dropped first; if the catalog is still above
// `maxPatterns`, the oldest-by-LastSeen unprotected patterns are dropped
// until it fits. Curated patterns are always kept and still count toward
// the cap, so a fully-curated catalog is never truncated. retention<=0
// disables age eviction; maxPatterns<=0 disables the cap. Returns the count
// removed. Designed to be called from the persist ticker.
func (c *Catalog) Sweep(maxPatterns int, retention time.Duration) int {
c.mu.Lock()
defer c.mu.Unlock()

now := time.Now()
removed := 0

if retention > 0 {
for id, p := range c.patterns {
if protectedFromEviction(p) {
continue
}
if now.Sub(p.LastSeen) > retention {
delete(c.patterns, id)
removed++
}
}
}

if maxPatterns > 0 && len(c.patterns) > maxPatterns {
type aged struct {
id string
seen time.Time
}
evictable := make([]aged, 0, len(c.patterns))
for id, p := range c.patterns {
if protectedFromEviction(p) {
continue
}
evictable = append(evictable, aged{id, p.LastSeen})
}
sort.Slice(evictable, func(i, j int) bool {
return evictable[i].seen.Before(evictable[j].seen)
})
over := len(c.patterns) - maxPatterns
for i := 0; i < len(evictable) && over > 0; i++ {
delete(c.patterns, evictable[i].id)
removed++
over--
}
}

if removed > 0 {
c.dirty = true
}
return removed
}

// PurgePatterns removes patterns matching the given filters (admin API).
// A non-empty `service` limits the purge to that service; `olderThan`>0
// limits it to patterns idle longer than the duration. With no filters it
// removes every pattern. Unlike Sweep this is an explicit operator action,
// so it does NOT spare curated patterns. Returns the count removed.
func (c *Catalog) PurgePatterns(service string, olderThan time.Duration) int {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
removed := 0
for id, p := range c.patterns {
if service != "" && p.Service != service {
continue
}
if olderThan > 0 && now.Sub(p.LastSeen) <= olderThan {
continue
}
delete(c.patterns, id)
removed++
}
if removed > 0 {
c.dirty = true
}
return removed
}

// DeleteService removes a service's first-seen tracking entry (admin API).
// Patterns attributed to the service are left intact — purge them via
// PurgePatterns if desired. Returns false when the service is unknown.
func (c *Catalog) DeleteService(name string) bool {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.services[name]; !ok {
return false
}
delete(c.services, name)
c.dirty = true
return true
}

// Dirty reports whether there are unflushed changes.
func (c *Catalog) Dirty() bool {
c.mu.RLock()
Expand Down
120 changes: 120 additions & 0 deletions pkg/agent/catalog_sweep_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package agent

import (
"fmt"
"testing"
"time"

"github.com/VersusControl/versus-incident/pkg/storage"
)

func TestCatalog_Sweep_Retention(t *testing.T) {
cat, err := LoadCatalog(storage.NewMemory())
if err != nil {
t.Fatalf("LoadCatalog: %v", err)
}
cat.Upsert("old", "t", "src", 1, 0.2, "default", "svc")
cat.Upsert("fresh", "t", "src", 1, 0.2, "default", "svc")
cat.Upsert("curated", "t", "src", 1, 0.2, "default", "svc")
cat.patterns["old"].LastSeen = time.Now().Add(-800 * time.Hour)
cat.patterns["curated"].LastSeen = time.Now().Add(-800 * time.Hour)
cat.Label("curated", "known", nil) // curated → never auto-evicted

if removed := cat.Sweep(0, 720*time.Hour); removed != 1 {
t.Fatalf("removed = %d, want 1 (only the uncurated stale pattern)", removed)
}
if cat.Get("old") != nil {
t.Error("stale uncurated pattern should be evicted")
}
if cat.Get("fresh") == nil {
t.Error("fresh pattern should survive")
}
if cat.Get("curated") == nil {
t.Error("curated pattern must never be auto-evicted")
}
}

func TestCatalog_Sweep_Cap(t *testing.T) {
cat, err := LoadCatalog(storage.NewMemory())
if err != nil {
t.Fatalf("LoadCatalog: %v", err)
}
base := time.Now()
for i := 0; i < 5; i++ {
id := fmt.Sprintf("p%d", i)
cat.Upsert(id, "t", "src", 1, 0.2, "default", "svc")
cat.patterns[id].LastSeen = base.Add(time.Duration(i) * time.Minute) // p0 oldest
}
cat.Label("p0", "known", nil) // curated oldest must survive the cap

if removed := cat.Sweep(3, 0); removed != 2 {
t.Fatalf("removed = %d, want 2", removed)
}
if cat.Len() != 3 {
t.Fatalf("len = %d, want 3", cat.Len())
}
if cat.Get("p0") == nil {
t.Error("curated oldest must survive the cap")
}
if cat.Get("p1") != nil || cat.Get("p2") != nil {
t.Error("oldest uncurated patterns should be evicted under the cap")
}
if cat.Get("p3") == nil || cat.Get("p4") == nil {
t.Error("newest patterns should survive")
}
}

func TestCatalog_Sweep_DisabledByZero(t *testing.T) {
cat, _ := LoadCatalog(storage.NewMemory())
cat.Upsert("a", "t", "src", 1, 0.2, "default", "svc")
cat.patterns["a"].LastSeen = time.Now().Add(-9000 * time.Hour)
if removed := cat.Sweep(0, 0); removed != 0 {
t.Errorf("cap=0 retention=0 must evict nothing, removed %d", removed)
}
}

func TestCatalog_PurgePatterns(t *testing.T) {
cat, _ := LoadCatalog(storage.NewMemory())
cat.Upsert("a", "t", "src", 1, 0.2, "default", "svc-a")
cat.Upsert("b", "t", "src", 1, 0.2, "default", "svc-b")
cat.Label("a", "known", nil) // explicit purge ignores curation

if n := cat.PurgePatterns("svc-a", 0); n != 1 {
t.Fatalf("purge by service removed %d, want 1", n)
}
if cat.Get("a") != nil {
t.Error("svc-a pattern should be purged even though curated (explicit action)")
}
if cat.Get("b") == nil {
t.Error("svc-b pattern should remain")
}
}

func TestCatalog_PurgePatterns_OlderThan(t *testing.T) {
cat, _ := LoadCatalog(storage.NewMemory())
cat.Upsert("old", "t", "src", 1, 0.2, "default", "svc")
cat.Upsert("new", "t", "src", 1, 0.2, "default", "svc")
cat.patterns["old"].LastSeen = time.Now().Add(-48 * time.Hour)

if n := cat.PurgePatterns("", 24*time.Hour); n != 1 {
t.Fatalf("purge older_than removed %d, want 1", n)
}
if cat.Get("old") != nil || cat.Get("new") == nil {
t.Error("only patterns older than the cutoff should be purged")
}
}

func TestCatalog_DeleteService(t *testing.T) {
cat, _ := LoadCatalog(storage.NewMemory())
cat.RegisterService("svc-x")

if !cat.DeleteService("svc-x") {
t.Error("DeleteService should return true for a known service")
}
if cat.DeleteService("svc-x") {
t.Error("DeleteService should return false once removed")
}
if cat.DeleteService("never-seen") {
t.Error("DeleteService should return false for an unknown service")
}
}
16 changes: 16 additions & 0 deletions pkg/agent/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ type Worker struct {
persistEvery time.Duration
lookback time.Duration
ewmaAlpha float64
maxPatterns int // 0 = no cap
retention time.Duration // 0 = no age eviction
services *ServiceMatcher // regex-based service-name extractor
newServiceGrace time.Duration // 0 = disabled
}
Expand Down Expand Up @@ -125,6 +127,13 @@ func NewWorker(opt WorkerOptions) (*Worker, error) {
w.lookback = parseDurationOr(opt.Cfg.Lookback, 5*time.Minute)
w.ewmaAlpha = 0.2 // configurable once spike detection lands
w.newServiceGrace = parseDurationOr(opt.Cfg.NewServiceGrace, 0)
// Catalog bounds: default 5000 patterns / 720h retention. A negative
// max_patterns disables the cap; "0" retention disables age eviction.
w.maxPatterns = 5000
if opt.Cfg.Catalog.MaxPatterns != 0 {
w.maxPatterns = opt.Cfg.Catalog.MaxPatterns
}
w.retention = parseDurationOr(opt.Cfg.Catalog.Retention, 720*time.Hour)

return w, nil
}
Expand Down Expand Up @@ -172,6 +181,13 @@ func (w *Worker) Run(ctx context.Context) {
case <-tick.C:
w.tick(ctx, mode)
case <-persist.C:
// Bound the catalog before flushing: drop idle/overflow uncurated
// patterns so the file (and the in-memory map) can't grow without
// limit. Curated patterns are always kept (see Catalog.Sweep).
if n := w.catalog.Sweep(w.maxPatterns, w.retention); n > 0 {
log.Printf("agent: catalog swept %d patterns (cap=%d retention=%s)",
n, w.maxPatterns, w.retention)
}
if w.catalog.Dirty() {
if err := w.catalog.Persist(); err != nil {
log.Printf("agent: catalog flush failed: %v", err)
Expand Down
8 changes: 8 additions & 0 deletions pkg/config/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ type AgentRedactionConfig struct {
type AgentCatalogConfig struct {
PersistInterval string `mapstructure:"persist_interval"` // e.g. "30s"
AutoPromoteAfter int `mapstructure:"auto_promote_after"` // 0 = never
// MaxPatterns caps the catalog size. When exceeded, the oldest
// uncurated patterns (no verdict, no tags) are evicted on the persist
// tick. 0 disables the cap. Default 5000.
MaxPatterns int `mapstructure:"max_patterns"`
// Retention drops uncurated patterns idle longer than this on the
// persist tick. Empty falls back to 720h (30d); "0" disables age
// eviction. Curated patterns (verdict or tags) are never auto-evicted.
Retention string `mapstructure:"retention"`
// SpikeMultiplier flags a tick as a frequency spike when the tick
// count exceeds the pattern's prior EWMA baseline by this factor.
// 0 disables spike detection. Default 5.0.
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/clone_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ func cloneAgentConfig(src AgentConfig) AgentConfig {
Catalog: AgentCatalogConfig{
PersistInterval: src.Catalog.PersistInterval,
AutoPromoteAfter: src.Catalog.AutoPromoteAfter,
MaxPatterns: src.Catalog.MaxPatterns,
Retention: src.Catalog.Retention,
SpikeMultiplier: src.Catalog.SpikeMultiplier,
SpikeMinFrequency: src.Catalog.SpikeMinFrequency,
SpikeMinBaselineCount: src.Catalog.SpikeMinBaselineCount,
Expand Down
Loading