mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 19:25:57 +00:00
8f9ff92701
A single noisy resource that trips several patrol detectors in a tight window currently emits one finding per symptom. The findings panel fans those out, which is the right surface for the detectors but the wrong surface for the operator: a flapping VM with CPU, swap, and disk pressure should read as one "this resource is in a bad state" event, not three concurrent rows competing for attention. This commit lands the throttler substrate. The new `findingStormThrottler` is invoked from `(*FindingsStore).Add`'s new-finding branch (while `s.mu` is held), clusters emissions by `Finding.ResourceID`, and when a cluster crosses `stormThreshold` (3) within `stormWindow` (60s) returns a storm finding for the caller to re-enter through `s.Add`. The stable ID `finding-storm:<resourceID>` dedups subsequent emissions through the existing-finding branch, and the cycle guard on `Source == "finding-storm"` keeps the storm finding's own emission from recursing back into the observer. Auto-resolve is lazy by design: on the next observe trip where a cluster has fallen below threshold AND the storm finding has been quiet for at least `2 * stormWindow`, the observer returns a sentinel that the caller routes through `ResolveWithReason`. No sweep goroutine, no extra wiring surface — the substrate is contained in `internal/ai/` and exposes a single nil-safe setter (`SetStormThrottler`). This commit is observer-only: nothing constructs a throttler yet, so in-tree behavior is unchanged. Wire-up lands in a follow-up.
2802 lines
101 KiB
Go
2802 lines
101 KiB
Go
// Package ai provides AI-powered infrastructure monitoring and investigation.
|
||
package ai
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
||
)
|
||
|
||
// Investigation limits for automatic finding investigation.
|
||
const (
|
||
investigationCooldown = 1 * time.Hour // Minimum time between investigation attempts
|
||
investigationTimeoutCooldown = 10 * time.Minute // Shorter cooldown for timeout failures
|
||
investigationFixRetryCooldown = 20 * time.Minute // Retry faster after failed fix attempts
|
||
maxInvestigationAttempts = 3 // Maximum number of investigation retries
|
||
)
|
||
|
||
// FindingSeverity represents how urgent a finding is
|
||
type FindingSeverity string
|
||
|
||
const (
|
||
// FindingSeverityInfo is informational - user may want to know
|
||
FindingSeverityInfo FindingSeverity = "info"
|
||
// FindingSeverityWatch means something to keep an eye on
|
||
FindingSeverityWatch FindingSeverity = "watch"
|
||
// FindingSeverityWarning means action should be taken soon
|
||
FindingSeverityWarning FindingSeverity = "warning"
|
||
// FindingSeverityCritical means immediate action needed
|
||
FindingSeverityCritical FindingSeverity = "critical"
|
||
)
|
||
|
||
// FindingCategory groups findings by type
|
||
type FindingCategory string
|
||
|
||
const (
|
||
FindingCategoryPerformance FindingCategory = "performance"
|
||
FindingCategoryCapacity FindingCategory = "capacity"
|
||
FindingCategoryReliability FindingCategory = "reliability"
|
||
FindingCategoryBackup FindingCategory = "backup"
|
||
FindingCategorySecurity FindingCategory = "security"
|
||
FindingCategoryGeneral FindingCategory = "general"
|
||
)
|
||
|
||
// CategorySupportsStaleAutoResolve reports whether a finding category
|
||
// represents a continuous current-state condition that Patrol can soundly
|
||
// auto-resolve from absence in a successful run. Only `performance` and
|
||
// `capacity` qualify: those are metric thresholds (CPU above X, disk above
|
||
// Y) where the most recent scan's observation is authoritative — if the
|
||
// threshold isn't tripped now, the condition has cleared.
|
||
//
|
||
// All other categories represent discrete events or persistent states:
|
||
// `reliability` (a service that crashed, a restart loop), `backup` (a
|
||
// backup task that failed), `security` (a vulnerability that was found),
|
||
// and `general` (catch-all). For these, absence in a Patrol report only
|
||
// means the LLM didn't re-mention the finding — it does not mean the
|
||
// underlying issue has been addressed. Auto-resolving them on absence
|
||
// produced false `auto_resolved` events that the next run re-detected as
|
||
// regressions, inflating the regression counter and polluting the trust
|
||
// strip with fictional "auto-resolved" credit.
|
||
//
|
||
// Such findings stay active until explicitly resolved — either by the LLM
|
||
// calling `patrol_resolve_finding` with evidence, or by operator action.
|
||
func CategorySupportsStaleAutoResolve(category FindingCategory) bool {
|
||
switch category {
|
||
case FindingCategoryPerformance, FindingCategoryCapacity:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// DismissReasonWillFixLater marks a finding as "I will fix this later" — an
|
||
// operational commitment with an implicit deadline. See Finding.RemindAt.
|
||
const DismissReasonWillFixLater = "will_fix_later"
|
||
|
||
// DefaultWillFixLaterRemindAfter is how long Pulse waits before nagging the
|
||
// operator about an open will_fix_later finding. This makes the dismissal a
|
||
// real commitment rather than a silent shut-up: if the finding still trips
|
||
// after this window, it surfaces again with a "reminded" lifecycle event
|
||
// instead of being swallowed forever.
|
||
const DefaultWillFixLaterRemindAfter = 7 * 24 * time.Hour
|
||
|
||
// FindingLoopState represents the current stage of the patrol detect/remediate loop.
|
||
type FindingLoopState string
|
||
|
||
const (
|
||
FindingLoopStateDetected FindingLoopState = "detected"
|
||
FindingLoopStateInvestigating FindingLoopState = "investigating"
|
||
FindingLoopStateRemediationPlanned FindingLoopState = "remediation_planned"
|
||
FindingLoopStateRemediating FindingLoopState = "remediating"
|
||
FindingLoopStateRemediationFailed FindingLoopState = "remediation_failed"
|
||
FindingLoopStateNeedsAttention FindingLoopState = "needs_attention"
|
||
FindingLoopStateTimedOut FindingLoopState = "timed_out"
|
||
FindingLoopStateResolved FindingLoopState = "resolved"
|
||
FindingLoopStateDismissed FindingLoopState = "dismissed"
|
||
FindingLoopStateSnoozed FindingLoopState = "snoozed"
|
||
FindingLoopStateSuppressed FindingLoopState = "suppressed"
|
||
)
|
||
|
||
// InvestigationStatus represents the current investigation state of a finding
|
||
type InvestigationStatus string
|
||
|
||
const (
|
||
InvestigationStatusPending InvestigationStatus = "pending" // Queued for investigation
|
||
InvestigationStatusRunning InvestigationStatus = "running" // Currently being investigated
|
||
InvestigationStatusCompleted InvestigationStatus = "completed" // Investigation finished
|
||
InvestigationStatusFailed InvestigationStatus = "failed" // Investigation errored out
|
||
InvestigationStatusNeedsAttention InvestigationStatus = "needs_attention" // Requires user input
|
||
)
|
||
|
||
// InvestigationOutcome represents the result of an investigation
|
||
type InvestigationOutcome string
|
||
|
||
const (
|
||
InvestigationOutcomeResolved InvestigationOutcome = "resolved" // Issue was automatically fixed
|
||
InvestigationOutcomeFixQueued InvestigationOutcome = "fix_queued" // Fix identified, awaiting approval
|
||
InvestigationOutcomeFixExecuted InvestigationOutcome = "fix_executed" // Fix command executed
|
||
InvestigationOutcomeFixFailed InvestigationOutcome = "fix_failed" // Fix command failed
|
||
InvestigationOutcomeFixVerified InvestigationOutcome = "fix_verified" // Fix command verified successful
|
||
InvestigationOutcomeFixVerificationFailed InvestigationOutcome = "fix_verification_failed" // Fix ran but issue persists
|
||
InvestigationOutcomeFixVerificationUnknown InvestigationOutcome = "fix_verification_unknown" // Fix ran but verification was inconclusive
|
||
InvestigationOutcomeNeedsAttention InvestigationOutcome = "needs_attention" // Requires user intervention
|
||
InvestigationOutcomeCannotFix InvestigationOutcome = "cannot_fix" // AI determined it cannot fix this
|
||
InvestigationOutcomeTimedOut InvestigationOutcome = "timed_out" // Transient timeout, will retry sooner
|
||
)
|
||
|
||
// FindingLifecycleEvent represents an append-only log entry of finding lifecycle transitions.
|
||
// It is used to make the patrol detect/remediate/verify loop observable and debuggable in the UI.
|
||
type FindingLifecycleEvent struct {
|
||
At time.Time `json:"at"`
|
||
Type string `json:"type"`
|
||
Message string `json:"message,omitempty"`
|
||
From string `json:"from,omitempty"`
|
||
To string `json:"to,omitempty"`
|
||
Metadata map[string]string `json:"metadata,omitempty"`
|
||
}
|
||
|
||
const maxFindingLifecycleEvents = 50
|
||
|
||
// Finding represents an AI-discovered insight about infrastructure
|
||
type Finding struct {
|
||
ID string `json:"id"`
|
||
Key string `json:"key,omitempty"` // Stable issue key for runbook matching
|
||
Severity FindingSeverity `json:"severity"`
|
||
Category FindingCategory `json:"category"`
|
||
ResourceID string `json:"resource_id"`
|
||
ResourceName string `json:"resource_name"`
|
||
ResourceType string `json:"resource_type"` // node, vm, container, docker, storage, agent, pbs, agent_raid
|
||
Node string `json:"node,omitempty"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Impact string `json:"impact,omitempty"` // Operator-facing consequence-if-ignored statement
|
||
// PreviousResolvedFixSummary preserves the description of the fix that
|
||
// resolved this finding the last time it was active. Captured at
|
||
// regression time before InvestigationRecord is cleared, so the next
|
||
// investigation has operational memory of what worked previously.
|
||
PreviousResolvedFixSummary string `json:"previous_resolved_fix_summary,omitempty"`
|
||
Recommendation string `json:"recommendation,omitempty"`
|
||
Evidence string `json:"evidence,omitempty"` // data/commands that led to this finding
|
||
Source string `json:"source,omitempty"` // "ai-analysis" for LLM findings, empty for rule-based
|
||
FailureCause string `json:"failure_cause,omitempty"`
|
||
DetectedAt time.Time `json:"detected_at"`
|
||
LastSeenAt time.Time `json:"last_seen_at"`
|
||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||
AutoResolved bool `json:"auto_resolved"`
|
||
ResolveReason string `json:"resolve_reason,omitempty"` // Why the finding was resolved (e.g., "No longer detected by patrol")
|
||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"` // Finding hidden until this time
|
||
// Link to alert if this finding was triggered by or attached to an alert.
|
||
AlertIdentifier string `json:"-"`
|
||
|
||
// User feedback fields - enables LLM "memory" by tracking how users respond
|
||
// This helps prevent the LLM from repeatedly raising the same dismissed issues
|
||
DismissedReason string `json:"dismissed_reason,omitempty"` // "not_an_issue", "expected_behavior", "will_fix_later"
|
||
UserNote string `json:"user_note,omitempty"` // Freeform user explanation, included in LLM context
|
||
TimesRaised int `json:"times_raised"` // How many times this finding has been detected
|
||
Suppressed bool `json:"suppressed"` // Permanently suppress similar findings for this resource
|
||
// RemindAt turns "will_fix_later" into a real operational commitment.
|
||
// When set, the dismissal stays quiet until this timestamp passes; after
|
||
// it passes, the next re-detection clears the dismissal and surfaces the
|
||
// finding again with a "reminded" lifecycle event so the operator sees
|
||
// "you said you'd fix this on <date>, it's still happening." Only
|
||
// populated for reason == "will_fix_later".
|
||
RemindAt *time.Time `json:"remind_at,omitempty"`
|
||
// RemindCount counts how many times a will_fix_later commitment has
|
||
// surfaced past its RemindAt deadline. Incremented next to each
|
||
// "reminded" lifecycle event (both the re-detection path and the
|
||
// proactive sweep). Survives the clear of RemindAt so the curator UI
|
||
// can show "reminded 3x".
|
||
RemindCount int `json:"remind_count,omitempty"`
|
||
|
||
// Investigation fields - tracks autonomous AI investigation of findings
|
||
InvestigationSessionID string `json:"investigation_session_id,omitempty"` // Chat session ID if being investigated
|
||
InvestigationStatus string `json:"investigation_status,omitempty"` // pending, running, completed, failed, needs_attention
|
||
InvestigationOutcome string `json:"investigation_outcome,omitempty"` // resolved, fix_queued, fix_executed, fix_failed, fix_verified, fix_verification_failed, needs_attention, cannot_fix
|
||
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"` // When last investigation completed
|
||
InvestigationAttempts int `json:"investigation_attempts"` // Number of investigation attempts
|
||
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"` // Durable contextual summary for Assistant/unified intelligence
|
||
LoopState string `json:"loop_state,omitempty"` // detected, investigating, remediating, resolved, etc.
|
||
Lifecycle []FindingLifecycleEvent `json:"lifecycle,omitempty"` // Bounded, append-only lifecycle log
|
||
RegressionCount int `json:"regression_count,omitempty"` // Times the issue reappeared after resolution
|
||
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"` // Timestamp of most recent regression
|
||
}
|
||
|
||
type findingJSON struct {
|
||
ID string `json:"id"`
|
||
Key string `json:"key,omitempty"`
|
||
Severity FindingSeverity `json:"severity"`
|
||
Category FindingCategory `json:"category"`
|
||
ResourceID string `json:"resource_id"`
|
||
ResourceName string `json:"resource_name"`
|
||
ResourceType string `json:"resource_type"`
|
||
Node string `json:"node,omitempty"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Impact string `json:"impact,omitempty"`
|
||
PreviousResolvedFixSummary string `json:"previous_resolved_fix_summary,omitempty"`
|
||
Recommendation string `json:"recommendation,omitempty"`
|
||
Evidence string `json:"evidence,omitempty"`
|
||
Source string `json:"source,omitempty"`
|
||
FailureCause string `json:"failure_cause,omitempty"`
|
||
DetectedAt time.Time `json:"detected_at"`
|
||
LastSeenAt time.Time `json:"last_seen_at"`
|
||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||
AutoResolved bool `json:"auto_resolved"`
|
||
ResolveReason string `json:"resolve_reason,omitempty"`
|
||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||
SnoozedUntil *time.Time `json:"snoozed_until,omitempty"`
|
||
AlertIdentifier string `json:"alert_identifier,omitempty"`
|
||
DismissedReason string `json:"dismissed_reason,omitempty"`
|
||
UserNote string `json:"user_note,omitempty"`
|
||
TimesRaised int `json:"times_raised"`
|
||
Suppressed bool `json:"suppressed"`
|
||
RemindAt *time.Time `json:"remind_at,omitempty"`
|
||
RemindCount int `json:"remind_count,omitempty"`
|
||
InvestigationSessionID string `json:"investigation_session_id,omitempty"`
|
||
InvestigationStatus string `json:"investigation_status,omitempty"`
|
||
InvestigationOutcome string `json:"investigation_outcome,omitempty"`
|
||
LastInvestigatedAt *time.Time `json:"last_investigated_at,omitempty"`
|
||
InvestigationAttempts int `json:"investigation_attempts"`
|
||
InvestigationRecord *aicontracts.InvestigationRecord `json:"investigation_record,omitempty"`
|
||
LoopState string `json:"loop_state,omitempty"`
|
||
Lifecycle []FindingLifecycleEvent `json:"lifecycle,omitempty"`
|
||
RegressionCount int `json:"regression_count,omitempty"`
|
||
LastRegressionAt *time.Time `json:"last_regression_at,omitempty"`
|
||
}
|
||
|
||
func (f Finding) MarshalJSON() ([]byte, error) {
|
||
alertIdentifier := strings.TrimSpace(f.AlertIdentifier)
|
||
return json.Marshal(findingJSON{
|
||
ID: f.ID,
|
||
Key: f.Key,
|
||
Severity: f.Severity,
|
||
Category: f.Category,
|
||
ResourceID: f.ResourceID,
|
||
ResourceName: f.ResourceName,
|
||
ResourceType: f.ResourceType,
|
||
Node: f.Node,
|
||
Title: f.Title,
|
||
Description: f.Description,
|
||
Impact: f.Impact,
|
||
PreviousResolvedFixSummary: f.PreviousResolvedFixSummary,
|
||
Recommendation: f.Recommendation,
|
||
Evidence: f.Evidence,
|
||
Source: f.Source,
|
||
FailureCause: f.FailureCause,
|
||
DetectedAt: f.DetectedAt,
|
||
LastSeenAt: f.LastSeenAt,
|
||
ResolvedAt: f.ResolvedAt,
|
||
AutoResolved: f.AutoResolved,
|
||
ResolveReason: f.ResolveReason,
|
||
AcknowledgedAt: f.AcknowledgedAt,
|
||
SnoozedUntil: f.SnoozedUntil,
|
||
AlertIdentifier: alertIdentifier,
|
||
DismissedReason: f.DismissedReason,
|
||
UserNote: f.UserNote,
|
||
TimesRaised: f.TimesRaised,
|
||
Suppressed: f.Suppressed,
|
||
RemindAt: f.RemindAt,
|
||
RemindCount: f.RemindCount,
|
||
InvestigationSessionID: f.InvestigationSessionID,
|
||
InvestigationStatus: f.InvestigationStatus,
|
||
InvestigationOutcome: f.InvestigationOutcome,
|
||
LastInvestigatedAt: f.LastInvestigatedAt,
|
||
InvestigationAttempts: f.InvestigationAttempts,
|
||
InvestigationRecord: f.InvestigationRecord,
|
||
LoopState: f.LoopState,
|
||
Lifecycle: f.Lifecycle,
|
||
RegressionCount: f.RegressionCount,
|
||
LastRegressionAt: f.LastRegressionAt,
|
||
})
|
||
}
|
||
|
||
func (f *Finding) UnmarshalJSON(data []byte) error {
|
||
var payload findingJSON
|
||
if err := json.Unmarshal(data, &payload); err != nil {
|
||
return err
|
||
}
|
||
|
||
*f = Finding{
|
||
ID: payload.ID,
|
||
Key: payload.Key,
|
||
Severity: payload.Severity,
|
||
Category: payload.Category,
|
||
ResourceID: payload.ResourceID,
|
||
ResourceName: payload.ResourceName,
|
||
ResourceType: payload.ResourceType,
|
||
Node: payload.Node,
|
||
Title: payload.Title,
|
||
Description: payload.Description,
|
||
Impact: payload.Impact,
|
||
PreviousResolvedFixSummary: payload.PreviousResolvedFixSummary,
|
||
Recommendation: payload.Recommendation,
|
||
Evidence: payload.Evidence,
|
||
Source: payload.Source,
|
||
FailureCause: payload.FailureCause,
|
||
DetectedAt: payload.DetectedAt,
|
||
LastSeenAt: payload.LastSeenAt,
|
||
ResolvedAt: payload.ResolvedAt,
|
||
AutoResolved: payload.AutoResolved,
|
||
ResolveReason: payload.ResolveReason,
|
||
AcknowledgedAt: payload.AcknowledgedAt,
|
||
SnoozedUntil: payload.SnoozedUntil,
|
||
AlertIdentifier: strings.TrimSpace(payload.AlertIdentifier),
|
||
DismissedReason: payload.DismissedReason,
|
||
UserNote: payload.UserNote,
|
||
TimesRaised: payload.TimesRaised,
|
||
Suppressed: payload.Suppressed,
|
||
RemindAt: payload.RemindAt,
|
||
RemindCount: payload.RemindCount,
|
||
InvestigationSessionID: payload.InvestigationSessionID,
|
||
InvestigationStatus: payload.InvestigationStatus,
|
||
InvestigationOutcome: payload.InvestigationOutcome,
|
||
LastInvestigatedAt: payload.LastInvestigatedAt,
|
||
InvestigationAttempts: payload.InvestigationAttempts,
|
||
InvestigationRecord: payload.InvestigationRecord,
|
||
LoopState: payload.LoopState,
|
||
Lifecycle: payload.Lifecycle,
|
||
RegressionCount: payload.RegressionCount,
|
||
LastRegressionAt: payload.LastRegressionAt,
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// IsActive returns true if the finding is still active (not resolved, not snoozed, not suppressed, not dismissed)
|
||
func (f *Finding) IsActive() bool {
|
||
return f.ResolvedAt == nil && !f.IsSnoozed() && !f.Suppressed && f.DismissedReason == ""
|
||
}
|
||
|
||
// IsDismissed returns true if the user has dismissed this finding with a reason
|
||
func (f *Finding) IsDismissed() bool {
|
||
return f.DismissedReason != ""
|
||
}
|
||
|
||
// IsSnoozed returns true if the finding is currently snoozed
|
||
func (f *Finding) IsSnoozed() bool {
|
||
return f.SnoozedUntil != nil && time.Now().Before(*f.SnoozedUntil)
|
||
}
|
||
|
||
// IsResolved returns true if the finding has been resolved (ignores snooze)
|
||
func (f *Finding) IsResolved() bool {
|
||
return f.ResolvedAt != nil
|
||
}
|
||
|
||
func investigationCooldownForOutcome(outcome string) time.Duration {
|
||
switch InvestigationOutcome(outcome) {
|
||
case InvestigationOutcomeTimedOut:
|
||
return investigationTimeoutCooldown
|
||
case InvestigationOutcomeFixFailed, InvestigationOutcomeFixVerificationFailed:
|
||
return investigationFixRetryCooldown
|
||
default:
|
||
return investigationCooldown
|
||
}
|
||
}
|
||
|
||
func deriveLoopState(f *Finding) FindingLoopState {
|
||
if f == nil {
|
||
return FindingLoopStateDetected
|
||
}
|
||
|
||
switch {
|
||
case f.Suppressed:
|
||
return FindingLoopStateSuppressed
|
||
case f.ResolvedAt != nil || InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeFixVerified:
|
||
return FindingLoopStateResolved
|
||
case f.IsSnoozed():
|
||
return FindingLoopStateSnoozed
|
||
case f.DismissedReason != "":
|
||
return FindingLoopStateDismissed
|
||
}
|
||
|
||
switch InvestigationStatus(f.InvestigationStatus) {
|
||
case InvestigationStatusRunning:
|
||
return FindingLoopStateInvestigating
|
||
case InvestigationStatusNeedsAttention:
|
||
return FindingLoopStateNeedsAttention
|
||
}
|
||
|
||
switch InvestigationOutcome(f.InvestigationOutcome) {
|
||
case InvestigationOutcomeFixQueued:
|
||
return FindingLoopStateRemediationPlanned
|
||
case InvestigationOutcomeFixExecuted:
|
||
return FindingLoopStateRemediating
|
||
case InvestigationOutcomeFixFailed, InvestigationOutcomeFixVerificationFailed:
|
||
return FindingLoopStateRemediationFailed
|
||
case InvestigationOutcomeFixVerificationUnknown, InvestigationOutcomeNeedsAttention, InvestigationOutcomeCannotFix:
|
||
return FindingLoopStateNeedsAttention
|
||
case InvestigationOutcomeTimedOut:
|
||
return FindingLoopStateTimedOut
|
||
}
|
||
|
||
return FindingLoopStateDetected
|
||
}
|
||
|
||
func (f *Finding) syncLoopState() {
|
||
if f == nil {
|
||
return
|
||
}
|
||
f.LoopState = string(deriveLoopState(f))
|
||
}
|
||
|
||
// ShouldInvestigate returns true if this finding should be automatically investigated
|
||
// based on autonomy level, severity, and investigation history.
|
||
// A finding will NOT be investigated if:
|
||
// - Already being investigated (status = running)
|
||
// - Investigated within the last hour (cooldown)
|
||
// - Already attempted 3 times (max attempts)
|
||
// - Severity is info/watch (not actionable)
|
||
// - Already resolved/dismissed/suppressed
|
||
func (f *Finding) ShouldInvestigate(autonomyLevel string) bool {
|
||
// Only investigate if autonomy is enabled (approval or full mode)
|
||
if autonomyLevel == "" || autonomyLevel == "monitor" {
|
||
return false
|
||
}
|
||
|
||
// Don't investigate already resolved/dismissed findings
|
||
if f.ResolvedAt != nil || f.Suppressed || f.DismissedReason != "" {
|
||
return false
|
||
}
|
||
|
||
// Don't investigate snoozed findings
|
||
if f.IsSnoozed() {
|
||
return false
|
||
}
|
||
|
||
// Don't re-investigate when a fix is queued for approval/user action.
|
||
if InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeFixQueued {
|
||
return false
|
||
}
|
||
|
||
// Don't auto-reinvestigate fully terminal outcomes.
|
||
if InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeFixVerified ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeResolved ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeCannotFix ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeNeedsAttention ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeFixVerificationUnknown {
|
||
return false
|
||
}
|
||
|
||
// Only investigate warning and critical severity (info/watch are not actionable)
|
||
if f.Severity != FindingSeverityWarning && f.Severity != FindingSeverityCritical {
|
||
return false
|
||
}
|
||
|
||
// Don't re-investigate if already running
|
||
if f.InvestigationStatus == string(InvestigationStatusRunning) {
|
||
return false
|
||
}
|
||
|
||
// Don't re-investigate if at max attempts (3)
|
||
if f.InvestigationAttempts >= maxInvestigationAttempts {
|
||
return false
|
||
}
|
||
|
||
// Don't re-investigate within cooldown period
|
||
// Timeout failures use a shorter cooldown (10min) since they're transient
|
||
if f.LastInvestigatedAt != nil {
|
||
cooldown := investigationCooldownForOutcome(f.InvestigationOutcome)
|
||
if time.Since(*f.LastInvestigatedAt) < cooldown {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
func inferFindingResourceType(resourceID, resourceName string) string {
|
||
joined := strings.ToLower(strings.TrimSpace(resourceID + " " + resourceName))
|
||
|
||
switch {
|
||
case strings.Contains(joined, "pbs") || strings.Contains(joined, "backup"):
|
||
return "pbs"
|
||
case strings.Contains(joined, "storage") || strings.Contains(joined, "pool") || strings.Contains(joined, "zfs"):
|
||
return "storage"
|
||
case strings.Contains(joined, "docker"):
|
||
return "app-container"
|
||
case strings.Contains(joined, "lxc") || strings.Contains(joined, "ct") || strings.Contains(joined, "container"):
|
||
return "system-container"
|
||
case strings.Contains(joined, "vm"):
|
||
return "vm"
|
||
case strings.Contains(joined, "agent"):
|
||
return "agent"
|
||
case strings.Contains(joined, "node"):
|
||
return "node"
|
||
case strings.Contains(joined, "host"):
|
||
return "agent"
|
||
}
|
||
|
||
if hasFindingNumericSuffix(resourceID) {
|
||
return "vm"
|
||
}
|
||
|
||
return "node"
|
||
}
|
||
|
||
func canonicalFindingResourceType(resourceType string) string {
|
||
normalized := canonicalizeAICompatibilityResourceType(resourceType)
|
||
if normalized == "" {
|
||
return ""
|
||
}
|
||
if isUnsupportedLegacyAIResourceTypeToken(normalized) {
|
||
return ""
|
||
}
|
||
switch normalized {
|
||
case "vm":
|
||
return "vm"
|
||
case "system-container":
|
||
return "system-container"
|
||
case "app-container":
|
||
return "app-container"
|
||
case "docker-host":
|
||
return "docker-host"
|
||
case "k8s-cluster":
|
||
return "k8s-cluster"
|
||
case "agent":
|
||
return "agent"
|
||
case "node":
|
||
return "node"
|
||
case "physical_disk":
|
||
return "physical_disk"
|
||
default:
|
||
return normalized
|
||
}
|
||
}
|
||
|
||
func hasFindingNumericSuffix(value string) bool {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return false
|
||
}
|
||
sep := strings.LastIndexAny(value, ":/")
|
||
if sep >= 0 && sep+1 < len(value) {
|
||
value = value[sep+1:]
|
||
}
|
||
if value == "" {
|
||
return false
|
||
}
|
||
for _, r := range value {
|
||
if r < '0' || r > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// IsBeingInvestigated returns true if an investigation is currently in progress
|
||
func (f *Finding) IsBeingInvestigated() bool {
|
||
return f.InvestigationStatus == string(InvestigationStatusRunning)
|
||
}
|
||
|
||
// CanRetryInvestigation returns true if the finding can be re-investigated
|
||
func (f *Finding) CanRetryInvestigation() bool {
|
||
// Can't retry if at max attempts
|
||
if f.InvestigationAttempts >= maxInvestigationAttempts {
|
||
return false
|
||
}
|
||
// Can't retry if still in cooldown
|
||
// Timeout failures use a shorter cooldown (10min) since they're transient
|
||
if f.LastInvestigatedAt != nil {
|
||
cooldown := investigationCooldownForOutcome(f.InvestigationOutcome)
|
||
if time.Since(*f.LastInvestigatedAt) < cooldown {
|
||
return false
|
||
}
|
||
}
|
||
if InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeFixQueued ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeFixVerified ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeResolved ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeCannotFix ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeNeedsAttention ||
|
||
InvestigationOutcome(f.InvestigationOutcome) == InvestigationOutcomeFixVerificationUnknown {
|
||
return false
|
||
}
|
||
// Can't retry if currently running
|
||
if f.InvestigationStatus == string(InvestigationStatusRunning) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// ToCoreFinding converts to the shared InvestigationFinding type used by the
|
||
// investigation orchestrator. Centralised here so a field added to
|
||
// InvestigationFinding causes a compile error in exactly one place.
|
||
//
|
||
// The OperationalMemory projection is populated unconditionally from
|
||
// fields the internal Finding already carries (regression count,
|
||
// previous resolved fix). OperatorContext is NOT populated here
|
||
// because that data lives in the unified-resources store, which this
|
||
// package does not import. Callers that have access to the operator
|
||
// state attach it via WithOperatorContext after conversion.
|
||
func (f *Finding) ToCoreFinding() *InvestigationFinding {
|
||
core := &InvestigationFinding{
|
||
ID: f.ID,
|
||
Key: f.Key,
|
||
Severity: string(f.Severity),
|
||
Category: string(f.Category),
|
||
ResourceID: f.ResourceID,
|
||
ResourceName: f.ResourceName,
|
||
ResourceType: f.ResourceType,
|
||
Title: f.Title,
|
||
Description: f.Description,
|
||
Recommendation: f.Recommendation,
|
||
Evidence: f.Evidence,
|
||
InvestigationSessionID: f.InvestigationSessionID,
|
||
InvestigationStatus: f.InvestigationStatus,
|
||
InvestigationOutcome: f.InvestigationOutcome,
|
||
LastInvestigatedAt: f.LastInvestigatedAt,
|
||
InvestigationAttempts: f.InvestigationAttempts,
|
||
}
|
||
|
||
// Populate operational memory from fields the internal Finding
|
||
// already accumulates. Only attach when there is something
|
||
// meaningful to say — a fresh finding (no regressions, no prior
|
||
// fix, no raises beyond 1) leaves the field nil so the
|
||
// orchestrator can treat absence as "no prior history" rather
|
||
// than parsing a zero-valued struct.
|
||
if f.RegressionCount > 0 || f.PreviousResolvedFixSummary != "" || f.TimesRaised > 1 {
|
||
core.OperationalMemory = &aicontracts.FindingOperationalMemory{
|
||
RegressionCount: f.RegressionCount,
|
||
LastRegressionAt: f.LastRegressionAt,
|
||
PreviousResolvedFixSummary: f.PreviousResolvedFixSummary,
|
||
TimesRaised: f.TimesRaised,
|
||
}
|
||
}
|
||
|
||
return core
|
||
}
|
||
|
||
// Getter methods for aicontracts.OrchestratorAIFinding interface
|
||
|
||
func (f *Finding) GetID() string { return f.ID }
|
||
func (f *Finding) GetSeverity() string { return string(f.Severity) }
|
||
func (f *Finding) GetCategory() string { return string(f.Category) }
|
||
func (f *Finding) GetResourceID() string { return f.ResourceID }
|
||
func (f *Finding) GetResourceName() string { return f.ResourceName }
|
||
func (f *Finding) GetResourceType() string { return f.ResourceType }
|
||
func (f *Finding) GetTitle() string { return f.Title }
|
||
func (f *Finding) GetDescription() string { return f.Description }
|
||
func (f *Finding) GetRecommendation() string { return f.Recommendation }
|
||
func (f *Finding) GetEvidence() string { return f.Evidence }
|
||
func (f *Finding) GetInvestigationSessionID() string { return f.InvestigationSessionID }
|
||
func (f *Finding) GetInvestigationStatus() string { return f.InvestigationStatus }
|
||
func (f *Finding) GetInvestigationOutcome() string { return f.InvestigationOutcome }
|
||
func (f *Finding) GetLastInvestigatedAt() *time.Time { return f.LastInvestigatedAt }
|
||
func (f *Finding) GetInvestigationAttempts() int { return f.InvestigationAttempts }
|
||
func (f *Finding) GetLoopState() string { return f.LoopState }
|
||
|
||
// Setter methods for aicontracts.OrchestratorAIFinding interface
|
||
|
||
func (f *Finding) SetInvestigationSessionID(v string) { f.InvestigationSessionID = v }
|
||
func (f *Finding) SetInvestigationStatus(v string) { f.InvestigationStatus = v }
|
||
func (f *Finding) SetInvestigationOutcome(v string) { f.InvestigationOutcome = v }
|
||
func (f *Finding) SetLastInvestigatedAt(v *time.Time) { f.LastInvestigatedAt = v }
|
||
func (f *Finding) SetInvestigationAttempts(v int) { f.InvestigationAttempts = v }
|
||
func (f *Finding) SetLoopState(v string) { f.LoopState = v }
|
||
|
||
// SuppressionRule represents a user-defined rule to suppress certain AI findings
|
||
// Users can create these manually to prevent alerts before they happen
|
||
type SuppressionRule struct {
|
||
ID string `json:"id"`
|
||
ResourceID string `json:"resource_id,omitempty"` // Empty means "any resource"
|
||
ResourceName string `json:"resource_name,omitempty"` // Human-readable name for display
|
||
Category FindingCategory `json:"category,omitempty"` // Empty means "any category"
|
||
Description string `json:"description"` // User's reason, e.g., "dev VM runs hot"
|
||
DismissedReason string `json:"dismissed_reason,omitempty"` // "not_an_issue", "expected_behavior", "will_fix_later"
|
||
CreatedAt time.Time `json:"created_at"`
|
||
CreatedFrom string `json:"created_from,omitempty"` // "finding" if suppressed, "dismissed" if just dismissed, "manual" if user-created
|
||
FindingID string `json:"finding_id,omitempty"` // Original finding ID if created from dismissal
|
||
}
|
||
|
||
// FindingsPersistence interface for saving/loading findings (avoids circular imports)
|
||
type FindingsPersistence interface {
|
||
SaveFindings(findings map[string]*Finding) error
|
||
LoadFindings() (map[string]*Finding, error)
|
||
}
|
||
|
||
// FindingsPersistenceWithSuppression extends persistence to include explicit suppression rules.
|
||
// This is versioned separately to keep backwards compatibility with existing persistence adapters.
|
||
type FindingsPersistenceWithSuppression interface {
|
||
FindingsPersistence
|
||
SaveFindingsAndSuppression(findings map[string]*Finding, suppressionRules map[string]*SuppressionRule) error
|
||
LoadFindingsAndSuppression() (map[string]*Finding, map[string]*SuppressionRule, error)
|
||
}
|
||
|
||
// FindingsStore provides thread-safe storage for AI findings with optional persistence
|
||
type FindingsStore struct {
|
||
mu sync.RWMutex
|
||
findings map[string]*Finding // keyed by ID
|
||
// Index by resource for quick lookups
|
||
byResource map[string][]string // resource_id -> []finding_id
|
||
// Keep track of active findings count by severity (cached, but GetSummary calculates dynamically)
|
||
activeCounts map[FindingSeverity]int
|
||
// User-defined suppression rules (separate from dismissed findings)
|
||
suppressionRules map[string]*SuppressionRule
|
||
// Persistence layer (optional)
|
||
persistence FindingsPersistence
|
||
// Debounce save operations
|
||
saveTimer *time.Timer
|
||
savePending bool
|
||
saveDebounce time.Duration
|
||
// Error tracking for persistence failures
|
||
lastSaveError error // Last error from save operation
|
||
onSaveError func(err error) // Optional callback for save errors
|
||
lastSaveTime time.Time // Last successful save time
|
||
// Optional resource-operator-state provider — when set, the store
|
||
// consults it during Add for per-resource maintenance windows and
|
||
// other operator-set intent, auto-acknowledging new findings whose
|
||
// resource is in a maintenance window. Nil-safe; an unset provider
|
||
// disables operator-state-driven suppression.
|
||
resourceOperatorStateProvider ResourceOperatorStateProvider
|
||
// Optional finding-storm throttler — when set, the store invokes it
|
||
// from Add's new-finding branch so a single noisy resource's burst
|
||
// of multi-symptom findings is summarised by one storm finding.
|
||
// Nil-safe; an unset throttler disables storm summarisation.
|
||
stormThrottler *findingStormThrottler
|
||
}
|
||
|
||
// ResourceOperatorStateMaintenanceWindow describes the maintenance
|
||
// window an operator set on a resource. The findings store consults
|
||
// this through ResourceOperatorStateProvider; the package layout puts
|
||
// the canonical type in `internal/unifiedresources` to avoid an import
|
||
// cycle, so this is the projected shape the findings runtime needs.
|
||
type ResourceOperatorStateMaintenanceWindow struct {
|
||
StartAt time.Time
|
||
EndAt time.Time
|
||
Reason string
|
||
}
|
||
|
||
// ResourceOperatorStateProjection is the per-resource view the
|
||
// findings runtime consumes during the new-finding path. One provider
|
||
// call returns every signal the runtime needs, so adding new signals
|
||
// does not multiply round-trips per finding. MaintenanceWindow is nil
|
||
// when no window is currently active; IntentionallyOffline reflects
|
||
// the operator's "this resource is expected to be offline" flag.
|
||
type ResourceOperatorStateProjection struct {
|
||
MaintenanceWindow *ResourceOperatorStateMaintenanceWindow
|
||
IntentionallyOffline bool
|
||
// NeverAutoRemediate is the operator's "do not act on this
|
||
// resource" flag. The findings store does not consume it during
|
||
// suppression (that's the action broker's concern), but the
|
||
// investigation runtime needs it so the orchestrator can avoid
|
||
// proposing fixes that the broker would refuse. Carrying it on
|
||
// the same projection means the suppression and investigation
|
||
// hot paths share a single read.
|
||
NeverAutoRemediate bool
|
||
}
|
||
|
||
// ResourceOperatorStateProvider is the narrow interface the findings
|
||
// store consumes for per-resource operator intent. Implementations
|
||
// adapt the unified-resources `ResourceOperatorState` shape into the
|
||
// projection that the findings runtime needs. Returning ok=false
|
||
// means "no operator state recorded for this resource" and the store
|
||
// falls through to the default new-finding path; ok=true with all
|
||
// projection fields zero means "operator state exists but no signal
|
||
// is currently active" (e.g. window scheduled for the future).
|
||
type ResourceOperatorStateProvider interface {
|
||
OperatorStateProjection(canonicalID string, now time.Time) (ResourceOperatorStateProjection, bool)
|
||
}
|
||
|
||
// ResourceOperatorStateProviderFunc is a function adapter so wire-up
|
||
// code in the API layer can pass a closure without declaring a struct.
|
||
type ResourceOperatorStateProviderFunc func(canonicalID string, now time.Time) (ResourceOperatorStateProjection, bool)
|
||
|
||
// OperatorStateProjection implements ResourceOperatorStateProvider.
|
||
func (f ResourceOperatorStateProviderFunc) OperatorStateProjection(canonicalID string, now time.Time) (ResourceOperatorStateProjection, bool) {
|
||
if f == nil {
|
||
return ResourceOperatorStateProjection{}, false
|
||
}
|
||
return f(canonicalID, now)
|
||
}
|
||
|
||
// NewFindingsStore creates a new findings store
|
||
func NewFindingsStore() *FindingsStore {
|
||
return &FindingsStore{
|
||
findings: make(map[string]*Finding),
|
||
byResource: make(map[string][]string),
|
||
activeCounts: make(map[FindingSeverity]int),
|
||
suppressionRules: make(map[string]*SuppressionRule),
|
||
saveDebounce: 5 * time.Second, // Debounce saves by 5 seconds
|
||
}
|
||
}
|
||
|
||
// SetResourceOperatorStateProvider installs the per-resource
|
||
// operator-state provider that the findings store consults during Add.
|
||
// Wired by the API layer at startup so the store can stay in
|
||
// `internal/ai` without importing `internal/unifiedresources`. Pass
|
||
// nil to disable operator-state-driven suppression (the default).
|
||
func (s *FindingsStore) SetResourceOperatorStateProvider(p ResourceOperatorStateProvider) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.resourceOperatorStateProvider = p
|
||
}
|
||
|
||
// SetStormThrottler installs the finding-storm throttler. The store
|
||
// consults it from Add's new-finding branch with the store mutex held;
|
||
// the throttler must not call back into the store. Pass nil to disable
|
||
// storm summarisation (the default).
|
||
func (s *FindingsStore) SetStormThrottler(t *findingStormThrottler) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.stormThrottler = t
|
||
}
|
||
|
||
// OperatorStateProjectionFor exposes the operator-state projection for
|
||
// a resource so callers outside the suppression hot path (e.g. the
|
||
// patrol investigation runtime, when it's about to hand a finding to
|
||
// the orchestrator) can attach the same per-resource intent the
|
||
// store uses internally. Returns false when no provider is wired or
|
||
// when the provider reports no state for the resource — callers
|
||
// branch on the bool rather than parsing zero-valued projections.
|
||
func (s *FindingsStore) OperatorStateProjectionFor(resourceID string, now time.Time) (ResourceOperatorStateProjection, bool) {
|
||
s.mu.RLock()
|
||
provider := s.resourceOperatorStateProvider
|
||
s.mu.RUnlock()
|
||
if provider == nil || resourceID == "" {
|
||
return ResourceOperatorStateProjection{}, false
|
||
}
|
||
return provider.OperatorStateProjection(resourceID, now)
|
||
}
|
||
|
||
// SetPersistence sets the persistence layer and loads existing findings
|
||
func (s *FindingsStore) SetPersistence(p FindingsPersistence) error {
|
||
s.mu.Lock()
|
||
s.persistence = p
|
||
s.mu.Unlock()
|
||
|
||
// Load existing findings from disk
|
||
if p != nil {
|
||
var (
|
||
findings map[string]*Finding
|
||
rules map[string]*SuppressionRule
|
||
err error
|
||
)
|
||
if p2, ok := p.(FindingsPersistenceWithSuppression); ok {
|
||
findings, rules, err = p2.LoadFindingsAndSuppression()
|
||
} else {
|
||
findings, err = p.LoadFindings()
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
s.mu.Lock()
|
||
// Reset derived indices/caches before rehydrating.
|
||
s.byResource = make(map[string][]string)
|
||
s.activeCounts = make(map[FindingSeverity]int)
|
||
normalizedLoadedState := false
|
||
if rules != nil {
|
||
s.suppressionRules = make(map[string]*SuppressionRule, len(rules))
|
||
for id, r := range rules {
|
||
if r == nil {
|
||
continue
|
||
}
|
||
copy := *r
|
||
s.suppressionRules[id] = ©
|
||
}
|
||
}
|
||
for id, f := range findings {
|
||
if f == nil {
|
||
continue
|
||
}
|
||
if normalizeLoadedFinding(f) {
|
||
normalizedLoadedState = true
|
||
}
|
||
// One-shot cleanup of legacy alert-mirror findings. The
|
||
// deterministic detectAlertSignals → SignalActiveAlert path
|
||
// has been removed; any "Active alert detected" finding
|
||
// still persisted from an earlier build is a stale mirror
|
||
// of an alert that already owns its own canonical surface.
|
||
//
|
||
// - Active mirrors are retired (auto-resolved with a
|
||
// clear reason) so the trust strip, regression counter,
|
||
// and health score stop reflecting the duplicate while
|
||
// the operator can still see why the finding closed.
|
||
// - Already-resolved mirrors are purged entirely. They
|
||
// were never canonical Patrol output and have no
|
||
// operator value in the Resolved tab; leaving them in
|
||
// place clutters the surface and inflates the
|
||
// "regressed" totals on the trust strip.
|
||
//
|
||
// Idempotent — after the first load with this code, no
|
||
// findings match the pattern.
|
||
if legacyAlertMirrorShape(f) {
|
||
if f.ResolvedAt != nil {
|
||
// Purge: do not rehydrate. The deletion is persisted
|
||
// by the scheduleSave() call after the load loop.
|
||
normalizedLoadedState = true
|
||
continue
|
||
}
|
||
now := time.Now()
|
||
prevLoopState := f.LoopState
|
||
f.ResolvedAt = &now
|
||
f.AutoResolved = true
|
||
f.ResolveReason = "Patrol no longer mirrors alerts; the Alerts page is the canonical surface for currently-firing alerts."
|
||
f.syncLoopState()
|
||
f.Lifecycle = append(f.Lifecycle, FindingLifecycleEvent{
|
||
At: now,
|
||
Type: "auto_resolved",
|
||
Message: f.ResolveReason,
|
||
From: prevLoopState,
|
||
To: f.LoopState,
|
||
})
|
||
normalizedLoadedState = true
|
||
}
|
||
// Reset regression counters polluted by bogus auto_resolve
|
||
// cycles. Before the category gate (commit b44d5892f) and the
|
||
// resource-presence gate that still needs the same treatment,
|
||
// findings of event/persistent categories were being
|
||
// auto-resolved on absence and re-detected next run, each
|
||
// cycle incrementing RegressionCount. The counter became
|
||
// unreliable as a signal — the user sees "regressed 6×" but
|
||
// most of those regressions weren't real recurrences, they
|
||
// were the system fighting itself. Once a finding shows
|
||
// evidence of the bogus pattern in its lifecycle, treat the
|
||
// entire counter as suspect and reset it; genuine recurrences
|
||
// will accrue cleanly from here. Idempotent: after reset,
|
||
// the lifecycle is annotated with a regression_counter_reset
|
||
// event so the migration only fires once per finding.
|
||
if f.ResolvedAt == nil && findingHasBogusAutoResolveCycle(f) {
|
||
f.RegressionCount = 0
|
||
f.LastRegressionAt = nil
|
||
f.Lifecycle = append(f.Lifecycle, FindingLifecycleEvent{
|
||
At: time.Now(),
|
||
Type: "regression_counter_reset",
|
||
Message: "Regression counter reset on migration: prior auto_resolve cycles were driven by the now-removed absence-based auto-resolve paths.",
|
||
})
|
||
normalizedLoadedState = true
|
||
}
|
||
// Ensure derived fields are consistent after load.
|
||
f.syncLoopState()
|
||
s.findings[id] = f
|
||
s.byResource[f.ResourceID] = append(s.byResource[f.ResourceID], id)
|
||
if f.IsActive() {
|
||
s.activeCounts[f.Severity]++
|
||
}
|
||
}
|
||
s.mu.Unlock()
|
||
if normalizedLoadedState {
|
||
s.scheduleSave()
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// findingHasBogusAutoResolveCycle reports whether the finding's
|
||
// RegressionCount was inflated by an unreliable auto_resolve cycle.
|
||
//
|
||
// For event/persistent categories (reliability, backup, security,
|
||
// general) there is no legitimate absence-driven resolution path — the
|
||
// underlying condition is a discrete event or persistent state that
|
||
// only clears on manual operator action or on a verified fix
|
||
// (lifecycle event type "verification_passed"). Any `auto_resolved`
|
||
// event on such a finding came from either:
|
||
//
|
||
// - One of the two now-gated absence paths (message
|
||
// "No longer detected by patrol" or "Resource no longer exists
|
||
// in infrastructure"), or
|
||
// - An LLM `patrol_resolve_finding` tool call (empty message,
|
||
// produced by Resolve(_, true)), which the LLM has repeatedly
|
||
// misjudged for findings backed by still-active conditions.
|
||
//
|
||
// In every observed instance, those auto_resolutions were reverted by
|
||
// a regressed event within hours when the underlying condition was
|
||
// re-detected — the system fighting itself. So once the lifecycle
|
||
// shows any auto_resolved on a non-eligible category and the finding
|
||
// is currently active again with a regression count, the counter is
|
||
// no longer a trustworthy signal and should be reset.
|
||
//
|
||
// For performance/capacity findings (eligible-category) we don't
|
||
// touch the counter: those categories have a legitimate
|
||
// metric-cleared resolution path and the absence model is sound.
|
||
func findingHasBogusAutoResolveCycle(f *Finding) bool {
|
||
if f == nil {
|
||
return false
|
||
}
|
||
if f.RegressionCount == 0 {
|
||
return false
|
||
}
|
||
if CategorySupportsStaleAutoResolve(f.Category) {
|
||
return false
|
||
}
|
||
hasAutoResolve := false
|
||
for _, e := range f.Lifecycle {
|
||
if e.Type == "regression_counter_reset" {
|
||
// Already migrated — must not be re-applied. Genuine
|
||
// regressions that accrued after the reset stand on their
|
||
// own and the counter is now trustworthy.
|
||
return false
|
||
}
|
||
if e.Type == "auto_resolved" {
|
||
hasAutoResolve = true
|
||
}
|
||
}
|
||
return hasAutoResolve
|
||
}
|
||
|
||
// legacyAlertMirrorShape reports whether the finding looks like an
|
||
// "Active alert detected" finding produced by the now-removed
|
||
// detectAlertSignals → SignalActiveAlert deterministic emitter. The title
|
||
// is the only surface that ever produced exactly this string, so matching
|
||
// on it is safe even without source/category checks; the additional
|
||
// checks tighten the match to avoid touching a hypothetical operator-
|
||
// authored finding with the same name. The predicate is state-agnostic:
|
||
// callers branch on ResolvedAt to choose between active-retirement and
|
||
// resolved-purge behavior.
|
||
func legacyAlertMirrorShape(f *Finding) bool {
|
||
if f == nil {
|
||
return false
|
||
}
|
||
if f.Title != "Active alert detected" {
|
||
return false
|
||
}
|
||
if f.Source != "ai-analysis" {
|
||
return false
|
||
}
|
||
if f.Category != FindingCategoryGeneral {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// SweepWillFixLaterReminders proactively wakes will_fix_later commitments
|
||
// whose RemindAt deadline has passed without waiting for re-detection. The
|
||
// existing wake path in Add() only fires on re-detection — if patrol's
|
||
// detection is sparse or the operational issue transiently clears between
|
||
// runs, an overdue commitment can sit forever without surfacing. The sweep
|
||
// closes that gap by walking all dismissed-will_fix_later findings on a
|
||
// timer and converging them onto the same in-memory state the re-detection
|
||
// path would have produced.
|
||
//
|
||
// Mirrors the field-clearing of the will_fix_later branch in Add (around
|
||
// findings.go line 1366): clear DismissedReason, Suppressed, RemindAt,
|
||
// AcknowledgedAt; keep UserNote because the operator's promise is still
|
||
// relevant context; bump RemindCount; emit a "reminded" lifecycle event.
|
||
//
|
||
// Returns the number of findings woken. Caller is patrol_run.remindSweepLoop.
|
||
func (s *FindingsStore) SweepWillFixLaterReminders(now time.Time) int {
|
||
s.mu.Lock()
|
||
swept := 0
|
||
for _, f := range s.findings {
|
||
if f == nil {
|
||
continue
|
||
}
|
||
if f.DismissedReason != DismissReasonWillFixLater {
|
||
continue
|
||
}
|
||
if f.RemindAt == nil {
|
||
continue
|
||
}
|
||
if !now.After(*f.RemindAt) {
|
||
continue
|
||
}
|
||
meta := map[string]string{
|
||
"reason_was": f.DismissedReason,
|
||
"remind_at": f.RemindAt.Format(time.RFC3339),
|
||
"trigger": "sweep",
|
||
}
|
||
f.RemindCount++
|
||
s.appendLifecycleLocked(f, "reminded", "will_fix_later remind-at deadline passed (sweep); re-surfacing finding", string(FindingLoopStateDismissed), string(FindingLoopStateDetected), meta)
|
||
f.DismissedReason = ""
|
||
f.Suppressed = false
|
||
f.RemindAt = nil
|
||
f.AcknowledgedAt = nil
|
||
f.LoopState = string(FindingLoopStateDetected)
|
||
swept++
|
||
}
|
||
s.mu.Unlock()
|
||
if swept > 0 {
|
||
s.scheduleSave()
|
||
}
|
||
return swept
|
||
}
|
||
|
||
// scheduleSave schedules a debounced save operation
|
||
// This method is lock-safe and can be called without holding the store lock.
|
||
func (s *FindingsStore) scheduleSave() {
|
||
s.mu.Lock()
|
||
if s.persistence == nil || s.savePending {
|
||
s.mu.Unlock()
|
||
return
|
||
}
|
||
|
||
s.savePending = true
|
||
saveDebounce := s.saveDebounce
|
||
s.saveTimer = time.AfterFunc(saveDebounce, func() {
|
||
s.mu.Lock()
|
||
s.savePending = false
|
||
// Make a copy for saving, excluding demo findings
|
||
findingsCopy := make(map[string]*Finding, len(s.findings))
|
||
for id, f := range s.findings {
|
||
// Skip demo findings - they should never be persisted
|
||
if strings.HasPrefix(id, "demo-") {
|
||
continue
|
||
}
|
||
copy := *f
|
||
findingsCopy[id] = ©
|
||
}
|
||
rulesCopy := make(map[string]*SuppressionRule, len(s.suppressionRules))
|
||
for id, r := range s.suppressionRules {
|
||
if r == nil {
|
||
continue
|
||
}
|
||
copy := *r
|
||
rulesCopy[id] = ©
|
||
}
|
||
persistence := s.persistence
|
||
onError := s.onSaveError
|
||
s.mu.Unlock()
|
||
|
||
if persistence != nil {
|
||
var err error
|
||
if p2, ok := persistence.(FindingsPersistenceWithSuppression); ok {
|
||
err = p2.SaveFindingsAndSuppression(findingsCopy, rulesCopy)
|
||
} else {
|
||
err = persistence.SaveFindings(findingsCopy)
|
||
}
|
||
if err != nil {
|
||
// Track the error for visibility
|
||
s.mu.Lock()
|
||
s.lastSaveError = err
|
||
s.mu.Unlock()
|
||
// Call error callback if set
|
||
if onError != nil {
|
||
onError(err)
|
||
}
|
||
} else {
|
||
// Clear error and update timestamp on success
|
||
s.mu.Lock()
|
||
s.lastSaveError = nil
|
||
s.lastSaveTime = time.Now()
|
||
s.mu.Unlock()
|
||
}
|
||
}
|
||
})
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
// ForceSave immediately saves findings (useful for shutdown)
|
||
func (s *FindingsStore) ForceSave() error {
|
||
s.mu.Lock()
|
||
if s.saveTimer != nil {
|
||
s.saveTimer.Stop()
|
||
}
|
||
s.savePending = false
|
||
|
||
findingsCopy := make(map[string]*Finding, len(s.findings))
|
||
for id, f := range s.findings {
|
||
// Skip demo findings - they should never be persisted
|
||
if strings.HasPrefix(id, "demo-") {
|
||
continue
|
||
}
|
||
copy := *f
|
||
findingsCopy[id] = ©
|
||
}
|
||
rulesCopy := make(map[string]*SuppressionRule, len(s.suppressionRules))
|
||
for id, r := range s.suppressionRules {
|
||
if r == nil {
|
||
continue
|
||
}
|
||
copy := *r
|
||
rulesCopy[id] = ©
|
||
}
|
||
persistence := s.persistence
|
||
s.mu.Unlock()
|
||
|
||
if persistence != nil {
|
||
if p2, ok := persistence.(FindingsPersistenceWithSuppression); ok {
|
||
return p2.SaveFindingsAndSuppression(findingsCopy, rulesCopy)
|
||
}
|
||
return persistence.SaveFindings(findingsCopy)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetOnSaveError sets a callback function that will be called when a save operation fails.
|
||
// This allows external code (e.g., logging) to be notified of persistence errors.
|
||
func (s *FindingsStore) SetOnSaveError(callback func(err error)) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
s.onSaveError = callback
|
||
}
|
||
|
||
// GetPersistenceStatus returns the current persistence state:
|
||
// - lastError: the most recent save error, or nil if last save succeeded
|
||
// - lastSaveTime: when findings were last successfully saved
|
||
// - hasPersistence: whether a persistence layer is configured
|
||
func (s *FindingsStore) GetPersistenceStatus() (lastError error, lastSaveTime time.Time, hasPersistence bool) {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.lastSaveError, s.lastSaveTime, s.persistence != nil
|
||
}
|
||
|
||
// appendLifecycleLocked appends a lifecycle event to a finding and bounds the history.
|
||
// Caller must hold s.mu.
|
||
func (s *FindingsStore) appendLifecycleLocked(f *Finding, typ, msg, from, to string, meta map[string]string) {
|
||
if f == nil {
|
||
return
|
||
}
|
||
e := FindingLifecycleEvent{
|
||
At: time.Now(),
|
||
Type: typ,
|
||
Message: msg,
|
||
From: from,
|
||
To: to,
|
||
Metadata: meta,
|
||
}
|
||
f.Lifecycle = append(f.Lifecycle, e)
|
||
if len(f.Lifecycle) > maxFindingLifecycleEvents {
|
||
f.Lifecycle = f.Lifecycle[len(f.Lifecycle)-maxFindingLifecycleEvents:]
|
||
}
|
||
}
|
||
|
||
func isKnownLoopState(v string) bool {
|
||
switch FindingLoopState(v) {
|
||
case FindingLoopStateDetected, FindingLoopStateInvestigating, FindingLoopStateRemediationPlanned, FindingLoopStateRemediating, FindingLoopStateRemediationFailed, FindingLoopStateNeedsAttention, FindingLoopStateTimedOut, FindingLoopStateResolved, FindingLoopStateDismissed, FindingLoopStateSnoozed, FindingLoopStateSuppressed:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func isAllowedLoopTransition(from, to string) bool {
|
||
if from == "" || from == to {
|
||
return true
|
||
}
|
||
if !isKnownLoopState(from) || !isKnownLoopState(to) {
|
||
return false
|
||
}
|
||
|
||
switch FindingLoopState(from) {
|
||
case FindingLoopStateResolved:
|
||
// Resolved findings should only remain resolved or reopen as detected.
|
||
return to == string(FindingLoopStateResolved) || to == string(FindingLoopStateDetected)
|
||
case FindingLoopStateSuppressed:
|
||
// Suppressed findings should remain suppressed unless explicitly unsuppressed.
|
||
return to == string(FindingLoopStateSuppressed) || to == string(FindingLoopStateDetected)
|
||
case FindingLoopStateDismissed:
|
||
// Dismissed findings can be re-opened or further suppressed/resolved.
|
||
return to == string(FindingLoopStateDismissed) ||
|
||
to == string(FindingLoopStateDetected) ||
|
||
to == string(FindingLoopStateSuppressed) ||
|
||
to == string(FindingLoopStateResolved)
|
||
case FindingLoopStateSnoozed:
|
||
// Snoozed findings can wake up (detected) or be resolved/suppressed/dismissed.
|
||
return to == string(FindingLoopStateSnoozed) ||
|
||
to == string(FindingLoopStateDetected) ||
|
||
to == string(FindingLoopStateResolved) ||
|
||
to == string(FindingLoopStateSuppressed) ||
|
||
to == string(FindingLoopStateDismissed)
|
||
default:
|
||
return true
|
||
}
|
||
}
|
||
|
||
func normalizeLoadedFinding(f *Finding) bool {
|
||
if f == nil {
|
||
return false
|
||
}
|
||
|
||
if f.ResolvedAt == nil && f.LastRegressionAt != nil && f.AcknowledgedAt != nil && !f.AcknowledgedAt.After(*f.LastRegressionAt) {
|
||
f.AcknowledgedAt = nil
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// findOperatorStateDismissCause returns the most recent
|
||
// `operator_state_cause` metadata value from the finding's lifecycle, or
|
||
// "" if the finding has not been auto-dismissed by operator-state
|
||
// suppression. Used by the dismissed-branch wake logic in Add to
|
||
// distinguish operator-state-driven dismissals (which should wake when
|
||
// suppression lifts) from manual operator dismissals (which stay quiet
|
||
// until severity escalates).
|
||
func findOperatorStateDismissCause(f *Finding) string {
|
||
if f == nil {
|
||
return ""
|
||
}
|
||
for i := len(f.Lifecycle) - 1; i >= 0; i-- {
|
||
ev := f.Lifecycle[i]
|
||
if ev.Type != "dismissed" {
|
||
continue
|
||
}
|
||
if cause, ok := ev.Metadata["operator_state_cause"]; ok && cause != "" {
|
||
return cause
|
||
}
|
||
// First "dismissed" event without operator_state_cause is a
|
||
// manual operator dismissal that supersedes any earlier
|
||
// auto-dismiss; stop scanning so a stale earlier cause does
|
||
// not falsely trigger a wake.
|
||
return ""
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// syncLoopStateLocked recomputes loop state and records a lifecycle event if it changed.
|
||
// Caller must hold s.mu.
|
||
func (s *FindingsStore) syncLoopStateLocked(f *Finding) {
|
||
if f == nil {
|
||
return
|
||
}
|
||
prev := f.LoopState
|
||
f.syncLoopState()
|
||
if prev == f.LoopState {
|
||
return
|
||
}
|
||
if !isAllowedLoopTransition(prev, f.LoopState) {
|
||
next := f.LoopState
|
||
f.LoopState = prev
|
||
s.appendLifecycleLocked(f, "loop_transition_violation", "Blocked invalid loop-state transition", prev, next, map[string]string{
|
||
"from": prev,
|
||
"to": next,
|
||
})
|
||
return
|
||
}
|
||
// Successful loop-state transitions are not recorded as a generic
|
||
// "loop_state" lifecycle event. Every caller of syncLoopStateLocked
|
||
// already emits a semantic event for the transition it caused
|
||
// ("auto_resolved", "regressed", "dismissed", "acknowledged",
|
||
// "snoozed", etc.) with the same from/to. Emitting an additional
|
||
// "loop_state" event here just duplicated every row in the finding's
|
||
// lifecycle drawer. The loop_transition_violation branch above stays:
|
||
// it's the only signal that a transition was rejected, not a duplicate.
|
||
}
|
||
|
||
// Add adds or updates a finding
|
||
// If a finding with the same ID exists, it updates LastSeenAt and increments TimesRaised
|
||
// If the finding is suppressed or dismissed, it may be skipped
|
||
// Returns true if this is a new finding
|
||
func (s *FindingsStore) Add(f *Finding) bool {
|
||
s.mu.Lock()
|
||
|
||
if f.ResourceType == "" {
|
||
f.ResourceType = inferFindingResourceType(f.ResourceID, f.ResourceName)
|
||
}
|
||
// Keep loop state derived from the current flags/outcomes.
|
||
f.syncLoopState()
|
||
|
||
existing, exists := s.findings[f.ID]
|
||
if exists {
|
||
wasResolved := existing.ResolvedAt != nil
|
||
if existing.ResourceType == "" {
|
||
if f.ResourceType != "" {
|
||
existing.ResourceType = f.ResourceType
|
||
} else {
|
||
existing.ResourceType = inferFindingResourceType(existing.ResourceID, existing.ResourceName)
|
||
}
|
||
}
|
||
|
||
// Check if dismissed or suppressed - only update if severity has escalated,
|
||
// or if a "will_fix_later" remind-at deadline has passed, or if the
|
||
// operator-state suppression that auto-dismissed the finding is no
|
||
// longer active.
|
||
if existing.DismissedReason != "" || existing.Suppressed {
|
||
severityOrder := map[FindingSeverity]int{
|
||
FindingSeverityInfo: 0,
|
||
FindingSeverityWatch: 1,
|
||
FindingSeverityWarning: 2,
|
||
FindingSeverityCritical: 3,
|
||
}
|
||
// "will_fix_later" with a passed RemindAt is a real operator
|
||
// commitment that has expired — surface the finding again. This
|
||
// is the only case where a same-or-lower-severity re-detection
|
||
// reactivates a dismissed finding; "expected_behavior" stays
|
||
// quiet, "not_an_issue" stays suppressed.
|
||
willFixReminderDue := existing.DismissedReason == DismissReasonWillFixLater &&
|
||
existing.RemindAt != nil &&
|
||
time.Now().After(*existing.RemindAt) &&
|
||
!existing.Suppressed
|
||
|
||
// Operator-state-driven auto-dismiss should be reversible when the
|
||
// underlying suppression ends. A maintenance window has a defined
|
||
// end; an IntentionallyOffline flag can be cleared. Findings
|
||
// auto-dismissed under either signal must wake at re-detection
|
||
// once the suppression no longer applies, otherwise the
|
||
// time-bounded suppression silently becomes permanent.
|
||
operatorStateLifted := false
|
||
if !willFixReminderDue && existing.DismissedReason == "expected_behavior" && !existing.Suppressed {
|
||
if cause := findOperatorStateDismissCause(existing); cause != "" {
|
||
if provider := s.resourceOperatorStateProvider; provider != nil && existing.ResourceID != "" {
|
||
projection, ok := provider.OperatorStateProjection(existing.ResourceID, time.Now())
|
||
currentlySuppressing := ok &&
|
||
(projection.MaintenanceWindow != nil || projection.IntentionallyOffline)
|
||
operatorStateLifted = !currentlySuppressing
|
||
} else {
|
||
// No provider currently wired but the finding was
|
||
// previously auto-dismissed by one — treat as lifted
|
||
// rather than keeping the suppression alive forever.
|
||
operatorStateLifted = true
|
||
}
|
||
}
|
||
}
|
||
|
||
if !willFixReminderDue && !operatorStateLifted &&
|
||
severityOrder[f.Severity] <= severityOrder[existing.Severity] {
|
||
// New severity is same or lower and no wake condition fired — don't reactivate.
|
||
existing.LastSeenAt = time.Now()
|
||
existing.TimesRaised++
|
||
s.appendLifecycleLocked(existing, "seen_while_suppressed", "Re-detected while dismissed/suppressed with non-escalated severity", existing.LoopState, existing.LoopState, nil)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return false
|
||
}
|
||
if willFixReminderDue {
|
||
prevRemindAt := existing.RemindAt
|
||
meta := map[string]string{"reason_was": existing.DismissedReason}
|
||
if prevRemindAt != nil {
|
||
meta["remind_at"] = prevRemindAt.Format(time.RFC3339)
|
||
}
|
||
existing.RemindCount++
|
||
s.appendLifecycleLocked(existing, "reminded", "will_fix_later remind-at deadline passed; re-surfacing finding", string(FindingLoopStateDismissed), string(FindingLoopStateDetected), meta)
|
||
}
|
||
if operatorStateLifted {
|
||
meta := map[string]string{}
|
||
if cause := findOperatorStateDismissCause(existing); cause != "" {
|
||
meta["previous_cause"] = cause
|
||
}
|
||
s.appendLifecycleLocked(existing, "suppression_lifted", "Operator-state suppression no longer applies; re-surfacing finding", string(FindingLoopStateDismissed), string(FindingLoopStateDetected), meta)
|
||
}
|
||
// Severity escalated, remind-at fired, or operator-state lifted — clear dismissal and reactivate.
|
||
existing.DismissedReason = ""
|
||
existing.Suppressed = false
|
||
existing.RemindAt = nil
|
||
if !willFixReminderDue {
|
||
// On severity escalation or operator-state-lift we drop the
|
||
// operator's note (the suppression that justified it is gone);
|
||
// on a remind-at wake we keep it because the operator's
|
||
// promise is still relevant context.
|
||
existing.UserNote = ""
|
||
}
|
||
existing.AcknowledgedAt = nil
|
||
}
|
||
|
||
// Update existing finding
|
||
existing.LastSeenAt = time.Now()
|
||
existing.Description = f.Description
|
||
existing.Impact = f.Impact
|
||
existing.Recommendation = f.Recommendation
|
||
existing.Evidence = f.Evidence
|
||
existing.Title = f.Title // Update title in case LLM phrased it better
|
||
existing.Severity = f.Severity
|
||
existing.TimesRaised++ // Track recurrence
|
||
if wasResolved {
|
||
prevResolvedAt := existing.ResolvedAt
|
||
prevResolveReason := existing.ResolveReason
|
||
hadAcknowledgement := existing.AcknowledgedAt != nil
|
||
// Capture the prior successful fix as operational memory before
|
||
// the investigation record is cleared. This preserves "what worked
|
||
// last time" across regressions so the next investigation does
|
||
// not start from blank context.
|
||
if existing.InvestigationRecord != nil && existing.InvestigationRecord.ProposedFix != nil {
|
||
if desc := strings.TrimSpace(existing.InvestigationRecord.ProposedFix.Description); desc != "" {
|
||
existing.PreviousResolvedFixSummary = desc
|
||
}
|
||
}
|
||
existing.ResolvedAt = nil
|
||
existing.AutoResolved = false
|
||
existing.ResolveReason = ""
|
||
existing.AcknowledgedAt = nil
|
||
// Reset investigation loop metadata when a previously resolved issue reappears.
|
||
existing.InvestigationSessionID = ""
|
||
existing.InvestigationStatus = ""
|
||
existing.InvestigationOutcome = ""
|
||
existing.LastInvestigatedAt = nil
|
||
existing.InvestigationAttempts = 0
|
||
existing.InvestigationRecord = nil
|
||
existing.RegressionCount++
|
||
now := time.Now()
|
||
existing.LastRegressionAt = &now
|
||
meta := map[string]string{}
|
||
if prevResolvedAt != nil {
|
||
meta["previous_resolved_at"] = prevResolvedAt.Format(time.RFC3339)
|
||
}
|
||
if prevResolveReason != "" {
|
||
meta["previous_resolve_reason"] = prevResolveReason
|
||
}
|
||
if hadAcknowledgement {
|
||
meta["previous_acknowledged"] = "true"
|
||
}
|
||
s.appendLifecycleLocked(existing, "regressed", "Finding re-detected after resolution", string(FindingLoopStateResolved), string(FindingLoopStateDetected), meta)
|
||
if existing.IsActive() {
|
||
s.activeCounts[existing.Severity]++
|
||
}
|
||
}
|
||
s.syncLoopStateLocked(existing)
|
||
// Re-detections of an existing finding are heartbeats, not transitions.
|
||
// TimesRaised and LastSeenAt already track recurrence. The actual
|
||
// transition events ("regressed", "reminded", "suppression_lifted",
|
||
// etc.) are emitted from their own branches above; appending an
|
||
// additional "detected (same -> same)" event on every Patrol scan
|
||
// pollutes the lifecycle with no-op rows.
|
||
severity := existing.Severity
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
// Bypass debounce for warning+ findings to avoid data loss on crash
|
||
if severity == FindingSeverityWarning || severity == FindingSeverityCritical {
|
||
_ = s.ForceSave()
|
||
}
|
||
return false
|
||
}
|
||
|
||
// New finding - check if resource+category is suppressed
|
||
if s.isSuppressedInternal(f.ResourceID, f.Category) {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
// New finding
|
||
if f.DetectedAt.IsZero() {
|
||
f.DetectedAt = time.Now()
|
||
}
|
||
f.LastSeenAt = time.Now()
|
||
if f.TimesRaised <= 0 {
|
||
f.TimesRaised = 1
|
||
}
|
||
s.syncLoopStateLocked(f)
|
||
s.appendLifecycleLocked(f, "detected", "Detected by Pulse Patrol", "", f.LoopState, nil)
|
||
|
||
// Operator-state suppression: when the operator has set per-resource
|
||
// intent that suppresses findings, auto-dismiss the finding as
|
||
// expected_behavior with a reason that names the cause. Maintenance
|
||
// windows take priority because they're time-bounded ("expect this
|
||
// noise until the window closes"); IntentionallyOffline is the
|
||
// fallback branch ("expect this resource to stay quiet
|
||
// indefinitely"). The finding still lands in durable history so
|
||
// the operator can audit what tripped during the suppression;
|
||
// severity escalation paths still wake it later if the underlying
|
||
// condition stays after the operator clears the state.
|
||
if provider := s.resourceOperatorStateProvider; provider != nil && f.ResourceID != "" {
|
||
if projection, ok := provider.OperatorStateProjection(f.ResourceID, time.Now()); ok {
|
||
now := time.Now()
|
||
switch {
|
||
case projection.MaintenanceWindow != nil:
|
||
window := projection.MaintenanceWindow
|
||
f.DismissedReason = "expected_behavior"
|
||
f.AcknowledgedAt = &now
|
||
noteParts := []string{fmt.Sprintf("Resource is in operator-set maintenance window until %s.", window.EndAt.Format(time.RFC3339))}
|
||
if reason := strings.TrimSpace(window.Reason); reason != "" {
|
||
noteParts = append(noteParts, "Reason: "+reason)
|
||
}
|
||
f.UserNote = strings.Join(noteParts, " ")
|
||
s.appendLifecycleLocked(f, "dismissed", "Auto-acknowledged: resource in maintenance window", f.LoopState, string(FindingLoopStateDismissed), map[string]string{
|
||
"reason": "expected_behavior",
|
||
"operator_state_cause": "maintenance_window",
|
||
"maintenance_end_at": window.EndAt.Format(time.RFC3339),
|
||
})
|
||
s.syncLoopStateLocked(f)
|
||
case projection.IntentionallyOffline:
|
||
// Operator has marked this resource as expected-to-be-offline,
|
||
// so any finding raised against it is by definition expected
|
||
// noise. Auto-dismiss with a clear note so the operator
|
||
// understands why future findings stayed quiet without
|
||
// expanding each row.
|
||
f.DismissedReason = "expected_behavior"
|
||
f.AcknowledgedAt = &now
|
||
f.UserNote = "Resource is marked intentionally offline by the operator. Clear the flag on the resource detail surface to resume notifications."
|
||
s.appendLifecycleLocked(f, "dismissed", "Auto-acknowledged: resource is intentionally offline", f.LoopState, string(FindingLoopStateDismissed), map[string]string{
|
||
"reason": "expected_behavior",
|
||
"operator_state_cause": "intentionally_offline",
|
||
})
|
||
s.syncLoopStateLocked(f)
|
||
}
|
||
}
|
||
}
|
||
|
||
s.findings[f.ID] = f
|
||
s.byResource[f.ResourceID] = append(s.byResource[f.ResourceID], f.ID)
|
||
if f.IsActive() {
|
||
s.activeCounts[f.Severity]++
|
||
}
|
||
// Storm throttler hook: still inside the new-finding branch with
|
||
// s.mu held so the observer reads a consistent index. The observer
|
||
// owns its own internal mutex and must not call back into the
|
||
// store; its return is dispatched after s.mu is released below.
|
||
var pendingStormFinding *Finding
|
||
if t := s.stormThrottler; t != nil {
|
||
pendingStormFinding = t.observeLocked(f, time.Now())
|
||
}
|
||
severity := f.Severity
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
// Bypass debounce for warning+ findings to avoid data loss on crash
|
||
if severity == FindingSeverityWarning || severity == FindingSeverityCritical {
|
||
_ = s.ForceSave()
|
||
}
|
||
if pendingStormFinding != nil {
|
||
if pendingStormFinding.ResolvedAt != nil {
|
||
s.ResolveWithReason(pendingStormFinding.ID, stormResolveReason)
|
||
} else {
|
||
s.Add(pendingStormFinding)
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// Resolve marks a finding as resolved
|
||
func (s *FindingsStore) Resolve(id string, auto bool) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists || !f.IsActive() {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
now := time.Now()
|
||
f.ResolvedAt = &now
|
||
f.AutoResolved = auto
|
||
etype := "resolved"
|
||
if auto {
|
||
etype = "auto_resolved"
|
||
}
|
||
s.appendLifecycleLocked(f, etype, f.ResolveReason, f.LoopState, string(FindingLoopStateResolved), nil)
|
||
s.syncLoopStateLocked(f)
|
||
s.activeCounts[f.Severity]--
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
|
||
return true
|
||
}
|
||
|
||
// ResolveWithReason marks a finding as resolved with a specific reason string.
|
||
// This is used by auto-resolution to distinguish why a finding was resolved.
|
||
func (s *FindingsStore) ResolveWithReason(id string, reason string) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists || !f.IsActive() {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
now := time.Now()
|
||
f.ResolvedAt = &now
|
||
f.AutoResolved = true
|
||
f.ResolveReason = reason
|
||
s.appendLifecycleLocked(f, "auto_resolved", reason, f.LoopState, string(FindingLoopStateResolved), nil)
|
||
s.syncLoopStateLocked(f)
|
||
s.activeCounts[f.Severity]--
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
|
||
return true
|
||
}
|
||
|
||
// Acknowledge marks a finding as acknowledged
|
||
func (s *FindingsStore) Acknowledge(id string) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
now := time.Now()
|
||
f.AcknowledgedAt = &now
|
||
s.appendLifecycleLocked(f, "acknowledged", "", "", "", nil)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// Snooze hides a finding for the specified duration
|
||
// Common durations: 1h, 24h, 7d (168h)
|
||
func (s *FindingsStore) Snooze(id string, duration time.Duration) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists || f.IsResolved() {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
// If was previously active (not snoozed), decrement count
|
||
if f.SnoozedUntil == nil || time.Now().After(*f.SnoozedUntil) {
|
||
s.activeCounts[f.Severity]--
|
||
}
|
||
|
||
until := time.Now().Add(duration)
|
||
f.SnoozedUntil = &until
|
||
s.appendLifecycleLocked(f, "snoozed", "", f.LoopState, string(FindingLoopStateSnoozed), map[string]string{
|
||
"until": until.Format(time.RFC3339),
|
||
})
|
||
s.syncLoopStateLocked(f)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// Unsnooze removes the snooze from a finding, making it active again
|
||
func (s *FindingsStore) Unsnooze(id string) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists || f.IsResolved() {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
if f.SnoozedUntil != nil {
|
||
prev := f.SnoozedUntil
|
||
f.SnoozedUntil = nil
|
||
s.activeCounts[f.Severity]++
|
||
meta := map[string]string{}
|
||
if prev != nil {
|
||
meta["previous_until"] = prev.Format(time.RFC3339)
|
||
}
|
||
s.appendLifecycleLocked(f, "unsnoozed", "", string(FindingLoopStateSnoozed), string(FindingLoopStateDetected), meta)
|
||
}
|
||
s.syncLoopStateLocked(f)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// Dismiss marks a finding as dismissed with a reason and optional note
|
||
// Reasons: "not_an_issue", "expected_behavior", "will_fix_later"
|
||
//
|
||
// Behavior by reason:
|
||
// - "not_an_issue": Permanent suppression (true false positive in detection logic)
|
||
// - "expected_behavior": Acknowledged only (removed from active list, stays in dismissed history)
|
||
// - "will_fix_later": Acknowledged only (removed from active list, stays in dismissed history)
|
||
//
|
||
// Rationale: Only true false positives ("not_an_issue") should be permanently suppressed.
|
||
// For "expected_behavior" and "will_fix_later", the finding stays visible (transparent)
|
||
// but is marked as acknowledged so the user knows they've reviewed it.
|
||
// Severity escalation will still clear the dismissal and reactivate the finding.
|
||
func (s *FindingsStore) Dismiss(id, reason, note string) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
f.DismissedReason = reason
|
||
if note != "" {
|
||
f.UserNote = note
|
||
}
|
||
// Mark as acknowledged for all dismiss reasons
|
||
now := time.Now()
|
||
f.AcknowledgedAt = &now
|
||
dismissMeta := map[string]string{"reason": reason}
|
||
|
||
// Only "not_an_issue" creates permanent suppression
|
||
// This is for true false positives where the detection logic is wrong
|
||
if reason == "not_an_issue" {
|
||
f.Suppressed = true
|
||
f.RemindAt = nil
|
||
}
|
||
// "will_fix_later" is an operational commitment, not a permanent silence.
|
||
// Default a 7-day remind-at so the next re-detection after the window
|
||
// surfaces the finding with a "reminded" lifecycle event instead of being
|
||
// swallowed forever like "expected_behavior".
|
||
if reason == DismissReasonWillFixLater {
|
||
remindAt := now.Add(DefaultWillFixLaterRemindAfter)
|
||
f.RemindAt = &remindAt
|
||
dismissMeta["remind_at"] = remindAt.Format(time.RFC3339)
|
||
} else if reason != "" {
|
||
f.RemindAt = nil
|
||
}
|
||
s.appendLifecycleLocked(f, "dismissed", "", f.LoopState, string(FindingLoopStateDismissed), dismissMeta)
|
||
s.syncLoopStateLocked(f)
|
||
// For "expected_behavior":
|
||
// - Finding stays visible (not suppressed, not snoozed)
|
||
// - But is marked as dismissed/acknowledged so user knows they've reviewed it
|
||
// - Severity escalation will clear DismissedReason and reactivate
|
||
// For "will_fix_later":
|
||
// - Same as expected_behavior, plus RemindAt is set; the next re-detection
|
||
// after RemindAt clears the dismissal and the finding surfaces again
|
||
// with a "reminded" lifecycle event.
|
||
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// Undismiss reverts a dismissed finding back to active state
|
||
// This clears DismissedReason, Suppressed, and AcknowledgedAt
|
||
// allowing the finding to be raised again
|
||
func (s *FindingsStore) Undismiss(id string) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
// Check if it was actually dismissed
|
||
if f.DismissedReason == "" && !f.Suppressed {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
// Clear dismissal state
|
||
f.DismissedReason = ""
|
||
f.Suppressed = false
|
||
f.AcknowledgedAt = nil
|
||
f.RemindAt = nil
|
||
s.appendLifecycleLocked(f, "undismissed", "", string(FindingLoopStateDismissed), string(FindingLoopStateDetected), nil)
|
||
// Keep UserNote in case user wants to see their notes
|
||
|
||
// If it was resolved, don't reactivate - user should manually reopen
|
||
// But if it's not resolved, it becomes active again
|
||
if f.ResolvedAt == nil && !f.IsSnoozed() {
|
||
s.activeCounts[f.Severity]++
|
||
}
|
||
s.syncLoopStateLocked(f)
|
||
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// UpdateInvestigationRecord stores the durable investigation record for a finding.
|
||
func (s *FindingsStore) UpdateInvestigationRecord(id string, record *aicontracts.InvestigationRecord) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
f.InvestigationRecord = record
|
||
meta := map[string]string{}
|
||
if record != nil {
|
||
meta["record_id"] = record.ID
|
||
meta["status"] = string(record.Status)
|
||
meta["outcome"] = string(record.Outcome)
|
||
}
|
||
s.appendLifecycleLocked(f, "investigation_record_updated", "Investigation record updated", f.LoopState, f.LoopState, meta)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// SetUserNote updates the user note on a finding
|
||
func (s *FindingsStore) SetUserNote(id, note string) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
f.UserNote = note
|
||
msg := "User note updated"
|
||
if strings.TrimSpace(note) == "" {
|
||
msg = "User note cleared"
|
||
}
|
||
s.appendLifecycleLocked(f, "user_note_updated", msg, "", "", nil)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// UpdateInvestigationOutcome updates the investigation outcome on a finding
|
||
func (s *FindingsStore) UpdateInvestigationOutcome(id, outcome string) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
prevOutcome := f.InvestigationOutcome
|
||
f.InvestigationOutcome = outcome
|
||
// If a fix was verified, close the loop by resolving the finding.
|
||
if InvestigationOutcome(outcome) == InvestigationOutcomeFixVerified {
|
||
now := time.Now()
|
||
// Only decrement active count once.
|
||
if f.ResolvedAt == nil && f.IsActive() {
|
||
s.activeCounts[f.Severity]--
|
||
}
|
||
f.ResolvedAt = &now
|
||
f.AutoResolved = true
|
||
if f.ResolveReason == "" {
|
||
f.ResolveReason = "Fix verified"
|
||
}
|
||
s.appendLifecycleLocked(f, "verification_passed", "Fix verified; finding resolved", f.LoopState, string(FindingLoopStateResolved), nil)
|
||
} else {
|
||
msg := "Investigation outcome updated"
|
||
if InvestigationOutcome(outcome) == InvestigationOutcomeFixFailed || InvestigationOutcome(outcome) == InvestigationOutcomeFixVerificationFailed {
|
||
msg = "Remediation failed verification"
|
||
}
|
||
s.appendLifecycleLocked(f, "investigation_outcome", msg, f.LoopState, f.LoopState, map[string]string{
|
||
"prev_outcome": prevOutcome,
|
||
"next_outcome": outcome,
|
||
})
|
||
}
|
||
s.syncLoopStateLocked(f)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// UpdateInvestigation updates all investigation fields on a finding
|
||
func (s *FindingsStore) UpdateInvestigation(id, sessionID, status, outcome string, lastInvestigatedAt *time.Time, attempts int) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
prevStatus := f.InvestigationStatus
|
||
prevOutcome := f.InvestigationOutcome
|
||
f.InvestigationSessionID = sessionID
|
||
f.InvestigationStatus = status
|
||
f.InvestigationOutcome = outcome
|
||
f.LastInvestigatedAt = lastInvestigatedAt
|
||
f.InvestigationAttempts = attempts
|
||
// If a fix was verified, close the loop by resolving the finding.
|
||
if InvestigationOutcome(outcome) == InvestigationOutcomeFixVerified {
|
||
now := time.Now()
|
||
if f.ResolvedAt == nil && f.IsActive() {
|
||
s.activeCounts[f.Severity]--
|
||
}
|
||
f.ResolvedAt = &now
|
||
f.AutoResolved = true
|
||
if f.ResolveReason == "" {
|
||
f.ResolveReason = "Fix verified"
|
||
}
|
||
s.appendLifecycleLocked(f, "verification_passed", "Fix verified; finding resolved", f.LoopState, string(FindingLoopStateResolved), nil)
|
||
} else {
|
||
s.appendLifecycleLocked(f, "investigation_updated", "Investigation state updated", f.LoopState, f.LoopState, map[string]string{
|
||
"prev_status": prevStatus,
|
||
"next_status": status,
|
||
"prev_outcome": prevOutcome,
|
||
"next_outcome": outcome,
|
||
})
|
||
}
|
||
s.syncLoopStateLocked(f)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// Suppress marks a finding type as permanently suppressed for a resource
|
||
// Future findings with the same resource+category will be auto-dismissed
|
||
func (s *FindingsStore) Suppress(id string) bool {
|
||
s.mu.Lock()
|
||
|
||
f, exists := s.findings[id]
|
||
if !exists {
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
f.Suppressed = true
|
||
f.DismissedReason = "suppressed"
|
||
now := time.Now()
|
||
f.AcknowledgedAt = &now
|
||
s.appendLifecycleLocked(f, "suppressed", "Permanently suppressed by user", f.LoopState, string(FindingLoopStateSuppressed), nil)
|
||
s.syncLoopStateLocked(f)
|
||
|
||
// Create a suppression rule to block future findings with same resource+category
|
||
s.addSuppressionRuleInternal(f.ResourceID, f.ResourceName, f.Category, "Suppressed via finding", "suppress")
|
||
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// IsSuppressed checks if findings of this type for this resource are suppressed
|
||
// Checks by resource+category only (not title, since LLM titles vary)
|
||
func (s *FindingsStore) IsSuppressed(resourceID string, category FindingCategory) bool {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.isSuppressedInternal(resourceID, category)
|
||
}
|
||
|
||
// isSuppressedInternal checks suppression without locking (caller must hold lock)
|
||
// IMPORTANT: Only explicit manual suppression rules block new findings.
|
||
// A dismissed finding (even with "not_an_issue") does NOT block future findings.
|
||
// This ensures that if an issue recurs, it will be detected and alerted.
|
||
// For permanent suppression, users must create an explicit suppression rule.
|
||
func (s *FindingsStore) isSuppressedInternal(resourceID string, category FindingCategory) bool {
|
||
// Only check manual suppression rules - dismissed findings should NOT block new findings
|
||
// Rationale: If frigate-storage was full and user dismissed it as "fixed", we still
|
||
// want to alert if frigate-storage fills up again in the future.
|
||
for _, rule := range s.suppressionRules {
|
||
resourceMatches := rule.ResourceID == "" || rule.ResourceID == resourceID
|
||
categoryMatches := rule.Category == "" || rule.Category == category
|
||
if resourceMatches && categoryMatches {
|
||
return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
// Get returns a finding by ID
|
||
func (s *FindingsStore) Get(id string) *Finding {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
if f, exists := s.findings[id]; exists {
|
||
// Return a copy to prevent mutations
|
||
copy := *f
|
||
return ©
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetByResource returns all active findings for a resource
|
||
func (s *FindingsStore) GetByResource(resourceID string) []*Finding {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
ids := s.byResource[resourceID]
|
||
result := make([]*Finding, 0, len(ids))
|
||
for _, id := range ids {
|
||
if f, exists := s.findings[id]; exists && f.IsActive() {
|
||
copy := *f
|
||
result = append(result, ©)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// GetActive returns all active findings, optionally filtered by severity
|
||
func (s *FindingsStore) GetActive(minSeverity FindingSeverity) []*Finding {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
severityOrder := map[FindingSeverity]int{
|
||
FindingSeverityInfo: 0,
|
||
FindingSeverityWatch: 1,
|
||
FindingSeverityWarning: 2,
|
||
FindingSeverityCritical: 3,
|
||
}
|
||
minOrder := severityOrder[minSeverity]
|
||
|
||
result := make([]*Finding, 0)
|
||
for _, f := range s.findings {
|
||
if f.IsActive() && severityOrder[f.Severity] >= minOrder {
|
||
copy := *f
|
||
result = append(result, ©)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// GetSummary returns a summary of active findings
|
||
// Note: This calculates counts dynamically to handle time-based snooze expiration
|
||
func (s *FindingsStore) GetSummary() FindingsSummary {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
summary := FindingsSummary{
|
||
Total: len(s.findings),
|
||
}
|
||
|
||
// Calculate active counts dynamically since IsActive() checks time-based snooze
|
||
for _, f := range s.findings {
|
||
if f.IsActive() {
|
||
switch f.Severity {
|
||
case FindingSeverityCritical:
|
||
summary.Critical++
|
||
case FindingSeverityWarning:
|
||
summary.Warning++
|
||
case FindingSeverityWatch:
|
||
summary.Watch++
|
||
case FindingSeverityInfo:
|
||
summary.Info++
|
||
}
|
||
}
|
||
}
|
||
|
||
return summary
|
||
}
|
||
|
||
// GetAll returns all findings including resolved ones (for history)
|
||
// Results can be filtered by time range using startTime parameter
|
||
func (s *FindingsStore) GetAll(startTime *time.Time) []*Finding {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
result := make([]*Finding, 0, len(s.findings))
|
||
for _, f := range s.findings {
|
||
// If startTime specified, only include findings detected after it
|
||
if startTime != nil && f.DetectedAt.Before(*startTime) {
|
||
continue
|
||
}
|
||
copy := *f
|
||
result = append(result, ©)
|
||
}
|
||
return result
|
||
}
|
||
|
||
// ClearAll removes all findings from the store
|
||
// Returns the number of findings removed
|
||
func (s *FindingsStore) ClearAll() int {
|
||
s.mu.Lock()
|
||
count := len(s.findings)
|
||
s.findings = make(map[string]*Finding)
|
||
s.byResource = make(map[string][]string)
|
||
s.activeCounts = make(map[FindingSeverity]int)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return count
|
||
}
|
||
|
||
// Cleanup removes old resolved findings (and trims stale dismissed history).
|
||
func (s *FindingsStore) Cleanup(maxAge time.Duration) int {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
|
||
now := time.Now()
|
||
cutoff := now.Add(-maxAge)
|
||
dismissedCutoff := now.Add(-30 * 24 * time.Hour)
|
||
removed := 0
|
||
|
||
for id, f := range s.findings {
|
||
shouldRemove := false
|
||
|
||
// Remove old resolved findings
|
||
if f.ResolvedAt != nil && f.ResolvedAt.Before(cutoff) {
|
||
shouldRemove = true
|
||
}
|
||
|
||
// Trim stale dismissed findings, but retain suppressed ones for memory.
|
||
if f.DismissedReason != "" && !f.Suppressed && f.LastSeenAt.Before(dismissedCutoff) {
|
||
shouldRemove = true
|
||
}
|
||
|
||
if shouldRemove {
|
||
delete(s.findings, id)
|
||
// Clean up resource index
|
||
ids := s.byResource[f.ResourceID]
|
||
for i, fid := range ids {
|
||
if fid == id {
|
||
s.byResource[f.ResourceID] = append(ids[:i], ids[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
removed++
|
||
}
|
||
}
|
||
|
||
return removed
|
||
}
|
||
|
||
// GetDismissedForContext returns findings that the user has dismissed/acknowledged,
|
||
// formatted for injection into LLM prompts. This is the core of the "memory" system -
|
||
// it tells the LLM what not to re-raise.
|
||
func (s *FindingsStore) GetDismissedForContext() string {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.dismissedContextLocked(nil)
|
||
}
|
||
|
||
// GetDismissedForContextForResources returns dismissed/suppressed/snoozed findings
|
||
// limited to the provided resource IDs/names. An empty filter behaves like the
|
||
// unscoped context and returns all eligible findings.
|
||
func (s *FindingsStore) GetDismissedForContextForResources(resourceFilter map[string]bool) string {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.dismissedContextLocked(resourceFilter)
|
||
}
|
||
|
||
func (s *FindingsStore) dismissedContextLocked(resourceFilter map[string]bool) string {
|
||
var suppressed, dismissed, snoozed []string
|
||
includeFinding := func(f *Finding) bool {
|
||
if len(resourceFilter) == 0 {
|
||
return true
|
||
}
|
||
return resourceFilter[f.ResourceID] || resourceFilter[f.ResourceName]
|
||
}
|
||
|
||
for _, f := range s.findings {
|
||
if !includeFinding(f) {
|
||
continue
|
||
}
|
||
|
||
// Collect suppressed findings
|
||
if f.Suppressed {
|
||
note := ""
|
||
if f.UserNote != "" {
|
||
note = " - User note: " + f.UserNote
|
||
}
|
||
suppressed = append(suppressed,
|
||
fmt.Sprintf("- %s on %s: %s%s", f.Title, f.ResourceName, f.DismissedReason, note))
|
||
continue
|
||
}
|
||
|
||
// Skip very old findings (more than 30 days)
|
||
if time.Since(f.LastSeenAt) > 30*24*time.Hour {
|
||
continue
|
||
}
|
||
|
||
// Collect dismissed/acknowledged findings
|
||
if f.DismissedReason != "" {
|
||
note := ""
|
||
if f.UserNote != "" {
|
||
note = " - User note: " + f.UserNote
|
||
}
|
||
dismissed = append(dismissed,
|
||
fmt.Sprintf("- %s on %s (%s)%s", f.Title, f.ResourceName, f.DismissedReason, note))
|
||
continue
|
||
}
|
||
|
||
// Collect snoozed findings
|
||
if f.IsSnoozed() {
|
||
snoozed = append(snoozed,
|
||
fmt.Sprintf("- %s on %s (snoozed until %s)",
|
||
f.Title, f.ResourceName, f.SnoozedUntil.Format("Jan 2")))
|
||
}
|
||
}
|
||
|
||
if len(suppressed) == 0 && len(dismissed) == 0 && len(snoozed) == 0 {
|
||
return ""
|
||
}
|
||
|
||
var result strings.Builder
|
||
result.WriteString("\n## Previous Findings - User Feedback\n")
|
||
result.WriteString("The following findings have been addressed by the user. Do NOT re-raise these unless the situation has significantly worsened:\n\n")
|
||
|
||
if len(suppressed) > 0 {
|
||
result.WriteString("### Permanently Suppressed (never re-raise):\n")
|
||
for _, s := range suppressed {
|
||
result.WriteString(s + "\n")
|
||
}
|
||
result.WriteString("\n")
|
||
}
|
||
|
||
if len(dismissed) > 0 {
|
||
result.WriteString("### Dismissed by User:\n")
|
||
for _, d := range dismissed {
|
||
result.WriteString(d + "\n")
|
||
}
|
||
result.WriteString("\n")
|
||
}
|
||
|
||
if len(snoozed) > 0 {
|
||
result.WriteString("### Temporarily Snoozed:\n")
|
||
for _, s := range snoozed {
|
||
result.WriteString(s + "\n")
|
||
}
|
||
}
|
||
|
||
return result.String()
|
||
}
|
||
|
||
// FindingsSummary provides a quick count of findings by severity
|
||
type FindingsSummary struct {
|
||
Critical int `json:"critical"`
|
||
Warning int `json:"warning"`
|
||
Watch int `json:"watch"`
|
||
Info int `json:"info"`
|
||
Total int `json:"total"`
|
||
}
|
||
|
||
// FindingsTrustSummary is a snapshot of how the currently-tracked findings
|
||
// have resolved over time. It answers the user-facing "do I trust Patrol?"
|
||
// question with concrete counts: how many were auto-resolved, how many were
|
||
// fix-verified, how many were dismissed as noise versus expected behavior.
|
||
//
|
||
// This is a snapshot of the in-memory store; it does not include historical
|
||
// findings that have been cleaned up. Treat the counts as "current state
|
||
// distribution of tracked findings," not lifetime totals.
|
||
type FindingsTrustSummary struct {
|
||
Tracked int `json:"tracked"`
|
||
CurrentlyActive int `json:"currently_active"`
|
||
Resolved int `json:"resolved"`
|
||
AutoResolved int `json:"auto_resolved"`
|
||
FixVerified int `json:"fix_verified"`
|
||
FixFailed int `json:"fix_failed"`
|
||
DismissedAsNoise int `json:"dismissed_as_noise"` // dismissed_reason=not_an_issue
|
||
DismissedAsExpected int `json:"dismissed_as_expected"` // dismissed_reason=expected_behavior
|
||
DismissedAsLater int `json:"dismissed_as_later"` // dismissed_reason=will_fix_later
|
||
Suppressed int `json:"suppressed"`
|
||
RegressedAtLeastOnce int `json:"regressed_at_least_once"`
|
||
}
|
||
|
||
// HasIssues returns true if there are any warning or critical findings
|
||
func (s FindingsSummary) HasIssues() bool {
|
||
return s.Critical > 0 || s.Warning > 0
|
||
}
|
||
|
||
// GetTrustSummary returns a snapshot of how currently-tracked findings have
|
||
// resolved. See FindingsTrustSummary for caveats on snapshot vs lifetime
|
||
// totals.
|
||
func (s *FindingsStore) GetTrustSummary() FindingsTrustSummary {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
var summary FindingsTrustSummary
|
||
for _, f := range s.findings {
|
||
summary.Tracked++
|
||
if f.IsActive() {
|
||
summary.CurrentlyActive++
|
||
}
|
||
if f.ResolvedAt != nil {
|
||
summary.Resolved++
|
||
if f.AutoResolved {
|
||
summary.AutoResolved++
|
||
}
|
||
}
|
||
if f.Suppressed {
|
||
summary.Suppressed++
|
||
}
|
||
if f.RegressionCount > 0 {
|
||
summary.RegressedAtLeastOnce++
|
||
}
|
||
switch f.InvestigationOutcome {
|
||
case string(aicontracts.OutcomeFixVerified):
|
||
summary.FixVerified++
|
||
case string(aicontracts.OutcomeFixFailed),
|
||
string(aicontracts.OutcomeFixVerificationFailed):
|
||
summary.FixFailed++
|
||
}
|
||
switch f.DismissedReason {
|
||
case "not_an_issue":
|
||
summary.DismissedAsNoise++
|
||
case "expected_behavior":
|
||
summary.DismissedAsExpected++
|
||
case "will_fix_later":
|
||
summary.DismissedAsLater++
|
||
}
|
||
}
|
||
return summary
|
||
}
|
||
|
||
// IsHealthy returns true if there are no watch, warning, or critical findings
|
||
func (s FindingsSummary) IsHealthy() bool {
|
||
return s.Critical == 0 && s.Warning == 0 && s.Watch == 0
|
||
}
|
||
|
||
// --- Suppression Rule Management ---
|
||
|
||
// AddSuppressionRule creates a new user-defined suppression rule
|
||
func (s *FindingsStore) AddSuppressionRule(resourceID, resourceName string, category FindingCategory, description string) *SuppressionRule {
|
||
s.mu.Lock()
|
||
rule := s.addSuppressionRuleInternal(resourceID, resourceName, category, description, "manual")
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return rule
|
||
}
|
||
|
||
// addSuppressionRuleInternal creates a suppression rule without locking (caller must hold lock)
|
||
func (s *FindingsStore) addSuppressionRuleInternal(resourceID, resourceName string, category FindingCategory, description, createdFrom string) *SuppressionRule {
|
||
// Generate ID based on resource+category
|
||
ruleID := fmt.Sprintf("rule_%s_%s_%d", resourceID, category, time.Now().UnixNano())
|
||
if resourceID == "" {
|
||
ruleID = fmt.Sprintf("rule_any_%s_%d", category, time.Now().UnixNano())
|
||
}
|
||
if category == "" {
|
||
ruleID = fmt.Sprintf("rule_%s_any_%d", resourceID, time.Now().UnixNano())
|
||
}
|
||
|
||
rule := &SuppressionRule{
|
||
ID: ruleID,
|
||
ResourceID: resourceID,
|
||
ResourceName: resourceName,
|
||
Category: category,
|
||
Description: description,
|
||
CreatedAt: time.Now(),
|
||
CreatedFrom: createdFrom,
|
||
}
|
||
|
||
s.suppressionRules[ruleID] = rule
|
||
return rule
|
||
}
|
||
|
||
// GetSuppressionRules returns all suppression rules (both manual and from dismissed findings)
|
||
func (s *FindingsStore) GetSuppressionRules() []*SuppressionRule {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
var rules []*SuppressionRule
|
||
|
||
// Add explicit suppression rules
|
||
for _, rule := range s.suppressionRules {
|
||
rules = append(rules, rule)
|
||
}
|
||
|
||
// Include all dismissed findings as rules (for visibility and to allow reverting)
|
||
// This covers both suppressed ("not_an_issue") and acknowledged ("expected_behavior", "will_fix_later")
|
||
for _, f := range s.findings {
|
||
if f.Suppressed || f.DismissedReason != "" {
|
||
createdFrom := "finding"
|
||
if !f.Suppressed && f.DismissedReason != "" {
|
||
// Mark as "dismissed" type to distinguish from permanent suppression
|
||
createdFrom = "dismissed"
|
||
}
|
||
// Handle nil AcknowledgedAt (shouldn't happen but be safe)
|
||
createdAt := f.LastSeenAt
|
||
if f.AcknowledgedAt != nil {
|
||
createdAt = *f.AcknowledgedAt
|
||
}
|
||
rules = append(rules, &SuppressionRule{
|
||
ID: "finding_" + f.ID,
|
||
ResourceID: f.ResourceID,
|
||
ResourceName: f.ResourceName,
|
||
Category: f.Category,
|
||
Description: f.UserNote,
|
||
DismissedReason: f.DismissedReason,
|
||
CreatedAt: createdAt,
|
||
CreatedFrom: createdFrom,
|
||
FindingID: f.ID,
|
||
})
|
||
}
|
||
}
|
||
|
||
return rules
|
||
}
|
||
|
||
// DeleteSuppressionRule removes a suppression rule
|
||
func (s *FindingsStore) DeleteSuppressionRule(ruleID string) bool {
|
||
s.mu.Lock()
|
||
|
||
// Check if it's an explicit rule
|
||
if _, exists := s.suppressionRules[ruleID]; exists {
|
||
delete(s.suppressionRules, ruleID)
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
|
||
// Check if it's a finding-based rule (e.g., "finding_abc123")
|
||
if strings.HasPrefix(ruleID, "finding_") {
|
||
findingID := strings.TrimPrefix(ruleID, "finding_")
|
||
if f, exists := s.findings[findingID]; exists && (f.Suppressed || f.DismissedReason != "") {
|
||
// Un-suppress/undismiss the finding
|
||
wasActive := f.IsActive()
|
||
f.Suppressed = false
|
||
f.DismissedReason = ""
|
||
f.AcknowledgedAt = nil
|
||
// If it wasn't active before but is now, increment count
|
||
if !wasActive && f.IsActive() {
|
||
s.activeCounts[f.Severity]++
|
||
}
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return true
|
||
}
|
||
}
|
||
|
||
s.mu.Unlock()
|
||
return false
|
||
}
|
||
|
||
// GetDismissedFindings returns all findings that have been dismissed or suppressed
|
||
func (s *FindingsStore) GetDismissedFindings() []*Finding {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
var dismissed []*Finding
|
||
for _, f := range s.findings {
|
||
if f.DismissedReason != "" || f.Suppressed {
|
||
copy := *f
|
||
dismissed = append(dismissed, ©)
|
||
}
|
||
}
|
||
return dismissed
|
||
}
|
||
|
||
// MatchesSuppressionRule checks if a finding matches any suppression rule
|
||
func (s *FindingsStore) MatchesSuppressionRule(resourceID string, category FindingCategory) bool {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
for _, rule := range s.suppressionRules {
|
||
// Check if resource matches (or rule is for "any resource")
|
||
resourceMatches := rule.ResourceID == "" || rule.ResourceID == resourceID
|
||
// Check if category matches (or rule is for "any category")
|
||
categoryMatches := rule.Category == "" || rule.Category == category
|
||
|
||
if resourceMatches && categoryMatches {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// --- Semantic Deduplication ---
|
||
|
||
// FindingCluster represents a group of semantically related findings
|
||
type FindingCluster struct {
|
||
ID string `json:"id"`
|
||
PrimaryFindingID string `json:"primary_finding_id"` // The "main" finding in the cluster
|
||
RelatedIDs []string `json:"related_ids"` // Other findings in the cluster
|
||
CommonCategory FindingCategory `json:"common_category"`
|
||
CommonResource string `json:"common_resource,omitempty"` // If all findings are for same resource
|
||
Summary string `json:"summary"` // Aggregated summary
|
||
HighestSeverity FindingSeverity `json:"highest_severity"`
|
||
TotalCount int `json:"total_count"`
|
||
}
|
||
|
||
// SemanticSimilarity calculates similarity between two findings (0-1)
|
||
func SemanticSimilarity(f1, f2 *Finding) float64 {
|
||
if f1 == nil || f2 == nil {
|
||
return 0
|
||
}
|
||
|
||
var similarity float64
|
||
|
||
// Same resource is a strong signal
|
||
if f1.ResourceID == f2.ResourceID {
|
||
similarity += 0.3
|
||
}
|
||
|
||
// Same category
|
||
if f1.Category == f2.Category {
|
||
similarity += 0.2
|
||
}
|
||
|
||
// Similar key (if set)
|
||
if f1.Key != "" && f1.Key == f2.Key {
|
||
similarity += 0.4
|
||
}
|
||
|
||
// Title keyword overlap
|
||
titleSim := keywordOverlap(f1.Title, f2.Title)
|
||
similarity += titleSim * 0.2
|
||
|
||
// Description keyword overlap
|
||
descSim := keywordOverlap(f1.Description, f2.Description)
|
||
similarity += descSim * 0.1
|
||
|
||
return minFloat(similarity, 1.0)
|
||
}
|
||
|
||
// keywordOverlap calculates the Jaccard similarity of keywords between two strings
|
||
func keywordOverlap(s1, s2 string) float64 {
|
||
words1 := extractKeywords(s1)
|
||
words2 := extractKeywords(s2)
|
||
|
||
if len(words1) == 0 || len(words2) == 0 {
|
||
return 0
|
||
}
|
||
|
||
// Calculate intersection
|
||
intersection := 0
|
||
for word := range words1 {
|
||
if words2[word] {
|
||
intersection++
|
||
}
|
||
}
|
||
|
||
// Calculate union
|
||
union := len(words1)
|
||
for word := range words2 {
|
||
if !words1[word] {
|
||
union++
|
||
}
|
||
}
|
||
|
||
if union == 0 {
|
||
return 0
|
||
}
|
||
|
||
return float64(intersection) / float64(union)
|
||
}
|
||
|
||
// extractKeywords extracts significant keywords from a string
|
||
func extractKeywords(s string) map[string]bool {
|
||
keywords := make(map[string]bool)
|
||
|
||
// Common stop words to ignore
|
||
stopWords := map[string]bool{
|
||
"the": true, "a": true, "an": true, "is": true, "are": true,
|
||
"was": true, "were": true, "be": true, "been": true, "being": true,
|
||
"have": true, "has": true, "had": true, "do": true, "does": true,
|
||
"did": true, "will": true, "would": true, "could": true, "should": true,
|
||
"may": true, "might": true, "must": true, "shall": true, "can": true,
|
||
"to": true, "of": true, "in": true, "for": true, "on": true, "with": true,
|
||
"at": true, "by": true, "from": true, "as": true, "into": true, "through": true,
|
||
"and": true, "or": true, "but": true, "if": true, "then": true, "than": true,
|
||
"this": true, "that": true, "these": true, "those": true, "it": true,
|
||
}
|
||
|
||
// Split into words and normalize
|
||
word := ""
|
||
for _, c := range s {
|
||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
||
if c >= 'A' && c <= 'Z' {
|
||
c = c + 32 // lowercase
|
||
}
|
||
word += string(c)
|
||
} else {
|
||
if len(word) > 2 && !stopWords[word] {
|
||
keywords[word] = true
|
||
}
|
||
word = ""
|
||
}
|
||
}
|
||
if len(word) > 2 && !stopWords[word] {
|
||
keywords[word] = true
|
||
}
|
||
|
||
return keywords
|
||
}
|
||
|
||
// FindSimilarFindings finds findings similar to the given one
|
||
func (s *FindingsStore) FindSimilarFindings(f *Finding, minSimilarity float64) []*Finding {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
var similar []*Finding
|
||
|
||
for _, existing := range s.findings {
|
||
if existing.ID == f.ID {
|
||
continue
|
||
}
|
||
if !existing.IsActive() {
|
||
continue
|
||
}
|
||
|
||
similarity := SemanticSimilarity(f, existing)
|
||
if similarity >= minSimilarity {
|
||
copy := *existing
|
||
similar = append(similar, ©)
|
||
}
|
||
}
|
||
|
||
return similar
|
||
}
|
||
|
||
// AddWithDeduplication adds a finding, merging with similar existing findings if found
|
||
// Returns the finding ID (may be existing finding if merged) and whether it was new
|
||
func (s *FindingsStore) AddWithDeduplication(f *Finding, minSimilarity float64) (string, bool) {
|
||
// First check for similar findings
|
||
similar := s.FindSimilarFindings(f, minSimilarity)
|
||
|
||
if len(similar) > 0 {
|
||
// Find the most similar one
|
||
var bestMatch *Finding
|
||
var bestSimilarity float64
|
||
|
||
for _, existing := range similar {
|
||
sim := SemanticSimilarity(f, existing)
|
||
if sim > bestSimilarity {
|
||
bestMatch = existing
|
||
bestSimilarity = sim
|
||
}
|
||
}
|
||
|
||
if bestMatch != nil {
|
||
// Merge with existing finding
|
||
s.mu.Lock()
|
||
if existing, ok := s.findings[bestMatch.ID]; ok {
|
||
existing.LastSeenAt = time.Now()
|
||
existing.TimesRaised++
|
||
|
||
// Update to higher severity if new finding has higher severity
|
||
severityOrder := map[FindingSeverity]int{
|
||
FindingSeverityInfo: 0,
|
||
FindingSeverityWatch: 1,
|
||
FindingSeverityWarning: 2,
|
||
FindingSeverityCritical: 3,
|
||
}
|
||
if severityOrder[f.Severity] > severityOrder[existing.Severity] {
|
||
existing.Severity = f.Severity
|
||
}
|
||
|
||
// Append evidence if different
|
||
if f.Evidence != "" && f.Evidence != existing.Evidence {
|
||
existing.Evidence = existing.Evidence + "\n---\n" + f.Evidence
|
||
// Truncate if too long
|
||
if len(existing.Evidence) > 5000 {
|
||
existing.Evidence = existing.Evidence[:5000] + "..."
|
||
}
|
||
}
|
||
}
|
||
s.mu.Unlock()
|
||
s.scheduleSave()
|
||
return bestMatch.ID, false
|
||
}
|
||
}
|
||
|
||
// No similar finding found, add as new
|
||
isNew := s.Add(f)
|
||
return f.ID, isNew
|
||
}
|
||
|
||
// GetFindingClusters groups active findings into clusters based on semantic similarity
|
||
func (s *FindingsStore) GetFindingClusters(minSimilarity float64) []*FindingCluster {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
|
||
// Get all active findings
|
||
var activeFindings []*Finding
|
||
for _, f := range s.findings {
|
||
if f.IsActive() {
|
||
copy := *f
|
||
activeFindings = append(activeFindings, ©)
|
||
}
|
||
}
|
||
|
||
if len(activeFindings) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Track which findings have been clustered
|
||
clustered := make(map[string]bool)
|
||
var clusters []*FindingCluster
|
||
|
||
// For each finding, find similar ones and create a cluster
|
||
for _, f := range activeFindings {
|
||
if clustered[f.ID] {
|
||
continue
|
||
}
|
||
|
||
cluster := &FindingCluster{
|
||
ID: fmt.Sprintf("cluster-%s", f.ID[:8]),
|
||
PrimaryFindingID: f.ID,
|
||
RelatedIDs: make([]string, 0),
|
||
CommonCategory: f.Category,
|
||
CommonResource: f.ResourceID,
|
||
HighestSeverity: f.Severity,
|
||
TotalCount: 1,
|
||
}
|
||
|
||
clustered[f.ID] = true
|
||
|
||
// Find all similar findings
|
||
for _, other := range activeFindings {
|
||
if other.ID == f.ID || clustered[other.ID] {
|
||
continue
|
||
}
|
||
|
||
similarity := SemanticSimilarity(f, other)
|
||
if similarity >= minSimilarity {
|
||
cluster.RelatedIDs = append(cluster.RelatedIDs, other.ID)
|
||
cluster.TotalCount++
|
||
clustered[other.ID] = true
|
||
|
||
// Track if all are same resource
|
||
if other.ResourceID != f.ResourceID {
|
||
cluster.CommonResource = ""
|
||
}
|
||
|
||
// Track highest severity
|
||
severityOrder := map[FindingSeverity]int{
|
||
FindingSeverityInfo: 0,
|
||
FindingSeverityWatch: 1,
|
||
FindingSeverityWarning: 2,
|
||
FindingSeverityCritical: 3,
|
||
}
|
||
if severityOrder[other.Severity] > severityOrder[cluster.HighestSeverity] {
|
||
cluster.HighestSeverity = other.Severity
|
||
}
|
||
}
|
||
}
|
||
|
||
// Generate cluster summary
|
||
if cluster.TotalCount > 1 {
|
||
cluster.Summary = fmt.Sprintf("%d related %s findings", cluster.TotalCount, cluster.CommonCategory)
|
||
if cluster.CommonResource != "" {
|
||
cluster.Summary += " for " + cluster.CommonResource
|
||
}
|
||
} else {
|
||
cluster.Summary = f.Title
|
||
}
|
||
|
||
clusters = append(clusters, cluster)
|
||
}
|
||
|
||
return clusters
|
||
}
|
||
|
||
func minFloat(a, b float64) float64 {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|