diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index a3188c1844..a3e6c4f8da 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -436,6 +436,11 @@ type UnifiedFindingCallback func(f *Finding) bool // PushNotifyCallback is called to send a push notification through the relay. type PushNotifyCallback func(notification relay.PushNotificationPayload) +// FindingNotifyCallback delivers a newly stored finding to the operator's +// alert notification channels (email, webhooks). The API layer owns config +// gating and the projection into the notification payload shape. +type FindingNotifyCallback func(f *Finding) + // InvestigationOrchestrator is the interface for autonomous investigation of findings. // Re-exported from pkg/aicontracts for backwards compatibility. type InvestigationOrchestrator = aicontracts.InvestigationOrchestrator @@ -533,6 +538,10 @@ type PatrolService struct { // Push notification callback - sends push via relay to mobile devices pushNotifyCallback PushNotifyCallback + // Finding notification callback - routes new warning+ findings to the + // operator's alert notification channels (email, webhooks) + findingNotifyCallback FindingNotifyCallback + // Cached thresholds (recalculated when thresholdProvider changes) thresholds PatrolThresholds proactiveMode bool // When true, warn before thresholds; when false, use exact thresholds diff --git a/internal/ai/patrol_finding_notify_callback_test.go b/internal/ai/patrol_finding_notify_callback_test.go new file mode 100644 index 0000000000..0c3f8677a5 --- /dev/null +++ b/internal/ai/patrol_finding_notify_callback_test.go @@ -0,0 +1,70 @@ +package ai + +import "testing" + +// The finding notification callback must fire exactly once per finding +// lifetime, on the genuinely-new path, and only for warning+ severities. +func TestRecordFinding_FindingNotifyCallback(t *testing.T) { + newFinding := func(id string, severity FindingSeverity) *Finding { + return &Finding{ + ID: id, + Severity: severity, + Category: FindingCategoryReliability, + ResourceID: "vm-101", + ResourceName: "web", + ResourceType: "vm", + Title: "Test finding " + id, + } + } + + t.Run("fires once for a new warning finding", func(t *testing.T) { + ps := NewPatrolService(nil, nil) + var notified []*Finding + ps.SetFindingNotifyCallback(func(f *Finding) { + notified = append(notified, f) + }) + + if !ps.recordFinding(newFinding("warn-1", FindingSeverityWarning)) { + t.Fatal("expected finding to record as new") + } + if len(notified) != 1 { + t.Fatalf("callback fired %d times, want 1", len(notified)) + } + if notified[0].ID != "warn-1" { + t.Fatalf("notified finding ID = %q", notified[0].ID) + } + + // A re-detection of the same finding must not notify again. + ps.recordFinding(newFinding("warn-1", FindingSeverityWarning)) + if len(notified) != 1 { + t.Fatalf("re-detection fired the callback; %d calls, want 1", len(notified)) + } + }) + + t.Run("fires for critical findings", func(t *testing.T) { + ps := NewPatrolService(nil, nil) + calls := 0 + ps.SetFindingNotifyCallback(func(*Finding) { calls++ }) + ps.recordFinding(newFinding("crit-1", FindingSeverityCritical)) + if calls != 1 { + t.Fatalf("callback fired %d times, want 1", calls) + } + }) + + t.Run("does not fire for info findings", func(t *testing.T) { + ps := NewPatrolService(nil, nil) + calls := 0 + ps.SetFindingNotifyCallback(func(*Finding) { calls++ }) + ps.recordFinding(newFinding("info-1", FindingSeverityInfo)) + if calls != 0 { + t.Fatalf("callback fired %d times for an info finding, want 0", calls) + } + }) + + t.Run("nil callback is safe", func(t *testing.T) { + ps := NewPatrolService(nil, nil) + if !ps.recordFinding(newFinding("warn-2", FindingSeverityWarning)) { + t.Fatal("expected finding to record as new") + } + }) +} diff --git a/internal/ai/patrol_findings.go b/internal/ai/patrol_findings.go index 1294978ec2..e69b5cf38e 100644 --- a/internal/ai/patrol_findings.go +++ b/internal/ai/patrol_findings.go @@ -78,6 +78,7 @@ func (p *PatrolService) recordFindingWithInvestigation(f *Finding, investigate b if stored.Severity == FindingSeverityCritical || stored.Severity == FindingSeverityWarning { p.mu.RLock() pushCb := p.pushNotifyCallback + notifyCb := p.findingNotifyCallback p.mu.RUnlock() if pushCb != nil { pushCb(relay.NewPatrolFindingNotification( @@ -87,6 +88,13 @@ func (p *PatrolService) recordFindingWithInvestigation(f *Finding, investigate b stored.Title, )) } + // Route the finding to the operator's alert notification + // channels. Fires only here, on the genuinely-new path, so a + // finding notifies at most once per lifetime regardless of how + // many later runs re-detect it. + if notifyCb != nil { + notifyCb(stored) + } } } diff --git a/internal/ai/patrol_init.go b/internal/ai/patrol_init.go index ea115592b1..569d3183f6 100644 --- a/internal/ai/patrol_init.go +++ b/internal/ai/patrol_init.go @@ -257,6 +257,14 @@ func (p *PatrolService) SetPushNotifyCallback(cb PushNotifyCallback) { p.pushNotifyCallback = cb } +// SetFindingNotifyCallback sets the callback that routes newly detected +// warning+ findings to the operator's alert notification channels. +func (p *PatrolService) SetFindingNotifyCallback(cb FindingNotifyCallback) { + p.mu.Lock() + defer p.mu.Unlock() + p.findingNotifyCallback = cb +} + // SetUnifiedFindingCallback sets the callback for pushing findings to the unified store // When set, it also syncs existing active findings to the unified store func (p *PatrolService) SetUnifiedFindingCallback(cb UnifiedFindingCallback) { diff --git a/internal/api/patrol_finding_notifications.go b/internal/api/patrol_finding_notifications.go new file mode 100644 index 0000000000..f06f757d68 --- /dev/null +++ b/internal/api/patrol_finding_notifications.go @@ -0,0 +1,61 @@ +package api + +import ( + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" +) + +// patrolFindingAlertIDPrefix namespaces synthetic notification alerts so +// their IDs can never collide with threshold alert IDs in the notification +// manager's cooldown map. +const patrolFindingAlertIDPrefix = "patrol-finding-" + +// patrolFindingNotificationAlert projects a Patrol finding into the alert +// shape the notification manager delivers, so findings reach the operator's +// existing email, webhook, and Apprise destinations. The synthetic alert is +// never stored in the active-alert store; it exists only for delivery. +func patrolFindingNotificationAlert(f *ai.Finding) *alerts.Alert { + if f == nil { + return nil + } + + level := alerts.AlertLevelWarning + if f.Severity == ai.FindingSeverityCritical { + level = alerts.AlertLevelCritical + } + + startTime := f.DetectedAt + if startTime.IsZero() { + startTime = time.Now() + } + lastSeen := f.LastSeenAt + if lastSeen.IsZero() { + lastSeen = startTime + } + + metadata := map[string]interface{}{ + "findingId": f.ID, + "category": string(f.Category), + } + if f.Description != "" { + metadata["description"] = f.Description + } + if f.Recommendation != "" { + metadata["recommendation"] = f.Recommendation + } + + return &alerts.Alert{ + ID: patrolFindingAlertIDPrefix + f.ID, + Type: "patrol_finding", + Level: level, + ResourceID: f.ResourceID, + ResourceName: f.ResourceName, + Node: f.Node, + Message: f.Title, + StartTime: startTime, + LastSeen: lastSeen, + Metadata: metadata, + } +} diff --git a/internal/api/patrol_finding_notifications_test.go b/internal/api/patrol_finding_notifications_test.go new file mode 100644 index 0000000000..afde3542ee --- /dev/null +++ b/internal/api/patrol_finding_notifications_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" +) + +func TestPatrolFindingNotificationAlert(t *testing.T) { + t.Run("nil finding yields nil alert", func(t *testing.T) { + if patrolFindingNotificationAlert(nil) != nil { + t.Fatal("nil finding must map to nil alert") + } + }) + + t.Run("critical finding maps to critical alert", func(t *testing.T) { + detected := time.Date(2026, 7, 18, 9, 0, 0, 0, time.UTC) + lastSeen := detected.Add(30 * time.Minute) + finding := &ai.Finding{ + ID: "f-123", + Severity: ai.FindingSeverityCritical, + Category: ai.FindingCategoryReliability, + ResourceID: "docker:abc123", + ResourceName: "app-container", + Node: "edge-host", + Title: "Container health check is failing", + Description: "The running container is reported unhealthy.", + Recommendation: "Inspect the container health-check output.", + DetectedAt: detected, + LastSeenAt: lastSeen, + } + + alert := patrolFindingNotificationAlert(finding) + if alert == nil { + t.Fatal("expected an alert") + } + if alert.ID != "patrol-finding-f-123" { + t.Fatalf("ID = %q", alert.ID) + } + if alert.Type != "patrol_finding" { + t.Fatalf("Type = %q", alert.Type) + } + if alert.Level != alerts.AlertLevelCritical { + t.Fatalf("Level = %q", alert.Level) + } + if alert.ResourceID != finding.ResourceID || alert.ResourceName != finding.ResourceName || alert.Node != finding.Node { + t.Fatalf("resource identity mismatch: %+v", alert) + } + if alert.Message != finding.Title { + t.Fatalf("Message = %q", alert.Message) + } + if !alert.StartTime.Equal(detected) || !alert.LastSeen.Equal(lastSeen) { + t.Fatalf("timestamps mismatch: start=%v lastSeen=%v", alert.StartTime, alert.LastSeen) + } + if alert.Metadata["findingId"] != "f-123" { + t.Fatalf("metadata findingId = %v", alert.Metadata["findingId"]) + } + if alert.Metadata["category"] != string(ai.FindingCategoryReliability) { + t.Fatalf("metadata category = %v", alert.Metadata["category"]) + } + if alert.Metadata["description"] != finding.Description { + t.Fatalf("metadata description = %v", alert.Metadata["description"]) + } + if alert.Metadata["recommendation"] != finding.Recommendation { + t.Fatalf("metadata recommendation = %v", alert.Metadata["recommendation"]) + } + }) + + t.Run("warning finding maps to warning alert with zero-time fallbacks", func(t *testing.T) { + finding := &ai.Finding{ + ID: "f-456", + Severity: ai.FindingSeverityWarning, + Category: ai.FindingCategoryGeneral, + Title: "Array running without parity protection", + } + + alert := patrolFindingNotificationAlert(finding) + if alert == nil { + t.Fatal("expected an alert") + } + if alert.Level != alerts.AlertLevelWarning { + t.Fatalf("Level = %q", alert.Level) + } + if alert.StartTime.IsZero() || alert.LastSeen.IsZero() { + t.Fatal("zero finding timestamps must fall back to a real time") + } + if _, present := alert.Metadata["description"]; present { + t.Fatal("empty description must not be present in metadata") + } + if _, present := alert.Metadata["recommendation"]; present { + t.Fatal("empty recommendation must not be present in metadata") + } + }) +} diff --git a/internal/api/router.go b/internal/api/router.go index 97e50b49df..f87d3bde56 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2298,6 +2298,26 @@ func (r *Router) startPatrolForContext(ctx context.Context, orgID string) bool { } }) + // Wire finding notifications: new warning+ patrol findings go to + // the operator's alert notification channels (email, webhooks, + // Apprise), so Patrol can reach operators who do not use the + // mobile push path. Config gating is read live per finding. + patrol.SetFindingNotifyCallback(func(f *ai.Finding) { + if f == nil || ai.IsDemoMode() { + return + } + cfg := aiService.GetAIConfig() + if cfg == nil || !cfg.PatrolFindingTriggersNotification(string(f.Severity)) { + return + } + if r.monitor == nil { + return + } + if nm := r.monitor.GetNotificationManager(); nm != nil { + nm.SendAlert(patrolFindingNotificationAlert(f)) + } + }) + log.Info().Msg("AI Intelligence: Patrol findings wired to unified store") // Sync existing findings from persistence to the unified store diff --git a/internal/config/ai.go b/internal/config/ai.go index 3aa2a3ce83..5788472b51 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -75,6 +75,18 @@ type AIConfig struct { UseProactiveThresholds bool `json:"use_proactive_thresholds,omitempty"` // When true, patrol warns 5-15% BEFORE alert thresholds (default: false, use exact thresholds) AutoFixModel string `json:"auto_fix_model,omitempty"` // Model for automatic remediation (defaults to PatrolModel, may want more capable model) + // Patrol finding notifications route each newly detected warning+ finding + // through the operator's alert notification channels (email, webhooks, + // Apprise). Findings are deduplicated upstream, so at most one + // notification fires per finding lifetime. The enabled flag is persisted + // without omitempty so an explicit opt-out survives reload; configs saved + // before the field existed inherit the default (enabled). + PatrolFindingNotificationsEnabled bool `json:"patrol_finding_notifications_enabled"` + // PatrolFindingNotifyMinSeverity is the minimum finding severity that + // warrants a notification ("warning" accepts warning+critical; + // "critical" accepts only critical; empty = "warning"). + PatrolFindingNotifyMinSeverity string `json:"patrol_finding_notify_min_severity,omitempty"` + // Alert-triggered AI analysis - analyze specific resources when alerts fire AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // Enable AI analysis when alerts fire (token-efficient) @@ -252,6 +264,10 @@ func NewDefaultAIConfig() *AIConfig { // Default to critical-only so alert-triggered investigations stay // token-conservative out of the box. Operators can opt warnings in. PatrolAlertTriggerMinSeverity: AlertTriggerSeverityCritical, + // Finding notifications default on at warning+, matching the + // long-standing default for Patrol mobile push. Findings fire once + // per lifetime, so the volume is inherently low. + PatrolFindingNotificationsEnabled: true, } } @@ -294,6 +310,39 @@ func (c *AIConfig) AlertTriggersInvestigation(alertType, level string) bool { return true } +// GetPatrolFindingNotifyMinSeverity returns the configured minimum finding +// severity that warrants a notification, normalizing the empty default to +// warning. Warning is the right floor here because most actionable findings +// (data-loss risks, failing health checks) are reported at warning severity. +func (c *AIConfig) GetPatrolFindingNotifyMinSeverity() string { + if c == nil { + return AlertTriggerSeverityWarning + } + min := strings.ToLower(strings.TrimSpace(c.PatrolFindingNotifyMinSeverity)) + switch min { + case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical: + return min + default: + return AlertTriggerSeverityWarning + } +} + +// PatrolFindingTriggersNotification reports whether a newly detected finding +// of the given severity should be routed to the operator's alert notification +// channels. Only warning and critical findings ever qualify; info and watch +// findings are UI-only detail. +func (c *AIConfig) PatrolFindingTriggersNotification(severity string) bool { + if c == nil || !c.Enabled || !c.PatrolFindingNotificationsEnabled { + return false + } + switch strings.ToLower(strings.TrimSpace(severity)) { + case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical: + default: + return false + } + return alertLevelMeetsMinimum(severity, c.GetPatrolFindingNotifyMinSeverity()) +} + // alertLevelMeetsMinimum ranks warning < critical. An empty minimum defaults to // critical-only. An unknown alert level is treated as critical so it is never // silently dropped. diff --git a/internal/config/ai_patrol_finding_notify_test.go b/internal/config/ai_patrol_finding_notify_test.go new file mode 100644 index 0000000000..ba0838ea0f --- /dev/null +++ b/internal/config/ai_patrol_finding_notify_test.go @@ -0,0 +1,116 @@ +package config + +import ( + "encoding/json" + "testing" +) + +func TestPatrolFindingTriggersNotification(t *testing.T) { + enabled := func() *AIConfig { + cfg := NewDefaultAIConfig() + cfg.Enabled = true + return cfg + } + + t.Run("nil config never notifies", func(t *testing.T) { + var cfg *AIConfig + if cfg.PatrolFindingTriggersNotification("critical") { + t.Fatal("nil config must not notify") + } + }) + + t.Run("disabled AI master switch blocks", func(t *testing.T) { + cfg := NewDefaultAIConfig() + if cfg.PatrolFindingTriggersNotification("critical") { + t.Fatal("AI disabled must not notify") + } + }) + + t.Run("explicit opt-out blocks", func(t *testing.T) { + cfg := enabled() + cfg.PatrolFindingNotificationsEnabled = false + if cfg.PatrolFindingTriggersNotification("critical") { + t.Fatal("opted-out config must not notify") + } + }) + + t.Run("default floor accepts warning and critical", func(t *testing.T) { + cfg := enabled() + if !cfg.PatrolFindingTriggersNotification("warning") { + t.Fatal("warning finding must notify at the default floor") + } + if !cfg.PatrolFindingTriggersNotification("critical") { + t.Fatal("critical finding must notify at the default floor") + } + }) + + t.Run("info and watch never notify", func(t *testing.T) { + cfg := enabled() + for _, severity := range []string{"info", "watch", "", "bogus"} { + if cfg.PatrolFindingTriggersNotification(severity) { + t.Fatalf("severity %q must not notify", severity) + } + } + }) + + t.Run("critical floor drops warning", func(t *testing.T) { + cfg := enabled() + cfg.PatrolFindingNotifyMinSeverity = AlertTriggerSeverityCritical + if cfg.PatrolFindingTriggersNotification("warning") { + t.Fatal("warning finding must not notify at the critical floor") + } + if !cfg.PatrolFindingTriggersNotification("critical") { + t.Fatal("critical finding must notify at the critical floor") + } + }) +} + +func TestGetPatrolFindingNotifyMinSeverity(t *testing.T) { + var nilCfg *AIConfig + if got := nilCfg.GetPatrolFindingNotifyMinSeverity(); got != AlertTriggerSeverityWarning { + t.Fatalf("nil config floor = %q, want warning", got) + } + cfg := &AIConfig{} + if got := cfg.GetPatrolFindingNotifyMinSeverity(); got != AlertTriggerSeverityWarning { + t.Fatalf("empty floor = %q, want warning", got) + } + cfg.PatrolFindingNotifyMinSeverity = " Critical " + if got := cfg.GetPatrolFindingNotifyMinSeverity(); got != AlertTriggerSeverityCritical { + t.Fatalf("normalized floor = %q, want critical", got) + } + cfg.PatrolFindingNotifyMinSeverity = "bogus" + if got := cfg.GetPatrolFindingNotifyMinSeverity(); got != AlertTriggerSeverityWarning { + t.Fatalf("invalid floor = %q, want warning", got) + } +} + +// Configs saved before the field existed must inherit the enabled default, +// while a persisted explicit opt-out must survive a save/load round trip. +// LoadAIConfig unmarshals over NewDefaultAIConfig, which this mirrors. +func TestPatrolFindingNotificationsPersistence(t *testing.T) { + t.Run("missing field inherits default on", func(t *testing.T) { + settings := NewDefaultAIConfig() + if err := json.Unmarshal([]byte(`{"enabled":true}`), settings); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !settings.PatrolFindingNotificationsEnabled { + t.Fatal("legacy config without the field must default to enabled") + } + }) + + t.Run("explicit false survives round trip", func(t *testing.T) { + saved := NewDefaultAIConfig() + saved.PatrolFindingNotificationsEnabled = false + data, err := json.Marshal(saved) + if err != nil { + t.Fatalf("marshal: %v", err) + } + loaded := NewDefaultAIConfig() + if err := json.Unmarshal(data, loaded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if loaded.PatrolFindingNotificationsEnabled { + t.Fatal("explicit opt-out must survive save/load") + } + }) +}