package api import ( "context" "encoding/csv" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/ai/approval" "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" "github.com/rcourtman/pulse-go-rewrite/internal/ai/circuit" "github.com/rcourtman/pulse-go-rewrite/internal/ai/cost" "github.com/rcourtman/pulse-go-rewrite/internal/ai/forecast" "github.com/rcourtman/pulse-go-rewrite/internal/ai/learning" "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" "github.com/rcourtman/pulse-go-rewrite/internal/ai/proxmox" "github.com/rcourtman/pulse-go-rewrite/internal/ai/tools" "github.com/rcourtman/pulse-go-rewrite/internal/ai/unified" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/metrics" "github.com/rcourtman/pulse-go-rewrite/internal/mockmode" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" "github.com/rcourtman/pulse-go-rewrite/internal/utils" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" "github.com/rcourtman/pulse-go-rewrite/pkg/extensions" "github.com/rs/zerolog/log" ) // AISettingsHandler handles AI settings endpoints type AISettingsHandler struct { stateMu sync.RWMutex mtPersistence *config.MultiTenantPersistence mtMonitor *monitoring.MultiTenantMonitor defaultConfig *config.Config defaultPersistence *config.ConfigPersistence hostedMode bool defaultAIService *ai.Service aiServices map[string]*ai.Service aiServicesMu sync.RWMutex agentServer *agentexec.Server onModelChange func() // Called when model or other AI chat-affecting settings change onControlSettingsChange func() // Called when control level or protected guests change // Providers to be applied to new services stateProvider ai.StateProvider readState unifiedresources.ReadState unifiedResourceProvider ai.UnifiedResourceProvider metadataProvider ai.MetadataProvider patrolThresholdProvider ai.ThresholdProvider metricsHistoryProvider ai.MetricsHistoryProvider baselineStore *ai.BaselineStore changeDetector *ai.ChangeDetector remediationLog *ai.RemediationLog incidentStore *memory.IncidentStore patternDetector *ai.PatternDetector correlationDetector *ai.CorrelationDetector licenseHandlers *LicenseHandlers // New AI intelligence services (Phase 6) circuitBreaker *circuit.Breaker // Circuit breaker for resilient patrol learningStore *learning.LearningStore // Feedback learning forecastService *forecast.Service // Trend forecasting proxmoxCorrelator *proxmox.EventCorrelator // Proxmox event correlation remediationEngine aicontracts.RemediationEngine // AI-guided remediation unifiedStore *unified.UnifiedStore // Unified alert/finding store alertBridge *unified.AlertBridge // Bridge between alerts and unified store // Event-driven patrol (Phase 7) triggerManager *ai.TriggerManager // Event-driven patrol trigger manager incidentCoordinator *ai.IncidentCoordinator // Incident recording coordinator incidentRecorder *metrics.IncidentRecorder // High-frequency incident recorder intelligenceMu sync.RWMutex proxmoxCorrelators map[string]*proxmox.EventCorrelator learningStores map[string]*learning.LearningStore forecastServices map[string]*forecast.Service remediationEngines map[string]aicontracts.RemediationEngine incidentStores map[string]*memory.IncidentStore circuitBreakers map[string]*circuit.Breaker discoveryStores map[string]*servicediscovery.Store unifiedStores map[string]*unified.UnifiedStore alertBridges map[string]*unified.AlertBridge triggerManagers map[string]*ai.TriggerManager incidentCoordinators map[string]*ai.IncidentCoordinator incidentRecorders map[string]*metrics.IncidentRecorder // Investigation orchestration (Patrol Autonomy) chatHandler *AIHandler // Chat service handler for investigations investigationStores map[string]aicontracts.InvestigationStore // Investigation stores per org investigationMu sync.RWMutex // Extension endpoints for enterprise feature gating aiAutoFixEndpoints extensions.AIAutoFixEndpoints // Discovery store for deep infrastructure discovery discoveryStore *servicediscovery.Store } // SetAIAutoFixEndpoints sets the resolved AI auto-fix extension endpoints. // Called during route registration so the approval handler can delegate investigation fix approvals. func (h *AISettingsHandler) SetAIAutoFixEndpoints(ep extensions.AIAutoFixEndpoints) { h.aiAutoFixEndpoints = ep } func (h *AISettingsHandler) stateRefs() ( *config.MultiTenantPersistence, *monitoring.MultiTenantMonitor, *config.Config, *config.ConfigPersistence, unifiedresources.ReadState, ai.UnifiedResourceProvider, ) { h.stateMu.RLock() defer h.stateMu.RUnlock() return h.mtPersistence, h.mtMonitor, h.defaultConfig, h.defaultPersistence, h.readState, h.unifiedResourceProvider } type aiSettingsProviderSnapshot struct { defaultAIService *ai.Service stateProvider ai.StateProvider metadataProvider ai.MetadataProvider patrolThresholdProvider ai.ThresholdProvider metricsHistoryProvider ai.MetricsHistoryProvider baselineStore *ai.BaselineStore changeDetector *ai.ChangeDetector remediationLog *ai.RemediationLog incidentStore *memory.IncidentStore patternDetector *ai.PatternDetector correlationDetector *ai.CorrelationDetector discoveryStore *servicediscovery.Store licenseHandlers *LicenseHandlers chatHandler *AIHandler } type failClosedLicenseChecker struct{} func (failClosedLicenseChecker) HasFeature(string) bool { return false } func (failClosedLicenseChecker) GetLicenseStateString() (string, bool) { return string(ai.LicenseStateNone), false } func (h *AISettingsHandler) providerSnapshot() aiSettingsProviderSnapshot { h.stateMu.RLock() defer h.stateMu.RUnlock() return aiSettingsProviderSnapshot{ defaultAIService: h.defaultAIService, stateProvider: h.stateProvider, metadataProvider: h.metadataProvider, patrolThresholdProvider: h.patrolThresholdProvider, metricsHistoryProvider: h.metricsHistoryProvider, baselineStore: h.baselineStore, changeDetector: h.changeDetector, remediationLog: h.remediationLog, incidentStore: h.incidentStore, patternDetector: h.patternDetector, correlationDetector: h.correlationDetector, discoveryStore: h.discoveryStore, licenseHandlers: h.licenseHandlers, chatHandler: h.chatHandler, } } func (h *AISettingsHandler) newFailClosedTenantService(orgID string) *ai.Service { svc := ai.NewService(nil, h.agentServer) svc.SetOrgID(orgID) svc.SetAlertAnalyzerFactory(getCreateAlertAnalyzer()) svc.SetLicenseChecker(failClosedLicenseChecker{}) return svc } // NewAISettingsHandler creates a new AI settings handler func NewAISettingsHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenantMonitor, agentServer *agentexec.Server) *AISettingsHandler { var defaultConfig *config.Config var defaultPersistence *config.ConfigPersistence var defaultAIService *ai.Service hostedMode := hostedModeEnabledFromEnv() if mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil && m != nil { defaultConfig = m.GetConfig() } } if mtp != nil { if p, err := mtp.GetPersistence("default"); err == nil { defaultPersistence = p } } handler := &AISettingsHandler{ mtPersistence: mtp, mtMonitor: mtm, defaultConfig: defaultConfig, defaultPersistence: defaultPersistence, hostedMode: hostedMode, aiServices: make(map[string]*ai.Service), agentServer: agentServer, proxmoxCorrelators: make(map[string]*proxmox.EventCorrelator), learningStores: make(map[string]*learning.LearningStore), forecastServices: make(map[string]*forecast.Service), remediationEngines: make(map[string]aicontracts.RemediationEngine), incidentStores: make(map[string]*memory.IncidentStore), circuitBreakers: make(map[string]*circuit.Breaker), discoveryStores: make(map[string]*servicediscovery.Store), unifiedStores: make(map[string]*unified.UnifiedStore), alertBridges: make(map[string]*unified.AlertBridge), triggerManagers: make(map[string]*ai.TriggerManager), incidentCoordinators: make(map[string]*ai.IncidentCoordinator), incidentRecorders: make(map[string]*metrics.IncidentRecorder), } defaultAIService = ai.NewService(defaultPersistence, agentServer) defaultAIService.SetOrgID("default") defaultAIService.SetAlertAnalyzerFactory(getCreateAlertAnalyzer()) if defaultPersistence != nil { if _, err := handler.loadAIConfigForPersistence(context.Background(), "default", defaultPersistence); err != nil { log.Warn().Err(err).Msg("Failed to bootstrap Pulse Assistant config on startup") } if err := defaultAIService.LoadConfig(); err != nil { log.Warn().Err(err).Msg("Failed to load AI config on startup") } else if aiSettingsUpdateRequiresPatrolPreflight(nil, defaultAIService.GetConfig()) { // Seed the Patrol preflight cache on startup so the UI's // "last verified" indicator is populated on first load after // a Pulse restart, without forcing operators to save // settings or click Verify Patrol just to recover the // observability they had before the restart. The dispatch // is async with its own timeout, so it cannot block boot. log.Info().Msg("Auto-seeding Patrol preflight cache for startup observability") defaultAIService.TriggerPatrolPreflightAsync("", "") } } handler.defaultAIService = defaultAIService return handler } func (h *AISettingsHandler) loadAIConfigForPersistence(_ context.Context, orgID string, persistence *config.ConfigPersistence) (*config.AIConfig, error) { if persistence == nil { return nil, fmt.Errorf("Pulse Assistant config persistence unavailable") } billingBaseDir := persistence.DataDir() if h != nil && h.mtPersistence != nil { billingBaseDir = h.mtPersistence.BaseDataDir() } return loadHostedAwareAIConfig(h != nil && h.hostedMode, billingBaseDir, orgID, persistence) } func (h *AISettingsHandler) loadAIConfig(ctx context.Context) (*config.AIConfig, error) { persistence := h.getPersistence(ctx) if persistence == nil { return nil, fmt.Errorf("Pulse Assistant config persistence unavailable") } orgID := strings.TrimSpace(GetOrgID(ctx)) if orgID == "" { orgID = "default" } return h.loadAIConfigForPersistence(ctx, orgID, persistence) } // GetAIService returns the underlying AI service func (h *AISettingsHandler) GetAIService(ctx context.Context) *ai.Service { mtPersistence, mtMonitor, _, _, _, _ := h.stateRefs() providers := h.providerSnapshot() defaultAIService := providers.defaultAIService orgID := GetOrgID(ctx) if orgID == "default" || orgID == "" { return defaultAIService } if mtPersistence == nil { if mtMonitor != nil { log.Warn().Str("orgID", orgID).Msg("Failed to get persistence manager for tenant AI service") return h.newFailClosedTenantService(orgID) } return defaultAIService } h.aiServicesMu.RLock() svc, exists := h.aiServices[orgID] h.aiServicesMu.RUnlock() if exists { return svc } h.aiServicesMu.Lock() defer h.aiServicesMu.Unlock() // Double check if svc, exists = h.aiServices[orgID]; exists { return svc } // Create new service for this tenant persistence, err := mtPersistence.GetPersistence(orgID) if err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to get persistence for AI service") return h.newFailClosedTenantService(orgID) } if persistence == nil { log.Warn().Str("orgID", orgID).Msg("Tenant persistence unavailable for AI service") return h.newFailClosedTenantService(orgID) } svc = ai.NewService(persistence, h.agentServer) svc.SetOrgID(orgID) svc.SetAlertAnalyzerFactory(getCreateAlertAnalyzer()) if _, err := h.loadAIConfigForPersistence(context.Background(), orgID, persistence); err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to bootstrap Pulse Assistant config for tenant") } if err := svc.LoadConfig(); err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to load AI config for tenant") } // Set providers on new service svc.SetStateProvider(h.stateProviderForOrg(orgID, providers.stateProvider)) if readState := h.readStateForOrg(orgID); readState != nil { svc.SetReadState(readState) } if provider := h.unifiedResourceProviderForOrg(orgID); provider != nil { svc.SetUnifiedResourceProvider(provider) } if providers.metadataProvider != nil { svc.SetMetadataProvider(providers.metadataProvider) } if orgID == "default" { if providers.patrolThresholdProvider != nil { svc.SetPatrolThresholdProvider(providers.patrolThresholdProvider) } if providers.metricsHistoryProvider != nil { svc.SetMetricsHistoryProvider(providers.metricsHistoryProvider) } if providers.baselineStore != nil { svc.SetBaselineStore(providers.baselineStore) } if providers.changeDetector != nil { svc.SetChangeDetector(providers.changeDetector) } if providers.remediationLog != nil { svc.SetRemediationLog(providers.remediationLog) } } if incidentStore := h.GetIncidentStoreForOrg(orgID); incidentStore != nil { svc.SetIncidentStore(incidentStore) } else if orgID == "default" && providers.incidentStore != nil { svc.SetIncidentStore(providers.incidentStore) } if orgID == "default" { if providers.patternDetector != nil { svc.SetPatternDetector(providers.patternDetector) } if providers.correlationDetector != nil { svc.SetCorrelationDetector(providers.correlationDetector) } } if discoveryStore := h.GetDiscoveryStoreForOrg(orgID); discoveryStore != nil { svc.SetDiscoveryStore(discoveryStore) } else if orgID == "default" && providers.discoveryStore != nil { svc.SetDiscoveryStore(providers.discoveryStore) } // Set license checker if handler available if providers.licenseHandlers != nil { // Used context to resolve tenant license service if licSvc, _, err := providers.licenseHandlers.getTenantComponents(ctx); err == nil { svc.SetLicenseChecker(licSvc) } } // Set up investigation orchestrator if chat handler is available if providers.chatHandler != nil && isAIInvestigationEnabled() { h.setupInvestigationOrchestrator(orgID, svc) } h.aiServices[orgID] = svc return svc } // RemoveTenantService removes the AI settings service for a specific tenant. func (h *AISettingsHandler) RemoveTenantService(orgID string) { orgID = strings.TrimSpace(orgID) if orgID == "default" || orgID == "" { return } h.aiServicesMu.Lock() svc := h.aiServices[orgID] delete(h.aiServices, orgID) h.aiServicesMu.Unlock() if svc != nil { svc.StopPatrol() } h.RemoveTenantIntelligence(orgID) h.investigationMu.Lock() delete(h.investigationStores, orgID) h.investigationMu.Unlock() log.Debug().Str("orgID", orgID).Msg("Removed AI settings service for tenant") } // getConfig returns the config for the current context func (h *AISettingsHandler) getConfig(ctx context.Context) *config.Config { _, mtMonitor, defaultConfig, _, _, _ := h.stateRefs() orgID := strings.TrimSpace(GetOrgID(ctx)) if orgID == "" || orgID == "default" { return defaultConfig } if mtMonitor != nil { if m, err := mtMonitor.GetMonitor(orgID); err == nil && m != nil { return m.GetConfig() } // Security: never fall back to default config for non-default orgs. return nil } return defaultConfig } // GetPersistence returns the persistence for the current context func (h *AISettingsHandler) getPersistence(ctx context.Context) *config.ConfigPersistence { mtPersistence, _, _, defaultPersistence, _, _ := h.stateRefs() orgID := strings.TrimSpace(GetOrgID(ctx)) if orgID == "" || orgID == "default" { return defaultPersistence } if mtPersistence != nil { if p, err := mtPersistence.GetPersistence(orgID); err == nil && p != nil { return p } // Security: never fall back to default persistence for non-default orgs. return nil } return defaultPersistence } // SetMultiTenantPersistence updates the persistence manager func (h *AISettingsHandler) SetMultiTenantPersistence(mtp *config.MultiTenantPersistence) { h.stateMu.Lock() defer h.stateMu.Unlock() h.mtPersistence = mtp } // SetMultiTenantMonitor updates the monitor manager func (h *AISettingsHandler) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) { h.stateMu.Lock() defer h.stateMu.Unlock() h.mtMonitor = mtm } // SetConfig updates the configuration reference used by the handler. func (h *AISettingsHandler) SetConfig(cfg *config.Config) { if cfg == nil { return } h.stateMu.Lock() defer h.stateMu.Unlock() h.defaultConfig = cfg } // setSSECORSHeaders validates the request origin against the configured AllowedOrigins // and sets CORS headers only for allowed origins. This prevents arbitrary origin reflection. func (h *AISettingsHandler) setSSECORSHeaders(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if origin == "" { return } cfg := h.getConfig(r.Context()) if cfg == nil { return } allowed := cfg.AllowedOrigins if allowed == "" { return } applyConfiguredCORSHeaders(w, origin, allowed, "GET, POST, OPTIONS", "Content-Type, Accept, Cookie") } // SetStateProvider sets the state provider for infrastructure context func (h *AISettingsHandler) SetStateProvider(sp ai.StateProvider) { h.stateMu.Lock() h.stateProvider = sp defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetStateProvider(sp) } h.aiServicesMu.Lock() for orgID, svc := range h.aiServices { svc.SetStateProvider(h.stateProviderForOrg(orgID, sp)) } h.aiServicesMu.Unlock() // Now that state provider is set, patrol service should be available. // Try to set up the investigation orchestrator if chat handler is ready. // Note: This usually fails because chat service isn't started yet. // The orchestrator will be wired via WireOrchestratorAfterChatStart() instead. if defaultAIService != nil && isAIInvestigationEnabled() { h.setupInvestigationOrchestrator("default", defaultAIService) h.aiServicesMu.RLock() for orgID, svc := range h.aiServices { h.setupInvestigationOrchestrator(orgID, svc) } h.aiServicesMu.RUnlock() } } // GetStateProvider returns the state provider for infrastructure context func (h *AISettingsHandler) GetStateProvider() ai.StateProvider { h.stateMu.RLock() defer h.stateMu.RUnlock() return h.stateProvider } func (h *AISettingsHandler) stateProviderForOrg(orgID string, fallback ai.StateProvider) ai.StateProvider { orgID = strings.TrimSpace(orgID) if orgID == "" || orgID == "default" { return fallback } // Security: never fall back to default-org provider for non-default orgs. return nil } func (h *AISettingsHandler) readStateForOrg(orgID string) unifiedresources.ReadState { if h == nil { return nil } _, mtMonitor, _, _, fallbackReadState, _ := h.stateRefs() orgID = strings.TrimSpace(orgID) if orgID == "" { orgID = "default" } if mtMonitor != nil { if monitor, err := mtMonitor.GetMonitor(orgID); err == nil && monitor != nil { if readState := monitor.GetUnifiedReadState(); readState != nil { return readState } } if orgID != "default" { // Security: never fall back to default-org read state for non-default orgs. return nil } } return fallbackReadState } func (h *AISettingsHandler) unifiedResourceProviderForOrg(orgID string) ai.UnifiedResourceProvider { if h == nil { return nil } _, mtMonitor, _, _, _, fallbackProvider := h.stateRefs() orgID = strings.TrimSpace(orgID) if orgID == "" { orgID = "default" } if mtMonitor != nil { if monitor, err := mtMonitor.GetMonitor(orgID); err == nil && monitor != nil { if readState := monitor.GetUnifiedReadState(); readState != nil { if provider, ok := readState.(ai.UnifiedResourceProvider); ok && provider != nil { return provider } } } if orgID != "default" { // Security: never fall back to default-org unified provider for non-default orgs. return nil } } return fallbackProvider } // SetReadState injects unified read-state context into AI services (patrol path). func (h *AISettingsHandler) SetReadState(rs unifiedresources.ReadState) { if h == nil { return } h.stateMu.Lock() h.readState = rs defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetReadState(h.readStateForOrg("default")) } h.aiServicesMu.Lock() for orgID, svc := range h.aiServices { if svc != nil { svc.SetReadState(h.readStateForOrg(orgID)) } } h.aiServicesMu.Unlock() } // SetUnifiedResourceProvider forwards unified-resource-native context to AI services. func (h *AISettingsHandler) SetUnifiedResourceProvider(urp ai.UnifiedResourceProvider) { if h == nil { return } h.stateMu.Lock() h.unifiedResourceProvider = urp defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetUnifiedResourceProvider(h.unifiedResourceProviderForOrg("default")) } h.aiServicesMu.Lock() defer h.aiServicesMu.Unlock() for orgID, svc := range h.aiServices { if svc != nil { svc.SetUnifiedResourceProvider(h.unifiedResourceProviderForOrg(orgID)) } } } // SetMetadataProvider sets the metadata provider for AI URL discovery func (h *AISettingsHandler) SetMetadataProvider(mp ai.MetadataProvider) { h.stateMu.Lock() h.metadataProvider = mp defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetMetadataProvider(mp) } h.aiServicesMu.Lock() defer h.aiServicesMu.Unlock() for _, svc := range h.aiServices { svc.SetMetadataProvider(mp) } } // StartPatrol starts the background AI patrol service func (h *AISettingsHandler) StartPatrol(ctx context.Context) { h.GetAIService(ctx).StartPatrol(ctx) } // IsAIEnabled returns true if AI features are enabled func (h *AISettingsHandler) IsAIEnabled(ctx context.Context) bool { return h.GetAIService(ctx).IsEnabled() } // SetPatrolThresholdProvider sets the threshold provider for the patrol service func (h *AISettingsHandler) SetPatrolThresholdProvider(provider ai.ThresholdProvider) { h.stateMu.Lock() h.patrolThresholdProvider = provider defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetPatrolThresholdProvider(provider) } } // SetPatrolFindingsPersistence enables findings persistence for the patrol service func (h *AISettingsHandler) SetPatrolFindingsPersistence(persistence ai.FindingsPersistence) error { var firstErr error if patrol := h.defaultAIService.GetPatrolService(); patrol != nil { if err := patrol.SetFindingsPersistence(persistence); err != nil { firstErr = err } } // Also apply to active services h.aiServicesMu.RLock() defer h.aiServicesMu.RUnlock() for orgID, svc := range h.aiServices { if patrol := svc.GetPatrolService(); patrol != nil { if err := patrol.SetFindingsPersistence(persistence); err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to set findings persistence for tenant") if firstErr == nil { firstErr = err } } } } return firstErr } // SetPatrolRunHistoryPersistence enables patrol run history persistence for the patrol service func (h *AISettingsHandler) SetPatrolRunHistoryPersistence(persistence ai.PatrolHistoryPersistence) error { var firstErr error if patrol := h.defaultAIService.GetPatrolService(); patrol != nil { if err := patrol.SetRunHistoryPersistence(persistence); err != nil { firstErr = err } } // Also apply to active services h.aiServicesMu.RLock() defer h.aiServicesMu.RUnlock() for orgID, svc := range h.aiServices { if patrol := svc.GetPatrolService(); patrol != nil { if err := patrol.SetRunHistoryPersistence(persistence); err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to set run history persistence for tenant") if firstErr == nil { firstErr = err } } } } return firstErr } // SetMetricsHistoryProvider sets the metrics history provider for enriched AI context func (h *AISettingsHandler) SetMetricsHistoryProvider(provider ai.MetricsHistoryProvider) { h.stateMu.Lock() h.metricsHistoryProvider = provider defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetMetricsHistoryProvider(provider) } } // SetBaselineStore sets the baseline store for anomaly detection func (h *AISettingsHandler) SetBaselineStore(store *ai.BaselineStore) { h.stateMu.Lock() h.baselineStore = store defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetBaselineStore(store) } } // SetChangeDetector sets the change detector for operational memory func (h *AISettingsHandler) SetChangeDetector(detector *ai.ChangeDetector) { h.stateMu.Lock() h.changeDetector = detector defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetChangeDetector(detector) } } // SetRemediationLog sets the remediation log for tracking fix attempts func (h *AISettingsHandler) SetRemediationLog(remLog *ai.RemediationLog) { h.stateMu.Lock() h.remediationLog = remLog defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetRemediationLog(remLog) } } // SetIncidentStore sets the incident store for the default org. func (h *AISettingsHandler) SetIncidentStore(store *memory.IncidentStore) { h.SetIncidentStoreForOrg("default", store) } // SetIncidentStoreForOrg sets the incident store for alert timelines for an org. func (h *AISettingsHandler) SetIncidentStoreForOrg(orgID string, store *memory.IncidentStore) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if store == nil { delete(h.incidentStores, orgID) } else { h.incidentStores[orgID] = store } h.intelligenceMu.Unlock() if orgID == "default" { h.stateMu.Lock() h.incidentStore = store defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetIncidentStore(store) } } h.aiServicesMu.RLock() svc := h.aiServices[orgID] h.aiServicesMu.RUnlock() if svc != nil { svc.SetIncidentStore(store) } } // GetIncidentStoreForOrg returns the incident store for an org. func (h *AISettingsHandler) GetIncidentStoreForOrg(orgID string) *memory.IncidentStore { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if store := h.incidentStores[orgID]; store != nil { h.intelligenceMu.RUnlock() return store } h.intelligenceMu.RUnlock() if orgID == "default" { h.stateMu.RLock() store := h.incidentStore h.stateMu.RUnlock() return store } return nil } // SetPatternDetector sets the pattern detector for failure prediction func (h *AISettingsHandler) SetPatternDetector(detector *ai.PatternDetector) { h.stateMu.Lock() h.patternDetector = detector defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetPatternDetector(detector) } } // SetCorrelationDetector sets the correlation detector for multi-resource correlation func (h *AISettingsHandler) SetCorrelationDetector(detector *ai.CorrelationDetector) { h.stateMu.Lock() h.correlationDetector = detector defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetCorrelationDetector(detector) } } // SetCircuitBreaker sets the circuit breaker for the default org. func (h *AISettingsHandler) SetCircuitBreaker(breaker *circuit.Breaker) { h.SetCircuitBreakerForOrg("default", breaker) } // SetCircuitBreakerForOrg sets the circuit breaker for an org. func (h *AISettingsHandler) SetCircuitBreakerForOrg(orgID string, breaker *circuit.Breaker) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if breaker == nil { delete(h.circuitBreakers, orgID) } else { h.circuitBreakers[orgID] = breaker } h.intelligenceMu.Unlock() if orgID == "default" { h.circuitBreaker = breaker } } // GetCircuitBreaker returns the circuit breaker for the default org. func (h *AISettingsHandler) GetCircuitBreaker() *circuit.Breaker { return h.GetCircuitBreakerForOrg("default") } // GetCircuitBreakerForOrg returns the circuit breaker for an org. func (h *AISettingsHandler) GetCircuitBreakerForOrg(orgID string) *circuit.Breaker { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if breaker := h.circuitBreakers[orgID]; breaker != nil { h.intelligenceMu.RUnlock() return breaker } h.intelligenceMu.RUnlock() if orgID == "default" { return h.circuitBreaker } return nil } // SetLearningStore sets the learning store for the default org. func (h *AISettingsHandler) SetLearningStore(store *learning.LearningStore) { h.SetLearningStoreForOrg("default", store) } // SetLearningStoreForOrg sets the learning store for an org. func (h *AISettingsHandler) SetLearningStoreForOrg(orgID string, store *learning.LearningStore) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if store == nil { delete(h.learningStores, orgID) } else { h.learningStores[orgID] = store } h.intelligenceMu.Unlock() if orgID == "default" { h.learningStore = store } } // GetLearningStore returns the learning store for the default org. func (h *AISettingsHandler) GetLearningStore() *learning.LearningStore { return h.GetLearningStoreForOrg("default") } // GetLearningStoreForOrg returns the learning store for an org. func (h *AISettingsHandler) GetLearningStoreForOrg(orgID string) *learning.LearningStore { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if store := h.learningStores[orgID]; store != nil { h.intelligenceMu.RUnlock() return store } h.intelligenceMu.RUnlock() if orgID == "default" { return h.learningStore } return nil } // SetForecastService sets the forecast service for the default org. func (h *AISettingsHandler) SetForecastService(svc *forecast.Service) { h.SetForecastServiceForOrg("default", svc) } // SetForecastServiceForOrg sets the forecast service for an org. func (h *AISettingsHandler) SetForecastServiceForOrg(orgID string, svc *forecast.Service) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if svc == nil { delete(h.forecastServices, orgID) } else { h.forecastServices[orgID] = svc } h.intelligenceMu.Unlock() if orgID == "default" { h.forecastService = svc } } // GetForecastService returns the forecast service for the default org. func (h *AISettingsHandler) GetForecastService() *forecast.Service { return h.GetForecastServiceForOrg("default") } // GetForecastServiceForOrg returns the forecast service for an org. func (h *AISettingsHandler) GetForecastServiceForOrg(orgID string) *forecast.Service { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if svc := h.forecastServices[orgID]; svc != nil { h.intelligenceMu.RUnlock() return svc } h.intelligenceMu.RUnlock() if orgID == "default" { return h.forecastService } return nil } func normalizeAIIntelligenceOrgID(orgID string) string { orgID = strings.TrimSpace(orgID) if orgID == "" { return "default" } return orgID } func (h *AISettingsHandler) ensureIntelligenceMapsLocked() { if h.proxmoxCorrelators == nil { h.proxmoxCorrelators = make(map[string]*proxmox.EventCorrelator) } if h.learningStores == nil { h.learningStores = make(map[string]*learning.LearningStore) } if h.forecastServices == nil { h.forecastServices = make(map[string]*forecast.Service) } if h.remediationEngines == nil { h.remediationEngines = make(map[string]aicontracts.RemediationEngine) } if h.incidentStores == nil { h.incidentStores = make(map[string]*memory.IncidentStore) } if h.circuitBreakers == nil { h.circuitBreakers = make(map[string]*circuit.Breaker) } if h.discoveryStores == nil { h.discoveryStores = make(map[string]*servicediscovery.Store) } if h.alertBridges == nil { h.alertBridges = make(map[string]*unified.AlertBridge) } if h.unifiedStores == nil { h.unifiedStores = make(map[string]*unified.UnifiedStore) } if h.triggerManagers == nil { h.triggerManagers = make(map[string]*ai.TriggerManager) } if h.incidentCoordinators == nil { h.incidentCoordinators = make(map[string]*ai.IncidentCoordinator) } if h.incidentRecorders == nil { h.incidentRecorders = make(map[string]*metrics.IncidentRecorder) } } // SetProxmoxCorrelatorForOrg sets the Proxmox event correlator for an org. func (h *AISettingsHandler) SetProxmoxCorrelatorForOrg(orgID string, correlator *proxmox.EventCorrelator) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if correlator == nil { delete(h.proxmoxCorrelators, orgID) } else { h.proxmoxCorrelators[orgID] = correlator } h.intelligenceMu.Unlock() if orgID == "default" { h.proxmoxCorrelator = correlator } } // SetProxmoxCorrelator sets the Proxmox event correlator func (h *AISettingsHandler) SetProxmoxCorrelator(correlator *proxmox.EventCorrelator) { h.SetProxmoxCorrelatorForOrg("default", correlator) } // GetProxmoxCorrelatorForOrg returns the Proxmox event correlator for an org. func (h *AISettingsHandler) GetProxmoxCorrelatorForOrg(orgID string) *proxmox.EventCorrelator { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if correlator := h.proxmoxCorrelators[orgID]; correlator != nil { h.intelligenceMu.RUnlock() return correlator } h.intelligenceMu.RUnlock() if orgID == "default" { return h.proxmoxCorrelator } return nil } // GetProxmoxCorrelator returns the Proxmox event correlator func (h *AISettingsHandler) GetProxmoxCorrelator() *proxmox.EventCorrelator { return h.GetProxmoxCorrelatorForOrg("default") } // SetRemediationEngine sets the remediation engine for the default org. func (h *AISettingsHandler) SetRemediationEngine(engine aicontracts.RemediationEngine) { h.SetRemediationEngineForOrg("default", engine) } // SetRemediationEngineForOrg sets the remediation engine for an org. func (h *AISettingsHandler) SetRemediationEngineForOrg(orgID string, engine aicontracts.RemediationEngine) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if engine == nil { delete(h.remediationEngines, orgID) } else { h.remediationEngines[orgID] = engine } h.intelligenceMu.Unlock() if orgID == "default" { h.remediationEngine = engine } } // GetRemediationEngine returns the remediation engine for the default org. func (h *AISettingsHandler) GetRemediationEngine() aicontracts.RemediationEngine { return h.GetRemediationEngineForOrg("default") } // GetRemediationEngineForOrg returns the remediation engine for an org. func (h *AISettingsHandler) GetRemediationEngineForOrg(orgID string) aicontracts.RemediationEngine { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if engine := h.remediationEngines[orgID]; engine != nil { h.intelligenceMu.RUnlock() return engine } h.intelligenceMu.RUnlock() if orgID == "default" { return h.remediationEngine } return nil } // SetUnifiedStore sets the unified store for the default org. func (h *AISettingsHandler) SetUnifiedStore(store *unified.UnifiedStore) { h.SetUnifiedStoreForOrg("default", store) } // SetUnifiedStoreForOrg sets the unified store for an org. func (h *AISettingsHandler) SetUnifiedStoreForOrg(orgID string, store *unified.UnifiedStore) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if store == nil { delete(h.unifiedStores, orgID) } else { h.unifiedStores[orgID] = store } h.intelligenceMu.Unlock() if orgID == "default" { h.unifiedStore = store } } // GetUnifiedStore returns the unified store for the default org. func (h *AISettingsHandler) GetUnifiedStore() *unified.UnifiedStore { return h.GetUnifiedStoreForOrg("default") } // GetUnifiedStoreForOrg returns the unified store for an org. func (h *AISettingsHandler) GetUnifiedStoreForOrg(orgID string) *unified.UnifiedStore { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if store := h.unifiedStores[orgID]; store != nil { h.intelligenceMu.RUnlock() return store } h.intelligenceMu.RUnlock() if orgID == "default" { return h.unifiedStore } return nil } // SetDiscoveryStore sets the discovery store for the default org. func (h *AISettingsHandler) SetDiscoveryStore(store *servicediscovery.Store) { h.SetDiscoveryStoreForOrg("default", store) } // SetDiscoveryStoreForOrg sets the discovery store for deep infrastructure discovery for an org. func (h *AISettingsHandler) SetDiscoveryStoreForOrg(orgID string, store *servicediscovery.Store) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if store == nil { delete(h.discoveryStores, orgID) } else { h.discoveryStores[orgID] = store } h.intelligenceMu.Unlock() if orgID == "default" { h.stateMu.Lock() h.discoveryStore = store defaultAIService := h.defaultAIService h.stateMu.Unlock() if defaultAIService != nil { defaultAIService.SetDiscoveryStore(store) } } h.aiServicesMu.RLock() svc := h.aiServices[orgID] h.aiServicesMu.RUnlock() if svc != nil { svc.SetDiscoveryStore(store) } } // GetDiscoveryStore returns the discovery store for the default org. func (h *AISettingsHandler) GetDiscoveryStore() *servicediscovery.Store { return h.GetDiscoveryStoreForOrg("default") } // GetDiscoveryStoreForOrg returns the discovery store for an org. func (h *AISettingsHandler) GetDiscoveryStoreForOrg(orgID string) *servicediscovery.Store { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if store := h.discoveryStores[orgID]; store != nil { h.intelligenceMu.RUnlock() return store } h.intelligenceMu.RUnlock() if orgID == "default" { h.stateMu.RLock() store := h.discoveryStore h.stateMu.RUnlock() return store } return nil } // SetAlertBridge sets the alert bridge func (h *AISettingsHandler) SetAlertBridge(bridge *unified.AlertBridge) { h.SetAlertBridgeForOrg("default", bridge) } // SetAlertBridgeForOrg sets the alert bridge for an org. func (h *AISettingsHandler) SetAlertBridgeForOrg(orgID string, bridge *unified.AlertBridge) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if bridge == nil { delete(h.alertBridges, orgID) } else { h.alertBridges[orgID] = bridge } h.intelligenceMu.Unlock() if orgID == "default" { h.alertBridge = bridge } } // GetAlertBridge returns the alert bridge func (h *AISettingsHandler) GetAlertBridge() *unified.AlertBridge { return h.GetAlertBridgeForOrg("default") } // GetAlertBridgeForOrg returns the alert bridge for an org. func (h *AISettingsHandler) GetAlertBridgeForOrg(orgID string) *unified.AlertBridge { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if bridge := h.alertBridges[orgID]; bridge != nil { h.intelligenceMu.RUnlock() return bridge } h.intelligenceMu.RUnlock() if orgID == "default" { return h.alertBridge } return nil } // SetTriggerManager sets the event-driven patrol trigger manager func (h *AISettingsHandler) SetTriggerManager(tm *ai.TriggerManager) { h.SetTriggerManagerForOrg("default", tm) } // SetTriggerManagerForOrg sets the event-driven patrol trigger manager for an org. func (h *AISettingsHandler) SetTriggerManagerForOrg(orgID string, tm *ai.TriggerManager) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if tm == nil { delete(h.triggerManagers, orgID) } else { h.triggerManagers[orgID] = tm } h.intelligenceMu.Unlock() if orgID == "default" { h.triggerManager = tm } } // GetTriggerManager returns the event-driven patrol trigger manager func (h *AISettingsHandler) GetTriggerManager() *ai.TriggerManager { return h.GetTriggerManagerForOrg("default") } // GetTriggerManagerForOrg returns the event-driven patrol trigger manager for an org. func (h *AISettingsHandler) GetTriggerManagerForOrg(orgID string) *ai.TriggerManager { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if tm := h.triggerManagers[orgID]; tm != nil { h.intelligenceMu.RUnlock() return tm } h.intelligenceMu.RUnlock() if orgID == "default" { return h.triggerManager } return nil } // SetIncidentCoordinator sets the incident recording coordinator func (h *AISettingsHandler) SetIncidentCoordinator(coordinator *ai.IncidentCoordinator) { h.SetIncidentCoordinatorForOrg("default", coordinator) } // SetIncidentCoordinatorForOrg sets the incident recording coordinator for an org. func (h *AISettingsHandler) SetIncidentCoordinatorForOrg(orgID string, coordinator *ai.IncidentCoordinator) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if coordinator == nil { delete(h.incidentCoordinators, orgID) } else { h.incidentCoordinators[orgID] = coordinator } h.intelligenceMu.Unlock() if orgID == "default" { h.incidentCoordinator = coordinator } } // GetIncidentCoordinator returns the incident recording coordinator func (h *AISettingsHandler) GetIncidentCoordinator() *ai.IncidentCoordinator { return h.GetIncidentCoordinatorForOrg("default") } // GetIncidentCoordinatorForOrg returns the incident recording coordinator for an org. func (h *AISettingsHandler) GetIncidentCoordinatorForOrg(orgID string) *ai.IncidentCoordinator { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if coordinator := h.incidentCoordinators[orgID]; coordinator != nil { h.intelligenceMu.RUnlock() return coordinator } h.intelligenceMu.RUnlock() if orgID == "default" { return h.incidentCoordinator } return nil } // SetIncidentRecorder sets the high-frequency incident recorder func (h *AISettingsHandler) SetIncidentRecorder(recorder *metrics.IncidentRecorder) { h.SetIncidentRecorderForOrg("default", recorder) } // SetIncidentRecorderForOrg sets the high-frequency incident recorder for an org. func (h *AISettingsHandler) SetIncidentRecorderForOrg(orgID string, recorder *metrics.IncidentRecorder) { if h == nil { return } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.Lock() h.ensureIntelligenceMapsLocked() if recorder == nil { delete(h.incidentRecorders, orgID) } else { h.incidentRecorders[orgID] = recorder } h.intelligenceMu.Unlock() if orgID == "default" { h.incidentRecorder = recorder } } // GetIncidentRecorder returns the high-frequency incident recorder func (h *AISettingsHandler) GetIncidentRecorder() *metrics.IncidentRecorder { return h.GetIncidentRecorderForOrg("default") } // GetIncidentRecorderForOrg returns the high-frequency incident recorder for an org. func (h *AISettingsHandler) GetIncidentRecorderForOrg(orgID string) *metrics.IncidentRecorder { if h == nil { return nil } orgID = normalizeAIIntelligenceOrgID(orgID) h.intelligenceMu.RLock() if recorder := h.incidentRecorders[orgID]; recorder != nil { h.intelligenceMu.RUnlock() return recorder } h.intelligenceMu.RUnlock() if orgID == "default" { return h.incidentRecorder } return nil } // ListLearningStores returns learning stores keyed by org. func (h *AISettingsHandler) ListLearningStores() map[string]*learning.LearningStore { out := make(map[string]*learning.LearningStore) if h == nil { return out } h.intelligenceMu.RLock() for orgID, store := range h.learningStores { if store != nil { out[orgID] = store } } h.intelligenceMu.RUnlock() if _, ok := out["default"]; !ok && h.learningStore != nil { out["default"] = h.learningStore } return out } // ListAlertBridges returns alert bridges keyed by org for shutdown/cleanup flows. func (h *AISettingsHandler) ListAlertBridges() map[string]*unified.AlertBridge { out := make(map[string]*unified.AlertBridge) if h == nil { return out } h.intelligenceMu.RLock() for orgID, bridge := range h.alertBridges { if bridge != nil { out[orgID] = bridge } } h.intelligenceMu.RUnlock() if _, ok := out["default"]; !ok && h.alertBridge != nil { out["default"] = h.alertBridge } return out } // ListTriggerManagers returns trigger managers keyed by org for shutdown/cleanup flows. func (h *AISettingsHandler) ListTriggerManagers() map[string]*ai.TriggerManager { out := make(map[string]*ai.TriggerManager) if h == nil { return out } h.intelligenceMu.RLock() for orgID, tm := range h.triggerManagers { if tm != nil { out[orgID] = tm } } h.intelligenceMu.RUnlock() if _, ok := out["default"]; !ok && h.triggerManager != nil { out["default"] = h.triggerManager } return out } // ListIncidentCoordinators returns incident coordinators keyed by org. func (h *AISettingsHandler) ListIncidentCoordinators() map[string]*ai.IncidentCoordinator { out := make(map[string]*ai.IncidentCoordinator) if h == nil { return out } h.intelligenceMu.RLock() for orgID, coordinator := range h.incidentCoordinators { if coordinator != nil { out[orgID] = coordinator } } h.intelligenceMu.RUnlock() if _, ok := out["default"]; !ok && h.incidentCoordinator != nil { out["default"] = h.incidentCoordinator } return out } // ListIncidentRecorders returns incident recorders keyed by org. func (h *AISettingsHandler) ListIncidentRecorders() map[string]*metrics.IncidentRecorder { out := make(map[string]*metrics.IncidentRecorder) if h == nil { return out } h.intelligenceMu.RLock() for orgID, recorder := range h.incidentRecorders { if recorder != nil { out[orgID] = recorder } } h.intelligenceMu.RUnlock() if _, ok := out["default"]; !ok && h.incidentRecorder != nil { out["default"] = h.incidentRecorder } return out } // StopPatrol stops the background AI patrol service func (h *AISettingsHandler) StopPatrol() { if h.defaultAIService != nil { h.defaultAIService.StopPatrol() } h.aiServicesMu.RLock() services := make([]*ai.Service, 0, len(h.aiServices)) for _, svc := range h.aiServices { if svc != nil { services = append(services, svc) } } h.aiServicesMu.RUnlock() for _, svc := range services { svc.StopPatrol() } } // StopServices stops all AI services owned by the handler, including their // debounced persistence timers and background discovery/patrol workers. func (h *AISettingsHandler) StopServices() { if h == nil { return } if h.defaultAIService != nil { h.defaultAIService.Stop() } h.aiServicesMu.RLock() services := make([]*ai.Service, 0, len(h.aiServices)) for _, svc := range h.aiServices { if svc != nil { services = append(services, svc) } } h.aiServicesMu.RUnlock() for _, svc := range services { svc.Stop() } } // StopPatrolForOrg stops patrol for a single org without affecting others. func (h *AISettingsHandler) StopPatrolForOrg(orgID string) { orgID = normalizeAIIntelligenceOrgID(orgID) if orgID == "default" { if h.defaultAIService != nil { h.defaultAIService.StopPatrol() } return } h.aiServicesMu.RLock() svc := h.aiServices[orgID] h.aiServicesMu.RUnlock() if svc != nil { svc.StopPatrol() } } // RemoveTenantIntelligence stops and removes org-scoped intelligence components. func (h *AISettingsHandler) RemoveTenantIntelligence(orgID string) { orgID = normalizeAIIntelligenceOrgID(orgID) if orgID == "default" { return } var ( bridge *unified.AlertBridge trigger *ai.TriggerManager coordinator *ai.IncidentCoordinator recorder *metrics.IncidentRecorder ) h.intelligenceMu.Lock() delete(h.learningStores, orgID) delete(h.forecastServices, orgID) delete(h.remediationEngines, orgID) delete(h.incidentStores, orgID) delete(h.circuitBreakers, orgID) delete(h.discoveryStores, orgID) bridge = h.alertBridges[orgID] trigger = h.triggerManagers[orgID] coordinator = h.incidentCoordinators[orgID] recorder = h.incidentRecorders[orgID] delete(h.unifiedStores, orgID) delete(h.alertBridges, orgID) delete(h.triggerManagers, orgID) delete(h.incidentCoordinators, orgID) delete(h.incidentRecorders, orgID) delete(h.proxmoxCorrelators, orgID) h.intelligenceMu.Unlock() if bridge != nil { bridge.Stop() } if trigger != nil { trigger.Stop() } if coordinator != nil { coordinator.Stop() } if recorder != nil { recorder.Stop() } } // GetAlertTriggeredAnalyzer returns the alert-triggered analyzer for wiring into alert callbacks func (h *AISettingsHandler) GetAlertTriggeredAnalyzer(ctx context.Context) aicontracts.AlertAnalyzer { return h.GetAIService(ctx).GetAlertTriggeredAnalyzer() } // SetLicenseHandlers sets the license handlers for Pro feature gating func (h *AISettingsHandler) SetLicenseHandlers(handlers *LicenseHandlers) { h.stateMu.Lock() h.licenseHandlers = handlers defaultAIService := h.defaultAIService h.stateMu.Unlock() if handlers == nil { return } // Update default-org service with the current license checker. // We can try to get it using background context (default tenant). if svc, _, err := handlers.getTenantComponents(context.Background()); err == nil { if defaultAIService != nil { defaultAIService.SetLicenseChecker(svc) } } } // SetOnModelChange sets a callback to be invoked when model settings change // Used by Router to trigger AI chat service restart func (h *AISettingsHandler) SetOnModelChange(callback func()) { h.onModelChange = callback } func shouldRestartAIChat(req AISettingsUpdateRequest) bool { return req.Enabled != nil || req.Model != nil || req.ChatModel != nil || req.PatrolModel != nil || req.AutoFixModel != nil || req.AuthMethod != nil || req.AnthropicAPIKey != nil || req.OpenAIAPIKey != nil || req.OpenRouterAPIKey != nil || req.DeepSeekAPIKey != nil || req.GeminiAPIKey != nil || req.OllamaBaseURL != nil || req.OllamaUsername != nil || req.OllamaPassword != nil || req.OpenAIBaseURL != nil || req.ClearAnthropicKey != nil || req.ClearOpenAIKey != nil || req.ClearOpenRouterKey != nil || req.ClearDeepSeekKey != nil || req.ClearGeminiKey != nil || req.ClearOllamaURL != nil || req.ClearOllamaUsername != nil || req.ClearOllamaPassword != nil } // aiSettingsUpdateRequiresPatrolPreflight reports whether the settings // transition from oldCfg to newCfg warrants an automatic Patrol // tool-call preflight. We only auto-preflight when the configuration // that affects Patrol's transport actually moved, so routine settings // saves (theme, control level, discovery toggles, etc.) don't burn // provider tokens or add 5-10s of latency to every save. // // Triggers preflight when: // - assistant is now enabled and a Patrol model is selected, AND // - either the Patrol model changed, the new patrol model's provider // API key changed, OR there was no prior config (first save). func aiSettingsUpdateRequiresPatrolPreflight(oldCfg, newCfg *config.AIConfig) bool { if newCfg == nil || !newCfg.Enabled { return false } newPatrolModel := strings.TrimSpace(newCfg.GetPatrolModel()) if newPatrolModel == "" { return false } if oldCfg == nil { return true } if !oldCfg.Enabled { return true } oldPatrolModel := strings.TrimSpace(oldCfg.GetPatrolModel()) if oldPatrolModel != newPatrolModel { return true } provider, _ := config.ParseModelString(newPatrolModel) if provider == "" { return false } return oldCfg.GetAPIKeyForProvider(provider) != newCfg.GetAPIKeyForProvider(provider) } func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool { return req.Enabled != nil || req.Model != nil || req.ChatModel != nil || req.PatrolModel != nil || req.PatrolEnabled != nil || req.PatrolIntervalMinutes != nil || req.AuthMethod != nil || req.AnthropicAPIKey != nil || req.OpenAIAPIKey != nil || req.OpenRouterAPIKey != nil || req.DeepSeekAPIKey != nil || req.GeminiAPIKey != nil || req.OllamaBaseURL != nil || req.OllamaUsername != nil || req.OllamaPassword != nil || req.OpenAIBaseURL != nil || req.ClearAnthropicKey != nil || req.ClearOpenAIKey != nil || req.ClearOpenRouterKey != nil || req.ClearDeepSeekKey != nil || req.ClearGeminiKey != nil || req.ClearOllamaURL != nil || req.ClearOllamaUsername != nil || req.ClearOllamaPassword != nil } // SetOnControlSettingsChange sets a callback to be invoked when control settings change // Used by Router to update MCP tool visibility without restarting AI chat func (h *AISettingsHandler) SetOnControlSettingsChange(callback func()) { h.onControlSettingsChange = callback } // EffectiveControlLevel returns the Assistant control level that should be // exposed or enforced for the current entitlement state. The stored setting can // remain autonomous so it comes back if the entitlement returns, but runtime // execution without ai_autofix must stay in approval mode. func (h *AISettingsHandler) EffectiveControlLevel(ctx context.Context, settings *config.AIConfig) string { if settings == nil { return config.ControlLevelReadOnly } return settings.GetEffectiveControlLevel( h.GetAIService(ctx).HasLicenseFeature(ai.FeatureAIAutoFix), ) } // SetChatHandler sets the chat handler for investigation orchestration // This enables the patrol service to spawn chat sessions to investigate findings func (h *AISettingsHandler) SetChatHandler(chatHandler *AIHandler) { h.stateMu.Lock() h.chatHandler = chatHandler defaultAIService := h.defaultAIService h.stateMu.Unlock() h.investigationMu.Lock() if h.investigationStores == nil { h.investigationStores = make(map[string]aicontracts.InvestigationStore) } h.investigationMu.Unlock() // Wire up orchestrator for the default-org service. // Note: This usually fails because chat service isn't started yet. // The orchestrator will be wired via WireOrchestratorAfterChatStart() instead. if defaultAIService != nil && isAIInvestigationEnabled() { h.setupInvestigationOrchestrator("default", defaultAIService) } // Wire up orchestrator for any existing services if isAIInvestigationEnabled() { h.aiServicesMu.RLock() for orgID, svc := range h.aiServices { h.setupInvestigationOrchestrator(orgID, svc) } h.aiServicesMu.RUnlock() } } // WireOrchestratorAfterChatStart is called after the chat service is started // to wire up the investigation orchestrator. This must be called after aiHandler.Start() // because the orchestrator needs an active chat service. func (h *AISettingsHandler) WireOrchestratorAfterChatStart() { if !isAIInvestigationEnabled() { log.Info().Msg("WireOrchestratorAfterChatStart skipped (requires Pulse Pro)") return } h.stateMu.RLock() hasChatHandler := h.chatHandler != nil defaultAIService := h.defaultAIService h.stateMu.RUnlock() if !hasChatHandler { log.Warn().Msg("WireOrchestratorAfterChatStart called but chatHandler is nil") return } // Wire up orchestrator for the default-org service. if defaultAIService != nil { h.setupInvestigationOrchestrator("default", defaultAIService) } // Wire up orchestrator for any existing services h.aiServicesMu.RLock() for orgID, svc := range h.aiServices { h.setupInvestigationOrchestrator(orgID, svc) } h.aiServicesMu.RUnlock() } // setupInvestigationOrchestrator creates and wires the investigation orchestrator for an AI service func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai.Service) { // Check factory exists first — if nil, this is OSS (no orchestrator available). // Clear any stale orchestrator from a prior setup so the patrol service // doesn't keep using a removed enterprise component. factory := getCreateInvestigationOrchestrator() if factory == nil { if patrol := svc.GetPatrolService(); patrol != nil { patrol.SetInvestigationOrchestrator(nil) } log.Debug().Str("orgID", orgID).Msg("Investigation orchestrator factory not registered (requires Pulse Pro)") return } h.stateMu.RLock() chatHandler := h.chatHandler h.stateMu.RUnlock() if chatHandler == nil { log.Debug().Str("orgID", orgID).Msg("Chat handler not set, skipping orchestrator setup") return } patrol := svc.GetPatrolService() if patrol == nil { log.Debug().Str("orgID", orgID).Msg("Patrol service not available, skipping orchestrator setup") return } // Get or create investigation store for this org h.investigationMu.Lock() store, exists := h.investigationStores[orgID] if !exists { // Get data directory from persistence var dataDir string mtPersistence, _, _, defaultPersistence, _, _ := h.stateRefs() if defaultPersistence != nil && orgID == "default" { dataDir = defaultPersistence.DataDir() } else if mtPersistence != nil { if p, err := mtPersistence.GetPersistence(orgID); err == nil { dataDir = p.DataDir() } } if storeFactory := getCreateInvestigationStore(); storeFactory != nil { store = storeFactory(dataDir) } if store == nil { log.Warn().Str("orgID", orgID).Msg("Investigation store not available (requires Pulse Pro)") h.investigationMu.Unlock() return } if err := store.LoadFromDisk(); err != nil { log.Warn().Err(err).Str("orgID", orgID).Msg("Failed to load investigation store") } h.investigationStores[orgID] = store } h.investigationMu.Unlock() // Get chat service for this org using org-scoped context ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID) chatSvc := chatHandler.GetService(ctx) if chatSvc == nil { log.Warn().Str("orgID", orgID).Msg("Chat service not available for orchestrator") return } // Create chat adapter - need to cast to *chat.Service chatService, ok := chatSvc.(*chat.Service) if !ok { log.Warn().Str("orgID", orgID).Msg("Chat service is not *chat.Service, cannot create adapter") return } // Mirror default-org router wiring for per-org services so patrol/investigation // executions always use the chat backend path with mid-run budget enforcement. svc.SetChatService(&chatServiceAdapter{svc: chatService}) chatService.SetBudgetChecker(func() error { return svc.CheckBudget("patrol") }) // Build local adapters that implement aicontracts.Orchestrator* interfaces chatAdapter := &orchestratorChatAdapter{svc: chatService} // Create findings store adapter findingsStore := patrol.GetFindings() if findingsStore == nil { log.Warn().Str("orgID", orgID).Msg("Findings store not available for orchestrator") return } findingsAdapter := &orchestratorFindingsAdapter{store: &findingsStoreWrapper{store: findingsStore}} // Create approval adapter from the global approval store var approvalAdapter aicontracts.OrchestratorApprovalStore if approvalStoreInst := approval.GetStore(); approvalStoreInst != nil { approvalAdapter = &orchestratorApprovalAdapter{ store: approvalStoreInst, orgID: approval.NormalizeOrgID(orgID), actionAuditStore: chatService.GetActionAuditStore(), } } // Get config for investigation settings cfg := svc.GetConfig() invConfig := aicontracts.DefaultInvestigationConfig() if cfg != nil { invConfig.MaxTurns = cfg.GetPatrolInvestigationBudget() invConfig.Timeout = cfg.GetPatrolInvestigationTimeout() } // Wire up discovery context to the knowledge store for infrastructure context var infraContext aicontracts.OrchestratorInfraContextProvider if knowledgeStore := svc.GetKnowledgeStore(); knowledgeStore != nil { if discoveryService := svc.GetDiscoveryService(); discoveryService != nil { knowledgeStore.SetDiscoveryContextProvider(func() string { discoveries, err := discoveryService.ListDiscoveries() if err != nil || len(discoveries) == 0 { return "" } return servicediscovery.FormatForAIContext(discoveries) }) knowledgeStore.SetDiscoveryContextProviderForResources(func(resourceIDs []string) string { if len(resourceIDs) == 0 { return "" } discoveries, err := discoveryService.ListDiscoveries() if err != nil || len(discoveries) == 0 { return "" } filtered := servicediscovery.FilterDiscoveriesByResourceIDs(discoveries, resourceIDs) return servicediscovery.FormatForAIContext(filtered) }) } infraContext = knowledgeStore } // Build deps struct and call factory deps := aicontracts.OrchestratorDeps{ ChatService: chatAdapter, CmdExecutor: chatAdapter, // ChatServiceAdapter implements both interfaces Store: store, FindingsStore: findingsAdapter, ApprovalStore: approvalAdapter, Config: invConfig, InfraContext: infraContext, Autonomy: &autonomyLevelProviderAdapter{svc: svc}, FixVerifier: &patrolFixVerifierAdapter{patrol: patrol}, License: &licenseCheckerForOrchestrator{svc: svc}, Metrics: &patrolMetricsCallbackAdapter{}, } orchestrator := factory(deps) if orchestrator == nil { log.Warn().Str("orgID", orgID).Msg("Investigation orchestrator factory returned nil") patrol.SetInvestigationOrchestrator(nil) return } // Set directly on patrol service — factory returns the interface patrol.SetInvestigationOrchestrator(orchestrator) log.Info().Str("orgID", orgID).Msg("Investigation orchestrator configured for patrol service") } // --------------------------------------------------------------------------- // Local adapters — bridge between OSS singletons and aicontracts interfaces // --------------------------------------------------------------------------- // orchestratorChatAdapter wraps *chat.Service to implement // aicontracts.OrchestratorChatService and OrchestratorCommandExecutor. type orchestratorChatAdapter struct { svc *chat.Service } func (a *orchestratorChatAdapter) CreateSession(ctx context.Context) (*aicontracts.OrchestratorChatSession, error) { session, err := a.svc.CreateSession(ctx) if err != nil { return nil, err } return &aicontracts.OrchestratorChatSession{ID: session.ID}, nil } func (a *orchestratorChatAdapter) ExecuteStream(ctx context.Context, req aicontracts.OrchestratorExecuteRequest, callback aicontracts.OrchestratorStreamCallback) error { if a.svc == nil { return fmt.Errorf("chat service is nil") } if !a.svc.IsRunning() { return fmt.Errorf("chat service is not running") } chatReq := chat.ExecuteRequest{ Prompt: req.Prompt, SessionID: req.SessionID, MaxTurns: req.MaxTurns, AutonomousMode: req.AutonomousMode, } return a.svc.ExecuteStream(ctx, chatReq, func(event chat.StreamEvent) { callback(aicontracts.OrchestratorStreamEvent{ Type: event.Type, Data: event.Data, }) }) } func (a *orchestratorChatAdapter) GetMessages(ctx context.Context, sessionID string) ([]aicontracts.OrchestratorMessage, error) { chatMessages, err := a.svc.GetMessages(ctx, sessionID) if err != nil { return nil, err } messages := make([]aicontracts.OrchestratorMessage, len(chatMessages)) for i, msg := range chatMessages { m := aicontracts.OrchestratorMessage{ ID: msg.ID, Role: msg.Role, Content: msg.Content, ReasoningContent: msg.ReasoningContent, Timestamp: msg.Timestamp, } for _, tc := range msg.ToolCalls { m.ToolCalls = append(m.ToolCalls, aicontracts.OrchestratorToolCallInfo{ ID: tc.ID, Name: tc.Name, Input: tc.Input, }) } if msg.ToolResult != nil { m.ToolResult = &aicontracts.OrchestratorToolResultInfo{ ToolUseID: msg.ToolResult.ToolUseID, Content: msg.ToolResult.Content, IsError: msg.ToolResult.IsError, } } messages[i] = m.NormalizeCollections() } return messages, nil } func (a *orchestratorChatAdapter) DeleteSession(ctx context.Context, sessionID string) error { return a.svc.DeleteSession(ctx, sessionID) } func (a *orchestratorChatAdapter) ListAvailableTools(ctx context.Context, prompt string) []string { if a.svc == nil { return nil } return a.svc.ListAvailableTools(ctx, prompt) } func (a *orchestratorChatAdapter) SetAutonomousMode(enabled bool) { if a.svc != nil { a.svc.SetAutonomousMode(enabled) } } func (a *orchestratorChatAdapter) ExecuteCommand(ctx context.Context, command, targetHost string) (string, int, error) { if a.svc == nil { return "", -1, fmt.Errorf("chat service not available") } return a.svc.ExecuteCommand(ctx, command, targetHost) } // orchestratorFindingsAdapter wraps findingsStoreWrapper to implement // aicontracts.OrchestratorFindingsStore. type orchestratorFindingsAdapter struct { store *findingsStoreWrapper } func (a *orchestratorFindingsAdapter) Get(id string) *aicontracts.Finding { if a.store == nil { return nil } f := a.store.Get(id) if f == nil { return nil } return &aicontracts.Finding{ ID: f.GetID(), Severity: f.GetSeverity(), Category: f.GetCategory(), ResourceID: f.GetResourceID(), ResourceName: f.GetResourceName(), ResourceType: f.GetResourceType(), Title: f.GetTitle(), Description: f.GetDescription(), Recommendation: f.GetRecommendation(), Evidence: f.GetEvidence(), InvestigationSessionID: f.GetInvestigationSessionID(), InvestigationStatus: f.GetInvestigationStatus(), InvestigationOutcome: f.GetInvestigationOutcome(), LastInvestigatedAt: f.GetLastInvestigatedAt(), InvestigationAttempts: f.GetInvestigationAttempts(), } } func (a *orchestratorFindingsAdapter) Update(f *aicontracts.Finding) bool { if a.store == nil || f == nil { return false } return a.store.UpdateInvestigation( f.ID, f.InvestigationSessionID, f.InvestigationStatus, f.InvestigationOutcome, f.LastInvestigatedAt, f.InvestigationAttempts, ) } // orchestratorApprovalAdapter wraps *approval.Store to implement // aicontracts.OrchestratorApprovalStore. type orchestratorApprovalAdapter struct { store *approval.Store orgID string actionAuditStore unifiedresources.ResourceStore } func (a *orchestratorApprovalAdapter) Create(appr *aicontracts.OrchestratorApproval) error { if a.store == nil { return nil } riskLevel := approval.RiskLow switch appr.RiskLevel { case "low": riskLevel = approval.RiskLow case "medium": riskLevel = approval.RiskMedium case "high", "critical": riskLevel = approval.RiskHigh } req := &approval.ApprovalRequest{ OrgID: a.orgID, ID: appr.ID, ToolID: "investigation_fix", Command: appr.Command, TargetType: "investigation", TargetID: appr.FindingID, TargetName: strings.TrimSpace(appr.TargetHost), Context: "Automated fix from patrol investigation: " + appr.Description, RiskLevel: riskLevel, } if req.TargetName == "" { req.TargetName = appr.Description } tools.AttachApprovalActionPlan(req, time.Now().UTC()) if err := a.store.CreateApproval(req); err != nil { return err } tools.RecordPendingApprovalAction(a.actionAuditStore, req) return nil } // patrolFixVerifierAdapter wraps *ai.PatrolService to implement // aicontracts.OrchestratorFixVerifier. type patrolFixVerifierAdapter struct { patrol *ai.PatrolService } func (v *patrolFixVerifierAdapter) VerifyFixResolved(ctx context.Context, finding *aicontracts.Finding) (bool, error) { return v.patrol.VerifyFixResolved(ctx, finding.ResourceID, finding.ResourceType, finding.Key, finding.ID) } // patrolMetricsCallbackAdapter implements aicontracts.OrchestratorMetricsCallback // by delegating to the global PatrolMetrics singleton. type patrolMetricsCallbackAdapter struct{} func (c *patrolMetricsCallbackAdapter) RecordInvestigationOutcome(outcome string) { ai.GetPatrolMetrics().RecordInvestigationOutcome(outcome) } func (c *patrolMetricsCallbackAdapter) RecordFixVerification(result string) { ai.GetPatrolMetrics().RecordFixVerification(result) } // licenseCheckerForOrchestrator adapts *ai.Service to aicontracts.OrchestratorLicenseChecker type licenseCheckerForOrchestrator struct { svc *ai.Service } func (l *licenseCheckerForOrchestrator) HasFeature(feature string) bool { return l.svc.HasLicenseFeature(feature) } // findingsStoreWrapper wraps *ai.FindingsStore to implement aicontracts.OrchestratorAIFindingsStore type findingsStoreWrapper struct { store *ai.FindingsStore } func (w *findingsStoreWrapper) Get(id string) aicontracts.OrchestratorAIFinding { if w.store == nil { return nil } f := w.store.Get(id) if f == nil { return nil } return f } func (w *findingsStoreWrapper) UpdateInvestigation(id, sessionID, status, outcome string, lastInvestigatedAt *time.Time, attempts int) bool { if w.store == nil { return false } return w.store.UpdateInvestigation(id, sessionID, status, outcome, lastInvestigatedAt, attempts) } // autonomyLevelProviderAdapter provides current autonomy level from config for re-checking before fix execution type autonomyLevelProviderAdapter struct { svc *ai.Service } func (a *autonomyLevelProviderAdapter) GetCurrentAutonomyLevel() string { if a.svc == nil { return config.PatrolAutonomyMonitor } return a.svc.GetEffectivePatrolAutonomyLevel() } func (a *autonomyLevelProviderAdapter) IsFullModeUnlocked() bool { if a.svc == nil { return false } cfg := a.svc.GetConfig() if cfg == nil { return false } return cfg.PatrolFullModeUnlocked } // AISettingsResponse is returned by GET /api/settings/ai // API keys are masked for security type AISettingsResponse struct { Enabled bool `json:"enabled"` Model string `json:"model"` ChatModel string `json:"chat_model,omitempty"` // Model for interactive chat (empty = use default) PatrolModel string `json:"patrol_model,omitempty"` // Model for patrol (empty = use default) AutoFixModel string `json:"auto_fix_model,omitempty"` // Model for auto-fix (empty = use patrol model) Configured bool `json:"configured"` // true if AI is ready to use CustomContext string `json:"custom_context"` // user-provided infrastructure context // OAuth fields for Claude Pro/Max subscription authentication AuthMethod string `json:"auth_method"` // "api_key" or "oauth" OAuthConnected bool `json:"oauth_connected"` // true if OAuth tokens are configured // Patrol settings for token efficiency PatrolIntervalMinutes int `json:"patrol_interval_minutes"` // Patrol interval in minutes (0 = disabled) PatrolEnabled bool `json:"patrol_enabled"` // true if patrol is enabled PatrolAutoFix bool `json:"patrol_auto_fix"` // true if patrol can auto-fix issues AlertTriggeredAnalysis bool `json:"alert_triggered_analysis"` // true if AI analyzes when alerts fire PatrolEventTriggersEnabled bool `json:"patrol_event_triggers_enabled"` // Legacy aggregate flag; true when any scoped Patrol trigger source is enabled PatrolAlertTriggersEnabled bool `json:"patrol_alert_triggers_enabled"` // true if alert-driven scoped Patrol triggers are enabled PatrolAnomalyTriggersEnabled bool `json:"patrol_anomaly_triggers_enabled"` // true if anomaly-driven scoped Patrol triggers are enabled UseProactiveThresholds bool `json:"use_proactive_thresholds"` // true if patrol warns before thresholds (false = use exact thresholds) AvailableModels []providers.ModelInfo `json:"available_models"` // List of models for current provider // Multi-provider credentials - shows which providers are configured AnthropicConfigured bool `json:"anthropic_configured"` // true if Anthropic API key or OAuth is set OpenAIConfigured bool `json:"openai_configured"` // true if OpenAI API key is set OpenRouterConfigured bool `json:"openrouter_configured"` // true if OpenRouter API key is set DeepSeekConfigured bool `json:"deepseek_configured"` // true if DeepSeek API key is set GeminiConfigured bool `json:"gemini_configured"` // true if Gemini API key is set OllamaConfigured bool `json:"ollama_configured"` // true (always available for attempt) OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama OllamaPasswordSet bool `json:"ollama_password_set"` // true if an Ollama password is stored OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials // Cost controls CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"` // Request timeout (seconds) - for slow hardware running local models RequestTimeoutSeconds int `json:"request_timeout_seconds,omitempty"` // Infrastructure control settings ControlLevel string `json:"control_level"` // "read_only", "controlled", "autonomous" ProtectedGuests []string `json:"protected_guests"` // VMIDs/names that AI cannot control // Discovery settings DiscoveryEnabled bool `json:"discovery_enabled"` // true if discovery is enabled DiscoveryIntervalHours int `json:"discovery_interval_hours,omitempty"` // Hours between auto-scans (0 = manual only) // Current Patrol runtime readiness after this settings snapshot is applied. PatrolReadiness *PatrolReadinessResponse `json:"patrol_readiness,omitempty"` // Most recent Patrol tool-call preflight outcome, surfaced so the UI // can render a "last verified" indicator without forcing operators // to re-click Verify Patrol on every page load. nil when preflight // has never run on this service instance. PatrolPreflight *PatrolPreflightSnapshot `json:"patrol_preflight,omitempty"` } // PatrolPreflightSnapshot is the API-shaped projection of the cached // PatrolPreflightResult plus the wall-clock time it was recorded. type PatrolPreflightSnapshot struct { Success bool `json:"success"` Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` ToolCallObserved bool `json:"tool_call_observed"` DurationMs int64 `json:"duration_ms"` Cause string `json:"cause,omitempty"` Title string `json:"title,omitempty"` Summary string `json:"summary,omitempty"` Recommendation string `json:"recommendation,omitempty"` RecordedAt string `json:"recorded_at"` RecordedAtUnix int64 `json:"recorded_at_unix"` } func EmptyAISettingsResponse() AISettingsResponse { return AISettingsResponse{}.NormalizeCollections() } func (r AISettingsResponse) NormalizeCollections() AISettingsResponse { if r.AvailableModels == nil { r.AvailableModels = []providers.ModelInfo{} } if r.ConfiguredProviders == nil { r.ConfiguredProviders = []string{} } if r.ProtectedGuests == nil { r.ProtectedGuests = []string{} } return r } // AISettingsUpdateRequest is the request body for PUT /api/settings/ai type AISettingsUpdateRequest struct { Enabled *bool `json:"enabled,omitempty"` Model *string `json:"model,omitempty"` ChatModel *string `json:"chat_model,omitempty"` // Model for interactive chat PatrolModel *string `json:"patrol_model,omitempty"` // Model for background patrol AutoFixModel *string `json:"auto_fix_model,omitempty"` // Model for auto-fix remediation CustomContext *string `json:"custom_context,omitempty"` // user-provided infrastructure context AuthMethod *string `json:"auth_method,omitempty"` // "api_key" or "oauth" // Patrol settings for token efficiency PatrolIntervalMinutes *int `json:"patrol_interval_minutes,omitempty"` // Custom interval in minutes (0 = disabled, minimum 10) PatrolEnabled *bool `json:"patrol_enabled,omitempty"` // true if patrol is enabled PatrolAutoFix *bool `json:"patrol_auto_fix,omitempty"` // true if patrol can auto-fix issues AlertTriggeredAnalysis *bool `json:"alert_triggered_analysis,omitempty"` // true if AI analyzes when alerts fire PatrolEventTriggersEnabled *bool `json:"patrol_event_triggers_enabled,omitempty"` // Legacy aggregate update; applies to both scoped Patrol trigger sources PatrolAlertTriggersEnabled *bool `json:"patrol_alert_triggers_enabled,omitempty"` // true if alert-driven scoped Patrol triggers are enabled PatrolAnomalyTriggersEnabled *bool `json:"patrol_anomaly_triggers_enabled,omitempty"` // true if anomaly-driven scoped Patrol triggers are enabled UseProactiveThresholds *bool `json:"use_proactive_thresholds,omitempty"` // true if patrol warns before thresholds (default: false = exact thresholds) // Multi-provider credentials AnthropicAPIKey *string `json:"anthropic_api_key,omitempty"` // Set Anthropic API key OpenAIAPIKey *string `json:"openai_api_key,omitempty"` // Set OpenAI API key OpenRouterAPIKey *string `json:"openrouter_api_key,omitempty"` // Set OpenRouter API key DeepSeekAPIKey *string `json:"deepseek_api_key,omitempty"` // Set DeepSeek API key GeminiAPIKey *string `json:"gemini_api_key,omitempty"` // Set Gemini API key OllamaBaseURL *string `json:"ollama_base_url,omitempty"` // Set Ollama server URL OllamaUsername *string `json:"ollama_username,omitempty"` // Set Ollama Basic Auth username OllamaPassword *string `json:"ollama_password,omitempty"` // Set Ollama Basic Auth password OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL // Clear flags for removing credentials ClearAnthropicKey *bool `json:"clear_anthropic_key,omitempty"` // Clear Anthropic API key ClearOpenAIKey *bool `json:"clear_openai_key,omitempty"` // Clear OpenAI API key ClearOpenRouterKey *bool `json:"clear_openrouter_key,omitempty"` // Clear OpenRouter API key ClearDeepSeekKey *bool `json:"clear_deepseek_key,omitempty"` // Clear DeepSeek API key ClearGeminiKey *bool `json:"clear_gemini_key,omitempty"` // Clear Gemini API key ClearOllamaURL *bool `json:"clear_ollama_url,omitempty"` // Clear Ollama URL ClearOllamaUsername *bool `json:"clear_ollama_username,omitempty"` // Clear Ollama Basic Auth username ClearOllamaPassword *bool `json:"clear_ollama_password,omitempty"` // Clear Ollama Basic Auth password // Cost controls CostBudgetUSD30d *float64 `json:"cost_budget_usd_30d,omitempty"` // Request timeout (seconds) - for slow hardware running local models RequestTimeoutSeconds *int `json:"request_timeout_seconds,omitempty"` // Infrastructure control settings ControlLevel *string `json:"control_level,omitempty"` // "read_only", "controlled", "autonomous" ProtectedGuests []string `json:"protected_guests,omitempty"` // VMIDs/names that AI cannot control (nil = don't update, empty = clear) // Discovery settings DiscoveryEnabled *bool `json:"discovery_enabled,omitempty"` // Enable discovery DiscoveryIntervalHours *int `json:"discovery_interval_hours,omitempty"` // Hours between auto-scans (0 = manual only) } // AssistantEnabled reports whether the Pulse Assistant affordance should be // shown in the authenticated shell without forcing the browser to probe the // full AI settings API on every route bootstrap. func (h *AISettingsHandler) AssistantEnabled(ctx context.Context) bool { if h == nil { return false } settings, err := h.loadAIConfig(ctx) if err != nil { return false } if settings == nil { settings = config.NewDefaultAIConfig() } enabled := settings.Enabled || mockmode.IsEnabled() if !enabled { return false } if settings.IsConfigured() || mockmode.IsEnabled() { return true } return false } func aiSettingsRequireModelResolution(settings *config.AIConfig) bool { if settings == nil || !settings.IsConfigured() { return false } model := strings.TrimSpace(settings.GetModel()) if model == "" { return true } providerName, _ := config.ParseModelString(model) if providerName == "" || providerName == config.AIProviderQuickstart { return false } return !settings.HasProvider(providerName) } // HandleGetAISettings returns the current AI settings (GET /api/settings/ai) func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !ensureSettingsReadScope(h.getConfig(r.Context()), w, r) { return } ctx := r.Context() settings, err := h.loadAIConfig(ctx) if err != nil { log.Error().Err(err).Msg("Failed to load Pulse Assistant settings") http.Error(w, "Failed to load Pulse Assistant settings", http.StatusInternalServerError) return } if settings == nil { settings = config.NewDefaultAIConfig() } settings.NormalizeQuickstartModelAliases() if aiSettingsRequireModelResolution(settings) { if resolvedModel, resolveErr := ai.ResolveConfiguredModel(ctx, settings); resolveErr == nil { settings.Model = resolvedModel } } // Determine auth method string authMethod := string(settings.AuthMethod) if authMethod == "" { authMethod = string(config.AuthMethodAPIKey) } // Determine if running in demo mode isDemo := mockmode.IsEnabled() triggerSettings := settings.GetPatrolEventTriggerSettings() aiService := h.GetAIService(ctx) hasAutoFixFeature := aiService.HasLicenseFeature(ai.FeatureAIAutoFix) hasAlertAnalysisFeature := aiService.HasLicenseFeature(ai.FeatureAIAlerts) response := AISettingsResponse{ Enabled: settings.Enabled || isDemo, Model: settings.GetModel(), ChatModel: config.NormalizeQuickstartModelString(settings.ChatModel), PatrolModel: config.NormalizeQuickstartModelString(settings.PatrolModel), AutoFixModel: config.NormalizeQuickstartModelString(settings.AutoFixModel), Configured: settings.IsConfigured() || isDemo, CustomContext: settings.CustomContext, AuthMethod: authMethod, OAuthConnected: settings.OAuthAccessToken != "", // Patrol settings PatrolIntervalMinutes: settings.PatrolIntervalMinutes, PatrolEnabled: settings.PatrolEnabled, PatrolAutoFix: settings.PatrolAutoFix && hasAutoFixFeature, AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis && hasAlertAnalysisFeature, PatrolEventTriggersEnabled: triggerSettings.AlertTriggersEnabled || triggerSettings.AnomalyTriggersEnabled, PatrolAlertTriggersEnabled: triggerSettings.AlertTriggersEnabled, PatrolAnomalyTriggersEnabled: triggerSettings.AnomalyTriggersEnabled, UseProactiveThresholds: settings.UseProactiveThresholds, AvailableModels: nil, // Now populated via /api/ai/models endpoint // Multi-provider configuration AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), GeminiConfigured: settings.HasProvider(config.AIProviderGemini), OllamaConfigured: settings.HasProvider(config.AIProviderOllama), OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), OllamaUsername: settings.OllamaUsername, OllamaPasswordSet: settings.OllamaPassword != "", OpenAIBaseURL: settings.OpenAIBaseURL, ConfiguredProviders: settings.GetConfiguredProviders(), CostBudgetUSD30d: settings.CostBudgetUSD30d, RequestTimeoutSeconds: settings.RequestTimeoutSeconds, ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), ProtectedGuests: settings.GetProtectedGuests(), DiscoveryEnabled: settings.IsDiscoveryEnabled(), DiscoveryIntervalHours: settings.DiscoveryIntervalHours, PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), PatrolReadiness: ptrToPatrolReadiness(h.buildPatrolReadiness(ctx, aiService, h.getPatrolService(ctx) != nil)), }.NormalizeCollections() if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write AI settings response") } } // HandleUpdateAISettings updates AI settings (PUT /api/settings/ai) func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut && r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require admin authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } // Check proxy auth admin status if applicable if h.getConfig(r.Context()).ProxyAuthSecret != "" { if valid, username, isAdmin := CheckProxyAuth(h.getConfig(r.Context()), r); valid && !isAdmin { log.Warn(). Str("ip", r.RemoteAddr). Str("path", r.URL.Path). Str("method", r.Method). Str("username", username). Msg("Non-admin user attempted to update AI settings") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _ = json.NewEncoder(w).Encode(map[string]string{"error": "Admin privileges required"}) return } } if !ensureSettingsWriteScope(h.getConfig(r.Context()), w, r) { return } // Load existing settings settings, err := h.loadAIConfig(r.Context()) if err != nil { log.Error().Err(err).Msg("Failed to load existing AI settings") settings = config.NewDefaultAIConfig() } if settings == nil { settings = config.NewDefaultAIConfig() } // Snapshot the pre-mutation config so we can detect Patrol-affecting // changes after save and decide whether to auto-trigger preflight. // Shallow copy is fine — the detection helper only reads scalar // fields (Enabled, PatrolModel, API keys). originalSettings := *settings // Parse request r.Body = http.MaxBytesReader(w, r.Body, 16*1024) var req AISettingsUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Validate and apply updates if req.Model != nil { settings.Model = strings.TrimSpace(*req.Model) } if req.ChatModel != nil { settings.ChatModel = strings.TrimSpace(*req.ChatModel) } if req.PatrolModel != nil { settings.PatrolModel = strings.TrimSpace(*req.PatrolModel) } if req.AutoFixModel != nil { settings.AutoFixModel = strings.TrimSpace(*req.AutoFixModel) } if req.PatrolAutoFix != nil { // Safe remediation requires Pro license with the ai_autofix feature. if *req.PatrolAutoFix && !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro") return } settings.PatrolAutoFix = *req.PatrolAutoFix } if req.UseProactiveThresholds != nil { settings.UseProactiveThresholds = *req.UseProactiveThresholds } if req.CustomContext != nil { settings.CustomContext = strings.TrimSpace(*req.CustomContext) } // Handle multi-provider credentials FIRST - before enabled check // This allows the setup flow to send API key + enabled:true together // Clear flags take priority over setting new values if req.ClearAnthropicKey != nil && *req.ClearAnthropicKey { settings.AnthropicAPIKey = "" } else if req.AnthropicAPIKey != nil { settings.AnthropicAPIKey = strings.TrimSpace(*req.AnthropicAPIKey) } if req.ClearOpenAIKey != nil && *req.ClearOpenAIKey { settings.OpenAIAPIKey = "" } else if req.OpenAIAPIKey != nil { settings.OpenAIAPIKey = strings.TrimSpace(*req.OpenAIAPIKey) } if req.ClearOpenRouterKey != nil && *req.ClearOpenRouterKey { settings.OpenRouterAPIKey = "" } else if req.OpenRouterAPIKey != nil { settings.OpenRouterAPIKey = strings.TrimSpace(*req.OpenRouterAPIKey) } if req.ClearDeepSeekKey != nil && *req.ClearDeepSeekKey { settings.DeepSeekAPIKey = "" } else if req.DeepSeekAPIKey != nil { settings.DeepSeekAPIKey = strings.TrimSpace(*req.DeepSeekAPIKey) } if req.ClearGeminiKey != nil && *req.ClearGeminiKey { settings.GeminiAPIKey = "" } else if req.GeminiAPIKey != nil { settings.GeminiAPIKey = strings.TrimSpace(*req.GeminiAPIKey) } if req.ClearOllamaURL != nil && *req.ClearOllamaURL { settings.OllamaBaseURL = "" } else if req.OllamaBaseURL != nil { settings.OllamaBaseURL = strings.TrimSpace(*req.OllamaBaseURL) } if req.ClearOllamaUsername != nil && *req.ClearOllamaUsername { settings.OllamaUsername = "" } else if req.OllamaUsername != nil { settings.OllamaUsername = strings.TrimSpace(*req.OllamaUsername) } if req.ClearOllamaPassword != nil && *req.ClearOllamaPassword { settings.OllamaPassword = "" } else if req.OllamaPassword != nil { settings.OllamaPassword = *req.OllamaPassword } if req.OpenAIBaseURL != nil { settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL) } if req.Enabled != nil { // Only allow enabling if at least one BYOK/local provider is configured. if *req.Enabled { configuredProviders := settings.GetConfiguredProviders() if len(configuredProviders) == 0 { http.Error(w, "Please configure a provider (API key or Ollama URL) before enabling Pulse Assistant", http.StatusBadRequest) return } else { if aiSettingsRequireModelResolution(settings) { resolvedModel, resolveErr := ai.ResolveConfiguredModel(r.Context(), settings) if resolveErr != nil { log.Warn().Err(resolveErr).Msg("Provider model resolution failed during enable; saving settings and reporting Patrol readiness instead") } else { settings.Model = resolvedModel } } } } settings.Enabled = *req.Enabled } // Handle patrol interval - prefer custom minutes over preset if req.PatrolIntervalMinutes != nil { minutes := *req.PatrolIntervalMinutes if minutes < 0 { http.Error(w, "patrol_interval_minutes cannot be negative", http.StatusBadRequest) return } if minutes > 0 && minutes < 10 { http.Error(w, "patrol_interval_minutes must be at least 10 minutes (or 0 to disable)", http.StatusBadRequest) return } if minutes > 10080 { // 7 days max http.Error(w, "patrol_interval_minutes cannot exceed 10080 (7 days)", http.StatusBadRequest) return } settings.PatrolIntervalMinutes = minutes if minutes > 0 { settings.PatrolEnabled = true // Enable patrol when setting custom interval } else { settings.PatrolEnabled = false // Disable patrol when setting interval to 0 } } if req.PatrolEnabled != nil && req.PatrolIntervalMinutes == nil { settings.PatrolEnabled = *req.PatrolEnabled if *req.PatrolEnabled { // Ensure we have a sane default interval when turning on if settings.PatrolIntervalMinutes <= 0 { settings.PatrolIntervalMinutes = 360 } } } if req.CostBudgetUSD30d != nil { if *req.CostBudgetUSD30d < 0 { http.Error(w, "cost_budget_usd_30d cannot be negative", http.StatusBadRequest) return } settings.CostBudgetUSD30d = *req.CostBudgetUSD30d } // Handle alert-triggered analysis toggle if req.AlertTriggeredAnalysis != nil { // Alert analysis requires Pro license with ai_alerts feature if *req.AlertTriggeredAnalysis && !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAlerts) { WriteLicenseRequired(w, ai.FeatureAIAlerts, "Pulse Alert Analysis requires Pulse Pro") return } settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis } // Handle legacy aggregate event-triggered patrol toggle if req.PatrolEventTriggersEnabled != nil { settings.SetPatrolEventTriggersEnabled(*req.PatrolEventTriggersEnabled) } // Handle split scoped patrol trigger toggles if req.PatrolAlertTriggersEnabled != nil || req.PatrolAnomalyTriggersEnabled != nil { triggerSettings := settings.GetPatrolEventTriggerSettings() if req.PatrolAlertTriggersEnabled != nil { triggerSettings.AlertTriggersEnabled = *req.PatrolAlertTriggersEnabled } if req.PatrolAnomalyTriggersEnabled != nil { triggerSettings.AnomalyTriggersEnabled = *req.PatrolAnomalyTriggersEnabled } settings.SetPatrolEventTriggerSettings( triggerSettings.AlertTriggersEnabled, triggerSettings.AnomalyTriggersEnabled, ) } // Handle request timeout (for slow hardware) if req.RequestTimeoutSeconds != nil { if *req.RequestTimeoutSeconds < 0 { http.Error(w, "request_timeout_seconds cannot be negative", http.StatusBadRequest) return } if *req.RequestTimeoutSeconds > 3600 { http.Error(w, "request_timeout_seconds cannot exceed 3600 (1 hour)", http.StatusBadRequest) return } settings.RequestTimeoutSeconds = *req.RequestTimeoutSeconds } // Handle infrastructure control settings if req.ControlLevel != nil { level := strings.TrimSpace(*req.ControlLevel) if !config.IsValidControlLevel(level) { http.Error(w, "invalid control_level: must be read_only, controlled, or autonomous", http.StatusBadRequest) return } // "autonomous" requires Pro license if level == config.ControlLevelAutonomous { if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Autonomous control requires Pulse Pro") return } } settings.ControlLevel = level } // Handle protected guests (nil = don't update) if req.ProtectedGuests != nil { settings.ProtectedGuests = req.ProtectedGuests } // Handle discovery settings if req.DiscoveryEnabled != nil { settings.DiscoveryEnabled = *req.DiscoveryEnabled } if req.DiscoveryIntervalHours != nil { if *req.DiscoveryIntervalHours < 0 { http.Error(w, "discovery_interval_hours cannot be negative", http.StatusBadRequest) return } settings.DiscoveryIntervalHours = *req.DiscoveryIntervalHours } // Auto-default discovery interval only when enabling without an explicit interval. // A provided interval of 0 is the canonical manual-only setting and must persist. if req.DiscoveryEnabled != nil && *req.DiscoveryEnabled && req.DiscoveryIntervalHours == nil && settings.DiscoveryIntervalHours == 0 { settings.DiscoveryIntervalHours = 24 } if aiSettingsRequireModelResolution(settings) { resolvedModel, resolveErr := ai.ResolveConfiguredModel(r.Context(), settings) if resolveErr != nil { log.Warn().Err(resolveErr).Msg("Provider model resolution failed during settings update; saving settings and reporting Patrol readiness instead") } else { settings.Model = resolvedModel } } settings.NormalizeQuickstartModelAliases() if settings.IsPatrolEnabled() && aiSettingsUpdateTouchesPatrolReadiness(req) { readiness := ai.EvaluatePatrolConfigReadiness(settings) if !readiness.Ready { log.Info(). Str("cause", string(readiness.Cause)). Str("provider", readiness.Provider). Str("model", config.NormalizeQuickstartModelString(readiness.Model)). Msg("AI settings saved with Patrol runtime readiness blocker") } } // Save settings if err := h.getPersistence(r.Context()).SaveAIConfig(*settings); err != nil { log.Error().Err(err).Msg("Failed to save AI settings") writeErrorResponse(w, http.StatusInternalServerError, "ai_settings_save_failed", "Failed to save Assistant & Patrol settings", nil) return } // Reload the AI service with new settings if err := h.GetAIService(r.Context()).Reload(); err != nil { log.Warn().Err(err).Msg("Failed to reload AI service after settings update") } // Reconfigure patrol service with new settings (applies interval changes immediately) h.GetAIService(r.Context()).ReconfigurePatrol() // Update alert-triggered analyzer if available if analyzer := h.GetAIService(r.Context()).GetAlertTriggeredAnalyzer(); analyzer != nil { analyzer.SetEnabled(settings.AlertTriggeredAnalysis) } // Auto-trigger Patrol tool-call preflight when the change actually // moved the Patrol transport (model swap or new key for the model's // provider). Runs in the background so the save response isn't // blocked; the cached result surfaces on the next /api/settings/ai // poll via patrol_preflight. Routine saves that don't touch Patrol // transport skip this entirely so they don't burn provider tokens. if aiSettingsUpdateRequiresPatrolPreflight(&originalSettings, settings) { h.GetAIService(r.Context()).TriggerPatrolPreflightAsync("", "") } // Trigger AI chat service restart when provider-affecting settings change. // The running service keeps provider configuration in memory, so credential, // base URL, or model updates must restart the chat runtime to take effect. if h.onModelChange != nil && shouldRestartAIChat(req) { h.onModelChange() } // Update MCP control settings if control level or protected guests changed // This updates tool visibility without restarting AI chat if h.onControlSettingsChange != nil && (req.ControlLevel != nil || req.ProtectedGuests != nil) { h.onControlSettingsChange() } providerName, _ := config.ParseModelString(settings.GetModel()) LogAuditEventForTenant(GetOrgID(r.Context()), "ai_settings_updated", getAuthUsername(h.getConfig(r.Context()), r), GetClientIP(r), r.URL.Path, true, fmt.Sprintf("AI settings updated: enabled=%t provider=%s model=%s", settings.Enabled, providerName, settings.GetModel())) log.Info(). Bool("enabled", settings.Enabled). Str("provider", providerName). Str("model", settings.GetModel()). Str("chatModel", config.NormalizeQuickstartModelString(settings.ChatModel)). Str("patrolModel", config.NormalizeQuickstartModelString(settings.PatrolModel)). Bool("alertTriggeredAnalysis", settings.AlertTriggeredAnalysis). Msg("AI settings updated") // Determine auth method for response authMethod := string(settings.AuthMethod) if authMethod == "" { authMethod = string(config.AuthMethodAPIKey) } triggerSettings := settings.GetPatrolEventTriggerSettings() aiService := h.GetAIService(r.Context()) hasAutoFixFeature := aiService.HasLicenseFeature(ai.FeatureAIAutoFix) hasAlertAnalysisFeature := aiService.HasLicenseFeature(ai.FeatureAIAlerts) patrolReadiness := patrolReadinessResponseFromConfigReadiness(ai.EvaluatePatrolConfigReadiness(settings)) // Return updated settings response := AISettingsResponse{ Enabled: settings.Enabled, Model: settings.GetModel(), ChatModel: config.NormalizeQuickstartModelString(settings.ChatModel), PatrolModel: config.NormalizeQuickstartModelString(settings.PatrolModel), AutoFixModel: config.NormalizeQuickstartModelString(settings.AutoFixModel), Configured: settings.IsConfigured(), CustomContext: settings.CustomContext, AuthMethod: authMethod, OAuthConnected: settings.OAuthAccessToken != "", PatrolIntervalMinutes: settings.PatrolIntervalMinutes, PatrolEnabled: settings.PatrolEnabled, PatrolAutoFix: settings.PatrolAutoFix && hasAutoFixFeature, AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis && hasAlertAnalysisFeature, PatrolEventTriggersEnabled: triggerSettings.AlertTriggersEnabled || triggerSettings.AnomalyTriggersEnabled, PatrolAlertTriggersEnabled: triggerSettings.AlertTriggersEnabled, PatrolAnomalyTriggersEnabled: triggerSettings.AnomalyTriggersEnabled, UseProactiveThresholds: settings.UseProactiveThresholds, AvailableModels: nil, // Now populated via /api/ai/models endpoint // Multi-provider configuration AnthropicConfigured: settings.HasProvider(config.AIProviderAnthropic), OpenAIConfigured: settings.HasProvider(config.AIProviderOpenAI), OpenRouterConfigured: settings.HasProvider(config.AIProviderOpenRouter), DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek), GeminiConfigured: settings.HasProvider(config.AIProviderGemini), OllamaConfigured: settings.HasProvider(config.AIProviderOllama), OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama), OllamaUsername: settings.OllamaUsername, OllamaPasswordSet: settings.OllamaPassword != "", OpenAIBaseURL: settings.OpenAIBaseURL, ConfiguredProviders: settings.GetConfiguredProviders(), RequestTimeoutSeconds: settings.RequestTimeoutSeconds, ControlLevel: settings.GetEffectiveControlLevel(hasAutoFixFeature), ProtectedGuests: settings.GetProtectedGuests(), DiscoveryEnabled: settings.DiscoveryEnabled, DiscoveryIntervalHours: settings.DiscoveryIntervalHours, PatrolReadiness: ptrToPatrolReadiness(patrolReadiness), PatrolPreflight: cachedPatrolPreflightSnapshot(aiService), }.NormalizeCollections() if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write AI settings update response") } } // cachedPatrolPreflightSnapshot projects the AI Service's cached // preflight result onto the API's wire shape. Returns nil when no // preflight has run on this service instance. func cachedPatrolPreflightSnapshot(aiService *ai.Service) *PatrolPreflightSnapshot { if aiService == nil { return nil } result, recordedAt := aiService.CachedPatrolPreflight() if result == nil || recordedAt.IsZero() { return nil } return &PatrolPreflightSnapshot{ Success: result.Success, Provider: result.Provider, Model: result.Model, ToolCallObserved: result.ToolCallObserved, DurationMs: result.DurationMs, Cause: string(result.Cause), Title: result.Title, Summary: result.Summary, Recommendation: result.Recommendation, RecordedAt: recordedAt.UTC().Format(time.RFC3339), RecordedAtUnix: recordedAt.Unix(), } } // HandleTestAIConnection tests the AI provider connection (POST /api/ai/test) // Auth is enforced by RequirePermission middleware at route registration; with // default authorizer, non-admin proxy users are hard-denied (with RBAC, deferred // to authorizer). Token scope is enforced by RequireScope middleware. // ensureSettingsWriteScope provides the additional admin-session identity guard // for session-based (non-token) users. func (h *AISettingsHandler) HandleTestAIConnection(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !ensureSettingsWriteScope(h.getConfig(r.Context()), w, r) { return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() var testResult struct { Success bool `json:"success"` Message string `json:"message"` Model string `json:"model,omitempty"` Cause string `json:"cause,omitempty"` Summary string `json:"summary,omitempty"` Recommendation string `json:"recommendation,omitempty"` Action string `json:"action,omitempty"` } cfg := h.GetAIService(r.Context()).GetConfig() err := h.GetAIService(r.Context()).TestConnection(ctx) if err != nil { diagnostic := ai.ClassifyPatrolRuntimeFailure(err) testResult.Success = false testResult.Message = diagnostic.Summary testResult.Cause = string(diagnostic.Cause) testResult.Summary = diagnostic.Description testResult.Recommendation = diagnostic.Recommendation testResult.Action = "open_provider_settings" if cfg != nil { testResult.Model = config.NormalizeQuickstartModelString(cfg.GetModel()) } log.Error().Err(err).Msg("AI connection test failed") } else { testResult.Success = true testResult.Message = "Connection successful" if cfg != nil { testResult.Model = config.NormalizeQuickstartModelString(cfg.GetModel()) } } if err := utils.WriteJSONResponse(w, testResult); err != nil { log.Error().Err(err).Msg("Failed to write AI test response") } } type aiProviderTestResponse struct { Success bool `json:"success"` Message string `json:"message"` Provider string `json:"provider"` Model string `json:"model,omitempty"` Cause string `json:"cause,omitempty"` Summary string `json:"summary,omitempty"` Recommendation string `json:"recommendation,omitempty"` Action string `json:"action,omitempty"` } func newAIProviderTestResponse(provider string) aiProviderTestResponse { return aiProviderTestResponse{Provider: provider} } func newAIProviderTestNotConfiguredResponse(provider string) aiProviderTestResponse { return aiProviderTestResponse{ Success: false, Message: "Provider not configured", Provider: provider, Cause: string(ai.PatrolFailureCauseProviderNotConfigured), Summary: "Pulse Patrol cannot test this provider because it is not configured for the current Assistant & Patrol settings.", Recommendation: "Open Assistant & Patrol provider settings, configure the provider credentials or base URL, choose a Patrol model, and run provider preflight again.", Action: "open_provider_settings", } } func newAIProviderTestFailureResponse(provider, model string, err error) aiProviderTestResponse { diagnostic := ai.ClassifyPatrolRuntimeFailure(err) return aiProviderTestResponse{ Success: false, Message: diagnostic.Summary, Provider: provider, Model: config.NormalizeQuickstartModelString(model), Cause: string(diagnostic.Cause), Summary: diagnostic.Description, Recommendation: diagnostic.Recommendation, Action: "open_provider_settings", } } // HandleTestProvider tests a specific AI provider connection (POST /api/ai/test/:provider) // Auth is enforced by RequirePermission middleware at route registration; with // default authorizer, non-admin proxy users are hard-denied (with RBAC, deferred // to authorizer). Token scope is enforced by RequireScope middleware. // ensureSettingsWriteScope provides the additional admin-session identity guard // for session-based (non-token) users. func (h *AISettingsHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !ensureSettingsWriteScope(h.getConfig(r.Context()), w, r) { return } // Get provider from URL path (e.g., /api/ai/test/anthropic -> anthropic) provider := strings.TrimPrefix(r.URL.Path, "/api/ai/test/") if provider == "" || provider == r.URL.Path { http.Error(w, `{"error":"Provider is required"}`, http.StatusBadRequest) return } // Validate provider name: only allow lowercase alphanumeric and hyphens, // max 64 chars. This rejects path traversal, slashes, and injection attempts. if len(provider) > 64 || !isValidProviderName(provider) { http.Error(w, `{"error":"Invalid provider name"}`, http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() testResult := newAIProviderTestResponse(provider) // Load config and create provider for testing cfg := h.GetAIService(r.Context()).GetConfig() if cfg == nil { testResult = newAIProviderTestNotConfiguredResponse(provider) testResult.Message = "Pulse Assistant not configured" if err := utils.WriteJSONResponse(w, testResult); err != nil { log.Error().Err(err).Msg("failed to write JSON response") } return } // Check if provider is configured if !cfg.HasProvider(provider) { testResult = newAIProviderTestNotConfiguredResponse(provider) if err := utils.WriteJSONResponse(w, testResult); err != nil { log.Error().Err(err).Msg("failed to write JSON response") } return } // Create provider and test connection model, err := ai.ResolvePreferredModelForProvider(ctx, cfg, provider) if err != nil { testResult = newAIProviderTestFailureResponse(provider, "", err) log.Error().Err(err).Str("provider", provider).Msg("AI provider model resolution failed") if err := utils.WriteJSONResponse(w, testResult); err != nil { log.Error().Err(err).Msg("failed to write provider test response") } return } testProvider, err := providers.NewForProvider(cfg, provider, model) if err != nil { testResult = newAIProviderTestFailureResponse(provider, model, err) log.Error().Err(err).Str("provider", provider).Msg("AI provider creation failed") if err := utils.WriteJSONResponse(w, testResult); err != nil { log.Error().Err(err).Msg("failed to write JSON response") } return } err = testProvider.TestConnection(ctx) if err != nil { testResult = newAIProviderTestFailureResponse(provider, model, err) log.Error().Err(err).Str("provider", provider).Msg("AI provider connection test failed") } else { testResult.Success = true testResult.Message = "Connection successful" testResult.Model = config.NormalizeQuickstartModelString(model) } if err := utils.WriteJSONResponse(w, testResult); err != nil { log.Error().Err(err).Msg("Failed to write provider test response") } } // isValidProviderName returns true if s contains only lowercase letters, digits, // and hyphens. This prevents path traversal, URL injection, and log injection. func isValidProviderName(s string) bool { for _, c := range s { if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') { return false } } return len(s) > 0 } // HandleListModels fetches available models from the configured AI provider (GET /api/ai/models) func (h *AISettingsHandler) HandleListModels(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Auth is enforced by RequireAuth + RequireScope middleware at the route level. ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() type ModelInfo struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` CreatedAt int64 `json:"created_at,omitempty"` Notable bool `json:"notable"` } type Response struct { Models []ModelInfo `json:"models"` Error string `json:"error,omitempty"` Cached bool `json:"cached"` } models, cached, err := h.GetAIService(r.Context()).ListModelsWithCache(ctx) if err != nil { // Return error but don't fail the request - frontend can show a fallback log.Error().Err(err).Msg("Failed to list AI models") resp := Response{ Models: []ModelInfo{}, Error: "Failed to fetch model list", } if jsonErr := utils.WriteJSONResponse(w, resp); jsonErr != nil { log.Error().Err(jsonErr).Msg("Failed to write AI models response") } return } // Convert provider models to response format responseModels := make([]ModelInfo, 0, len(models)) notableCount := 0 for _, m := range models { if m.Notable { notableCount++ } responseModels = append(responseModels, ModelInfo{ ID: m.ID, Name: m.Name, Description: m.Description, CreatedAt: m.CreatedAt, Notable: m.Notable, }) } log.Debug().Int("total", len(responseModels)).Int("notable", notableCount).Msg("Returning AI models") resp := Response{ Models: responseModels, Cached: cached, } if err := utils.WriteJSONResponse(w, resp); err != nil { log.Error().Err(err).Msg("Failed to write AI models response") } } // AIExecuteRequest is the request body for POST /api/ai/execute // AIConversationMessage represents a message in conversation history type AIConversationMessage struct { Role string `json:"role"` // "user" or "assistant" Content string `json:"content"` } type AIExecuteRequest struct { Prompt string `json:"prompt"` TargetType string `json:"target_type,omitempty"` // "agent", "system-container", "vm" TargetID string `json:"target_id,omitempty"` Context map[string]interface{} `json:"context,omitempty"` // Current metrics, state, etc. History []AIConversationMessage `json:"history,omitempty"` // Previous conversation messages FindingID string `json:"finding_id,omitempty"` Model string `json:"model,omitempty"` UseCase string `json:"use_case,omitempty"` // "chat" or "patrol" } func normalizeAIExecuteTargetType(raw string) string { return strings.ToLower(strings.TrimSpace(raw)) } func normalizeAndValidateAIExecuteTargetType(raw string) (string, error) { targetType := normalizeAIExecuteTargetType(raw) if targetType == "" { return "", nil } switch targetType { case "agent", "system-container", "vm": return targetType, nil default: return "", fmt.Errorf("invalid target_type") } } // AIExecuteResponse is the response from POST /api/ai/execute type AIExecuteResponse struct { Content string `json:"content"` Model string `json:"model"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` ToolCalls []ai.ToolExecution `json:"tool_calls"` // Commands that were executed PendingApprovals []ai.ApprovalNeededData `json:"pending_approvals"` // Commands that require approval (non-streaming) } func EmptyAIExecuteResponse() AIExecuteResponse { return AIExecuteResponse{}.NormalizeCollections() } func (r AIExecuteResponse) NormalizeCollections() AIExecuteResponse { if r.ToolCalls == nil { r.ToolCalls = []ai.ToolExecution{} } if r.PendingApprovals == nil { r.PendingApprovals = []ai.ApprovalNeededData{} } return r } type aiExecuteStreamCompleteEvent struct { Type string `json:"type"` Model string `json:"model"` InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` ToolCalls []ai.ToolExecution `json:"tool_calls"` } func emptyAIExecuteStreamCompleteEvent() aiExecuteStreamCompleteEvent { return aiExecuteStreamCompleteEvent{}.NormalizeCollections() } func (e aiExecuteStreamCompleteEvent) NormalizeCollections() aiExecuteStreamCompleteEvent { if e.ToolCalls == nil { e.ToolCalls = []ai.ToolExecution{} } return e } type AIKubernetesAnalyzeRequest struct { ClusterID string `json:"cluster_id"` } // HandleExecute executes an AI prompt (POST /api/ai/execute) func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Authentication is enforced by RequireAdmin middleware at route registration. // Check if AI is enabled if !h.GetAIService(r.Context()).IsEnabled() { http.Error(w, "Pulse Assistant is not enabled or configured", http.StatusBadRequest) return } // Parse request r.Body = http.MaxBytesReader(w, r.Body, 64*1024) bodyBytes, readErr := io.ReadAll(r.Body) if readErr != nil { log.Error().Err(readErr).Msg("Failed to read request body") http.Error(w, "Invalid request body", http.StatusBadRequest) return } var req AIExecuteRequest if err := json.Unmarshal(bodyBytes, &req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Fine-grained license checks based on UseCase useCase := strings.ToLower(strings.TrimSpace(req.UseCase)) if useCase == "autofix" || useCase == "remediation" { if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro") return } } if strings.TrimSpace(req.Prompt) == "" { http.Error(w, "Prompt is required", http.StatusBadRequest) return } // Validate and normalize target_type if provided targetType, err := normalizeAndValidateAIExecuteTargetType(req.TargetType) if err != nil { http.Error(w, "Invalid target_type (allowed: agent, system-container, vm)", http.StatusBadRequest) return } // Validate target_id length if provided if len(req.TargetID) > 256 { http.Error(w, "target_id exceeds maximum length", http.StatusBadRequest) return } // Validate conversation history roles for _, msg := range req.History { switch msg.Role { case "user", "assistant": // valid default: http.Error(w, "Invalid role in history (allowed: user, assistant)", http.StatusBadRequest) return } } // Execute the prompt with a timeout ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) defer cancel() // Convert history from API type to service type var history []ai.ConversationMessage for _, msg := range req.History { history = append(history, ai.ConversationMessage{ Role: msg.Role, Content: msg.Content, }) } if useCase == "" { useCase = "chat" } resp, err := h.GetAIService(r.Context()).Execute(ctx, ai.ExecuteRequest{ Prompt: req.Prompt, TargetType: targetType, TargetID: req.TargetID, Context: req.Context, History: history, FindingID: req.FindingID, Model: req.Model, UseCase: useCase, }) if err != nil { log.Error().Err(err).Msg("AI execution failed") http.Error(w, "Pulse Assistant request failed", http.StatusInternalServerError) return } response := AIExecuteResponse{ Content: resp.Content, Model: resp.Model, InputTokens: resp.InputTokens, OutputTokens: resp.OutputTokens, ToolCalls: resp.ToolCalls, PendingApprovals: resp.PendingApprovals, }.NormalizeCollections() if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write AI execute response") } } // HandleAnalyzeKubernetesCluster analyzes a Kubernetes cluster with AI (POST /api/ai/kubernetes/analyze) func (h *AISettingsHandler) HandleAnalyzeKubernetesCluster(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } if !h.GetAIService(r.Context()).IsEnabled() { http.Error(w, "Pulse Assistant is not enabled or configured", http.StatusBadRequest) return } r.Body = http.MaxBytesReader(w, r.Body, 16*1024) var req AIKubernetesAnalyzeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if strings.TrimSpace(req.ClusterID) == "" { http.Error(w, "cluster_id is required", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second) defer cancel() resp, err := h.GetAIService(r.Context()).AnalyzeKubernetesCluster(ctx, req.ClusterID) if err != nil { switch { case errors.Is(err, ai.ErrKubernetesClusterNotFound): http.Error(w, "Kubernetes cluster not found", http.StatusNotFound) return case errors.Is(err, ai.ErrKubernetesStateUnavailable): http.Error(w, "Kubernetes state not available", http.StatusServiceUnavailable) return default: log.Error().Err(err).Str("cluster_id", req.ClusterID).Msg("Kubernetes AI analysis failed") http.Error(w, "Pulse Assistant request failed", http.StatusInternalServerError) return } } response := AIExecuteResponse{ Content: resp.Content, Model: resp.Model, InputTokens: resp.InputTokens, OutputTokens: resp.OutputTokens, ToolCalls: resp.ToolCalls, PendingApprovals: resp.PendingApprovals, } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write Kubernetes AI response") } } // HandleExecuteStream executes an AI prompt with SSE streaming (POST /api/ai/execute/stream) func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.Request) { // Handle CORS for dev mode (frontend on different port) h.setSSECORSHeaders(w, r) // Handle preflight if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // NOTE: Authentication is enforced by RequireAdmin middleware at route // registration (router_routes_ai_relay.go). No redundant CheckAuth needed. // Check if AI is enabled if !h.GetAIService(r.Context()).IsEnabled() { http.Error(w, "Pulse Assistant is not enabled or configured", http.StatusBadRequest) return } // Parse request r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max var req AIExecuteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Warn().Err(err).Msg("Failed to decode AI execute stream request") http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Fine-grained license checks based on UseCase (before SSE headers) useCase := strings.ToLower(strings.TrimSpace(req.UseCase)) if useCase == "autofix" || useCase == "remediation" { if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro") return } } if strings.TrimSpace(req.Prompt) == "" { http.Error(w, "Prompt is required", http.StatusBadRequest) return } // Validate and normalize target_type if provided targetType, err := normalizeAndValidateAIExecuteTargetType(req.TargetType) if err != nil { http.Error(w, "Invalid target_type (allowed: agent, system-container, vm)", http.StatusBadRequest) return } // Validate target_id length if provided if len(req.TargetID) > 256 { http.Error(w, "target_id exceeds maximum length", http.StatusBadRequest) return } // Validate conversation history roles for _, msg := range req.History { switch msg.Role { case "user", "assistant": // valid default: http.Error(w, "Invalid role in history (allowed: user, assistant)", http.StatusBadRequest) return } } log.Info(). Int("prompt_len", len(req.Prompt)). Str("target_type", targetType). Str("target_id", req.TargetID). Msg("AI streaming request started") // Set up SSE headers // IMPORTANT: Set headers BEFORE any writes to prevent Go from auto-adding Transfer-Encoding: chunked w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering // Prevent chunked encoding which causes "Invalid character in chunk size" errors in Vite proxy w.Header().Set("Transfer-Encoding", "identity") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming not supported", http.StatusInternalServerError) return } // Disable the server's write deadline for this SSE connection // This is critical for long-running AI requests that can take several minutes rc := http.NewResponseController(w) if err := rc.SetWriteDeadline(time.Time{}); err != nil { log.Warn().Err(err).Msg("Failed to disable write deadline for SSE") } // Also disable read deadline if err := rc.SetReadDeadline(time.Time{}); err != nil { log.Warn().Err(err).Msg("Failed to disable read deadline for SSE") } // Flush headers immediately flusher.Flush() // Create context with timeout (15 minutes for complex analysis with multiple tool calls) // Use background context to avoid browser disconnect canceling the request // DeepSeek reasoning models + multiple tool executions can easily take 5+ minutes ctx, cancel := context.WithTimeout(context.Background(), 900*time.Second) defer cancel() // Set up heartbeat to keep connection alive during long tool executions // NOTE: We don't check r.Context().Done() because Vite proxy may close // the request context prematurely. We detect real disconnection via write failures. heartbeatDone := make(chan struct{}) var clientDisconnected atomic.Bool go func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: // Extend write deadline before heartbeat _ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second)) // Send SSE comment as heartbeat _, err := w.Write([]byte(": heartbeat\n\n")) if err != nil { log.Debug().Err(err).Msg("Heartbeat write failed, stopping heartbeat (AI continues)") clientDisconnected.Store(true) // Don't cancel the AI request - let it complete with its own timeout // The SSE connection may have issues but the AI work can still finish return } flusher.Flush() log.Debug().Msg("Sent SSE heartbeat") case <-heartbeatDone: return } } }() defer close(heartbeatDone) // Helper to safely write SSE events, tracking if client disconnected safeWrite := func(data []byte) bool { if clientDisconnected.Load() { return false } _ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err := w.Write(data) if err != nil { log.Debug().Err(err).Msg("Failed to write SSE event (client may have disconnected)") clientDisconnected.Store(true) return false } flusher.Flush() return true } // Stream callback - write SSE events callback := func(event ai.StreamEvent) { // Skip the 'done' event from service - we'll send our own at the end // This ensures 'complete' comes before 'done' if event.Type == "done" { log.Debug().Msg("Skipping service 'done' event - will send final 'done' after 'complete'") return } data, err := json.Marshal(event) if err != nil { log.Error().Err(err).Msg("Failed to marshal stream event") return } log.Debug(). Str("event_type", event.Type). Msg("Streaming AI event") // SSE format: data: \n\n safeWrite([]byte("data: " + string(data) + "\n\n")) } // Convert history from API type to service type var history []ai.ConversationMessage for _, msg := range req.History { history = append(history, ai.ConversationMessage{ Role: msg.Role, Content: msg.Content, }) } if useCase == "" { useCase = "chat" } // Ensure we always send a final 'done' event defer func() { if !clientDisconnected.Load() { doneEvent := ai.StreamEvent{Type: "done"} data, _ := json.Marshal(doneEvent) safeWrite([]byte("data: " + string(data) + "\n\n")) log.Debug().Msg("Sent final 'done' event") } }() // Execute with streaming resp, err := h.GetAIService(r.Context()).ExecuteStream(ctx, ai.ExecuteRequest{ Prompt: req.Prompt, TargetType: targetType, TargetID: req.TargetID, Context: req.Context, History: history, FindingID: req.FindingID, Model: req.Model, UseCase: useCase, }, callback) if err != nil { log.Error().Err(err).Msg("AI streaming execution failed") // Send error event — use generic message to avoid leaking internal details errEvent := ai.StreamEvent{Type: "error", Data: map[string]string{"message": "AI request failed. Please try again."}} data, _ := json.Marshal(errEvent) safeWrite([]byte("data: " + string(data) + "\n\n")) return } log.Info(). Str("model", resp.Model). Int("input_tokens", resp.InputTokens). Int("output_tokens", resp.OutputTokens). Int("tool_calls", len(resp.ToolCalls)). Msg("AI streaming request completed") // Send final response with metadata (before 'done') finalEvent := aiExecuteStreamCompleteEvent{ Type: "complete", Model: resp.Model, InputTokens: resp.InputTokens, OutputTokens: resp.OutputTokens, ToolCalls: resp.ToolCalls, }.NormalizeCollections() data, _ := json.Marshal(finalEvent) safeWrite([]byte("data: " + string(data) + "\n\n")) // 'done' event is sent by the defer above } // AIRunCommandRequest is the request body for POST /api/ai/run-command type AIRunCommandRequest struct { Command string `json:"command"` ApprovalID string `json:"approval_id"` TargetType string `json:"target_type"` TargetID string `json:"target_id"` RunOnHost bool `json:"run_on_host"` VMID string `json:"vmid,omitempty"` TargetHost string `json:"target_host,omitempty"` // Explicit host for routing } // HandleRunCommand executes a single approved command (POST /api/ai/run-command) func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } // Parse request r.Body = http.MaxBytesReader(w, r.Body, 16*1024) bodyBytes, readErr := io.ReadAll(r.Body) if readErr != nil { log.Error().Err(readErr).Msg("Failed to read request body") http.Error(w, "Invalid request body", http.StatusBadRequest) return } log.Debug().Int("body_len", len(bodyBytes)).Msg("run-command request received") var req AIRunCommandRequest if err := json.Unmarshal(bodyBytes, &req); err != nil { log.Error().Err(err).Msg("Failed to decode JSON body") http.Error(w, "Invalid request body", http.StatusBadRequest) return } if strings.TrimSpace(req.Command) == "" { http.Error(w, "Command is required", http.StatusBadRequest) return } if strings.TrimSpace(req.ApprovalID) == "" { http.Error(w, "approval_id is required", http.StatusBadRequest) return } approvalTargetType, approvalTargetID, targetErr := normalizeRunCommandApprovalTarget(req) if targetErr != nil { http.Error(w, targetErr.Error(), http.StatusBadRequest) return } // Gated for safe remediation workflows (Pro feature). Request shape is validated before the // entitlement check so clients get deterministic 400s for malformed calls. if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro") return } store := approval.GetStore() if store == nil { http.Error(w, "Approval store not initialized", http.StatusServiceUnavailable) return } orgID := approval.NormalizeOrgID(GetOrgID(r.Context())) approvalReq, ok := store.GetApproval(req.ApprovalID) if !ok || !approval.BelongsToOrg(approvalReq, orgID) { http.Error(w, "Approval request not found", http.StatusNotFound) return } if _, err := store.ConsumeApproval(req.ApprovalID, req.Command, approvalTargetType, approvalTargetID); err != nil { log.Error().Err(err).Str("approval_id", req.ApprovalID).Msg("Failed to consume approval") http.Error(w, "Failed to consume approval", http.StatusConflict) return } log.Info(). Str("command_hash", agentexec.ComputeCommandApprovalHash(req.Command, approvalTargetType, approvalTargetID)). Str("approval_id", req.ApprovalID). Str("target_type", approvalTargetType). Str("target_id", approvalTargetID). Bool("run_on_host", req.RunOnHost). Str("target_host", req.TargetHost). Msg("Executing approved command") // Execute with timeout (5 minutes for long-running commands) ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second) defer cancel() resp, err := h.GetAIService(r.Context()).RunCommand(ctx, ai.RunCommandRequest{ Command: req.Command, ApprovalID: req.ApprovalID, TargetType: approvalTargetType, TargetID: approvalTargetID, RunOnHost: req.RunOnHost, VMID: req.VMID, TargetHost: strings.ToLower(strings.TrimSpace(req.TargetHost)), }) if err != nil { log.Error().Err(err).Msg("Failed to execute command") http.Error(w, "Failed to execute command", http.StatusInternalServerError) return } if err := utils.WriteJSONResponse(w, resp); err != nil { log.Error().Err(err).Msg("Failed to write run command response") } } func normalizeRunCommandApprovalTarget(req AIRunCommandRequest) (string, string, error) { targetType := normalizeAIExecuteTargetType(req.TargetType) targetID := strings.TrimSpace(req.TargetID) targetHost := strings.ToLower(strings.TrimSpace(req.TargetHost)) allowedTargetTypes := map[string]struct{}{ "agent": {}, "system-container": {}, "vm": {}, } if req.RunOnHost { targetType = "agent" if targetHost == "" { return "", "", errors.New("target_host is required when run_on_host is true") } targetID = targetHost } if targetType == "" { targetType = "agent" } if _, ok := allowedTargetTypes[targetType]; !ok { return "", "", fmt.Errorf("unsupported target_type %q (allowed: agent, system-container, vm)", targetType) } if (targetType == "system-container" || targetType == "vm") && strings.TrimSpace(req.VMID) != "" { targetID = strings.TrimSpace(req.VMID) } if targetType == "agent" { if targetID == "" { targetID = targetHost } if targetID == "" { return "", "", errors.New("target_id or target_host is required for agent commands") } targetID = strings.ToLower(targetID) } if targetID == "" { return "", "", fmt.Errorf("target_id is required for target_type '%s'", targetType) } return targetType, targetID, nil } // maxGuestIDLength is the maximum allowed length for guest_id across all // knowledge endpoints, preventing abuse via oversized identifiers. const maxGuestIDLength = 256 // maxImportNotes is the maximum number of notes allowed in a single import // request. This prevents abuse even within the 1MB body size limit. const maxImportNotes = 500 // sanitizeFilenameComponent strips characters unsafe for Content-Disposition // filenames. Only alphanumeric, hyphens, underscores, and dots are kept. func sanitizeFilenameComponent(s string) string { var b strings.Builder b.Grow(len(s)) for _, c := range s { if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' { b.WriteRune(c) } } result := b.String() if result == "" { return "export" } return result } // HandleGetGuestKnowledge returns all notes for a guest func (h *AISettingsHandler) HandleGetGuestKnowledge(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } guestID := r.URL.Query().Get("guest_id") if guestID == "" { http.Error(w, "guest_id is required", http.StatusBadRequest) return } if len(guestID) > maxGuestIDLength { http.Error(w, "guest_id too long", http.StatusBadRequest) return } knowledge, err := h.GetAIService(r.Context()).GetGuestKnowledge(guestID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Failed to get knowledge"), http.StatusInternalServerError) return } if err := utils.WriteJSONResponse(w, knowledge); err != nil { log.Error().Err(err).Msg("Failed to write knowledge response") } } // HandleSaveGuestNote saves a note for a guest func (h *AISettingsHandler) HandleSaveGuestNote(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // 64KB max var req struct { GuestID string `json:"guest_id"` GuestName string `json:"guest_name"` GuestType string `json:"guest_type"` Category string `json:"category"` Title string `json:"title"` Content string `json:"content"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.GuestID == "" || req.Category == "" || req.Title == "" || req.Content == "" { http.Error(w, "guest_id, category, title, and content are required", http.StatusBadRequest) return } if len(req.GuestID) > maxGuestIDLength { http.Error(w, "guest_id too long", http.StatusBadRequest) return } if len(req.Category) > 128 { http.Error(w, "category too long", http.StatusBadRequest) return } if len(req.Title) > 1024 { http.Error(w, "title too long", http.StatusBadRequest) return } if len(req.Content) > 32*1024 { http.Error(w, "content too long", http.StatusBadRequest) return } if err := h.GetAIService(r.Context()).SaveGuestNote(req.GuestID, req.GuestName, req.GuestType, req.Category, req.Title, req.Content); err != nil { http.Error(w, sanitizeErrorForClient(err, "Failed to save note"), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"success": true}`)) } // HandleDeleteGuestNote deletes a note from a guest func (h *AISettingsHandler) HandleDeleteGuestNote(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 4*1024) // 4KB max — only needs guest_id + note_id var req struct { GuestID string `json:"guest_id"` NoteID string `json:"note_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.GuestID == "" || req.NoteID == "" { http.Error(w, "guest_id and note_id are required", http.StatusBadRequest) return } if len(req.GuestID) > maxGuestIDLength { http.Error(w, "guest_id too long", http.StatusBadRequest) return } if len(req.NoteID) > maxGuestIDLength { http.Error(w, "note_id too long", http.StatusBadRequest) return } if err := h.GetAIService(r.Context()).DeleteGuestNote(req.GuestID, req.NoteID); err != nil { if strings.Contains(err.Error(), "not found") { http.Error(w, "Note not found", http.StatusNotFound) return } http.Error(w, sanitizeErrorForClient(err, "Failed to delete note"), http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`{"success": true}`)) } // HandleExportGuestKnowledge exports all knowledge for a guest as JSON func (h *AISettingsHandler) HandleExportGuestKnowledge(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } guestID := r.URL.Query().Get("guest_id") if guestID == "" { http.Error(w, "guest_id is required", http.StatusBadRequest) return } if len(guestID) > maxGuestIDLength { http.Error(w, "guest_id too long", http.StatusBadRequest) return } knowledge, err := h.GetAIService(r.Context()).GetGuestKnowledge(guestID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Failed to get knowledge"), http.StatusInternalServerError) return } // Sanitize guestID for Content-Disposition header to prevent header injection. // Only allow alphanumeric, hyphens, underscores, and dots. safeID := sanitizeFilenameComponent(guestID) // Set headers for file download w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", "attachment; filename=\"pulse-notes-"+safeID+".json\"") if err := json.NewEncoder(w).Encode(knowledge); err != nil { log.Error().Err(err).Msg("Failed to encode knowledge export") } } // HandleImportGuestKnowledge imports knowledge from a JSON export func (h *AISettingsHandler) HandleImportGuestKnowledge(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Limit request body size to 1MB r.Body = http.MaxBytesReader(w, r.Body, 1024*1024) var importData struct { GuestID string `json:"guest_id"` GuestName string `json:"guest_name"` GuestType string `json:"guest_type"` Notes []struct { Category string `json:"category"` Title string `json:"title"` Content string `json:"content"` } `json:"notes"` Merge bool `json:"merge"` // If true, add to existing notes; if false, replace } if err := json.NewDecoder(r.Body).Decode(&importData); err != nil { http.Error(w, "Invalid import data", http.StatusBadRequest) return } if importData.GuestID == "" { http.Error(w, "guest_id is required in import data", http.StatusBadRequest) return } if len(importData.GuestID) > maxGuestIDLength { http.Error(w, "guest_id too long", http.StatusBadRequest) return } if len(importData.Notes) == 0 { http.Error(w, "No notes to import", http.StatusBadRequest) return } if len(importData.Notes) > maxImportNotes { http.Error(w, fmt.Sprintf("Too many notes: %d exceeds maximum of %d", len(importData.Notes), maxImportNotes), http.StatusBadRequest) return } // Pre-filter valid notes before deleting in replace mode to avoid data loss // when all incoming notes fail validation. isValidNote := func(n struct { Category string `json:"category"` Title string `json:"title"` Content string `json:"content"` }) bool { if n.Category == "" || n.Title == "" || n.Content == "" { return false } if len(n.Category) > 128 || len(n.Title) > 1024 || len(n.Content) > 32*1024 { return false } return true } validCount := 0 for _, note := range importData.Notes { if isValidNote(note) { validCount++ } } if validCount == 0 { http.Error(w, "No valid notes to import", http.StatusBadRequest) return } // If not merging, we need to delete existing notes first if !importData.Merge { existing, err := h.GetAIService(r.Context()).GetGuestKnowledge(importData.GuestID) if err == nil && existing != nil { for _, note := range existing.Notes { _ = h.GetAIService(r.Context()).DeleteGuestNote(importData.GuestID, note.ID) } } } // Import each note imported := 0 for _, note := range importData.Notes { if !isValidNote(note) { continue } if err := h.GetAIService(r.Context()).SaveGuestNote( importData.GuestID, importData.GuestName, importData.GuestType, note.Category, note.Title, note.Content, ); err != nil { log.Warn().Err(err).Str("title", note.Title).Msg("Failed to import note") continue } imported++ } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "imported": imported, "total": len(importData.Notes), }) } // HandleClearGuestKnowledge deletes all notes for a guest func (h *AISettingsHandler) HandleClearGuestKnowledge(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 4*1024) // 4KB max — only needs guest_id + confirm var req struct { GuestID string `json:"guest_id"` Confirm bool `json:"confirm"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.GuestID == "" { http.Error(w, "guest_id is required", http.StatusBadRequest) return } if len(req.GuestID) > maxGuestIDLength { http.Error(w, "guest_id too long", http.StatusBadRequest) return } if !req.Confirm { http.Error(w, "confirm must be true to clear all notes", http.StatusBadRequest) return } // Get existing knowledge and delete all notes existing, err := h.GetAIService(r.Context()).GetGuestKnowledge(req.GuestID) if err != nil { http.Error(w, sanitizeErrorForClient(err, "Failed to get knowledge"), http.StatusInternalServerError) return } deleted := 0 for _, note := range existing.Notes { if err := h.GetAIService(r.Context()).DeleteGuestNote(req.GuestID, note.ID); err != nil { log.Warn().Err(err).Str("note_id", note.ID).Msg("Failed to delete note") continue } deleted++ } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "deleted": deleted, }) } // HandleDebugContext returns the system prompt and context that would be sent to the AI // This is useful for debugging when the AI gives incorrect information func (h *AISettingsHandler) HandleDebugContext(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Build a sample request to see what context would be sent req := ai.ExecuteRequest{ Prompt: "Debug context request", TargetType: r.URL.Query().Get("target_type"), TargetID: r.URL.Query().Get("target_id"), } // Get the debug context from the service debugInfo := h.GetAIService(r.Context()).GetDebugContext(req) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(debugInfo) } // HandleGetConnectedAgents returns the list of agents currently connected via WebSocket // This is useful for debugging when AI can't reach certain agents func (h *AISettingsHandler) HandleGetConnectedAgents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } type agentInfo struct { AgentID string `json:"agent_id"` Hostname string `json:"hostname"` Version string `json:"version"` Platform string `json:"platform"` ConnectedAt string `json:"connected_at"` } var agents []agentInfo if h.agentServer != nil { for _, a := range h.agentServer.GetConnectedAgents() { agents = append(agents, agentInfo{ AgentID: a.AgentID, Hostname: a.Hostname, Version: a.Version, Platform: a.Platform, ConnectedAt: a.ConnectedAt.Format(time.RFC3339), }) } } response := map[string]interface{}{ "count": len(agents), "agents": agents, "note": "Agents connect via WebSocket to /api/agent/ws. If an agent is missing, check that pulse-agent is installed and can reach the Pulse server.", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // AIInvestigateAlertRequest is the request body for POST /api/ai/investigate-alert type AIInvestigateAlertRequest struct { AlertIdentifier string `json:"alertIdentifier"` ResourceID string `json:"resource_id"` ResourceName string `json:"resource_name"` ResourceType string `json:"resource_type"` // canonical v6 resource type AlertType string `json:"alert_type"` // cpu, memory, disk, offline, etc. Level string `json:"level"` // warning, critical Value float64 `json:"value"` Threshold float64 `json:"threshold"` Message string `json:"message"` Duration string `json:"duration"` // How long the alert has been active Node string `json:"node,omitempty"` VMID int `json:"vmid,omitempty"` } func (r AIInvestigateAlertRequest) alertIdentifier() string { return strings.TrimSpace(r.AlertIdentifier) } func normalizeInvestigateAlertTargetType(raw string) (string, error) { resourceType := normalizeAITransportResourceType(raw) switch resourceType { case "vm": return "vm", nil case "system-container", "oci-container": return "system-container", nil case "agent", "node", "docker-host", "app-container", "pod", "k8s-node", "k8s-cluster", "k8s-deployment", "k8s-service", "storage", "disk", "pbs", "pmg", "proxmox", "ceph": return "agent", nil case "": return "", errors.New("resource_type is required") default: return "", fmt.Errorf("unsupported resource_type %q (allowed: vm, system-container, oci-container, app-container, pod, agent, node, docker-host, k8s-cluster, k8s-node, k8s-deployment, k8s-service, storage, disk, pbs, pmg, proxmox, ceph)", raw) } } // HandleInvestigateAlert investigates an alert using AI (POST /api/ai/investigate-alert) // This is a dedicated endpoint for one-click alert investigation from the UI func (h *AISettingsHandler) HandleInvestigateAlert(w http.ResponseWriter, r *http.Request) { // Handle CORS h.setSSECORSHeaders(w, r) if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } // Check if AI is enabled if !h.GetAIService(r.Context()).IsEnabled() { http.Error(w, "Pulse Assistant is not enabled or configured", http.StatusBadRequest) return } // Parse request r.Body = http.MaxBytesReader(w, r.Body, 16*1024) var req AIInvestigateAlertRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Build investigation prompt alertIdentifier := req.alertIdentifier() investigationPrompt := ai.GenerateAlertInvestigationPrompt(ai.AlertInvestigationRequest{ AlertIdentifier: alertIdentifier, ResourceID: req.ResourceID, ResourceName: req.ResourceName, ResourceType: req.ResourceType, AlertType: req.AlertType, Level: req.Level, Value: req.Value, Threshold: req.Threshold, Message: req.Message, Duration: req.Duration, Node: req.Node, VMID: req.VMID, }) log.Info(). Str("alert_identifier", alertIdentifier). Str("resource", req.ResourceName). Str("type", req.AlertType). Msg("AI alert investigation started") targetType, err := normalizeInvestigateAlertTargetType(req.ResourceType) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } resourceType := strings.ToLower(strings.TrimSpace(req.ResourceType)) targetID := strings.TrimSpace(req.ResourceID) if targetType == "vm" || targetType == "system-container" { if req.VMID > 0 { targetID = strconv.Itoa(req.VMID) } } else if targetType == "agent" { if node := strings.ToLower(strings.TrimSpace(req.Node)); node != "" { targetID = node } else if resourceType != "app-container" { // Keep explicit host-like IDs for node/agent resources. targetID = strings.ToLower(strings.TrimSpace(req.ResourceID)) } else { // app-container IDs are container-scoped, not host routing IDs. targetID = "" } } // Set up SSE streaming w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("Transfer-Encoding", "identity") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming not supported", http.StatusInternalServerError) return } // Disable write/read deadlines for SSE rc := http.NewResponseController(w) _ = rc.SetWriteDeadline(time.Time{}) _ = rc.SetReadDeadline(time.Time{}) flusher.Flush() // Create context with timeout ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) defer cancel() // Heartbeat routine heartbeatDone := make(chan struct{}) var clientDisconnected atomic.Bool go func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for { select { case <-ticker.C: _ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err := w.Write([]byte(": heartbeat\n\n")) if err != nil { clientDisconnected.Store(true) return } flusher.Flush() case <-heartbeatDone: return } } }() defer close(heartbeatDone) safeWrite := func(data []byte) bool { if clientDisconnected.Load() { return false } _ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second)) _, err := w.Write(data) if err != nil { clientDisconnected.Store(true) return false } flusher.Flush() return true } // Stream callback callback := func(event ai.StreamEvent) { if event.Type == "done" { return } data, err := json.Marshal(event) if err != nil { return } safeWrite([]byte("data: " + string(data) + "\n\n")) } // Execute with streaming defer func() { if !clientDisconnected.Load() { doneEvent := ai.StreamEvent{Type: "done"} data, _ := json.Marshal(doneEvent) safeWrite([]byte("data: " + string(data) + "\n\n")) } }() autonomousMode := false resp, err := h.GetAIService(r.Context()).ExecuteStream(ctx, ai.ExecuteRequest{ Prompt: investigationPrompt, TargetType: targetType, TargetID: targetID, AutonomousMode: &autonomousMode, RequireCommandApproval: true, Context: map[string]interface{}{ "alertIdentifier": alertIdentifier, "alertType": req.AlertType, "alertLevel": req.Level, "alertMessage": req.Message, "guestName": req.ResourceName, "node": req.Node, }, }, callback) if err != nil { log.Error().Err(err).Msg("AI alert investigation failed") errEvent := ai.StreamEvent{Type: "error", Data: map[string]string{"message": "Alert investigation failed. Please try again."}} data, _ := json.Marshal(errEvent) safeWrite([]byte("data: " + string(data) + "\n\n")) return } // Send completion event finalEvent := aiExecuteStreamCompleteEvent{ Type: "complete", Model: resp.Model, InputTokens: resp.InputTokens, OutputTokens: resp.OutputTokens, ToolCalls: resp.ToolCalls, }.NormalizeCollections() data, _ := json.Marshal(finalEvent) safeWrite([]byte("data: " + string(data) + "\n\n")) if alertIdentifier != "" { h.GetAIService(r.Context()).RecordIncidentAnalysis(alertIdentifier, "Pulse Assistant alert investigation completed", map[string]interface{}{ "model": resp.Model, "tool_calls": len(resp.ToolCalls), "input_tokens": resp.InputTokens, "output_tokens": resp.OutputTokens, }) } log.Info(). Str("alert_identifier", alertIdentifier). Str("model", resp.Model). Int("tool_calls", len(resp.ToolCalls)). Msg("AI alert investigation completed") if alertIdentifier != "" { h.GetAIService(r.Context()).RecordIncidentAnalysis(alertIdentifier, "Pulse Assistant investigation completed", map[string]interface{}{ "model": resp.Model, "input_tokens": resp.InputTokens, "output_tokens": resp.OutputTokens, "tool_calls": len(resp.ToolCalls), }) } } // SetAlertProvider sets the alert provider for AI context // Sets on both the default-org service and all tenant services to ensure multi-tenant support. func (h *AISettingsHandler) SetAlertProvider(ap ai.AlertProvider) { h.defaultAIService.SetAlertProvider(ap) h.aiServicesMu.RLock() defer h.aiServicesMu.RUnlock() for _, svc := range h.aiServices { svc.SetAlertProvider(ap) } } // SetAlertResolver sets the alert resolver for AI Patrol autonomous alert management // Sets on both the default-org service and all tenant services to ensure multi-tenant support. func (h *AISettingsHandler) SetAlertResolver(resolver ai.AlertResolver) { h.defaultAIService.SetAlertResolver(resolver) h.aiServicesMu.RLock() defer h.aiServicesMu.RUnlock() for _, svc := range h.aiServices { svc.SetAlertResolver(resolver) } } // oauthSessions stores active OAuth sessions (state -> session) // In production, consider using a more robust session store with expiry type oauthSessionBinding struct { session *providers.OAuthSession orgID string } const oauthSessionTTL = 15 * time.Minute var oauthSessions = make(map[string]*oauthSessionBinding) var oauthSessionsMu sync.Mutex var exchangeOAuthCodeForTokens = providers.ExchangeCodeForTokens var createAPIKeyFromOAuth = providers.CreateAPIKeyFromOAuth func storeOAuthSession(session *providers.OAuthSession, orgID string) { oauthSessionsMu.Lock() defer oauthSessionsMu.Unlock() cutoff := time.Now().Add(-oauthSessionTTL) for state, binding := range oauthSessions { if binding == nil || binding.session == nil || binding.session.CreatedAt.Before(cutoff) { delete(oauthSessions, state) } } oauthSessions[session.State] = &oauthSessionBinding{ session: session, orgID: orgID, } } func consumeOAuthSession(state string) (*oauthSessionBinding, bool) { oauthSessionsMu.Lock() binding, ok := oauthSessions[state] if ok { delete(oauthSessions, state) // One-time use } oauthSessionsMu.Unlock() if !ok || binding == nil || binding.session == nil { return nil, false } if time.Since(binding.session.CreatedAt) > oauthSessionTTL { return nil, false } if !isValidOrganizationID(binding.orgID) { return nil, false } return binding, true } // HandleOAuthStart initiates the OAuth flow for Claude Pro/Max subscription (POST /api/ai/oauth/start) // Returns an authorization URL for the user to visit manually func (h *AISettingsHandler) HandleOAuthStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Generate OAuth session (redirect URI is not used since we use Anthropic's callback) session, err := providers.GenerateOAuthSession("") if err != nil { log.Error().Err(err).Msg("Failed to generate OAuth session") http.Error(w, "Failed to start OAuth flow", http.StatusInternalServerError) return } orgID := strings.TrimSpace(GetOrgID(r.Context())) if !isValidOrganizationID(orgID) { orgID = "default" } // Store session, bound to the tenant context that initiated OAuth. storeOAuthSession(session, orgID) // Get authorization URL authURL := providers.GetAuthorizationURL(session) log.Info(). Str("state", safePrefixForLog(session.State, 8)+"..."). Str("org_id", orgID). Str("verifier_len", fmt.Sprintf("%d", len(session.CodeVerifier))). Str("auth_url", authURL). Msg("Starting Claude OAuth flow - user must visit URL and paste code back") // Return the URL for the user to visit response := map[string]string{ "auth_url": authURL, "state": session.State, } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write OAuth start response") } } // HandleOAuthExchange exchanges a manually-pasted authorization code for tokens (POST /api/ai/oauth/exchange) func (h *AISettingsHandler) HandleOAuthExchange(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Limit body size — OAuth codes are short strings, 4 KB is plenty. r.Body = http.MaxBytesReader(w, r.Body, 4*1024) // Parse request body var req struct { Code string `json:"code"` State string `json:"state"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Code == "" || req.State == "" { http.Error(w, "Missing code or state", http.StatusBadRequest) return } // Trim any whitespace from the code (user might have copied extra spaces) code := strings.TrimSpace(req.Code) // Anthropic's callback page displays the code as "code#state" // We need to extract just the code part before the # if idx := strings.Index(code, "#"); idx > 0 { code = code[:idx] } log.Debug(). Str("code_len", fmt.Sprintf("%d", len(code))). Str("code_prefix", code[:min(20, len(code))]). Str("state_prefix", req.State[:min(8, len(req.State))]). Msg("Processing OAuth code exchange") // Consume one-time OAuth session and enforce TTL. binding, ok := consumeOAuthSession(req.State) if !ok { log.Error().Str("state", req.State[:min(8, len(req.State))]+"...").Msg("OAuth exchange with unknown state") http.Error(w, "Invalid or expired session. Please start the OAuth flow again.", http.StatusBadRequest) return } orgID := binding.orgID oauthCtx := context.WithValue(r.Context(), OrgIDContextKey, orgID) // Exchange code for tokens ctx, cancel := context.WithTimeout(oauthCtx, 30*time.Second) defer cancel() tokens, err := exchangeOAuthCodeForTokens(ctx, code, binding.session) if err != nil { log.Error().Err(err).Msg("Failed to exchange OAuth code for tokens") http.Error(w, "Failed to exchange authorization code", http.StatusBadRequest) return } // Try to create an API key from the OAuth access token // Team/Enterprise users get org:create_api_key scope and can create API keys // Pro/Max users don't have this scope and will use OAuth tokens directly apiKey, err := createAPIKeyFromOAuth(ctx, tokens.AccessToken) if err != nil { // Check if it's a permission error (Pro/Max users) if strings.Contains(err.Error(), "org:create_api_key") || strings.Contains(err.Error(), "403") { log.Info().Msg("User doesn't have org:create_api_key permission - will use OAuth tokens directly") // This is fine for Pro/Max users - they'll use OAuth tokens } else { log.Error().Err(err).Msg("Failed to create API key from OAuth token") http.Error(w, "Failed to create API key", http.StatusBadRequest) return } } if apiKey != "" { log.Info().Msg("Successfully created API key from OAuth - using subscription-based billing") } // Load existing settings persistence := h.getPersistence(oauthCtx) if persistence == nil { http.Error(w, "Tenant persistence unavailable", http.StatusInternalServerError) return } settings, err := h.loadAIConfig(oauthCtx) if err != nil { log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth") settings = config.NewDefaultAIConfig() } if settings == nil { settings = config.NewDefaultAIConfig() } // Update settings settings.AuthMethod = config.AuthMethodOAuth settings.OAuthAccessToken = tokens.AccessToken settings.OAuthRefreshToken = tokens.RefreshToken settings.OAuthExpiresAt = tokens.ExpiresAt settings.Enabled = true // If we got an API key, use it; otherwise use OAuth tokens directly if apiKey != "" { settings.AnthropicAPIKey = apiKey } else { // Pro/Max users: clear any old API key, will use OAuth client settings.ClearAPIKey() } // Save settings if err := persistence.SaveAIConfig(*settings); err != nil { log.Error().Err(err).Msg("Failed to save OAuth tokens") http.Error(w, "Failed to save OAuth credentials", http.StatusInternalServerError) return } // Reload the AI service with new settings if svc := h.GetAIService(oauthCtx); svc != nil { if err := svc.Reload(); err != nil { log.Warn().Err(err).Msg("Failed to reload AI service after OAuth setup") } } log.Info().Msg("Claude OAuth authentication successful") response := map[string]interface{}{ "success": true, "message": "Successfully connected to Claude with your subscription", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write OAuth exchange response") } } // HandleOAuthCallback handles the OAuth callback (GET /api/ai/oauth/callback) // This is kept for backwards compatibility but mainly serves as a fallback func (h *AISettingsHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Get code and state from query params code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") errParam := r.URL.Query().Get("error") errDesc := r.URL.Query().Get("error_description") // Check for OAuth error if errParam != "" { log.Error(). Str("error", errParam). Str("description", errDesc). Msg("OAuth authorization failed") // Redirect to settings page with error (URL-encode to prevent injection) http.Redirect(w, r, "/settings?ai_oauth_error="+url.QueryEscape(errParam), http.StatusTemporaryRedirect) return } if code == "" || state == "" { log.Error().Msg("OAuth callback missing code or state") http.Redirect(w, r, "/settings?ai_oauth_error=missing_params", http.StatusTemporaryRedirect) return } // Consume one-time OAuth session and enforce TTL. binding, ok := consumeOAuthSession(state) if !ok { log.Error().Str("state", state).Msg("OAuth callback with unknown state") http.Redirect(w, r, "/settings?ai_oauth_error=invalid_state", http.StatusTemporaryRedirect) return } orgID := binding.orgID oauthCtx := context.WithValue(r.Context(), OrgIDContextKey, orgID) // Exchange code for tokens ctx, cancel := context.WithTimeout(oauthCtx, 30*time.Second) defer cancel() tokens, err := exchangeOAuthCodeForTokens(ctx, code, binding.session) if err != nil { log.Error().Err(err).Msg("Failed to exchange OAuth code for tokens") http.Redirect(w, r, "/settings?ai_oauth_error=token_exchange_failed", http.StatusTemporaryRedirect) return } // Load existing settings persistence := h.getPersistence(oauthCtx) if persistence == nil { http.Redirect(w, r, "/settings?ai_oauth_error=save_failed", http.StatusTemporaryRedirect) return } settings, err := h.loadAIConfig(oauthCtx) if err != nil { log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth") settings = config.NewDefaultAIConfig() } if settings == nil { settings = config.NewDefaultAIConfig() } // Update settings with OAuth tokens settings.AuthMethod = config.AuthMethodOAuth settings.OAuthAccessToken = tokens.AccessToken settings.OAuthRefreshToken = tokens.RefreshToken settings.OAuthExpiresAt = tokens.ExpiresAt settings.Enabled = true // Clear API key since we're using OAuth settings.ClearAPIKey() // Save settings if err := persistence.SaveAIConfig(*settings); err != nil { log.Error().Err(err).Msg("Failed to save OAuth tokens") http.Redirect(w, r, "/settings?ai_oauth_error=save_failed", http.StatusTemporaryRedirect) return } // Reload the AI service with new settings if svc := h.GetAIService(oauthCtx); svc != nil { if err := svc.Reload(); err != nil { log.Warn().Err(err).Msg("Failed to reload AI service after OAuth setup") } } log.Info().Msg("Claude OAuth authentication successful") // Redirect to settings page with success http.Redirect(w, r, "/settings?ai_oauth_success=true", http.StatusTemporaryRedirect) } // HandleOAuthDisconnect disconnects OAuth and clears tokens (POST /api/ai/oauth/disconnect) func (h *AISettingsHandler) HandleOAuthDisconnect(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require admin authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } // Load existing settings persistence := h.getPersistence(r.Context()) if persistence == nil { log.Error().Msg("No persistence available for OAuth disconnect") http.Error(w, "Failed to load settings", http.StatusInternalServerError) return } settings, err := h.loadAIConfig(r.Context()) if err != nil { log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth disconnect") http.Error(w, "Failed to load settings", http.StatusInternalServerError) return } if settings == nil { settings = config.NewDefaultAIConfig() } // Clear OAuth tokens settings.ClearOAuthTokens() settings.AuthMethod = config.AuthMethodAPIKey // Save settings if err := persistence.SaveAIConfig(*settings); err != nil { log.Error().Err(err).Msg("Failed to save settings after OAuth disconnect") http.Error(w, "Failed to save settings", http.StatusInternalServerError) return } // Reload the AI service if svc := h.GetAIService(r.Context()); svc != nil { if err := svc.Reload(); err != nil { log.Warn().Err(err).Msg("Failed to reload AI service after OAuth disconnect") } } log.Info().Msg("Claude OAuth disconnected") response := map[string]interface{}{ "success": true, "message": "OAuth disconnected successfully", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write OAuth disconnect response") } } // PatrolStatusResponse is the response for GET /api/ai/patrol/status type PatrolStatusResponse struct { RuntimeState ai.PatrolRuntimeState `json:"runtime_state"` Running bool `json:"running"` Enabled bool `json:"enabled"` LastPatrolAt *time.Time `json:"last_patrol_at,omitempty"` LastActivityAt *time.Time `json:"last_activity_at,omitempty"` TriggerStatus *ai.TriggerStatus `json:"trigger_status,omitempty"` NextPatrolAt *time.Time `json:"next_patrol_at,omitempty"` LastDurationMs int64 `json:"last_duration_ms"` ResourcesChecked int `json:"resources_checked"` FindingsCount int `json:"findings_count"` ErrorCount int `json:"error_count"` Healthy bool `json:"healthy"` IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds FixedCount int `json:"fixed_count"` // Number of issues remediated by Patrol BlockedReason string `json:"blocked_reason,omitempty"` BlockedCause string `json:"blocked_cause,omitempty"` BlockedAt *time.Time `json:"blocked_at,omitempty"` // License status for Pro feature gating LicenseRequired bool `json:"license_required"` // True if Pro license needed for full features LicenseStatus string `json:"license_status"` // "active", "expired", "grace_period", "none" UpgradeURL string `json:"upgrade_url,omitempty"` Summary struct { Critical int `json:"critical"` Warning int `json:"warning"` Watch int `json:"watch"` Info int `json:"info"` } `json:"summary"` // Trust is a snapshot of how currently-tracked findings have resolved. // Operators use this to scan whether Patrol's analysis has been useful: // fix-verified and dismissed-as-noise grow as the system gets sharper. // Snapshot semantics, not lifetime totals; nil when no patrol service. Trust *ai.FindingsTrustSummary `json:"trust,omitempty"` Readiness *PatrolReadinessResponse `json:"readiness,omitempty"` } type PatrolReadinessResponse struct { Status string `json:"status"` Ready bool `json:"ready"` Cause string `json:"cause,omitempty"` Summary string `json:"summary"` Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` Checks []PatrolReadinessCheck `json:"checks"` } type PatrolReadinessCheck struct { ID string `json:"id"` Status string `json:"status"` Cause string `json:"cause,omitempty"` Label string `json:"label"` Message string `json:"message"` Action string `json:"action,omitempty"` } const ( patrolReadinessReady = ai.PatrolReadinessReady patrolReadinessWarning = ai.PatrolReadinessWarning patrolReadinessNotReady = ai.PatrolReadinessNotReady defaultResolvedPatrolFindingsLimit = 200 maxPatrolFindingsLimit = 500 ) func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService *ai.Service, patrolAvailable bool) PatrolReadinessResponse { checks := make([]PatrolReadinessCheck, 0, 4) addCheck := func(id, status string, cause ai.PatrolFailureCause, label, message, action string) { checks = append(checks, PatrolReadinessCheck{ ID: id, Status: status, Cause: patrolFailureCauseResponse(cause), Label: label, Message: message, Action: action, }) } if aiService == nil { addCheck("service", patrolReadinessNotReady, ai.PatrolFailureCauseServiceUnavailable, "AI runtime service", "Pulse AI runtime service is not available.", "restart_service") return summarizePatrolReadiness("", "", checks) } if !patrolAvailable { addCheck("service", patrolReadinessNotReady, ai.PatrolFailureCauseServiceUnavailable, "Patrol service", "Pulse Patrol service is not available.", "restart_service") return summarizePatrolReadiness("", "", checks) } addCheck("service", patrolReadinessReady, ai.PatrolFailureCauseNone, "Patrol service", "Pulse Patrol service is available.", "") cfg, err := h.loadAIConfig(ctx) if err != nil || cfg == nil { addCheck("settings", patrolReadinessNotReady, ai.PatrolFailureCauseSettingsPersistence, "Settings persistence", "Pulse Assistant settings could not be loaded from persistence.", "open_provider_settings") return summarizePatrolReadiness("", "", checks) } addCheck("settings", patrolReadinessReady, ai.PatrolFailureCauseNone, "Settings persistence", "Pulse Assistant and Patrol settings are readable.", "") if !cfg.Enabled { addCheck("enabled", patrolReadinessNotReady, ai.PatrolFailureCauseAssistantDisabled, "Assistant enabled", "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.", "open_provider_settings") } else { addCheck("enabled", patrolReadinessReady, ai.PatrolFailureCauseNone, "Assistant enabled", "Pulse Assistant is enabled for Patrol verification.", "") } if !cfg.IsConfigured() { addCheck("provider", patrolReadinessNotReady, ai.PatrolFailureCauseProviderNotConfigured, "Provider configured", "No AI provider is configured for Patrol.", "open_provider_settings") return summarizePatrolReadiness("", "", checks) } addCheck("provider", patrolReadinessReady, ai.PatrolFailureCauseNone, "Provider configured", "At least one AI provider is configured.", "") model := strings.TrimSpace(cfg.GetPatrolModel()) if model == "" { model = strings.TrimSpace(cfg.GetChatModel()) } provider, _ := config.ParseModelString(model) if model == "" || provider == "" || provider == config.AIProviderQuickstart { addCheck("model", patrolReadinessNotReady, ai.PatrolFailureCauseModelNotSelected, "Patrol model", "No concrete Patrol model is selected.", "open_provider_settings") return summarizePatrolReadiness(provider, model, checks) } if !cfg.HasProvider(provider) { addCheck("model", patrolReadinessNotReady, ai.PatrolFailureCauseModelProviderUnconfigured, "Patrol model", fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", provider), "open_provider_settings") return summarizePatrolReadiness(provider, model, checks) } addCheck("model", patrolReadinessReady, ai.PatrolFailureCauseNone, "Patrol model", "Patrol has a model selected from a configured provider.", "") toolStatus, toolCause, toolMessage, toolAction := resolvePatrolToolsCheck(aiService, provider, model) addCheck("tools", toolStatus, toolCause, "Patrol tools", toolMessage, toolAction) return summarizePatrolReadiness(provider, model, checks) } // resolvePatrolToolsCheck grounds the "Patrol tools" readiness check in // actual evidence when the preflight cache holds a recent result for // the configured provider+model. The cache is populated by // (a) auto-trigger on settings save when transport changes, // (b) startup-seed when assistant + patrol model are configured, and // (c) manual Verify Patrol clicks. When no cached evidence is // available, fall back to the static `PatrolToolReadinessForModel` // classifier so the check still has a meaningful baseline. func resolvePatrolToolsCheck(aiService *ai.Service, provider, model string) (status string, cause ai.PatrolFailureCause, message, action string) { staticStatus, staticCause, staticMessage := ai.PatrolToolReadinessForModel(provider, model) staticAction := "" if staticStatus != patrolReadinessReady { staticAction = "open_provider_settings" } if aiService == nil { return staticStatus, staticCause, staticMessage, staticAction } cached, recordedAt := aiService.CachedPatrolPreflight() if cached == nil || recordedAt.IsZero() { return staticStatus, staticCause, staticMessage, staticAction } // Cache only carries the most recent result regardless of which // model was tested, so a mismatch means the configured model // hasn't been preflighted yet. Fall back to static. _, bareModel := config.ParseModelString(model) if !strings.EqualFold(cached.Provider, provider) || cached.Model != bareModel { return staticStatus, staticCause, staticMessage, staticAction } age := formatPatrolPreflightAge(time.Since(recordedAt)) if cached.Success && cached.ToolCallObserved { return patrolReadinessReady, ai.PatrolFailureCauseNone, fmt.Sprintf("Tool calling verified %s against %s.", age, bareModel), "" } // Cached failure or soft warning — surface the classified summary // so the Patrol page reads the same diagnostic the operator would // see on the AI settings preflight panel. resolvedStatus := patrolReadinessNotReady if cached.Cause == ai.PatrolFailureCauseModelToolSupportUnverified { resolvedStatus = patrolReadinessWarning } msg := strings.TrimSpace(cached.Summary) if msg == "" { msg = strings.TrimSpace(cached.Title) } if msg == "" { msg = staticMessage } return resolvedStatus, cached.Cause, fmt.Sprintf("%s (last preflight %s).", msg, age), "open_provider_settings" } func formatPatrolPreflightAge(age time.Duration) string { switch { case age < time.Minute: return "just now" case age < time.Hour: return fmt.Sprintf("%dm ago", int(age.Minutes())) case age < 24*time.Hour: return fmt.Sprintf("%dh ago", int(age.Hours())) default: return fmt.Sprintf("%dd ago", int(age.Hours()/24)) } } func summarizePatrolReadiness(provider, model string, checks []PatrolReadinessCheck) PatrolReadinessResponse { status := patrolReadinessReady cause := "" summary := "Patrol is ready to run tool-backed verification." for _, check := range checks { if check.Status == patrolReadinessNotReady { status = patrolReadinessNotReady cause = check.Cause summary = check.Message break } if check.Status == patrolReadinessWarning && status == patrolReadinessReady { status = patrolReadinessWarning cause = check.Cause summary = check.Message } } return PatrolReadinessResponse{ Status: status, Ready: status != patrolReadinessNotReady, Cause: cause, Summary: summary, Provider: provider, Model: model, Checks: checks, } } func patrolFailureCauseResponse(cause ai.PatrolFailureCause) string { if cause == "" || cause == ai.PatrolFailureCauseNone { return "" } return string(cause) } func patrolReadinessResponseFromConfigReadiness(readiness ai.PatrolConfigReadiness) PatrolReadinessResponse { action := "" if !readiness.Ready { action = "open_provider_settings" } cause := patrolFailureCauseResponse(readiness.Cause) return PatrolReadinessResponse{ Status: readiness.Status, Ready: readiness.Ready, Cause: cause, Summary: readiness.Summary, Provider: readiness.Provider, Model: readiness.Model, Checks: []PatrolReadinessCheck{ { ID: "configuration", Status: readiness.Status, Cause: cause, Label: "Patrol configuration", Message: readiness.Summary, Action: action, }, }, } } func ptrToPatrolReadiness(readiness PatrolReadinessResponse) *PatrolReadinessResponse { return &readiness } func (h *AISettingsHandler) getPatrolService(ctx context.Context) *ai.PatrolService { aiService := h.GetAIService(ctx) if aiService == nil { return nil } return aiService.GetPatrolService() } func writePatrolServiceUnavailableResponse(w http.ResponseWriter) { writeErrorResponse(w, http.StatusServiceUnavailable, "service_unavailable", "Pulse Patrol service not available", nil) } func writePatrolReadinessNotReadyResponse(w http.ResponseWriter, statusCode int, readiness ai.PatrolConfigReadiness) { details := map[string]string{"status": readiness.Status} if cause := patrolFailureCauseResponse(readiness.Cause); cause != "" { details["cause"] = cause } if readiness.Provider != "" { details["provider"] = readiness.Provider } if readiness.Model != "" { details["model"] = readiness.Model } writeErrorResponse(w, statusCode, "patrol_readiness_not_ready", readiness.Summary, details) } func retireQuickstartPatrolStatus(status ai.PatrolStatus) ai.PatrolStatus { if isRetiredQuickstartBlockedReason(status.BlockedReason) { status.BlockedReason = "" status.BlockedAt = nil if status.RuntimeState == ai.PatrolRuntimeStateBlocked { if status.Enabled { status.RuntimeState = ai.PatrolRuntimeStateActive } else { status.RuntimeState = ai.PatrolRuntimeStateUnavailable } status.Healthy = true } } return status } func isRetiredQuickstartBlockedReason(reason string) bool { normalized := strings.ToLower(strings.TrimSpace(reason)) return strings.Contains(normalized, "quickstart credits exhausted") || strings.Contains(normalized, "hosted quickstart requires") } // HandleGetPatrolStatus returns the current patrol status (GET /api/ai/patrol/status) func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) if aiService == nil { // Service not initialized (e.g. no persistence/config yet). Return safe defaults. response := PatrolStatusResponse{ RuntimeState: ai.PatrolRuntimeStateUnavailable, Running: false, Enabled: false, Healthy: true, LicenseRequired: true, LicenseStatus: "none", UpgradeURL: upgradeURLForFeatureFromLicensing(featureAIAutoFixValue), Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), nil, false)), } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write patrol status response (no AI service)") } return } patrol := aiService.GetPatrolService() if patrol == nil { // Patrol not initialized licenseStatus, _ := aiService.GetLicenseState() hasAutoFixFeature := aiService.HasLicenseFeature(featureAIAutoFixValue) response := PatrolStatusResponse{ RuntimeState: ai.PatrolRuntimeStateUnavailable, Running: false, Enabled: false, Healthy: true, LicenseRequired: !hasAutoFixFeature, LicenseStatus: licenseStatus, Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), aiService, false)), } if !hasAutoFixFeature { response.UpgradeURL = upgradeURLForFeatureFromLicensing(featureAIAutoFixValue) } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write patrol status response") } return } status := retireQuickstartPatrolStatus(patrol.GetStatus()) summary := patrol.GetFindingsSummary() // Determine license status for Pro feature gating // GetLicenseState returns accurate state: none, active, expired, grace_period licenseStatus, _ := aiService.GetLicenseState() // Check for auto-fix feature - patrol itself is free, auto-fix requires Pro hasAutoFixFeature := aiService.HasLicenseFeature(featureAIAutoFixValue) // Get fixed count from investigation orchestrator fixedCount := 0 if orchestrator := patrol.GetInvestigationOrchestrator(); orchestrator != nil { fixedCount = orchestrator.GetFixedCount() } response := PatrolStatusResponse{ RuntimeState: status.RuntimeState, Running: status.Running, Enabled: status.Enabled, LastPatrolAt: status.LastPatrolAt, LastActivityAt: status.LastActivityAt, TriggerStatus: status.TriggerStatus, NextPatrolAt: status.NextPatrolAt, LastDurationMs: status.LastDuration.Milliseconds(), ResourcesChecked: status.ResourcesChecked, FindingsCount: status.FindingsCount, ErrorCount: status.ErrorCount, Healthy: status.Healthy, IntervalMs: status.IntervalMs, FixedCount: fixedCount, BlockedReason: status.BlockedReason, BlockedCause: patrolFailureCauseResponse(status.BlockedCause), BlockedAt: status.BlockedAt, LicenseRequired: !hasAutoFixFeature, LicenseStatus: licenseStatus, Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), aiService, true)), } if !hasAutoFixFeature { response.UpgradeURL = upgradeURLForFeatureFromLicensing(featureAIAutoFixValue) } response.Summary.Critical = summary.Critical response.Summary.Warning = summary.Warning response.Summary.Watch = summary.Watch response.Summary.Info = summary.Info trust := patrol.GetFindingsTrustSummary() response.Trust = &trust if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write patrol status response") } } // HandleGetIntelligence returns the unified AI intelligence summary (GET /api/ai/intelligence) // This provides a single endpoint for system-wide AI insights including: // - Overall health score and grade // - Active findings summary // - Upcoming predictions // - Recent activity // - Learning progress // - Resources at risk func (h *AISettingsHandler) HandleGetIntelligence(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) if aiService == nil { response := map[string]interface{}{ "error": "Pulse Patrol service not available", } w.WriteHeader(http.StatusServiceUnavailable) if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write intelligence response") } return } patrol := aiService.GetPatrolService() if patrol == nil { response := map[string]interface{}{ "error": "Pulse Patrol service not available", } w.WriteHeader(http.StatusServiceUnavailable) if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write intelligence response") } return } // Get unified intelligence facade intel := patrol.GetIntelligence() if intel == nil { response := map[string]interface{}{ "error": "Intelligence not initialized", } w.WriteHeader(http.StatusServiceUnavailable) if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write intelligence response") } return } // Check for resource_id query parameter for resource-specific intelligence resourceID := r.URL.Query().Get("resource_id") if resourceID != "" { if len(resourceID) > 500 { http.Error(w, "resource_id too long", http.StatusBadRequest) return } // Return resource-specific intelligence resourceIntel := intel.GetResourceIntelligence(resourceID) if err := utils.WriteJSONResponse(w, resourceIntel); err != nil { log.Error().Err(err).Msg("Failed to write resource intelligence response") } return } // Return system-wide intelligence summary summary := intel.GetSummary() if err := utils.WriteJSONResponse(w, summary); err != nil { log.Error().Err(err).Msg("Failed to write intelligence summary response") } } // HandlePatrolStream streams real-time patrol analysis via SSE (GET /api/ai/patrol/stream) func (h *AISettingsHandler) HandlePatrolStream(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) if aiService == nil { writePatrolServiceUnavailableResponse(w) return } patrol := aiService.GetPatrolService() if patrol == nil { writePatrolServiceUnavailableResponse(w) return } // Set SSE headers h.setSSECORSHeaders(w, r) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") // Disable proxy buffering (e.g. nginx) so events reach clients promptly. w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming not supported", http.StatusInternalServerError) return } // Send an SSE comment to flush headers immediately so clients get the // HTTP 200 response right away instead of blocking until the first event. if _, err := fmt.Fprintf(w, ": connected\n\n"); err != nil { log.Debug().Err(err).Msg("Patrol stream: failed to write initial SSE comment") return } flusher.Flush() // Subscribe to patrol stream // Note: SubscribeToStream already sends the current buffered output to the channel lastID := int64(0) if raw := strings.TrimSpace(r.Header.Get("Last-Event-ID")); raw != "" { if v, err := strconv.ParseInt(raw, 10, 64); err == nil && v > 0 { lastID = v } } // EventSource doesn't allow setting headers manually. Accept a query param fallback // so clients can resume even when they create a new EventSource instance. if lastID == 0 { if raw := strings.TrimSpace(r.URL.Query().Get("last_event_id")); raw != "" { if v, err := strconv.ParseInt(raw, 10, 64); err == nil && v > 0 { lastID = v } } } ch := patrol.SubscribeToStreamFrom(lastID) defer patrol.UnsubscribeFromStream(ch) // Stream events until client disconnects ctx := r.Context() heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() for { select { case <-ctx.Done(): return case <-heartbeat.C: // SSE comment heartbeat keeps intermediaries from timing out the stream. if _, err := fmt.Fprintf(w, ": ping %d\n\n", time.Now().Unix()); err != nil { log.Debug().Err(err).Msg("Patrol stream: failed to write heartbeat") return } flusher.Flush() case event, ok := <-ch: if !ok { return } data, err := json.Marshal(event) if err != nil { continue } // Best-effort event id for Last-Event-ID support. if event.Seq != 0 { if _, err := fmt.Fprintf(w, "id: %d\n", event.Seq); err != nil { log.Debug().Err(err).Msg("Patrol stream: failed to write event id") return } } if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { log.Debug().Err(err).Msg("Patrol stream: failed to write event data") return } flusher.Flush() } } } // HandleGetPatrolFindings returns all active findings (GET /api/ai/patrol/findings) func (h *AISettingsHandler) HandleGetPatrolFindings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) if aiService == nil { // Return empty findings if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { log.Error().Err(err).Msg("Failed to write patrol findings response (no AI service)") } return } patrol := aiService.GetPatrolService() if patrol == nil { // Return empty findings if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { log.Error().Err(err).Msg("Failed to write patrol findings response") } return } // Check for resource_id query parameter resourceID := r.URL.Query().Get("resource_id") // include_resolved=1 returns active + resolved + dismissed + snoozed // findings, so the Patrol UI's Resolved tab can audit the // auto_resolved set credited in the trust strip. Default behaviour // remains active-only for clients that just want to render the // live findings list. includeResolved := r.URL.Query().Get("include_resolved") == "1" var findings []*ai.Finding if resourceID != "" { findings = patrol.GetFindingsForResource(resourceID) } else if includeResolved { findings = patrol.GetAllFindingsIncludingResolved() } else { findings = patrol.GetAllFindings() } limit := patrolFindingsLimitFromQuery(r.URL.Query(), includeResolved) if limit > 0 && limit < len(findings) { findings = findings[:limit] } if err := utils.WriteJSONResponse(w, findings); err != nil { log.Error().Err(err).Msg("Failed to write patrol findings response") } } func patrolFindingsLimitFromQuery(query url.Values, includeResolved bool) int { limit := 0 if includeResolved { limit = defaultResolvedPatrolFindingsLimit } if limitStr := query.Get("limit"); limitStr != "" { parsed, err := strconv.Atoi(limitStr) if err != nil || parsed <= 0 { return limit } if parsed > maxPatrolFindingsLimit { return maxPatrolFindingsLimit } return parsed } return limit } // HandleForcePatrol triggers an immediate patrol run (POST /api/ai/patrol/run) func (h *AISettingsHandler) HandleForcePatrol(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require admin authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } aiService := h.GetAIService(r.Context()) if aiService == nil { writePatrolServiceUnavailableResponse(w) return } patrol := aiService.GetPatrolService() if patrol == nil { writePatrolServiceUnavailableResponse(w) return } if readiness := aiService.PatrolRuntimeReadiness(); !readiness.Ready { writePatrolReadinessNotReadyResponse(w, http.StatusConflict, readiness) return } // Cadence cap: Community tier is limited to 1 patrol run per hour. // Patrol itself is free (ai_patrol), but higher cadence is gated behind Pro/Cloud. if !aiService.HasLicenseFeature(featureAIAutoFixValue) { if last := patrol.GetStatus().LastPatrolAt; last != nil { if since := time.Since(*last); since < 1*time.Hour { remaining := (1*time.Hour - since).Round(time.Minute) writeErrorResponse(w, http.StatusTooManyRequests, "patrol_rate_limited", fmt.Sprintf("Community tier is limited to 1 patrol run per hour. Try again in %s.", remaining), nil) return } } } // Trigger patrol asynchronously patrol.ForcePatrol(r.Context()) response := map[string]interface{}{ "success": true, "message": "Triggered patrol run", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write force patrol response") } } // aiPatrolPreflightResponse is the JSON shape returned by // HandlePatrolPreflight. It mirrors aiProviderTestResponse for the // classified-error fields and adds two preflight-specific signals: // ToolCallObserved (whether the model emitted a tool call) and // DurationMs (round-trip latency, useful for spotting slow providers). type aiPatrolPreflightResponse struct { Success bool `json:"success"` Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` ToolCallObserved bool `json:"tool_call_observed"` DurationMs int64 `json:"duration_ms"` Message string `json:"message"` Cause string `json:"cause,omitempty"` Summary string `json:"summary,omitempty"` Description string `json:"description,omitempty"` Recommendation string `json:"recommendation,omitempty"` Action string `json:"action,omitempty"` } type aiPatrolPreflightRequest struct { Provider string `json:"provider,omitempty"` Model string `json:"model,omitempty"` } // HandlePatrolPreflight runs a one-shot tool-call round-trip against the // configured (or overridden) Patrol provider+model so operators can // verify tool calling actually works before relying on Patrol's // scheduled cadence (POST /api/ai/patrol/preflight). // // This is distinct from POST /api/ai/test/{provider}, which only lists // models and therefore can pass while Patrol fails 100% of runs (the // failure mode that bit Pulse for 33 days before the DeepSeek fix // landed). func (h *AISettingsHandler) HandlePatrolPreflight(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !ensureSettingsWriteScope(h.getConfig(r.Context()), w, r) { return } aiService := h.GetAIService(r.Context()) if aiService == nil { writePatrolServiceUnavailableResponse(w) return } // Optional body: override the provider and/or model. Empty body is // allowed and means "use the configured Patrol provider+model". var body aiPatrolPreflightRequest if r.ContentLength > 0 { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, `{"error":"Invalid JSON body"}`, http.StatusBadRequest) return } } if body.Provider != "" { if len(body.Provider) > 64 || !isValidProviderName(body.Provider) { http.Error(w, `{"error":"Invalid provider name"}`, http.StatusBadRequest) return } } if len(body.Model) > 256 { http.Error(w, `{"error":"Model id too long"}`, http.StatusBadRequest) return } // Tight timeout: preflight is a single round-trip, not a Patrol pass. // 30s matches the per-provider connection-test budget; in practice // this completes in well under 10s for any healthy provider. ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() preflight := aiService.RunPatrolToolPreflight(ctx, body.Provider, body.Model) response := aiPatrolPreflightResponse{ Success: preflight.Success && preflight.ToolCallObserved, Provider: preflight.Provider, Model: preflight.Model, ToolCallObserved: preflight.ToolCallObserved, DurationMs: preflight.DurationMs, Message: preflight.Summary, Cause: string(preflight.Cause), Summary: preflight.Description, Recommendation: preflight.Recommendation, } if !response.Success { response.Action = "open_provider_settings" } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write Patrol preflight response") } } // HandleAcknowledgeFinding acknowledges a finding (POST /api/ai/patrol/acknowledge) // This marks the finding as seen but keeps it visible (dimmed). Auto-resolve removes it when condition clears. // This matches alert acknowledgement behavior for UI consistency. func (h *AISettingsHandler) HandleAcknowledgeFinding(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } var req struct { FindingID string `json:"finding_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.FindingID == "" { http.Error(w, "finding_id is required", http.StatusBadRequest) return } findings := patrol.GetFindings() // Try patrol findings store first var detectedAt time.Time var category, severity, resourceID, findingKey string foundInPatrol := false finding := findings.Get(req.FindingID) if finding != nil { foundInPatrol = true detectedAt = finding.DetectedAt category = string(finding.Category) severity = string(finding.Severity) resourceID = finding.ResourceID findingKey = finding.Key } // If not in patrol findings, check the unified store (for threshold alerts) unifiedStore := h.GetUnifiedStoreForOrg(GetOrgID(r.Context())) if !foundInPatrol && unifiedStore != nil { unifiedFinding := unifiedStore.Get(req.FindingID) if unifiedFinding != nil { detectedAt = unifiedFinding.DetectedAt category = string(unifiedFinding.Category) severity = string(unifiedFinding.Severity) resourceID = unifiedFinding.ResourceID findingKey = unifiedFinding.ID } else { http.Error(w, "Finding not found", http.StatusNotFound) return } } else if !foundInPatrol { http.Error(w, "Finding not found", http.StatusNotFound) return } if foundInPatrol { if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "acknowledged"); err != nil { http.Error(w, err.Error(), http.StatusConflict) return } } // Acknowledge in patrol findings if it exists there if foundInPatrol { if !findings.Acknowledge(req.FindingID) { http.Error(w, "Finding not found", http.StatusNotFound) return } } // Acknowledge in unified store (for both patrol and threshold alerts) if unifiedStore != nil { unifiedStore.Acknowledge(req.FindingID) } // Record to learning store if learningStore := h.GetLearningStoreForOrg(GetOrgID(r.Context())); learningStore != nil { learningStore.RecordFeedback(learning.FeedbackRecord{ FindingID: req.FindingID, FindingKey: findingKey, ResourceID: resourceID, Category: category, Severity: severity, Action: learning.ActionAcknowledge, TimeToAction: time.Since(detectedAt), }) } log.Info(). Str("finding_id", req.FindingID). Msg("AI Patrol: Finding acknowledged by user") response := map[string]interface{}{ "success": true, "message": "Finding acknowledged", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write acknowledge response") } } // HandleSnoozeFinding snoozes a finding for a specified duration (POST /api/ai/patrol/snooze) // Snoozed findings are hidden from the active list but will reappear if condition persists after snooze expires func (h *AISettingsHandler) HandleSnoozeFinding(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } var req struct { FindingID string `json:"finding_id"` DurationHours int `json:"duration_hours"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.FindingID == "" { http.Error(w, "finding_id is required", http.StatusBadRequest) return } if req.DurationHours <= 0 { http.Error(w, "duration_hours must be positive", http.StatusBadRequest) return } // Cap snooze duration at 7 days if req.DurationHours > 168 { req.DurationHours = 168 } findings := patrol.GetFindings() duration := time.Duration(req.DurationHours) * time.Hour // Try patrol findings store first var detectedAt time.Time var category, severity, resourceID, findingKey string foundInPatrol := false finding := findings.Get(req.FindingID) if finding != nil { foundInPatrol = true detectedAt = finding.DetectedAt category = string(finding.Category) severity = string(finding.Severity) resourceID = finding.ResourceID findingKey = finding.Key } // If not in patrol findings, check the unified store (for threshold alerts) unifiedStore := h.GetUnifiedStoreForOrg(GetOrgID(r.Context())) if !foundInPatrol && unifiedStore != nil { unifiedFinding := unifiedStore.Get(req.FindingID) if unifiedFinding != nil { detectedAt = unifiedFinding.DetectedAt category = string(unifiedFinding.Category) severity = string(unifiedFinding.Severity) resourceID = unifiedFinding.ResourceID findingKey = unifiedFinding.ID } else { http.Error(w, "Finding not found or already resolved", http.StatusNotFound) return } } else if !foundInPatrol { http.Error(w, "Finding not found or already resolved", http.StatusNotFound) return } if foundInPatrol { if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "snoozed"); err != nil { http.Error(w, err.Error(), http.StatusConflict) return } } // Snooze in patrol findings if it exists there if foundInPatrol { if !findings.Snooze(req.FindingID, duration) { http.Error(w, "Finding not found or already resolved", http.StatusNotFound) return } } // Snooze in unified store (for both patrol and threshold alerts) if unifiedStore != nil { unifiedStore.Snooze(req.FindingID, duration) } // Record to learning store if learningStore := h.GetLearningStoreForOrg(GetOrgID(r.Context())); learningStore != nil { learningStore.RecordFeedback(learning.FeedbackRecord{ FindingID: req.FindingID, FindingKey: findingKey, ResourceID: resourceID, Category: category, Severity: severity, Action: learning.ActionSnooze, TimeToAction: time.Since(detectedAt), }) } log.Info(). Str("finding_id", req.FindingID). Int("hours", req.DurationHours). Msg("AI Patrol: Finding snoozed by user") response := map[string]interface{}{ "success": true, "message": fmt.Sprintf("Finding snoozed for %d hours", req.DurationHours), } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write snooze response") } } // HandleResolveFinding manually marks a finding as resolved (POST /api/ai/patrol/resolve) // Use this when the user has fixed the issue and wants to mark it as resolved func (h *AISettingsHandler) HandleResolveFinding(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } var req struct { FindingID string `json:"finding_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.FindingID == "" { http.Error(w, "finding_id is required", http.StatusBadRequest) return } findings := patrol.GetFindings() // Get finding details before resolving (for learning/analytics) finding := findings.Get(req.FindingID) if finding == nil { http.Error(w, "Finding not found or already resolved", http.StatusNotFound) return } if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "resolved"); err != nil { http.Error(w, err.Error(), http.StatusConflict) return } // Capture details before action detectedAt := finding.DetectedAt category := string(finding.Category) severity := string(finding.Severity) resourceID := finding.ResourceID // Mark as manually resolved (auto=false since user did it) if !findings.Resolve(req.FindingID, false) { http.Error(w, "Finding not found or already resolved", http.StatusNotFound) return } // Mirror into unified store for consistent UI state if store := h.GetUnifiedStoreForOrg(GetOrgID(r.Context())); store != nil { store.Resolve(req.FindingID) } // Record to learning store - manual resolve = user fixed the issue if learningStore := h.GetLearningStoreForOrg(GetOrgID(r.Context())); learningStore != nil { learningStore.RecordFeedback(learning.FeedbackRecord{ FindingID: req.FindingID, FindingKey: finding.Key, ResourceID: resourceID, Category: category, Severity: severity, Action: learning.ActionQuickFix, // Manual resolve means user took action to fix TimeToAction: time.Since(detectedAt), }) } log.Info(). Str("finding_id", req.FindingID). Msg("AI Patrol: Finding manually resolved by user") response := map[string]interface{}{ "success": true, "message": "Finding marked as resolved", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write resolve response") } } // HandleSetFindingNote sets or updates a user note on a finding (POST /api/ai/patrol/findings/note) // Notes provide context that Patrol sees on future runs (e.g., "PBS server was decommissioned"). func (h *AISettingsHandler) HandleSetFindingNote(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } var req struct { FindingID string `json:"finding_id"` Note string `json:"note"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.FindingID == "" { http.Error(w, "finding_id is required", http.StatusBadRequest) return } findings := patrol.GetFindings() ok := findings.SetUserNote(req.FindingID, req.Note) if !ok { http.Error(w, "Finding not found", http.StatusNotFound) return } // Mirror the note to the unified store immediately so it's visible // without waiting for the next patrol sync cycle if unifiedStore := h.GetUnifiedStoreForOrg(GetOrgID(r.Context())); unifiedStore != nil { unifiedStore.SetUserNote(req.FindingID, req.Note) } response := map[string]interface{}{ "success": true, "message": "Note updated", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write set-note response") } } // HandleDismissFinding dismisses a finding with a reason and optional note (POST /api/ai/patrol/dismiss) // This is part of the LLM memory system - dismissed findings are included in context to prevent re-raising // Valid reasons: "not_an_issue", "expected_behavior", "will_fix_later" func (h *AISettingsHandler) HandleDismissFinding(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } var req struct { FindingID string `json:"finding_id"` Reason string `json:"reason"` // "not_an_issue", "expected_behavior", "will_fix_later" Note string `json:"note"` // Optional freeform note } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.FindingID == "" { http.Error(w, "finding_id is required", http.StatusBadRequest) return } if req.Reason == "" { http.Error(w, "reason is required", http.StatusBadRequest) return } // Validate reason validReasons := map[string]bool{ "not_an_issue": true, "expected_behavior": true, "will_fix_later": true, } if req.Reason != "" && !validReasons[req.Reason] { http.Error(w, "Invalid reason. Valid values: not_an_issue, expected_behavior, will_fix_later", http.StatusBadRequest) return } findings := patrol.GetFindings() // Try patrol findings store first var detectedAt time.Time var category, severity, resourceID, findingKey string foundInPatrol := false finding := findings.Get(req.FindingID) if finding != nil { foundInPatrol = true detectedAt = finding.DetectedAt category = string(finding.Category) severity = string(finding.Severity) resourceID = finding.ResourceID findingKey = finding.Key } // If not in patrol findings, check the unified store (for threshold alerts) unifiedStore := h.GetUnifiedStoreForOrg(GetOrgID(r.Context())) if !foundInPatrol && unifiedStore != nil { unifiedFinding := unifiedStore.Get(req.FindingID) if unifiedFinding != nil { detectedAt = unifiedFinding.DetectedAt category = string(unifiedFinding.Category) severity = string(unifiedFinding.Severity) resourceID = unifiedFinding.ResourceID findingKey = unifiedFinding.ID // Use ID as key for unified findings } else { http.Error(w, "Finding not found", http.StatusNotFound) return } } else if !foundInPatrol { http.Error(w, "Finding not found", http.StatusNotFound) return } if foundInPatrol { if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "dismissed"); err != nil { http.Error(w, err.Error(), http.StatusConflict) return } } // Dismiss in patrol findings if it exists there if foundInPatrol { if !findings.Dismiss(req.FindingID, req.Reason, req.Note) { http.Error(w, "Finding not found", http.StatusNotFound) return } } // Dismiss in unified store (for both patrol and threshold alerts) if unifiedStore != nil { unifiedStore.Dismiss(req.FindingID, req.Reason, req.Note) } // Map dismiss reason to learning action var learningAction learning.UserAction switch req.Reason { case "not_an_issue": learningAction = learning.ActionDismissNotAnIssue case "expected_behavior": learningAction = learning.ActionDismissExpected case "will_fix_later": learningAction = learning.ActionDismissWillFixLater default: learningAction = learning.ActionDismissNotAnIssue // Default } // Record to learning store if learningStore := h.GetLearningStoreForOrg(GetOrgID(r.Context())); learningStore != nil { learningStore.RecordFeedback(learning.FeedbackRecord{ FindingID: req.FindingID, FindingKey: findingKey, ResourceID: resourceID, Category: category, Severity: severity, Action: learningAction, UserNote: req.Note, TimeToAction: time.Since(detectedAt), }) } log.Info(). Str("finding_id", req.FindingID). Str("reason", req.Reason). Bool("has_note", req.Note != ""). Msg("AI Patrol: Finding dismissed by user with reason") response := map[string]interface{}{ "success": true, "message": "Finding dismissed", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write dismiss response") } } // HandleSuppressFinding permanently suppresses similar findings for a resource (POST /api/ai/patrol/suppress) // The LLM will be told never to re-raise findings of this type for this resource func (h *AISettingsHandler) HandleSuppressFinding(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } var req struct { FindingID string `json:"finding_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.FindingID == "" { http.Error(w, "finding_id is required", http.StatusBadRequest) return } findings := patrol.GetFindings() // Get finding details before suppressing (for learning/analytics) finding := findings.Get(req.FindingID) if finding == nil { http.Error(w, "Finding not found", http.StatusNotFound) return } if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "suppressed"); err != nil { http.Error(w, err.Error(), http.StatusConflict) return } // Capture details before action detectedAt := finding.DetectedAt category := string(finding.Category) severity := string(finding.Severity) resourceID := finding.ResourceID if !findings.Suppress(req.FindingID) { http.Error(w, "Finding not found", http.StatusNotFound) return } // Mirror into unified store for consistent UI state if store := h.GetUnifiedStoreForOrg(GetOrgID(r.Context())); store != nil { store.Dismiss(req.FindingID, "not_an_issue", "Permanently suppressed by user") } // Record to learning store - suppress is a strong "not an issue" signal if learningStore := h.GetLearningStoreForOrg(GetOrgID(r.Context())); learningStore != nil { learningStore.RecordFeedback(learning.FeedbackRecord{ FindingID: req.FindingID, FindingKey: finding.Key, ResourceID: resourceID, Category: category, Severity: severity, Action: learning.ActionDismissNotAnIssue, // Suppress = permanent dismissal UserNote: "Permanently suppressed by user", TimeToAction: time.Since(detectedAt), }) } log.Info(). Str("finding_id", req.FindingID). Msg("AI Patrol: Finding type permanently suppressed by user") response := map[string]interface{}{ "success": true, "message": "Finding type suppressed - similar issues will not be raised again", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write suppress response") } } // HandleClearAllFindings clears all AI findings (DELETE /api/ai/patrol/findings) // This allows users to clear accumulated findings, especially useful for users who // accumulated findings before the patrol-without-AI bug was fixed. func (h *AISettingsHandler) HandleClearAllFindings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication (already wrapped by RequireAuth in router) if !CheckAuth(h.getConfig(r.Context()), w, r) { return } // Check for confirm parameter if r.URL.Query().Get("confirm") != "true" { http.Error(w, "confirm=true query parameter required", http.StatusBadRequest) return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } findings := patrol.GetFindings() count := findings.ClearAll() log.Info().Int("count", count).Msg("Cleared all AI findings") response := map[string]interface{}{ "success": true, "cleared": count, "message": fmt.Sprintf("Cleared %d findings", count), } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write clear findings response") } } // HandleGetFindingsHistory returns all findings including resolved for history (GET /api/ai/patrol/history) func (h *AISettingsHandler) HandleGetFindingsHistory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { // Return empty history if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { log.Error().Err(err).Msg("Failed to write findings history response") } return } // Parse optional startTime query parameter var startTime *time.Time if startTimeStr := r.URL.Query().Get("start_time"); startTimeStr != "" { if t, err := time.Parse(time.RFC3339, startTimeStr); err == nil { startTime = &t } } findings := patrol.GetFindingsHistory(startTime) if err := utils.WriteJSONResponse(w, findings); err != nil { log.Error().Err(err).Msg("Failed to write findings history response") } } // HandleGetPatrolRunHistory returns the history of patrol check runs (GET /api/ai/patrol/runs) func (h *AISettingsHandler) HandleGetPatrolRunHistory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) if aiService == nil { // Return empty history if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { log.Error().Err(err).Msg("Failed to write patrol run history response (no AI service)") } return } patrol := aiService.GetPatrolService() if patrol == nil { // Return empty history if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { log.Error().Err(err).Msg("Failed to write patrol run history response") } return } // Parse optional limit query parameter (default: 30) limit := 30 if limitStr := r.URL.Query().Get("limit"); limitStr != "" { if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 { limit = parsed if limit > 100 { limit = 100 // Cap at MaxPatrolRunHistory } } } runs := patrol.GetRunHistory(limit) // By default, omit full tool call arrays to keep payloads lean. // Use ?include=tool_calls to get the full array. includeToolCalls := r.URL.Query().Get("include") == "tool_calls" if !includeToolCalls { for i := range runs { runs[i].ToolCalls = nil } } if err := utils.WriteJSONResponse(w, runs); err != nil { log.Error().Err(err).Msg("Failed to write patrol run history response") } } // HandleGetPatrolRun returns a single patrol run by ID (GET /api/ai/patrol/runs/{id}) func (h *AISettingsHandler) HandleGetPatrolRun(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } const prefix = "/api/ai/patrol/runs/" if !strings.HasPrefix(r.URL.Path, prefix) { http.Error(w, "Invalid path", http.StatusBadRequest) return } runID := strings.TrimPrefix(r.URL.Path, prefix) if runID == "" { http.Error(w, "run_id is required", http.StatusBadRequest) return } if decoded, err := url.PathUnescape(runID); err == nil { runID = decoded } if strings.TrimSpace(runID) == "" { http.Error(w, "run_id is required", http.StatusBadRequest) return } aiService := h.GetAIService(r.Context()) if aiService == nil { writeErrorResponse( w, http.StatusServiceUnavailable, "service_unavailable", "Pulse Patrol service not available", nil, ) return } patrol := aiService.GetPatrolService() if patrol == nil { writeErrorResponse( w, http.StatusServiceUnavailable, "service_unavailable", "Pulse Patrol service not available", nil, ) return } run, ok := patrol.GetRunByID(runID) if !ok { writeErrorResponse(w, http.StatusNotFound, "not_found", "Patrol run not found", nil) return } // By default, omit full tool call arrays to keep payloads lean. // Use ?include=tool_calls to get the full array. if r.URL.Query().Get("include") != "tool_calls" { run.ToolCalls = nil } if err := utils.WriteJSONResponse(w, run); err != nil { log.Error().Err(err).Msg("Failed to write patrol run response") } } // HandleGetAICostSummary returns AI usage rollups (GET /api/ai/cost/summary?days=N). func (h *AISettingsHandler) HandleGetAICostSummary(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Parse optional days query parameter (default: 30, max: 365) days := 30 if daysStr := r.URL.Query().Get("days"); daysStr != "" { var parsed int if _, err := fmt.Sscanf(daysStr, "%d", &parsed); err == nil && parsed > 0 { days = parsed if days > 365 { days = 365 } } } var summary cost.Summary if h.GetAIService(r.Context()) != nil { summary = h.GetAIService(r.Context()).GetCostSummary(days) } else { summary = cost.EmptySummary(days, cost.DefaultMaxDays, days, false) } if err := utils.WriteJSONResponse(w, summary); err != nil { log.Error().Err(err).Msg("Failed to write AI cost summary response") } } // HandleResetAICostHistory deletes retained AI usage events (POST /api/ai/cost/reset). func (h *AISettingsHandler) HandleResetAICostHistory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Limit body size — this endpoint takes no body but cap it to prevent abuse. r.Body = http.MaxBytesReader(w, r.Body, 1024) if h.GetAIService(r.Context()) == nil { http.Error(w, "Pulse Assistant service unavailable", http.StatusServiceUnavailable) return } backupFile := "" if h.getPersistence(r.Context()) != nil { configDir := h.getPersistence(r.Context()).DataDir() if strings.TrimSpace(configDir) != "" { usagePath := filepath.Join(configDir, "ai_usage_history.json") if _, err := os.Stat(usagePath); err == nil { ts := time.Now().UTC().Format("20060102-150405") backupFile = fmt.Sprintf("ai_usage_history.json.bak-%s", ts) backupPath := filepath.Join(configDir, backupFile) if err := os.Rename(usagePath, backupPath); err != nil { log.Error().Err(err).Str("path", usagePath).Msg("Failed to backup Pulse Assistant usage history before reset") http.Error(w, "Failed to backup Pulse Assistant usage history", http.StatusInternalServerError) return } } } } if err := h.GetAIService(r.Context()).ClearCostHistory(); err != nil { log.Error().Err(err).Msg("Failed to clear Pulse Assistant usage history") http.Error(w, "Failed to clear Pulse Assistant usage history", http.StatusInternalServerError) return } resp := map[string]any{"ok": true} if backupFile != "" { resp["backup_file"] = backupFile } if err := utils.WriteJSONResponse(w, resp); err != nil { log.Error().Err(err).Msg("Failed to write clear cost history response") } } // HandleExportAICostHistory exports recent AI usage history as JSON or CSV (GET /api/ai/cost/export?days=N&format=csv|json). func (h *AISettingsHandler) HandleExportAICostHistory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if h.GetAIService(r.Context()) == nil { http.Error(w, "Pulse Assistant service unavailable", http.StatusServiceUnavailable) return } days := 30 if daysStr := r.URL.Query().Get("days"); daysStr != "" { if v, err := strconv.Atoi(daysStr); err == nil && v > 0 { if v > 365 { v = 365 } days = v } } format := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) if format == "" { format = "json" } if format != "json" && format != "csv" { http.Error(w, "format must be 'json' or 'csv'", http.StatusBadRequest) return } events := h.GetAIService(r.Context()).ListCostEvents(days) filename := fmt.Sprintf("pulse-ai-usage-%s-%dd.%s", time.Now().UTC().Format("20060102"), days, format) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) if format == "json" { w.Header().Set("Content-Type", "application/json") type exportEvent struct { cost.UsageEvent EstimatedUSD float64 `json:"estimated_usd,omitempty"` PricingKnown bool `json:"pricing_known"` } exported := make([]exportEvent, 0, len(events)) for _, e := range events { provider, model := cost.ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel) usd, ok, _ := cost.EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens)) exported = append(exported, exportEvent{ UsageEvent: e, EstimatedUSD: usd, PricingKnown: ok, }) } resp := map[string]any{ "days": days, "events": exported, } if err := json.NewEncoder(w).Encode(resp); err != nil { log.Error().Err(err).Msg("Failed to write AI cost export JSON") } return } w.Header().Set("Content-Type", "text/csv") cw := csv.NewWriter(w) _ = cw.Write([]string{ "timestamp", "provider", "request_model", "response_model", "use_case", "input_tokens", "output_tokens", "estimated_usd", "pricing_known", "target_type", "target_id", "finding_id", }) for _, e := range events { provider, model := cost.ResolveProviderAndModel(e.Provider, e.RequestModel, e.ResponseModel) usd, ok, _ := cost.EstimateUSD(provider, model, int64(e.InputTokens), int64(e.OutputTokens)) _ = cw.Write([]string{ e.Timestamp.UTC().Format(time.RFC3339Nano), e.Provider, e.RequestModel, e.ResponseModel, e.UseCase, strconv.Itoa(e.InputTokens), strconv.Itoa(e.OutputTokens), strconv.FormatFloat(usd, 'f', 6, 64), strconv.FormatBool(ok), e.TargetType, e.TargetID, e.FindingID, }) } cw.Flush() if err := cw.Error(); err != nil { log.Error().Err(err).Msg("Failed to write AI cost export CSV") } } // HandleGetSuppressionRules returns all suppression rules (GET /api/ai/patrol/suppressions) func (h *AISettingsHandler) HandleGetSuppressionRules(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { log.Error().Err(err).Msg("Failed to write suppression rules response") } return } findings := patrol.GetFindings() rules := findings.GetSuppressionRules() if err := utils.WriteJSONResponse(w, rules); err != nil { log.Error().Err(err).Msg("Failed to write suppression rules response") } } // HandleAddSuppressionRule creates a new suppression rule (POST /api/ai/patrol/suppressions) func (h *AISettingsHandler) HandleAddSuppressionRule(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } var req struct { ResourceID string `json:"resource_id"` // Can be empty for "any resource" when AllowBroadScope is true ResourceName string `json:"resource_name"` // Human-readable name Category string `json:"category"` // Can be empty for "any category" when AllowBroadScope is true Description string `json:"description"` // Required - user's reason AllowBroadScope bool `json:"allow_broad_scope"` // Required when resource or category is intentionally wildcarded } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } req.ResourceID = strings.TrimSpace(req.ResourceID) req.ResourceName = strings.TrimSpace(req.ResourceName) req.Category = strings.TrimSpace(req.Category) req.Description = strings.TrimSpace(req.Description) if req.Description == "" { http.Error(w, "description is required", http.StatusBadRequest) return } if (req.ResourceID == "" || req.Category == "") && !req.AllowBroadScope { http.Error(w, "broad suppression scope requires allow_broad_scope", http.StatusBadRequest) return } // Convert category string to FindingCategory var category ai.FindingCategory switch req.Category { case "performance": category = ai.FindingCategoryPerformance case "capacity": category = ai.FindingCategoryCapacity case "reliability": category = ai.FindingCategoryReliability case "backup": category = ai.FindingCategoryBackup case "security": category = ai.FindingCategorySecurity case "general": category = ai.FindingCategoryGeneral case "": category = "" // Any category default: http.Error(w, "Invalid category", http.StatusBadRequest) return } findings := patrol.GetFindings() rule := findings.AddSuppressionRule(req.ResourceID, req.ResourceName, category, req.Description) log.Info(). Str("rule_id", rule.ID). Str("resource_id", req.ResourceID). Str("category", req.Category). Str("description", req.Description). Msg("AI Patrol: Manual suppression rule created") response := map[string]interface{}{ "success": true, "message": "Suppression rule created", "rule": rule, } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write add suppression rule response") } } // HandleDeleteSuppressionRule removes a suppression rule (DELETE /api/ai/patrol/suppressions/:id) func (h *AISettingsHandler) HandleDeleteSuppressionRule(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { writePatrolServiceUnavailableResponse(w) return } // Get rule ID from URL path ruleID := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/suppressions/") if ruleID == "" { http.Error(w, "rule_id is required", http.StatusBadRequest) return } findings := patrol.GetFindings() if !findings.DeleteSuppressionRule(ruleID) { http.Error(w, "Rule not found", http.StatusNotFound) return } log.Info(). Str("rule_id", ruleID). Msg("AI Patrol: Suppression rule deleted") response := map[string]interface{}{ "success": true, "message": "Suppression rule deleted", } if err := utils.WriteJSONResponse(w, response); err != nil { log.Error().Err(err).Msg("Failed to write delete suppression rule response") } } // HandleGetDismissedFindings returns all dismissed/suppressed findings (GET /api/ai/patrol/dismissed) func (h *AISettingsHandler) HandleGetDismissedFindings(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Require authentication if !CheckAuth(h.getConfig(r.Context()), w, r) { return } patrol := h.getPatrolService(r.Context()) if patrol == nil { if err := utils.WriteJSONResponse(w, []interface{}{}); err != nil { log.Error().Err(err).Msg("Failed to write dismissed findings response") } return } findings := patrol.GetFindings() dismissed := findings.GetDismissedFindings() if err := utils.WriteJSONResponse(w, dismissed); err != nil { log.Error().Err(err).Msg("Failed to write dismissed findings response") } } // getAuthUsername extracts the username from the current auth context func getAuthUsername(cfg *config.Config, r *http.Request) string { if token := getAPITokenRecordFromRequest(r); token != nil { if username := apiTokenAuthenticatedUser(token); username != "" { return username } } // Check OIDC session first if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" { if username := GetSessionUsername(cookie.Value); username != "" { return username } } // Check proxy auth if cfg.ProxyAuthSecret != "" { if valid, username, _ := CheckProxyAuth(cfg, r); valid && username != "" { return username } } // Fall back to basic auth username if cfg.AuthUser != "" { return cfg.AuthUser } // Single-user mode without auth return "" } // ============================================================================ // Approval Workflow Handlers // ============================================================================ // HandleListApprovals returns pending approval requests for the current org. // Pulse Assistant command approvals are a core chat workflow; investigation fix // approval execution remains gated by the auto-fix endpoint when approved. func (h *AISettingsHandler) HandleListApprovals(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } store := approval.GetStore() if store == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) return } orgID := approval.NormalizeOrgID(GetOrgID(r.Context())) response := map[string]interface{}{ "approvals": store.GetPendingApprovalsForOrg(orgID), "stats": store.GetStatsForOrg(orgID), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // HandleGetApproval returns a specific approval request. func (h *AISettingsHandler) HandleGetApproval(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Extract ID from path: /api/ai/approvals/{id} approvalID := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/") approvalID = strings.TrimSuffix(approvalID, "/") approvalID = strings.Split(approvalID, "/")[0] // Handle /approve or /deny suffixes if approvalID == "" { writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Approval ID is required", nil) return } store := approval.GetStore() if store == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) return } orgID := approval.NormalizeOrgID(GetOrgID(r.Context())) req, ok := store.GetApproval(approvalID) if !ok || !approval.BelongsToOrg(req, orgID) { writeErrorResponse(w, http.StatusNotFound, "not_found", "Approval request not found", nil) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(req) } // HandleApproveCommand approves a pending command and executes it. func (h *AISettingsHandler) HandleApproveCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // SECURITY: Approval execution accepts the dedicated mobile relay capability // for new pairings while remaining backward-compatible with older mobile // tokens that still carry ai:execute. if !ensureRelayMobileRuntimeRoute(w, r, relayMobileRouteApprovalApprove) { return } // Extract ID from path: /api/ai/approvals/{id}/approve path := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/") path = strings.TrimSuffix(path, "/approve") approvalID := strings.TrimSuffix(path, "/") if approvalID == "" { writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Approval ID is required", nil) return } store := approval.GetStore() if store == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) return } orgID := approval.NormalizeOrgID(GetOrgID(r.Context())) existingReq, ok := store.GetApproval(approvalID) if !ok || !approval.BelongsToOrg(existingReq, orgID) { writeErrorResponse(w, http.StatusNotFound, "not_found", "Approval request not found", nil) return } // Investigation fix approvals are gated by the safe remediation extension point. // The free adapter returns 402; the enterprise adapter executes the fix. if existingReq.ToolID == "investigation_fix" { if h.aiAutoFixEndpoints != nil { h.aiAutoFixEndpoints.HandleApproveInvestigationFix(w, r) } else { WriteLicenseRequired(w, featureAIAutoFixValue, "Safe remediation workflows require Pulse Pro") } return } username := getAuthUsername(h.getConfig(r.Context()), r) if username == "" { username = "anonymous" } req, err := store.Approve(approvalID, username) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "approval_failed", err.Error(), nil) return } // Log audit event LogAuditEvent("ai_command_approved", username, GetClientIP(r), r.URL.Path, true, fmt.Sprintf("Approved command: %s", truncateForLog(req.Command, 100))) // For chat sidebar approvals, the agentic loop will detect approval and execute response := map[string]interface{}{ "approved": true, "request": req, "approval_id": req.ID, "message": "Command approved. Pulse Assistant will now execute it.", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // HandleApproveAndExecuteInvestigationFix has been moved to enterprise. // The route now delegates to aiAutoFixEndpoints.HandleApproveInvestigationFix. // executeInvestigationFix has been moved to enterprise. // isMCPToolCall, cleanTargetHost, executeMCPToolFix, parseMCPToolCall, // splitToolArgs, and findAgentForTarget have been moved to // router_routes_ai_relay.go as package-level functions used by the // AIAutoFixHandlerDeps adapters. // updateFindingOutcome updates the investigation outcome on a finding func (h *AISettingsHandler) updateFindingOutcome(ctx context.Context, orgID, findingID, outcome string) { // Get AI service for this org svc := h.GetAIService(ctx) if svc == nil { log.Warn().Str("orgID", orgID).Msg("AI service not available for finding update") return } patrol := svc.GetPatrolService() if patrol == nil { log.Warn().Str("orgID", orgID).Msg("Patrol service not available for finding update") return } findingsStore := patrol.GetFindings() if findingsStore == nil { log.Warn().Str("orgID", orgID).Msg("Findings store not available for finding update") return } if !findingsStore.UpdateInvestigationOutcome(findingID, outcome) { log.Warn().Str("findingID", findingID).Msg("Finding not found for outcome update") return } log.Info().Str("findingID", findingID).Str("outcome", outcome).Msg("Updated finding investigation outcome") } // HandleDenyCommand denies a pending command. func (h *AISettingsHandler) HandleDenyCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if !ensureRelayMobileRuntimeRoute(w, r, relayMobileRouteApprovalDeny) { return } // Extract ID from path: /api/ai/approvals/{id}/deny path := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/") path = strings.TrimSuffix(path, "/deny") approvalID := strings.TrimSuffix(path, "/") if approvalID == "" { writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Approval ID is required", nil) return } // Parse optional reason from body var body struct { Reason string `json:"reason"` } if r.Body != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { log.Warn().Err(err).Msg("Failed to decode deny request body") } } store := approval.GetStore() if store == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil) return } orgID := approval.NormalizeOrgID(GetOrgID(r.Context())) existingReq, ok := store.GetApproval(approvalID) if !ok || !approval.BelongsToOrg(existingReq, orgID) { writeErrorResponse(w, http.StatusNotFound, "not_found", "Approval request not found", nil) return } username := getAuthUsername(h.getConfig(r.Context()), r) if username == "" { username = "anonymous" } req, err := store.Deny(approvalID, username, body.Reason) if err != nil { writeErrorResponse(w, http.StatusBadRequest, "denial_failed", err.Error(), nil) return } // Log audit event LogAuditEvent("ai_command_denied", username, GetClientIP(r), r.URL.Path, true, fmt.Sprintf("Denied command: %s (reason: %s)", truncateForLog(req.Command, 100), body.Reason)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "denied": true, "request": req, "message": "Command denied.", }) } // truncateForLog truncates a string for logging purposes. func truncateForLog(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // PatrolAutonomySettings represents the patrol autonomy configuration for API requests // Uses pointer for FullModeUnlocked to distinguish "not sent" from "sent as false" type PatrolAutonomySettings struct { AutonomyLevel string `json:"autonomy_level"` // "monitor", "approval", "assisted", "full" FullModeUnlocked *bool `json:"full_mode_unlocked,omitempty"` // User has acknowledged Full mode risks (nil = preserve existing) InvestigationBudget int `json:"investigation_budget"` // Max turns per investigation (5-30) InvestigationTimeoutSec int `json:"investigation_timeout_sec"` // Max seconds per investigation (60-1800) } // PatrolAutonomyResponse represents the patrol autonomy configuration for API responses // Uses plain bool for FullModeUnlocked since responses always include the actual value type PatrolAutonomyResponse struct { AutonomyLevel string `json:"autonomy_level"` FullModeUnlocked bool `json:"full_mode_unlocked"` InvestigationBudget int `json:"investigation_budget"` InvestigationTimeoutSec int `json:"investigation_timeout_sec"` } // HandleGetPatrolAutonomy returns the current patrol autonomy settings (GET /api/ai/patrol/autonomy) func (h *AISettingsHandler) HandleGetPatrolAutonomy(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) if aiService == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "service_unavailable", "Pulse Patrol service not available", nil) return } cfg := aiService.GetConfig() if cfg == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "Pulse Patrol not configured", nil) return } autonomyLevel := cfg.GetPatrolAutonomyLevel() fullModeUnlocked := cfg.PatrolFullModeUnlocked // Community tier lock: without ai_autofix, patrol autonomy is findings-only ("monitor"). // If config contains a higher level from a previous Pro/trial period, clamp the effective // value at read time so the UI reflects runtime enforcement. hasAutoFix := aiService.HasLicenseFeature(featureAIAutoFixValue) if !hasAutoFix && autonomyLevel != config.PatrolAutonomyMonitor { autonomyLevel = config.PatrolAutonomyMonitor } if !hasAutoFix { fullModeUnlocked = false } settings := PatrolAutonomyResponse{ AutonomyLevel: autonomyLevel, FullModeUnlocked: fullModeUnlocked, InvestigationBudget: cfg.GetPatrolInvestigationBudget(), InvestigationTimeoutSec: int(cfg.GetPatrolInvestigationTimeout().Seconds()), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(settings) } // HandleUpdatePatrolAutonomyMonitorOnly persists findings-only Patrol autonomy // settings for builds without the safe remediation extension. func (h *AISettingsHandler) HandleUpdatePatrolAutonomyMonitorOnly(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } aiService := h.GetAIService(r.Context()) if aiService == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "service_unavailable", "Pulse Patrol service not available", nil) return } cfg := aiService.GetConfig() if cfg == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "Pulse Patrol not configured", nil) return } var req PatrolAutonomySettings if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil) return } if !config.IsValidPatrolAutonomyLevel(req.AutonomyLevel) { writeErrorResponse(w, http.StatusBadRequest, "invalid_autonomy_level", fmt.Sprintf("Invalid autonomy level: %s. Must be 'monitor', 'approval', 'assisted', or 'full'", req.AutonomyLevel), nil) return } if req.AutonomyLevel != config.PatrolAutonomyMonitor { WriteLicenseRequired(w, featureAIAutoFixKey, "Investigation and auto-fix require Pulse Pro. Community tier is limited to Monitor (findings-only) autonomy.") return } if req.InvestigationBudget < 5 { req.InvestigationBudget = 5 } if req.InvestigationBudget > 30 { req.InvestigationBudget = 30 } if req.InvestigationTimeoutSec < 60 { req.InvestigationTimeoutSec = 60 } if req.InvestigationTimeoutSec > 1800 { req.InvestigationTimeoutSec = 1800 } // This handler is only used when the safe-remediation extension is not active. // Do not preserve stale full-mode acknowledgement or permission state through a // monitor-only save. effectiveUnlocked := false cfg.PatrolAutonomyLevel = config.PatrolAutonomyMonitor cfg.PatrolFullModeUnlocked = effectiveUnlocked cfg.PatrolInvestigationBudget = req.InvestigationBudget cfg.PatrolInvestigationTimeoutSec = req.InvestigationTimeoutSec persistence := h.getPersistence(r.Context()) if persistence == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "Pulse Patrol not configured", nil) return } if err := persistence.SaveAIConfig(*cfg); err != nil { writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save Pulse Patrol autonomy settings", nil) return } if err := aiService.LoadConfig(); err != nil { log.Warn().Err(err).Msg("Patrol autonomy settings saved but config reload failed") } settings := PatrolAutonomyResponse{ AutonomyLevel: config.PatrolAutonomyMonitor, FullModeUnlocked: effectiveUnlocked, InvestigationBudget: req.InvestigationBudget, InvestigationTimeoutSec: req.InvestigationTimeoutSec, } if err := utils.WriteJSONResponse(w, map[string]interface{}{ "success": true, "settings": settings, }); err != nil { log.Error().Err(err).Msg("Failed to write patrol autonomy update response") } } // Premium Patrol autonomy updates have been moved to enterprise. // The route delegates to aiAutoFixEndpoints.HandleUpdatePatrolAutonomy. // maxFindingIDLength is the maximum allowed length for finding IDs in URL paths. // Real finding IDs are 16 hex chars (SHA256[:8]), but we accept up to 256 for // forward compatibility with any future ID scheme. const maxFindingIDLength = 256 // HandleGetInvestigation returns investigation details for a finding (GET /api/ai/findings/{id}/investigation) func (h *AISettingsHandler) HandleGetInvestigation(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Extract finding ID from path findingID := strings.TrimPrefix(r.URL.Path, "/api/ai/findings/") findingID = strings.TrimSuffix(findingID, "/investigation") if findingID == "" { writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Finding ID is required", nil) return } if len(findingID) > maxFindingIDLength { writeErrorResponse(w, http.StatusBadRequest, "invalid_id", "Finding ID is too long", nil) return } aiService := h.GetAIService(r.Context()) if aiService == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Patrol service not initialized", nil) return } patrol := aiService.GetPatrolService() if patrol == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Patrol service not initialized", nil) return } // Get investigation from orchestrator orchestrator := patrol.GetInvestigationOrchestrator() if orchestrator == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Investigation orchestrator not initialized", nil) return } investigation := orchestrator.GetInvestigationByFinding(findingID) if investigation == nil { writeErrorResponse(w, http.StatusNotFound, "not_found", "No investigation found for this finding", nil) return } normalizedInvestigation := investigation.NormalizeCollections() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(normalizedInvestigation) } // HandleReapproveInvestigationFix has been moved to enterprise. // The route now delegates to aiAutoFixEndpoints.HandleReapproveInvestigationFix. // HandleGetInvestigationMessages returns chat messages for an investigation (GET /api/ai/findings/{id}/investigation/messages) func (h *AISettingsHandler) HandleGetInvestigationMessages(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Extract finding ID from path findingID := strings.TrimPrefix(r.URL.Path, "/api/ai/findings/") findingID = strings.TrimSuffix(findingID, "/investigation/messages") if findingID == "" { writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Finding ID is required", nil) return } if len(findingID) > maxFindingIDLength { writeErrorResponse(w, http.StatusBadRequest, "invalid_id", "Finding ID is too long", nil) return } aiService := h.GetAIService(r.Context()) if aiService == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Patrol service not initialized", nil) return } patrol := aiService.GetPatrolService() if patrol == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Patrol service not initialized", nil) return } // Get investigation from orchestrator orchestrator := patrol.GetInvestigationOrchestrator() if orchestrator == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Investigation orchestrator not initialized", nil) return } investigation := orchestrator.GetInvestigationByFinding(findingID) if investigation == nil { writeErrorResponse(w, http.StatusNotFound, "not_found", "No investigation found for this finding", nil) return } // Get chat messages for the investigation session chatService := aiService.GetChatService() if chatService == nil { writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Chat service not initialized", nil) return } messages, err := chatService.GetMessages(r.Context(), investigation.SessionID) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, "fetch_failed", "Failed to get investigation messages", nil) return } for i := range messages { messages[i] = messages[i].NormalizeCollections() } normalizedInvestigation := investigation.NormalizeCollections() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "investigation_id": normalizedInvestigation.ID, "session_id": normalizedInvestigation.SessionID, "messages": messages, }) } // HandleReinvestigateFinding has been moved to enterprise. // The route now delegates to aiAutoFixEndpoints.HandleReinvestigateFinding.