mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 19:25:57 +00:00
23695681ac
Patrol findings previously reached operators only through the Relay mobile push path. Anyone relying on the email, webhook, or Apprise destinations they already configured for alerts got no proactive signal when Patrol detected a problem and had to open /patrol to learn about it. This is the delivery half of #1369. Each newly stored warning or critical finding now also flows through a FindingNotifyCallback wired in the router, which projects the finding into the alert shape the notification manager delivers. The callback fires only on the genuinely-new path in recordFindingWithInvestigation, so a finding notifies at most once per lifetime regardless of how many later runs re-detect it, and SendAlert's own per-ID cooldown backstops that. Demo mode never notifies. Gating lives in AIConfig via patrol_finding_notifications_enabled (default on, matching the long-standing default for mobile push) and patrol_finding_notify_min_severity (warning or critical, default warning). The enabled flag persists without omitempty so an explicit opt-out survives reload while pre-existing configs inherit the default. The settings surface for these fields follows in a separate commit once the AI settings handler is free.
1022 lines
40 KiB
Go
1022 lines
40 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
// AuthMethod represents how Anthropic authentication is performed
|
|
type AuthMethod string
|
|
|
|
const (
|
|
// AuthMethodAPIKey uses a traditional API key (pay-per-use billing)
|
|
AuthMethodAPIKey AuthMethod = "api_key"
|
|
// AuthMethodOAuth is a legacy stored value retained only so old tokens can be cleared.
|
|
AuthMethodOAuth AuthMethod = "oauth"
|
|
)
|
|
|
|
// PatrolEventTriggerSettings describes which event sources may enqueue scoped patrol runs.
|
|
type PatrolEventTriggerSettings struct {
|
|
AlertTriggersEnabled bool
|
|
AnomalyTriggersEnabled bool
|
|
}
|
|
|
|
// AIConfig holds AI feature configuration
|
|
// This is stored in ai.enc (encrypted) in the config directory
|
|
type AIConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Model string `json:"model"` // Currently selected default model (format: "provider:model-name")
|
|
ChatModel string `json:"chat_model,omitempty"` // Model for interactive chat (defaults to Model)
|
|
PatrolModel string `json:"patrol_model,omitempty"` // Model for background patrol (defaults to Model, can be cheaper)
|
|
DiscoveryModel string `json:"discovery_model,omitempty"` // Model for infrastructure discovery (defaults to cheapest available, e.g., haiku)
|
|
CustomContext string `json:"custom_context"` // user-provided context about their infrastructure
|
|
|
|
// Multi-provider credentials - each provider can be configured independently
|
|
AnthropicAPIKey string `json:"anthropic_api_key,omitempty"` // Anthropic API key
|
|
OpenAIAPIKey string `json:"openai_api_key,omitempty"` // OpenAI API key
|
|
OpenRouterAPIKey string `json:"openrouter_api_key,omitempty"` // OpenRouter API key
|
|
DeepSeekAPIKey string `json:"deepseek_api_key,omitempty"` // DeepSeek API key
|
|
GeminiAPIKey string `json:"gemini_api_key,omitempty"` // Google Gemini API key
|
|
ZaiAPIKey string `json:"zai_api_key,omitempty"` // Z.ai (Zhipu GLM) API key
|
|
ZaiBaseURL string `json:"zai_base_url,omitempty"` // Custom Z.ai OpenAI-compatible base URL (e.g. coding endpoint)
|
|
GroqAPIKey string `json:"groq_api_key,omitempty"` // Groq API key
|
|
MistralAPIKey string `json:"mistral_api_key,omitempty"` // Mistral API key
|
|
CerebrasAPIKey string `json:"cerebras_api_key,omitempty"` // Cerebras API key
|
|
TogetherAPIKey string `json:"together_api_key,omitempty"` // Together AI API key
|
|
FireworksAPIKey string `json:"fireworks_api_key,omitempty"` // Fireworks AI API key
|
|
CodexSubscriptionEnabled bool `json:"codex_subscription_enabled,omitempty"` // Use the locally authenticated Codex CLI for Pulse model turns
|
|
ClaudeSubscriptionEnabled bool `json:"claude_subscription_enabled,omitempty"` // Use the locally authenticated Claude CLI for Pulse model turns
|
|
OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434)
|
|
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
|
|
OllamaPassword string `json:"ollama_password,omitempty"` // Optional Basic Auth password for Ollama
|
|
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses the server default
|
|
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI-compatible base URL (optional)
|
|
|
|
// Legacy Anthropic OAuth fields are retained only for cleanup/migration.
|
|
AuthMethod AuthMethod `json:"auth_method,omitempty"` // "api_key" or legacy "oauth"
|
|
OAuthAccessToken string `json:"oauth_access_token,omitempty"` // legacy OAuth access token (encrypted at rest)
|
|
OAuthRefreshToken string `json:"oauth_refresh_token,omitempty"` // legacy OAuth refresh token (encrypted at rest)
|
|
OAuthExpiresAt time.Time `json:"oauth_expires_at,omitempty"` // legacy token expiration time
|
|
|
|
// Patrol settings for background AI monitoring
|
|
PatrolEnabled bool `json:"patrol_enabled"` // Enable background AI health patrol
|
|
PatrolIntervalMinutes int `json:"patrol_interval_minutes"` // How often to run quick patrols (default: 360 = 6 hours)
|
|
PatrolAnalyzeNodes bool `json:"patrol_analyze_nodes"` // Include Proxmox nodes in patrol
|
|
PatrolAnalyzeGuests bool `json:"patrol_analyze_guests"` // Include VMs/containers in patrol
|
|
PatrolAnalyzeDocker bool `json:"patrol_analyze_docker"` // Include Docker hosts in patrol
|
|
PatrolAnalyzeStorage bool `json:"patrol_analyze_storage"` // Include storage in patrol
|
|
PatrolAutoFix bool `json:"patrol_auto_fix,omitempty"` // When true, patrol can attempt automatic remediation (default: false, observe only)
|
|
UseProactiveThresholds bool `json:"use_proactive_thresholds,omitempty"` // When true, patrol warns 5-15% BEFORE alert thresholds (default: false, use exact thresholds)
|
|
AutoFixModel string `json:"auto_fix_model,omitempty"` // Model for automatic remediation (defaults to PatrolModel, may want more capable model)
|
|
|
|
// Patrol finding notifications route each newly detected warning+ finding
|
|
// through the operator's alert notification channels (email, webhooks,
|
|
// Apprise). Findings are deduplicated upstream, so at most one
|
|
// notification fires per finding lifetime. The enabled flag is persisted
|
|
// without omitempty so an explicit opt-out survives reload; configs saved
|
|
// before the field existed inherit the default (enabled).
|
|
PatrolFindingNotificationsEnabled bool `json:"patrol_finding_notifications_enabled"`
|
|
// PatrolFindingNotifyMinSeverity is the minimum finding severity that
|
|
// warrants a notification ("warning" accepts warning+critical;
|
|
// "critical" accepts only critical; empty = "warning").
|
|
PatrolFindingNotifyMinSeverity string `json:"patrol_finding_notify_min_severity,omitempty"`
|
|
|
|
// Alert-triggered AI analysis - analyze specific resources when alerts fire
|
|
AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // Enable AI analysis when alerts fire (token-efficient)
|
|
|
|
// Event-triggered patrols - run extra patrols when alerts fire or anomalies are detected.
|
|
// The legacy aggregate flag is retained for compatibility; the canonical model is now split
|
|
// between alert-triggered and anomaly-triggered scoped patrol preferences.
|
|
PatrolEventTriggersEnabled bool `json:"patrol_event_triggers_enabled"`
|
|
PatrolAlertTriggersEnabled bool `json:"patrol_alert_triggers_enabled"`
|
|
PatrolAnomalyTriggersEnabled bool `json:"patrol_anomaly_triggers_enabled"`
|
|
|
|
// Fine-grained control over which firing alerts trigger a scoped patrol.
|
|
// PatrolAlertTriggerMinSeverity is the minimum alert level that warrants an
|
|
// investigation ("warning" accepts warning+critical; "critical" accepts only
|
|
// critical; empty = "critical"). PatrolAlertTriggerTypes optionally restricts
|
|
// triggering to specific alert types (cpu, memory, disk, ...); empty = all types.
|
|
PatrolAlertTriggerMinSeverity string `json:"patrol_alert_trigger_min_severity,omitempty"`
|
|
PatrolAlertTriggerTypes []string `json:"patrol_alert_trigger_types,omitempty"`
|
|
|
|
// Request timeout - how long to wait for AI responses (default: 300s / 5 min)
|
|
// Increase this for slow hardware running local models (e.g., Ollama on low-power devices)
|
|
RequestTimeoutSeconds int `json:"request_timeout_seconds,omitempty"`
|
|
|
|
// AI cost controls
|
|
// Budget is expressed as an estimated USD amount over a 30-day window (pro-rated in UI for other ranges).
|
|
CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"`
|
|
|
|
// AI Infrastructure Control settings
|
|
// These control whether AI can take actions on infrastructure (start/stop VMs, containers, etc.)
|
|
ControlLevel string `json:"control_level,omitempty"` // "read_only", "controlled", "autonomous"
|
|
ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs or names that AI cannot control
|
|
|
|
// Patrol Autonomy settings - controls automatic investigation and remediation of findings
|
|
PatrolAutonomyLevel string `json:"patrol_autonomy_level,omitempty"` // "monitor", "approval", "assisted", "full"
|
|
PatrolFullModeUnlocked bool `json:"patrol_full_mode_unlocked"` // Legacy wire/storage compatibility only; never authority for effective full mode.
|
|
PatrolAutopilotAcknowledgements []unifiedresources.PatrolAutopilotAcknowledgement `json:"patrol_autopilot_acknowledgements,omitempty"`
|
|
PatrolAutopilotRevocations []unifiedresources.PatrolAutopilotRevocation `json:"patrol_autopilot_revocations,omitempty"`
|
|
PatrolAutopilotActivation *unifiedresources.PatrolAutopilotActivation `json:"patrol_autopilot_activation,omitempty"`
|
|
PatrolActionEmergencyStop bool `json:"patrol_action_emergency_stop"` // Blocks new human and policy action admission; does not imply rollback
|
|
PatrolInvestigationBudget int `json:"patrol_investigation_budget,omitempty"` // Max evidence calls per investigation (default: 15)
|
|
PatrolInvestigationTimeoutSec int `json:"patrol_investigation_timeout_sec,omitempty"` // Max seconds per investigation (default: 300)
|
|
|
|
// Discovery settings - controls automatic infrastructure discovery
|
|
DiscoveryEnabled bool `json:"discovery_enabled"` // Enable infrastructure discovery
|
|
DiscoveryIntervalHours int `json:"discovery_interval_hours,omitempty"` // Hours between automatic re-scans (0 = manual only, default: 0)
|
|
|
|
}
|
|
|
|
// AIProvider constants
|
|
const (
|
|
AIProviderAnthropic = "anthropic"
|
|
AIProviderOpenAI = "openai"
|
|
AIProviderOpenRouter = "openrouter"
|
|
AIProviderOllama = "ollama"
|
|
AIProviderDeepSeek = "deepseek"
|
|
AIProviderGemini = "gemini"
|
|
AIProviderZai = "zai" // Z.ai OpenAI-compatible provider
|
|
AIProviderGroq = "groq"
|
|
AIProviderMistral = "mistral"
|
|
AIProviderCerebras = "cerebras"
|
|
AIProviderTogether = "together"
|
|
AIProviderFireworks = "fireworks"
|
|
AIProviderCodexSubscription = "codex-subscription"
|
|
AIProviderClaudeSubscription = "claude-subscription"
|
|
AIProviderQuickstart = "quickstart" // Retired Pulse-hosted proxy marker retained only for legacy config migration.
|
|
)
|
|
|
|
// AI Control Level constants
|
|
const (
|
|
// ControlLevelReadOnly - AI can only query infrastructure, no control tools available
|
|
ControlLevelReadOnly = string(agentcapabilities.ControlLevelReadOnly)
|
|
// ControlLevelControlled - AI can execute with per-command approval
|
|
ControlLevelControlled = string(agentcapabilities.ControlLevelControlled)
|
|
// ControlLevelAutonomous - AI executes without approval (requires Pro license)
|
|
ControlLevelAutonomous = string(agentcapabilities.ControlLevelAutonomous)
|
|
)
|
|
|
|
// Patrol Autonomy Level constants
|
|
const (
|
|
// PatrolAutonomyMonitor - Detect issues and create findings, no automatic investigation
|
|
PatrolAutonomyMonitor = "monitor"
|
|
// PatrolAutonomyApproval - Spawn Chat sessions to investigate, queue ALL fixes for user approval
|
|
PatrolAutonomyApproval = "approval"
|
|
// PatrolAutonomyAssisted - Auto-fix warnings, critical findings still need approval
|
|
PatrolAutonomyAssisted = "assisted"
|
|
// PatrolAutonomyFull - Full autonomy, auto-fix everything including critical (user accepts risk)
|
|
PatrolAutonomyFull = "full"
|
|
)
|
|
|
|
// Default patrol investigation settings
|
|
const (
|
|
DefaultPatrolInvestigationBudget = 15 // Max turns (tool calls) per investigation
|
|
DefaultPatrolInvestigationTimeoutSec = 600 // 10 minutes
|
|
MaxConcurrentInvestigations = 3 // Max parallel investigations
|
|
MaxInvestigationAttempts = 3 // Max retry attempts per finding
|
|
InvestigationCooldownHours = 1 // Hours before re-investigating same finding
|
|
)
|
|
|
|
const (
|
|
// DefaultAIModelQuickstart is retained only to recognize and retire legacy
|
|
// Pulse-hosted model aliases from pre-GA config.
|
|
DefaultAIModelQuickstart = "pulse-hosted"
|
|
DefaultOllamaBaseURL = "http://localhost:11434"
|
|
DefaultOllamaKeepAlive = "30s"
|
|
DefaultOpenRouterBaseURL = "https://openrouter.ai/api/v1"
|
|
DefaultDeepSeekBaseURL = "https://api.deepseek.com"
|
|
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
|
DefaultZaiBaseURL = "https://api.z.ai/api/paas/v4"
|
|
DefaultGroqBaseURL = "https://api.groq.com/openai/v1"
|
|
DefaultMistralBaseURL = "https://api.mistral.ai/v1"
|
|
DefaultCerebrasBaseURL = "https://api.cerebras.ai/v1"
|
|
DefaultTogetherBaseURL = "https://api.together.xyz/v1"
|
|
DefaultFireworksBaseURL = "https://api.fireworks.ai/inference/v1"
|
|
)
|
|
|
|
const (
|
|
DeepSeekModelV4Flash = "deepseek-v4-flash"
|
|
DeepSeekModelV4Pro = "deepseek-v4-pro"
|
|
DeepSeekModelLegacyChat = "deepseek-chat"
|
|
DeepSeekModelLegacyReasoner = "deepseek-reasoner"
|
|
)
|
|
|
|
// DeepSeekV4ModelIDs returns the current direct DeepSeek model IDs Pulse treats
|
|
// as tool-capable for Assistant and Patrol runtime paths.
|
|
func DeepSeekV4ModelIDs() []string {
|
|
return []string{DeepSeekModelV4Flash, DeepSeekModelV4Pro}
|
|
}
|
|
|
|
// DeepSeekLegacyAliasModelIDs returns direct DeepSeek aliases that are still
|
|
// accepted by the provider but should be presented as legacy aliases.
|
|
func DeepSeekLegacyAliasModelIDs() []string {
|
|
return []string{DeepSeekModelLegacyChat, DeepSeekModelLegacyReasoner}
|
|
}
|
|
|
|
func IsDeepSeekV4Model(model string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(model)) {
|
|
case DeepSeekModelV4Flash, DeepSeekModelV4Pro:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func IsDeepSeekLegacyAliasModel(model string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(model)) {
|
|
case DeepSeekModelLegacyChat, DeepSeekModelLegacyReasoner:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// NewDefaultAIConfig returns an AIConfig with sensible defaults
|
|
func NewDefaultAIConfig() *AIConfig {
|
|
return &AIConfig{
|
|
Enabled: false,
|
|
Model: "",
|
|
AuthMethod: AuthMethodAPIKey,
|
|
// Pulse keeps the v6 cost-control default explicit. Operators can
|
|
// clear this value to let the Ollama server's own default apply.
|
|
OllamaKeepAlive: DefaultOllamaKeepAlive,
|
|
// Patrol defaults - enabled when AI is enabled
|
|
// Default to 6 hour intervals (much more token-efficient than 15 min)
|
|
PatrolEnabled: true,
|
|
PatrolIntervalMinutes: 360, // 6 hours - balance between coverage and token efficiency
|
|
PatrolAnalyzeNodes: true,
|
|
PatrolAnalyzeGuests: true,
|
|
PatrolAnalyzeDocker: true,
|
|
PatrolAnalyzeStorage: true,
|
|
// Alert-triggered analysis is highly token-efficient - enabled by default
|
|
AlertTriggeredAnalysis: true,
|
|
// Event-triggered patrols enabled by default (alerts, anomalies trigger extra patrols)
|
|
PatrolEventTriggersEnabled: true,
|
|
PatrolAlertTriggersEnabled: true,
|
|
PatrolAnomalyTriggersEnabled: true,
|
|
// Default to critical-only so alert-triggered investigations stay
|
|
// token-conservative out of the box. Operators can opt warnings in.
|
|
PatrolAlertTriggerMinSeverity: AlertTriggerSeverityCritical,
|
|
// Finding notifications default on at warning+, matching the
|
|
// long-standing default for Patrol mobile push. Findings fire once
|
|
// per lifetime, so the volume is inherently low.
|
|
PatrolFindingNotificationsEnabled: true,
|
|
}
|
|
}
|
|
|
|
// Alert-trigger minimum-severity sentinels.
|
|
const (
|
|
AlertTriggerSeverityWarning = "warning"
|
|
AlertTriggerSeverityCritical = "critical"
|
|
)
|
|
|
|
// GetPatrolAlertTriggerMinSeverity returns the configured minimum alert level
|
|
// that warrants a scoped investigation patrol, normalizing the empty default to
|
|
// critical-only.
|
|
func (c *AIConfig) GetPatrolAlertTriggerMinSeverity() string {
|
|
if c == nil {
|
|
return AlertTriggerSeverityCritical
|
|
}
|
|
min := strings.ToLower(strings.TrimSpace(c.PatrolAlertTriggerMinSeverity))
|
|
switch min {
|
|
case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical:
|
|
return min
|
|
default:
|
|
return AlertTriggerSeverityCritical
|
|
}
|
|
}
|
|
|
|
// AlertTriggersInvestigation reports whether a firing alert of the given type
|
|
// and level should trigger a scoped investigation patrol, per the operator's
|
|
// alert-trigger policy. It enforces the master enable, the minimum-severity
|
|
// floor, and the optional alert-type allowlist.
|
|
func (c *AIConfig) AlertTriggersInvestigation(alertType, level string) bool {
|
|
if c == nil || !c.PatrolAlertTriggersEnabled {
|
|
return false
|
|
}
|
|
if !alertLevelMeetsMinimum(level, c.PatrolAlertTriggerMinSeverity) {
|
|
return false
|
|
}
|
|
if len(c.PatrolAlertTriggerTypes) > 0 && !alertTypeAllowed(alertType, c.PatrolAlertTriggerTypes) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// GetPatrolFindingNotifyMinSeverity returns the configured minimum finding
|
|
// severity that warrants a notification, normalizing the empty default to
|
|
// warning. Warning is the right floor here because most actionable findings
|
|
// (data-loss risks, failing health checks) are reported at warning severity.
|
|
func (c *AIConfig) GetPatrolFindingNotifyMinSeverity() string {
|
|
if c == nil {
|
|
return AlertTriggerSeverityWarning
|
|
}
|
|
min := strings.ToLower(strings.TrimSpace(c.PatrolFindingNotifyMinSeverity))
|
|
switch min {
|
|
case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical:
|
|
return min
|
|
default:
|
|
return AlertTriggerSeverityWarning
|
|
}
|
|
}
|
|
|
|
// PatrolFindingTriggersNotification reports whether a newly detected finding
|
|
// of the given severity should be routed to the operator's alert notification
|
|
// channels. Only warning and critical findings ever qualify; info and watch
|
|
// findings are UI-only detail.
|
|
func (c *AIConfig) PatrolFindingTriggersNotification(severity string) bool {
|
|
if c == nil || !c.Enabled || !c.PatrolFindingNotificationsEnabled {
|
|
return false
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(severity)) {
|
|
case AlertTriggerSeverityWarning, AlertTriggerSeverityCritical:
|
|
default:
|
|
return false
|
|
}
|
|
return alertLevelMeetsMinimum(severity, c.GetPatrolFindingNotifyMinSeverity())
|
|
}
|
|
|
|
// alertLevelMeetsMinimum ranks warning < critical. An empty minimum defaults to
|
|
// critical-only. An unknown alert level is treated as critical so it is never
|
|
// silently dropped.
|
|
func alertLevelMeetsMinimum(level, minimum string) bool {
|
|
rank := func(s string) int {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case AlertTriggerSeverityWarning:
|
|
return 1
|
|
case AlertTriggerSeverityCritical:
|
|
return 2
|
|
default:
|
|
return 2
|
|
}
|
|
}
|
|
min := strings.ToLower(strings.TrimSpace(minimum))
|
|
if min == "" {
|
|
min = AlertTriggerSeverityCritical
|
|
}
|
|
return rank(level) >= rank(min)
|
|
}
|
|
|
|
func alertTypeAllowed(alertType string, allowed []string) bool {
|
|
at := strings.ToLower(strings.TrimSpace(alertType))
|
|
for _, a := range allowed {
|
|
if strings.ToLower(strings.TrimSpace(a)) == at {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// NormalizeOllamaKeepAlive validates the value Pulse sends as Ollama's
|
|
// keep_alive request option. Empty is intentional and means "omit keep_alive"
|
|
// so the Ollama server default applies.
|
|
func NormalizeOllamaKeepAlive(value string) (string, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", nil
|
|
}
|
|
if numeric, err := strconv.ParseFloat(trimmed, 64); err == nil {
|
|
if math.IsInf(numeric, 0) || math.IsNaN(numeric) {
|
|
return "", fmt.Errorf("must be a finite duration or second count")
|
|
}
|
|
return trimmed, nil
|
|
}
|
|
if _, err := time.ParseDuration(trimmed); err == nil {
|
|
return trimmed, nil
|
|
}
|
|
return "", fmt.Errorf("must be a duration such as 30s, 5m, or 24h; seconds such as 3600; -1 to keep loaded; 0 to unload; or empty to use the Ollama server default")
|
|
}
|
|
|
|
// GetOllamaKeepAlive returns the configured keep_alive value. A nil config
|
|
// keeps the Pulse default; an explicitly empty config value is preserved so
|
|
// callers can omit keep_alive and defer to the Ollama server.
|
|
func (c *AIConfig) GetOllamaKeepAlive() string {
|
|
if c == nil {
|
|
return DefaultOllamaKeepAlive
|
|
}
|
|
return strings.TrimSpace(c.OllamaKeepAlive)
|
|
}
|
|
|
|
// IsConfigured returns true if the AI config has enough info to make API calls
|
|
// For multi-provider setup, returns true if ANY provider is configured
|
|
func (c *AIConfig) IsConfigured() bool {
|
|
if c == nil || !c.Enabled {
|
|
return false
|
|
}
|
|
return len(c.GetConfiguredProviders()) > 0
|
|
}
|
|
|
|
// HasProvider returns true if the specified provider has credentials configured
|
|
func (c *AIConfig) HasProvider(provider string) bool {
|
|
if c == nil {
|
|
return false
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
case AIProviderAnthropic:
|
|
return c.AnthropicAPIKey != ""
|
|
case AIProviderOpenAI:
|
|
return c.OpenAIAPIKey != ""
|
|
case AIProviderOpenRouter:
|
|
return c.OpenRouterAPIKey != ""
|
|
case AIProviderDeepSeek:
|
|
return c.DeepSeekAPIKey != ""
|
|
case AIProviderGemini:
|
|
return c.GeminiAPIKey != ""
|
|
case AIProviderZai:
|
|
return c.ZaiAPIKey != ""
|
|
case AIProviderGroq:
|
|
return c.GroqAPIKey != ""
|
|
case AIProviderMistral:
|
|
return c.MistralAPIKey != ""
|
|
case AIProviderCerebras:
|
|
return c.CerebrasAPIKey != ""
|
|
case AIProviderTogether:
|
|
return c.TogetherAPIKey != ""
|
|
case AIProviderFireworks:
|
|
return c.FireworksAPIKey != ""
|
|
case AIProviderOllama:
|
|
// Ollama is only "configured" if user has explicitly set a base URL
|
|
return c.OllamaBaseURL != ""
|
|
case AIProviderCodexSubscription:
|
|
return c.CodexSubscriptionEnabled
|
|
case AIProviderClaudeSubscription:
|
|
return c.ClaudeSubscriptionEnabled
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// GetConfiguredProviders returns a list of all providers with credentials configured
|
|
func (c *AIConfig) GetConfiguredProviders() []string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
var configured []string
|
|
for _, def := range AIConfigurableProviderDefinitions() {
|
|
if c.HasProvider(def.ID) {
|
|
configured = append(configured, def.ID)
|
|
}
|
|
}
|
|
return configured
|
|
}
|
|
|
|
// GetAPIKeyForProvider returns the API key for the specified provider
|
|
func (c *AIConfig) GetAPIKeyForProvider(provider string) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
|
case AIProviderAnthropic:
|
|
if c.AnthropicAPIKey != "" {
|
|
return c.AnthropicAPIKey
|
|
}
|
|
case AIProviderOpenAI:
|
|
if c.OpenAIAPIKey != "" {
|
|
return c.OpenAIAPIKey
|
|
}
|
|
case AIProviderOpenRouter:
|
|
if c.OpenRouterAPIKey != "" {
|
|
return c.OpenRouterAPIKey
|
|
}
|
|
case AIProviderDeepSeek:
|
|
if c.DeepSeekAPIKey != "" {
|
|
return c.DeepSeekAPIKey
|
|
}
|
|
case AIProviderGemini:
|
|
if c.GeminiAPIKey != "" {
|
|
return c.GeminiAPIKey
|
|
}
|
|
case AIProviderZai:
|
|
if c.ZaiAPIKey != "" {
|
|
return c.ZaiAPIKey
|
|
}
|
|
case AIProviderGroq:
|
|
if c.GroqAPIKey != "" {
|
|
return c.GroqAPIKey
|
|
}
|
|
case AIProviderMistral:
|
|
if c.MistralAPIKey != "" {
|
|
return c.MistralAPIKey
|
|
}
|
|
case AIProviderCerebras:
|
|
if c.CerebrasAPIKey != "" {
|
|
return c.CerebrasAPIKey
|
|
}
|
|
case AIProviderTogether:
|
|
if c.TogetherAPIKey != "" {
|
|
return c.TogetherAPIKey
|
|
}
|
|
case AIProviderFireworks:
|
|
if c.FireworksAPIKey != "" {
|
|
return c.FireworksAPIKey
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetBaseURLForProvider returns the base URL for the specified provider
|
|
func (c *AIConfig) GetBaseURLForProvider(provider string) string {
|
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
|
switch provider {
|
|
case AIProviderOllama:
|
|
if c != nil && c.OllamaBaseURL != "" {
|
|
return c.OllamaBaseURL
|
|
}
|
|
case AIProviderOpenAI:
|
|
if c != nil && c.OpenAIBaseURL != "" {
|
|
return c.OpenAIBaseURL
|
|
}
|
|
case AIProviderZai:
|
|
if c != nil && c.ZaiBaseURL != "" {
|
|
return c.ZaiBaseURL
|
|
}
|
|
}
|
|
if def, ok := LookupAIProviderDefinition(provider); ok {
|
|
return def.DefaultBaseURL
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsUsingOAuth is retained for legacy config introspection. OAuth is not a
|
|
// supported runtime authentication method for Anthropic provider execution.
|
|
func (c *AIConfig) IsUsingOAuth() bool {
|
|
return false
|
|
}
|
|
|
|
// ParseModelString parses a model string in "provider:model-name" format
|
|
// Returns the provider and model name. If no provider prefix, attempts to detect.
|
|
func ParseModelString(model string) (provider, modelName string) {
|
|
model = strings.TrimSpace(model)
|
|
// Check for explicit provider prefix
|
|
for _, def := range AIProviderDefinitions() {
|
|
p := def.ID
|
|
prefix := p + ":"
|
|
if len(model) > len(prefix) && model[:len(prefix)] == prefix {
|
|
return p, model[len(prefix):]
|
|
}
|
|
}
|
|
|
|
// No prefix - try to detect from model name patterns
|
|
switch {
|
|
case len(model) >= 6 && model[:6] == "claude":
|
|
return AIProviderAnthropic, model
|
|
case len(model) >= 3 && (model[:3] == "gpt" || model[:2] == "o1" || model[:2] == "o3" || model[:2] == "o4"):
|
|
return AIProviderOpenAI, model
|
|
case len(model) >= 8 && model[:8] == "deepseek":
|
|
return AIProviderDeepSeek, model
|
|
case len(model) >= 6 && model[:6] == "gemini":
|
|
return AIProviderGemini, model
|
|
case strings.HasPrefix(model, "openai/"), strings.HasPrefix(model, "anthropic/"), strings.HasPrefix(model, "google/"),
|
|
strings.HasPrefix(model, "deepseek/"), strings.HasPrefix(model, "meta-llama/"), strings.HasPrefix(model, "mistralai/"),
|
|
strings.HasPrefix(model, "x-ai/"), strings.HasPrefix(model, "xai/"), strings.HasPrefix(model, "cohere/"),
|
|
strings.HasPrefix(model, "qwen/"):
|
|
return AIProviderOpenRouter, model
|
|
default:
|
|
// Assume Ollama for unrecognized models (local models have varied names)
|
|
return AIProviderOllama, model
|
|
}
|
|
}
|
|
|
|
// FormatModelString creates a "provider:model-name" format string
|
|
func FormatModelString(provider, modelName string) string {
|
|
return provider + ":" + modelName
|
|
}
|
|
|
|
// NormalizeQuickstartModelString retires legacy Pulse-hosted quickstart model
|
|
// strings. Self-hosted v6 GA requires BYOK or a local model for AI runtime use.
|
|
func NormalizeQuickstartModelString(model string) string {
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
return ""
|
|
}
|
|
if strings.EqualFold(model, DefaultAIModelQuickstart) || strings.EqualFold(model, AIProviderQuickstart) {
|
|
return ""
|
|
}
|
|
provider, _ := ParseModelString(model)
|
|
if provider == AIProviderQuickstart {
|
|
return ""
|
|
}
|
|
return model
|
|
}
|
|
|
|
// DefaultModelForProvider returns the default "provider:model" string for a given provider name.
|
|
// Returns empty string if the provider is unknown.
|
|
func DefaultModelForProvider(provider string) string {
|
|
def, ok := LookupAIProviderDefinition(provider)
|
|
if !ok || def.DefaultModel == "" || def.Protocol == AIProviderProtocolRetired {
|
|
return ""
|
|
}
|
|
return FormatModelString(def.ID, def.DefaultModel)
|
|
}
|
|
|
|
// GetModel returns the explicitly configured model, if any.
|
|
func (c *AIConfig) GetModel() string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return NormalizeQuickstartModelString(c.Model)
|
|
}
|
|
|
|
// NormalizeQuickstartModelAliases removes retired quickstart model strings in-place.
|
|
// Self-hosted v6 GA does not silently fall back to Pulse-hosted AI.
|
|
func (c *AIConfig) NormalizeQuickstartModelAliases() bool {
|
|
if c == nil {
|
|
return false
|
|
}
|
|
|
|
changed := false
|
|
normalizeField := func(field *string) {
|
|
normalized := NormalizeQuickstartModelString(*field)
|
|
if normalized == strings.TrimSpace(*field) {
|
|
return
|
|
}
|
|
*field = normalized
|
|
changed = true
|
|
}
|
|
|
|
normalizeField(&c.Model)
|
|
normalizeField(&c.ChatModel)
|
|
normalizeField(&c.PatrolModel)
|
|
normalizeField(&c.DiscoveryModel)
|
|
normalizeField(&c.AutoFixModel)
|
|
|
|
return changed
|
|
}
|
|
|
|
// GetPreferredModelForProvider returns the most relevant configured model for a provider.
|
|
// It prefers explicitly selected models for that provider and does not invent a model
|
|
// for retired providers.
|
|
func (c *AIConfig) GetPreferredModelForProvider(provider string) string {
|
|
for _, candidate := range []string{c.Model, c.ChatModel, c.PatrolModel, c.AutoFixModel, c.DiscoveryModel} {
|
|
candidate = NormalizeQuickstartModelString(candidate)
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
candidateProvider, _ := ParseModelString(candidate)
|
|
if candidateProvider == provider {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// GetChatModel returns the model for interactive chat conversations
|
|
// Falls back to the main Model if ChatModel is not set
|
|
func (c *AIConfig) GetChatModel() string {
|
|
if c.ChatModel != "" {
|
|
return NormalizeQuickstartModelString(c.ChatModel)
|
|
}
|
|
return c.GetModel()
|
|
}
|
|
|
|
// GetPatrolModel returns the model for background patrol analysis
|
|
// Falls back to the main Model if PatrolModel is not set
|
|
func (c *AIConfig) GetPatrolModel() string {
|
|
if c.PatrolModel != "" {
|
|
return NormalizeQuickstartModelString(c.PatrolModel)
|
|
}
|
|
return c.GetModel()
|
|
}
|
|
|
|
// GetDiscoveryModel returns the model for infrastructure discovery
|
|
// Falls back to PatrolModel, then to the main Model if DiscoveryModel is not set
|
|
// Discovery is high-fan-out background work (one call per container/service),
|
|
// so it belongs on the operator's background-work model, not the shared default
|
|
func (c *AIConfig) GetDiscoveryModel() string {
|
|
if c.DiscoveryModel != "" {
|
|
return NormalizeQuickstartModelString(c.DiscoveryModel)
|
|
}
|
|
return c.GetPatrolModel()
|
|
}
|
|
|
|
// GetAutoFixModel returns the model for automatic remediation actions
|
|
// Falls back to PatrolModel, then to the main Model if AutoFixModel is not set
|
|
// Auto-fix may warrant a more capable model since it takes actions
|
|
func (c *AIConfig) GetAutoFixModel() string {
|
|
if c.AutoFixModel != "" {
|
|
return NormalizeQuickstartModelString(c.AutoFixModel)
|
|
}
|
|
return c.GetPatrolModel()
|
|
}
|
|
|
|
// ClearOAuthTokens clears legacy OAuth tokens.
|
|
func (c *AIConfig) ClearOAuthTokens() {
|
|
c.OAuthAccessToken = ""
|
|
c.OAuthRefreshToken = ""
|
|
c.OAuthExpiresAt = time.Time{}
|
|
}
|
|
|
|
// ClearAPIKey clears the Anthropic API key.
|
|
func (c *AIConfig) ClearAPIKey() {
|
|
c.AnthropicAPIKey = ""
|
|
}
|
|
|
|
// GetPatrolInterval returns the patrol interval as a duration.
|
|
func (c *AIConfig) GetPatrolInterval() time.Duration {
|
|
// Use configured custom minutes when set.
|
|
if c.PatrolIntervalMinutes > 0 {
|
|
return time.Duration(c.PatrolIntervalMinutes) * time.Minute
|
|
}
|
|
|
|
return 6 * time.Hour // default to 6 hours
|
|
}
|
|
|
|
// IsPatrolEnabled returns true if patrol should run
|
|
// Note: Patrol uses local heuristics and doesn't require an AI API key,
|
|
// but still requires AI to be enabled as a master switch
|
|
func (c *AIConfig) IsPatrolEnabled() bool {
|
|
// If AI is disabled globally, patrol is disabled
|
|
if !c.Enabled {
|
|
return false
|
|
}
|
|
return c.PatrolEnabled
|
|
}
|
|
|
|
// IsAlertTriggeredAnalysisEnabled returns true if AI should analyze resources when alerts fire
|
|
func (c *AIConfig) IsAlertTriggeredAnalysisEnabled() bool {
|
|
// Requires AI to be enabled as a master switch
|
|
if !c.Enabled {
|
|
return false
|
|
}
|
|
return c.AlertTriggeredAnalysis
|
|
}
|
|
|
|
func (c *AIConfig) patrolEventTriggerPreferences() (bool, bool) {
|
|
if c == nil {
|
|
return false, false
|
|
}
|
|
|
|
alertEnabled := c.PatrolAlertTriggersEnabled
|
|
anomalyEnabled := c.PatrolAnomalyTriggersEnabled
|
|
|
|
// Compatibility: older callers and persisted configs may still use only the
|
|
// legacy aggregate flag. When neither granular preference is enabled but the
|
|
// legacy flag is on, treat both trigger sources as enabled.
|
|
if !alertEnabled && !anomalyEnabled && c.PatrolEventTriggersEnabled {
|
|
return true, true
|
|
}
|
|
|
|
return alertEnabled, anomalyEnabled
|
|
}
|
|
|
|
// GetPatrolEventTriggerSettings returns the persisted scoped patrol trigger preferences
|
|
// without applying the AI master-switch gating used by runtime checks.
|
|
func (c *AIConfig) GetPatrolEventTriggerSettings() PatrolEventTriggerSettings {
|
|
alertEnabled, anomalyEnabled := c.patrolEventTriggerPreferences()
|
|
return PatrolEventTriggerSettings{
|
|
AlertTriggersEnabled: alertEnabled,
|
|
AnomalyTriggersEnabled: anomalyEnabled,
|
|
}
|
|
}
|
|
|
|
// NormalizePatrolEventTriggerSettings synchronizes the legacy aggregate field with the
|
|
// canonical split trigger preferences.
|
|
func (c *AIConfig) NormalizePatrolEventTriggerSettings() bool {
|
|
if c == nil {
|
|
return false
|
|
}
|
|
|
|
settings := c.GetPatrolEventTriggerSettings()
|
|
changed := false
|
|
if c.PatrolAlertTriggersEnabled != settings.AlertTriggersEnabled {
|
|
c.PatrolAlertTriggersEnabled = settings.AlertTriggersEnabled
|
|
changed = true
|
|
}
|
|
if c.PatrolAnomalyTriggersEnabled != settings.AnomalyTriggersEnabled {
|
|
c.PatrolAnomalyTriggersEnabled = settings.AnomalyTriggersEnabled
|
|
changed = true
|
|
}
|
|
aggregateEnabled := settings.AlertTriggersEnabled || settings.AnomalyTriggersEnabled
|
|
if c.PatrolEventTriggersEnabled != aggregateEnabled {
|
|
c.PatrolEventTriggersEnabled = aggregateEnabled
|
|
changed = true
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// SetPatrolEventTriggersEnabled updates both scoped patrol trigger sources together.
|
|
func (c *AIConfig) SetPatrolEventTriggersEnabled(enabled bool) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.PatrolAlertTriggersEnabled = enabled
|
|
c.PatrolAnomalyTriggersEnabled = enabled
|
|
c.PatrolEventTriggersEnabled = enabled
|
|
}
|
|
|
|
// SetPatrolEventTriggerSettings updates the canonical scoped patrol trigger preferences.
|
|
func (c *AIConfig) SetPatrolEventTriggerSettings(alertEnabled, anomalyEnabled bool) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.PatrolAlertTriggersEnabled = alertEnabled
|
|
c.PatrolAnomalyTriggersEnabled = anomalyEnabled
|
|
c.PatrolEventTriggersEnabled = alertEnabled || anomalyEnabled
|
|
}
|
|
|
|
// IsPatrolEventTriggersEnabled returns true if event-driven patrol triggers (alerts, anomalies) are enabled
|
|
func (c *AIConfig) IsPatrolEventTriggersEnabled() bool {
|
|
if !c.Enabled {
|
|
return false
|
|
}
|
|
settings := c.GetPatrolEventTriggerSettings()
|
|
return settings.AlertTriggersEnabled || settings.AnomalyTriggersEnabled
|
|
}
|
|
|
|
// IsPatrolAlertTriggersEnabled returns true if alert-triggered scoped patrols are enabled.
|
|
func (c *AIConfig) IsPatrolAlertTriggersEnabled() bool {
|
|
if !c.Enabled {
|
|
return false
|
|
}
|
|
return c.GetPatrolEventTriggerSettings().AlertTriggersEnabled
|
|
}
|
|
|
|
// IsPatrolAnomalyTriggersEnabled returns true if anomaly-triggered scoped patrols are enabled.
|
|
func (c *AIConfig) IsPatrolAnomalyTriggersEnabled() bool {
|
|
if !c.Enabled {
|
|
return false
|
|
}
|
|
return c.GetPatrolEventTriggerSettings().AnomalyTriggersEnabled
|
|
}
|
|
|
|
// GetRequestTimeout returns the timeout duration for AI requests
|
|
// Default is 5 minutes (300 seconds) if not configured
|
|
func (c *AIConfig) GetRequestTimeout() time.Duration {
|
|
if c.RequestTimeoutSeconds > 0 {
|
|
return time.Duration(c.RequestTimeoutSeconds) * time.Second
|
|
}
|
|
return 300 * time.Second // 5 minutes default
|
|
}
|
|
|
|
// GetControlLevel returns the AI control level, defaulting to read_only if not set.
|
|
func (c *AIConfig) GetControlLevel() string {
|
|
if c.ControlLevel == "" {
|
|
return ControlLevelReadOnly
|
|
}
|
|
return string(agentcapabilities.NormalizeControlLevel(c.ControlLevel))
|
|
}
|
|
|
|
// EffectiveControlLevelForEntitlement returns the control level that may be
|
|
// enforced for the current entitlement state. Stored autonomous preferences are
|
|
// preserved in config, but without the autonomous entitlement they run as
|
|
// controlled approval mode.
|
|
func EffectiveControlLevelForEntitlement(level string, autonomousAllowed bool) string {
|
|
cfg := AIConfig{ControlLevel: level}
|
|
normalized := cfg.GetControlLevel()
|
|
if normalized == ControlLevelAutonomous && !autonomousAllowed {
|
|
return ControlLevelControlled
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// GetEffectiveControlLevel returns the AI control level that should be exposed
|
|
// or enforced for the current entitlement state.
|
|
func (c *AIConfig) GetEffectiveControlLevel(autonomousAllowed bool) string {
|
|
if c == nil {
|
|
return ControlLevelReadOnly
|
|
}
|
|
return EffectiveControlLevelForEntitlement(c.GetControlLevel(), autonomousAllowed)
|
|
}
|
|
|
|
// IsControlEnabled returns true if AI has any control capability beyond read-only
|
|
func (c *AIConfig) IsControlEnabled() bool {
|
|
return agentcapabilities.ControlLevelAllowsControlTools(agentcapabilities.ControlLevel(c.GetControlLevel()))
|
|
}
|
|
|
|
// IsAutonomous returns true if AI is configured for autonomous operation (no approval needed)
|
|
func (c *AIConfig) IsAutonomous() bool {
|
|
return c.GetControlLevel() == ControlLevelAutonomous
|
|
}
|
|
|
|
// IsValidControlLevel checks if a control level string is valid
|
|
func IsValidControlLevel(level string) bool {
|
|
return agentcapabilities.IsValidControlLevel(level)
|
|
}
|
|
|
|
// GetProtectedGuests returns the list of protected guests (VMIDs or names)
|
|
func (c *AIConfig) GetProtectedGuests() []string {
|
|
if c.ProtectedGuests == nil {
|
|
return []string{}
|
|
}
|
|
return c.ProtectedGuests
|
|
}
|
|
|
|
// GetPatrolAutonomyLevel returns the patrol autonomy level, defaulting to "monitor" if not set
|
|
func (c *AIConfig) GetPatrolAutonomyLevel() string {
|
|
if c.PatrolAutonomyLevel == "" {
|
|
return PatrolAutonomyMonitor
|
|
}
|
|
switch c.PatrolAutonomyLevel {
|
|
case PatrolAutonomyMonitor, PatrolAutonomyApproval, PatrolAutonomyAssisted, PatrolAutonomyFull:
|
|
return c.PatrolAutonomyLevel
|
|
// Migration: treat old "autonomous" as new "full"
|
|
case "autonomous":
|
|
return PatrolAutonomyFull
|
|
default:
|
|
return PatrolAutonomyMonitor
|
|
}
|
|
}
|
|
|
|
// GetEffectivePatrolAutonomy evaluates the requested Patrol mode against the
|
|
// server-owned Autopilot acknowledgement and activation evidence. The legacy
|
|
// PatrolFullModeUnlocked boolean is intentionally passed only so the evaluator
|
|
// can report that it was ignored; it can never authorize full mode.
|
|
func (c *AIConfig) GetEffectivePatrolAutonomy(orgID string, now time.Time) (string, unifiedresources.PatrolAutopilotStatus) {
|
|
return c.GetEffectivePatrolAutonomyWithPolicy(orgID, unifiedresources.CurrentPatrolAutopilotServerPolicy(now))
|
|
}
|
|
|
|
func (c *AIConfig) GetEffectivePatrolAutonomyWithPolicy(orgID string, policy unifiedresources.PatrolAutopilotServerPolicy) (string, unifiedresources.PatrolAutopilotStatus) {
|
|
if c == nil {
|
|
contract, _ := unifiedresources.PatrolAutopilotContractForVersion(policy.CurrentVersion)
|
|
return PatrolAutonomyMonitor, unifiedresources.PatrolAutopilotStatus{
|
|
Code: unifiedresources.PatrolAutopilotStatusAcknowledgementRequired,
|
|
CurrentVersion: policy.CurrentVersion,
|
|
AcceptedScope: contract.AcceptedScope,
|
|
AcceptedLimits: contract.AcceptedLimits,
|
|
}
|
|
}
|
|
return unifiedresources.EvaluatePatrolAutopilot(
|
|
c.GetPatrolAutonomyLevel(),
|
|
PatrolAutonomyApproval,
|
|
strings.TrimSpace(orgID),
|
|
c.PatrolFullModeUnlocked,
|
|
c.PatrolAutopilotAcknowledgements,
|
|
c.PatrolAutopilotRevocations,
|
|
c.PatrolAutopilotActivation,
|
|
policy,
|
|
)
|
|
}
|
|
|
|
func (c *AIConfig) IsPatrolFullModeActive(orgID string, now time.Time) bool {
|
|
level, status := c.GetEffectivePatrolAutonomy(orgID, now)
|
|
return level == PatrolAutonomyFull && status.Active
|
|
}
|
|
|
|
// GetPatrolInvestigationBudget returns the maximum evidence calls per investigation.
|
|
func (c *AIConfig) GetPatrolInvestigationBudget() int {
|
|
if c.PatrolInvestigationBudget <= 0 {
|
|
return DefaultPatrolInvestigationBudget
|
|
}
|
|
// Clamp to reasonable range (5-30)
|
|
if c.PatrolInvestigationBudget < 5 {
|
|
return 5
|
|
}
|
|
if c.PatrolInvestigationBudget > 30 {
|
|
return 30
|
|
}
|
|
return c.PatrolInvestigationBudget
|
|
}
|
|
|
|
// GetPatrolInvestigationTimeout returns the investigation timeout as a duration
|
|
func (c *AIConfig) GetPatrolInvestigationTimeout() time.Duration {
|
|
if c.PatrolInvestigationTimeoutSec <= 0 {
|
|
return time.Duration(DefaultPatrolInvestigationTimeoutSec) * time.Second
|
|
}
|
|
// Clamp to reasonable range (60-1800 seconds / 30 minutes)
|
|
if c.PatrolInvestigationTimeoutSec < 60 {
|
|
return 60 * time.Second
|
|
}
|
|
if c.PatrolInvestigationTimeoutSec > 1800 {
|
|
return 1800 * time.Second
|
|
}
|
|
return time.Duration(c.PatrolInvestigationTimeoutSec) * time.Second
|
|
}
|
|
|
|
// IsValidPatrolAutonomyLevel checks if a patrol autonomy level string is valid
|
|
func IsValidPatrolAutonomyLevel(level string) bool {
|
|
switch level {
|
|
case PatrolAutonomyMonitor, PatrolAutonomyApproval, PatrolAutonomyAssisted, PatrolAutonomyFull:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsPatrolAutonomyEnabled returns true if patrol has any autonomy beyond monitor mode
|
|
func (c *AIConfig) IsPatrolAutonomyEnabled() bool {
|
|
level := c.GetPatrolAutonomyLevel()
|
|
return level != PatrolAutonomyMonitor
|
|
}
|
|
|
|
// IsDiscoveryEnabled returns whether AI-powered infrastructure discovery is enabled
|
|
func (c *AIConfig) IsDiscoveryEnabled() bool {
|
|
return c.DiscoveryEnabled
|
|
}
|
|
|
|
// GetDiscoveryInterval returns the interval between automatic discovery scans
|
|
// Returns 0 if discovery is manual-only
|
|
func (c *AIConfig) GetDiscoveryInterval() time.Duration {
|
|
if c.DiscoveryIntervalHours <= 0 {
|
|
return 0 // Manual only
|
|
}
|
|
return time.Duration(c.DiscoveryIntervalHours) * time.Hour
|
|
}
|