Files
pulse/internal/api/ai_handlers.go
T
2026-07-16 17:50:08 +01:00

8179 lines
279 KiB
Go

package api
import (
"bytes"
"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/agentcapabilities"
"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
resourceStoreProvider func(orgID string) (unifiedresources.ResourceStore, error)
actionBrokerFactory func(orgID string) aicontracts.OrchestratorActionBroker
proposalCatalogFactory func(orgID string) tools.ProposalCatalog
policyMutation func(func() error) error
patrolAutopilotPolicy func() unifiedresources.PatrolAutopilotServerPolicy
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
}
// SetResourceStoreProvider installs the resource-store resolver used by
// read-side Patrol response enrichment. The findings store owns durable
// suppression/stamping during Add; this provider lets the HTTP response
// reflect operator priority changes immediately, without waiting for the
// next Patrol detection pass.
func (h *AISettingsHandler) SetResourceStoreProvider(provider func(orgID string) (unifiedresources.ResourceStore, error)) {
if h == nil {
return
}
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.resourceStoreProvider = provider
}
func (h *AISettingsHandler) SetPolicyMutationCoordinator(coordinator func(func() error) error) {
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.policyMutation = coordinator
}
func (h *AISettingsHandler) SetPatrolAutopilotServerPolicyProvider(provider func() unifiedresources.PatrolAutopilotServerPolicy) {
h.stateMu.Lock()
h.patrolAutopilotPolicy = provider
defaultService := h.defaultAIService
h.stateMu.Unlock()
if defaultService != nil {
defaultService.SetPatrolAutopilotServerPolicyProvider(provider)
}
h.aiServicesMu.RLock()
defer h.aiServicesMu.RUnlock()
for _, service := range h.aiServices {
service.SetPatrolAutopilotServerPolicyProvider(provider)
}
}
func (h *AISettingsHandler) currentPatrolAutopilotServerPolicy() unifiedresources.PatrolAutopilotServerPolicy {
h.stateMu.RLock()
provider := h.patrolAutopilotPolicy
h.stateMu.RUnlock()
if provider != nil {
return provider()
}
return unifiedresources.CurrentPatrolAutopilotServerPolicy(time.Now().UTC())
}
// SetActionBrokerFactory installs the per-org typed action proposal broker
// used by the investigation orchestrator. Core-owned: it binds the tenant
// and the fixed Patrol actor, so enterprise code can only propose typed
// capabilities, never dispatch commands or claim authority.
func (h *AISettingsHandler) SetActionBrokerFactory(factory func(orgID string) aicontracts.OrchestratorActionBroker) {
if h == nil {
return
}
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.actionBrokerFactory = factory
}
// SetProposalCatalogFactory installs the per-org capability catalog used
// for proposal validation, resolved from the tenant-bound action
// lifecycle service so acceptance and planning share one contract.
func (h *AISettingsHandler) SetProposalCatalogFactory(factory func(orgID string) tools.ProposalCatalog) {
if h == nil {
return
}
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.proposalCatalogFactory = factory
}
func (h *AISettingsHandler) actionBrokerFor(orgID string) aicontracts.OrchestratorActionBroker {
if h == nil {
return nil
}
h.stateMu.RLock()
factory := h.actionBrokerFactory
h.stateMu.RUnlock()
if factory == nil {
return nil
}
return factory(orgID)
}
func (h *AISettingsHandler) proposalCatalogFor(orgID string) tools.ProposalCatalog {
if h == nil {
return nil
}
h.stateMu.RLock()
factory := h.proposalCatalogFactory
h.stateMu.RUnlock()
if factory == nil {
return nil
}
return factory(orgID)
}
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
}
type actionAuditStoreProvider interface {
GetActionAuditStore() unifiedresources.ResourceStore
}
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)
h.stateMu.RLock()
patrolAutopilotPolicy := h.patrolAutopilotPolicy
h.stateMu.RUnlock()
svc.SetPatrolAutopilotServerPolicyProvider(patrolAutopilotPolicy)
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)
h.stateMu.RLock()
patrolAutopilotPolicy := h.patrolAutopilotPolicy
h.stateMu.RUnlock()
svc.SetPatrolAutopilotServerPolicyProvider(patrolAutopilotPolicy)
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.ZaiAPIKey != nil ||
req.GroqAPIKey != nil ||
req.MistralAPIKey != nil ||
req.CerebrasAPIKey != nil ||
req.TogetherAPIKey != nil ||
req.FireworksAPIKey != nil ||
req.GeminiAPIKey != nil ||
req.OllamaBaseURL != nil ||
req.OllamaUsername != nil ||
req.OllamaPassword != nil ||
req.OllamaKeepAlive != nil ||
req.OpenAIBaseURL != nil ||
req.ZaiBaseURL != nil ||
req.ClearAnthropicKey != nil ||
req.ClearOpenAIKey != nil ||
req.ClearOpenRouterKey != nil ||
req.ClearDeepSeekKey != nil ||
req.ClearZaiKey != nil ||
req.ClearGroqKey != nil ||
req.ClearMistralKey != nil ||
req.ClearCerebrasKey != nil ||
req.ClearTogetherKey != nil ||
req.ClearFireworksKey != nil ||
req.ClearGeminiKey != nil ||
req.ClearOllamaURL != nil ||
req.ClearOllamaUsername != nil ||
req.ClearOllamaPassword != nil ||
req.CodexSubscriptionEnabled != nil ||
req.ClaudeSubscriptionEnabled != 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
}
if provider == config.AIProviderCodexSubscription {
return oldCfg.CodexSubscriptionEnabled != newCfg.CodexSubscriptionEnabled
}
if provider == config.AIProviderClaudeSubscription {
return oldCfg.ClaudeSubscriptionEnabled != newCfg.ClaudeSubscriptionEnabled
}
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.ZaiAPIKey != nil ||
req.GroqAPIKey != nil ||
req.MistralAPIKey != nil ||
req.CerebrasAPIKey != nil ||
req.TogetherAPIKey != nil ||
req.FireworksAPIKey != nil ||
req.GeminiAPIKey != nil ||
req.OllamaBaseURL != nil ||
req.OllamaUsername != nil ||
req.OllamaPassword != nil ||
req.OllamaKeepAlive != nil ||
req.OpenAIBaseURL != nil ||
req.ZaiBaseURL != nil ||
req.ClearAnthropicKey != nil ||
req.ClearOpenAIKey != nil ||
req.ClearOpenRouterKey != nil ||
req.ClearDeepSeekKey != nil ||
req.ClearZaiKey != nil ||
req.ClearGroqKey != nil ||
req.ClearMistralKey != nil ||
req.ClearCerebrasKey != nil ||
req.ClearTogetherKey != nil ||
req.ClearFireworksKey != nil ||
req.ClearGeminiKey != nil ||
req.ClearOllamaURL != nil ||
req.ClearOllamaUsername != nil ||
req.ClearOllamaPassword != nil ||
req.CodexSubscriptionEnabled != nil ||
req.ClaudeSubscriptionEnabled != nil
}
// SetOnControlSettingsChange sets a callback to be invoked when control settings change
// Used by Router to update Assistant 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,
catalog: h.proposalCatalogFor(orgID),
}
// 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}}
// Get config for investigation settings
cfg := svc.GetConfig()
invConfig := aicontracts.DefaultInvestigationConfig()
if cfg != nil {
invConfig.MaxEvidenceCalls = cfg.GetPatrolInvestigationBudget()
invConfig.MaxTurns = aicontracts.InvestigationModelTurnLimit(invConfig.MaxEvidenceCalls)
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. The typed action broker is
// REQUIRED: without it the factory disables the orchestrator - there
// is no command-execution fallback.
deps := aicontracts.OrchestratorDeps{
ChatService: chatAdapter,
Store: store,
FindingsStore: findingsAdapter,
ActionBroker: h.actionBrokerFor(approval.NormalizeOrgID(orgID)),
Config: invConfig,
InfraContext: infraContext,
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. It exposes only the
// investigation-specific execution/listing surface: there is no generic
// chat execution, no autonomy control, and no command execution here -
// the typed proposal channel is the only route from an investigation to
// an infrastructure mutation.
type orchestratorChatAdapter struct {
svc *chat.Service
// catalog resolves advertised resource capabilities for proposal
// validation, from the tenant-bound action lifecycle service.
catalog tools.ProposalCatalog
}
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) ExecuteInvestigationStream(ctx context.Context, req aicontracts.OrchestratorInvestigationRequest, callback aicontracts.OrchestratorStreamCallback) (*aicontracts.OrchestratorInvestigationResult, error) {
if a.svc == nil {
return nil, fmt.Errorf("chat service is nil")
}
if !a.svc.IsRunning() {
return nil, fmt.Errorf("chat service is not running")
}
runResult, err := a.svc.ExecuteInvestigationStream(ctx, chat.InvestigationRunRequest{
SessionID: req.SessionID,
Prompt: req.Prompt,
SystemPrompt: req.SystemPrompt,
MaxTurns: req.MaxTurns,
MaxEvidenceCalls: req.MaxEvidenceCalls,
ExecutionID: req.ExecutionID,
Identity: tools.ProposalIdentity{
ProposalID: req.ProposalID,
FindingID: req.FindingID,
InvestigationID: req.InvestigationID,
EvidenceIDs: req.EvidenceIDs,
},
Catalog: a.catalog,
}, func(event chat.StreamEvent) {
callback(aicontracts.OrchestratorStreamEvent{
Type: event.Type,
Data: event.Data,
})
})
if runResult == nil {
return nil, mapInvestigationProposalError(err)
}
result := &aicontracts.OrchestratorInvestigationResult{
Content: runResult.Content,
FailedProposalAttempts: runResult.FailedProposalAttempts,
InputTokens: runResult.InputTokens,
OutputTokens: runResult.OutputTokens,
ModelTurns: runResult.ModelTurns,
EvidenceCalls: runResult.EvidenceCalls,
ToolCalls: runResult.ToolCalls,
}
if runResult.Proposal != nil {
captured := runResult.Proposal
result.Proposal = &aicontracts.ActionProposal{
ProposalID: captured.Identity.ProposalID,
FindingID: captured.Identity.FindingID,
InvestigationID: captured.Identity.InvestigationID,
ResourceID: captured.ResourceID,
CapabilityName: captured.CapabilityName,
Params: captured.Params,
Reason: captured.Reason,
EvidenceIDs: captured.Identity.EvidenceIDs,
}
}
return result, mapInvestigationProposalError(err)
}
// mapInvestigationProposalError projects the core proposal-channel errors
// onto the public contract sentinels so enterprise outcome mapping can
// key on errors.Is without importing internal packages.
func mapInvestigationProposalError(err error) error {
var runErr *chat.InvestigationRunError
if errors.As(err, &runErr) {
return aicontracts.NewOrchestratorInvestigationError(
runErr.RunFailure(),
mapInvestigationProposalSentinel(runErr.ProposalFailure()),
)
}
return mapInvestigationProposalSentinel(err)
}
func mapInvestigationProposalSentinel(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, tools.ErrProposalAmbiguous):
return aicontracts.ErrInvestigationProposalAmbiguous
case errors.Is(err, tools.ErrProposalIntegrity):
return aicontracts.ErrInvestigationProposalIntegrity
case errors.Is(err, tools.ErrProposalAttemptsFailed):
return aicontracts.ErrInvestigationProposalAttemptsFailed
default:
return err
}
}
//nolint:dupl // mirrors chatServiceAdapter.GetMessages: same source messages mapped onto a deliberately separate output contract that may diverge
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.OrchestratorToolCallInfoFromProvider(tc.ProviderToolCall()))
}
if msg.ToolResult != nil {
toolResult := aicontracts.OrchestratorToolResultInfoFromProvider(*msg.ToolResult)
m.ToolResult = &toolResult
}
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) ListInvestigationTools(ctx context.Context) []string {
_ = ctx
if a.svc == nil {
return nil
}
return a.svc.ListInvestigationTools()
}
// 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,
)
}
// 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)
}
// 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)
}
// 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
// Legacy OAuth fields are retained for cleanup/migration only.
AuthMethod string `json:"auth_method"` // "api_key" or legacy "oauth"
OAuthConnected bool `json:"oauth_connected"` // true if legacy OAuth tokens are stored
// 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
// Per-rule policy for alert-driven scoped Patrol triggers.
PatrolAlertTriggerMinSeverity string `json:"patrol_alert_trigger_min_severity"` // "warning" | "critical"; minimum alert level that warrants investigation
PatrolAlertTriggerTypes []string `json:"patrol_alert_trigger_types"` // optional allowlist of alert types (empty = all types)
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 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
ZaiConfigured bool `json:"zai_configured"` // true if Z.ai (Zhipu) API key is set
GroqConfigured bool `json:"groq_configured"` // true if Groq API key is set
MistralConfigured bool `json:"mistral_configured"` // true if Mistral API key is set
CerebrasConfigured bool `json:"cerebras_configured"` // true if Cerebras API key is set
TogetherConfigured bool `json:"together_configured"` // true if Together AI API key is set
FireworksConfigured bool `json:"fireworks_configured"` // true if Fireworks AI 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
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses server default
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
ZaiBaseURL string `json:"zai_base_url,omitempty"` // Custom Z.ai base URL (e.g. coding endpoint)
CodexSubscriptionEnabled bool `json:"codex_subscription_enabled,omitempty"`
ClaudeSubscriptionEnabled bool `json:"claude_subscription_enabled,omitempty"`
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
Providers []AIProviderDefinitionResponse `json:"providers"` // Canonical provider registry metadata
// 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"`
}
// AIProviderDefinitionResponse exposes provider metadata without credentials.
type AIProviderDefinitionResponse struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
Protocol string `json:"protocol"`
DefaultModel string `json:"default_model,omitempty"`
DefaultBaseURL string `json:"default_base_url,omitempty"`
APIKeyField string `json:"api_key_field,omitempty"`
ConfiguredField string `json:"configured_field,omitempty"`
ClearKeyField string `json:"clear_key_field,omitempty"`
BaseURLField string `json:"base_url_field,omitempty"`
RequiresAPIKey bool `json:"requires_api_key"`
UserConfigurable bool `json:"user_configurable"`
Gateway bool `json:"gateway"`
Configured bool `json:"configured"`
ModelsDevProviderID string `json:"models_dev_provider_id,omitempty"`
EnvVars []string `json:"env_vars"`
DocsURL string `json:"docs_url,omitempty"`
// Patrol-blessed quickstart model for providers where users must pick a
// model themselves (Ollama). Empty for curated-catalog providers.
SuggestedModel string `json:"suggested_model,omitempty"`
SuggestedModelNote string `json:"suggested_model_note,omitempty"`
SuggestedModelEquivalents []string `json:"suggested_model_equivalents,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.Providers == nil {
r.Providers = []AIProviderDefinitionResponse{}
}
if r.ProtectedGuests == nil {
r.ProtectedGuests = []string{}
}
if r.PatrolAlertTriggerTypes == nil {
r.PatrolAlertTriggerTypes = []string{}
}
return r
}
func aiProviderDefinitionResponses(settings *config.AIConfig) []AIProviderDefinitionResponse {
defs := config.AIProviderDefinitions()
responses := make([]AIProviderDefinitionResponse, 0, len(defs))
for _, def := range defs {
if !def.UserConfigurable {
continue
}
responses = append(responses, AIProviderDefinitionResponse{
ID: def.ID,
DisplayName: def.DisplayName,
Description: def.Description,
Protocol: string(def.Protocol),
DefaultModel: config.DefaultModelForProvider(def.ID),
DefaultBaseURL: def.DefaultBaseURL,
APIKeyField: def.APIKeyField,
ConfiguredField: def.ConfiguredField,
ClearKeyField: def.ClearKeyField,
BaseURLField: def.BaseURLField,
RequiresAPIKey: def.RequiresAPIKey,
UserConfigurable: def.UserConfigurable,
Gateway: def.Gateway,
Configured: settings != nil && settings.HasProvider(def.ID),
ModelsDevProviderID: def.ModelsDevProviderID,
EnvVars: append([]string(nil), def.EnvVars...),
DocsURL: def.DocsURL,
SuggestedModel: def.SuggestedModel,
SuggestedModelNote: def.SuggestedModelNote,
SuggestedModelEquivalents: append(
[]string(nil), def.SuggestedModelEquivalents...),
})
}
return responses
}
// 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
// Per-rule policy for alert-driven scoped Patrol triggers.
PatrolAlertTriggerMinSeverity *string `json:"patrol_alert_trigger_min_severity,omitempty"` // "warning" | "critical"
PatrolAlertTriggerTypes *[]string `json:"patrol_alert_trigger_types,omitempty"` // allowlist of alert types (empty slice = all types)
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
ZaiAPIKey *string `json:"zai_api_key,omitempty"` // Set Z.ai (Zhipu) API key
GroqAPIKey *string `json:"groq_api_key,omitempty"` // Set Groq API key
MistralAPIKey *string `json:"mistral_api_key,omitempty"` // Set Mistral API key
CerebrasAPIKey *string `json:"cerebras_api_key,omitempty"` // Set Cerebras API key
TogetherAPIKey *string `json:"together_api_key,omitempty"` // Set Together AI API key
FireworksAPIKey *string `json:"fireworks_api_key,omitempty"` // Set Fireworks AI 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
OllamaKeepAlive *string `json:"ollama_keep_alive,omitempty"` // Set Ollama keep_alive; empty uses server default
OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL
ZaiBaseURL *string `json:"zai_base_url,omitempty"` // Set custom Z.ai base URL (e.g. coding endpoint)
CodexSubscriptionEnabled *bool `json:"codex_subscription_enabled,omitempty"`
ClaudeSubscriptionEnabled *bool `json:"claude_subscription_enabled,omitempty"`
// 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
ClearZaiKey *bool `json:"clear_zai_key,omitempty"` // Clear Z.ai (Zhipu) API key
ClearGroqKey *bool `json:"clear_groq_key,omitempty"` // Clear Groq API key
ClearMistralKey *bool `json:"clear_mistral_key,omitempty"` // Clear Mistral API key
ClearCerebrasKey *bool `json:"clear_cerebras_key,omitempty"` // Clear Cerebras API key
ClearTogetherKey *bool `json:"clear_together_key,omitempty"` // Clear Together AI API key
ClearFireworksKey *bool `json:"clear_fireworks_key,omitempty"` // Clear Fireworks AI 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 Intelligence settings")
http.Error(w, "Failed to load Pulse Intelligence 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,
PatrolAlertTriggerMinSeverity: settings.GetPatrolAlertTriggerMinSeverity(),
PatrolAlertTriggerTypes: settings.PatrolAlertTriggerTypes,
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),
ZaiConfigured: settings.HasProvider(config.AIProviderZai),
GroqConfigured: settings.HasProvider(config.AIProviderGroq),
MistralConfigured: settings.HasProvider(config.AIProviderMistral),
CerebrasConfigured: settings.HasProvider(config.AIProviderCerebras),
TogetherConfigured: settings.HasProvider(config.AIProviderTogether),
FireworksConfigured: settings.HasProvider(config.AIProviderFireworks),
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OllamaUsername: settings.OllamaUsername,
OllamaPasswordSet: settings.OllamaPassword != "",
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
OpenAIBaseURL: settings.OpenAIBaseURL,
ZaiBaseURL: settings.ZaiBaseURL,
CodexSubscriptionEnabled: settings.CodexSubscriptionEnabled,
ClaudeSubscriptionEnabled: settings.ClaudeSubscriptionEnabled,
ConfiguredProviders: settings.GetConfiguredProviders(),
Providers: aiProviderDefinitionResponses(settings),
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 {
// Patrol fix actions require Pro license with the ai_autofix feature.
if *req.PatrolAutoFix && !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Patrol fix actions 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.ClearZaiKey != nil && *req.ClearZaiKey {
settings.ZaiAPIKey = ""
} else if req.ZaiAPIKey != nil {
settings.ZaiAPIKey = strings.TrimSpace(*req.ZaiAPIKey)
}
if req.ClearGroqKey != nil && *req.ClearGroqKey {
settings.GroqAPIKey = ""
} else if req.GroqAPIKey != nil {
settings.GroqAPIKey = strings.TrimSpace(*req.GroqAPIKey)
}
if req.ClearMistralKey != nil && *req.ClearMistralKey {
settings.MistralAPIKey = ""
} else if req.MistralAPIKey != nil {
settings.MistralAPIKey = strings.TrimSpace(*req.MistralAPIKey)
}
if req.ClearCerebrasKey != nil && *req.ClearCerebrasKey {
settings.CerebrasAPIKey = ""
} else if req.CerebrasAPIKey != nil {
settings.CerebrasAPIKey = strings.TrimSpace(*req.CerebrasAPIKey)
}
if req.ClearTogetherKey != nil && *req.ClearTogetherKey {
settings.TogetherAPIKey = ""
} else if req.TogetherAPIKey != nil {
settings.TogetherAPIKey = strings.TrimSpace(*req.TogetherAPIKey)
}
if req.ClearFireworksKey != nil && *req.ClearFireworksKey {
settings.FireworksAPIKey = ""
} else if req.FireworksAPIKey != nil {
settings.FireworksAPIKey = strings.TrimSpace(*req.FireworksAPIKey)
}
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.OllamaKeepAlive != nil {
keepAlive, err := config.NormalizeOllamaKeepAlive(*req.OllamaKeepAlive)
if err != nil {
http.Error(w, "ollama_keep_alive "+err.Error(), http.StatusBadRequest)
return
}
settings.OllamaKeepAlive = keepAlive
}
if req.OpenAIBaseURL != nil {
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
}
if req.ZaiBaseURL != nil {
settings.ZaiBaseURL = strings.TrimSpace(*req.ZaiBaseURL)
}
if req.CodexSubscriptionEnabled != nil {
settings.CodexSubscriptionEnabled = *req.CodexSubscriptionEnabled
}
if req.ClaudeSubscriptionEnabled != nil {
settings.ClaudeSubscriptionEnabled = *req.ClaudeSubscriptionEnabled
}
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 alert-trigger investigation policy (minimum severity + type allowlist)
if req.PatrolAlertTriggerMinSeverity != nil {
sev := strings.ToLower(strings.TrimSpace(*req.PatrolAlertTriggerMinSeverity))
if sev != config.AlertTriggerSeverityWarning && sev != config.AlertTriggerSeverityCritical {
http.Error(w, "patrol_alert_trigger_min_severity must be 'warning' or 'critical'", http.StatusBadRequest)
return
}
settings.PatrolAlertTriggerMinSeverity = sev
}
if req.PatrolAlertTriggerTypes != nil {
cleaned := make([]string, 0, len(*req.PatrolAlertTriggerTypes))
seen := make(map[string]struct{}, len(*req.PatrolAlertTriggerTypes))
for _, t := range *req.PatrolAlertTriggerTypes {
t = strings.ToLower(strings.TrimSpace(t))
if t == "" {
continue
}
if _, dup := seen[t]; dup {
continue
}
seen[t] = struct{}{}
cleaned = append(cleaned, t)
}
settings.PatrolAlertTriggerTypes = cleaned
}
// 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 Pulse Intelligence 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 Assistant 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,
PatrolAlertTriggerMinSeverity: settings.GetPatrolAlertTriggerMinSeverity(),
PatrolAlertTriggerTypes: settings.PatrolAlertTriggerTypes,
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),
ZaiConfigured: settings.HasProvider(config.AIProviderZai),
GroqConfigured: settings.HasProvider(config.AIProviderGroq),
MistralConfigured: settings.HasProvider(config.AIProviderMistral),
CerebrasConfigured: settings.HasProvider(config.AIProviderCerebras),
TogetherConfigured: settings.HasProvider(config.AIProviderTogether),
FireworksConfigured: settings.HasProvider(config.AIProviderFireworks),
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OllamaUsername: settings.OllamaUsername,
OllamaPasswordSet: settings.OllamaPassword != "",
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
OpenAIBaseURL: settings.OpenAIBaseURL,
ZaiBaseURL: settings.ZaiBaseURL,
CodexSubscriptionEnabled: settings.CodexSubscriptionEnabled,
ClaudeSubscriptionEnabled: settings.ClaudeSubscriptionEnabled,
ConfiguredProviders: settings.GetConfiguredProviders(),
Providers: aiProviderDefinitionResponses(settings),
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.ClassifyProviderConnectionFailure(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"`
}
type aiProviderTestRequest struct {
Model string `json:"model,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 cannot test this provider because it is not configured for the current Pulse Intelligence settings.",
Recommendation: "Open the Provider & Models settings page, configure the provider credentials or base URL, choose a model, and retry.",
Action: "open_provider_settings",
}
}
func newAIProviderTestFailureResponse(provider, model string, err error) aiProviderTestResponse {
diagnostic := ai.ClassifyProviderConnectionFailure(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
}
var body aiProviderTestRequest
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, `{"error":"Invalid JSON body"}`, http.StatusBadRequest)
return
}
}
requestedModel := config.NormalizeQuickstartModelString(body.Model)
if len(requestedModel) > 256 {
http.Error(w, `{"error":"Model id too long"}`, http.StatusBadRequest)
return
}
if requestedModel != "" && strings.Contains(requestedModel, ":") {
modelProvider, _ := config.ParseModelString(requestedModel)
if modelProvider != provider {
http.Error(w, `{"error":"Model provider does not match test provider"}`, 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 := requestedModel
if model == "" {
var err error
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"`
Provider string `json:"provider,omitempty"`
}
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,
Provider: m.Provider,
})
}
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 agentcapabilities.NormalizeActionTargetType(raw)
}
func normalizeAndValidateAIExecuteTargetType(raw string) (string, error) {
return agentcapabilities.NormalizeAndValidateOptionalActionTargetType(raw)
}
// 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 legacyAssistantSSEWriter struct {
w http.ResponseWriter
flusher http.Flusher
rc *http.ResponseController
cancel context.CancelFunc
cancelOnDisconnect bool
writeMu sync.Mutex
clientDisconnected atomic.Bool
lastClientEventUnixMS atomic.Int64
terminalEventsStarted atomic.Bool
heartbeatDone chan struct{}
heartbeatDoneClosed atomic.Bool
executionDone chan struct{}
executionDoneClosed atomic.Bool
}
func newLegacyAssistantSSEWriter(w http.ResponseWriter, flusher http.Flusher, rc *http.ResponseController, cancel context.CancelFunc, cancelOnDisconnect bool) *legacyAssistantSSEWriter {
writer := &legacyAssistantSSEWriter{
w: w,
flusher: flusher,
rc: rc,
cancel: cancel,
cancelOnDisconnect: cancelOnDisconnect,
heartbeatDone: make(chan struct{}),
executionDone: make(chan struct{}),
}
writer.lastClientEventUnixMS.Store(time.Now().UnixMilli())
return writer
}
func (s *legacyAssistantSSEWriter) start(ctx context.Context) {
s.startHeartbeat(ctx)
s.startIdleProgress(ctx)
}
func (s *legacyAssistantSSEWriter) stop() {
s.closeHeartbeat()
s.finishExecution()
}
func (s *legacyAssistantSSEWriter) closeHeartbeat() {
if s.heartbeatDoneClosed.CompareAndSwap(false, true) {
close(s.heartbeatDone)
}
}
func (s *legacyAssistantSSEWriter) finishExecution() {
if s.executionDoneClosed.CompareAndSwap(false, true) {
close(s.executionDone)
}
}
func (s *legacyAssistantSSEWriter) disconnected() bool {
return s.clientDisconnected.Load()
}
func (s *legacyAssistantSSEWriter) startHeartbeat(ctx context.Context) {
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.writeMu.Lock()
if s.clientDisconnected.Load() {
s.writeMu.Unlock()
return
}
_ = s.rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
_, err := s.w.Write([]byte(": heartbeat\n\n"))
if err != nil {
s.writeMu.Unlock()
s.markDisconnected()
return
}
s.flusher.Flush()
s.writeMu.Unlock()
log.Debug().Msg("Sent SSE heartbeat")
case <-s.heartbeatDone:
return
}
}
}()
}
func (s *legacyAssistantSSEWriter) startIdleProgress(ctx context.Context) {
interval := chatStreamIdleProgressInterval
if interval <= 0 {
return
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.heartbeatDone:
return
case <-s.executionDone:
return
case <-ticker.C:
if s.clientDisconnected.Load() {
return
}
if s.terminalEventsStarted.Load() || s.executionDoneClosed.Load() {
return
}
lastEventAt := time.UnixMilli(s.lastClientEventUnixMS.Load())
if time.Since(lastEventAt) < interval {
continue
}
progressEvent := ai.StreamEvent{
Type: "workflow_state",
Data: chat.WorkflowStateData{
Phase: "stream_idle",
Message: chatStreamIdleProgressMessage,
},
}
s.writePayloadIf(progressEvent, true, func() bool {
return !s.terminalEventsStarted.Load() && !s.executionDoneClosed.Load()
})
}
}
}()
}
func (s *legacyAssistantSSEWriter) markDisconnected() {
s.clientDisconnected.Store(true)
if s.cancelOnDisconnect && s.cancel != nil {
s.cancel()
}
}
func (s *legacyAssistantSSEWriter) writePayload(payload interface{}) bool {
return s.writePayloadIf(payload, true, nil)
}
func (s *legacyAssistantSSEWriter) writeTerminalPayload(payload interface{}) bool {
s.terminalEventsStarted.Store(true)
s.finishExecution()
return s.writePayloadIf(payload, true, nil)
}
func (s *legacyAssistantSSEWriter) writePayloadIf(payload interface{}, visible bool, shouldWrite func() bool) bool {
if s.clientDisconnected.Load() {
return false
}
data, err := json.Marshal(payload)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal SSE event")
return false
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
if s.clientDisconnected.Load() {
return false
}
if shouldWrite != nil && !shouldWrite() {
return false
}
_ = s.rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
_, err = s.w.Write([]byte("data: " + string(data) + "\n\n"))
if err != nil {
log.Debug().Err(err).Msg("Failed to write SSE event (client may have disconnected)")
s.markDisconnected()
return false
}
s.flusher.Flush()
if visible {
s.lastClientEventUnixMS.Store(time.Now().UnixMilli())
}
return true
}
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, "Patrol fix actions 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, "Patrol fix actions 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()
// Keep the historical background execution behavior for this legacy route,
// but expose transport progress through the same visible idle event contract
// as /api/ai/chat.
streamWriter := newLegacyAssistantSSEWriter(w, flusher, rc, cancel, false)
streamWriter.start(ctx)
defer streamWriter.stop()
// 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
}
log.Debug().
Str("event_type", event.Type).
Msg("Streaming AI event")
if event.Type == "error" {
streamWriter.writeTerminalPayload(event)
return
}
streamWriter.writePayload(event)
}
// 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 !streamWriter.disconnected() {
doneEvent := ai.StreamEvent{Type: "done"}
streamWriter.writeTerminalPayload(doneEvent)
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."}}
streamWriter.writeTerminalPayload(errEvent)
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()
streamWriter.writeTerminalPayload(finalEvent)
// '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
}
writeJSONError(w, http.StatusGone, agentcapabilities.AgentErrCodeRawCommandRetired, "Raw command execution is retired. Use an advertised typed resource action.")
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 Patrol fix actions (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, "Patrol fix actions 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 approvalReq.Plan == nil || strings.TrimSpace(approvalReq.Plan.ActionID) == "" {
http.Error(w, "Approval authorization is incomplete", http.StatusConflict)
return
}
approvedHash := strings.TrimSpace(approvalReq.CommandHash)
if approvedHash == "" {
approvedHash = approval.ComputeCommandHash(approvalReq.Command, approvalReq.TargetType, approvalReq.TargetID)
}
if approvedHash != approval.ComputeCommandHash(req.Command, approvalTargetType, approvalTargetID) {
http.Error(w, "Approval command does not match", 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)),
OrgID: orgID,
ActionID: approvalReq.Plan.ActionID,
})
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))
if req.RunOnHost {
targetType = agentcapabilities.ActionTargetTypeAgent
if targetHost == "" {
return "", "", errors.New("target_host is required when run_on_host is true")
}
targetID = targetHost
}
if targetType == "" {
targetType = agentcapabilities.ActionTargetTypeAgent
}
if !agentcapabilities.IsActionTargetType(targetType) {
return "", "", fmt.Errorf("unsupported target_type %q (allowed: %s)", targetType, agentcapabilities.ActionTargetTypeAllowedDescription())
}
if (targetType == agentcapabilities.ActionTargetTypeSystemContainer || targetType == agentcapabilities.ActionTargetTypeVM) && strings.TrimSpace(req.VMID) != "" {
targetID = strings.TrimSpace(req.VMID)
}
if targetType == agentcapabilities.ActionTargetTypeAgent {
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) {
return agentcapabilities.ActionTargetTypeForResourceType(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()
streamWriter := newLegacyAssistantSSEWriter(w, flusher, rc, cancel, false)
streamWriter.start(ctx)
defer streamWriter.stop()
// Stream callback
callback := func(event ai.StreamEvent) {
if event.Type == "done" {
return
}
if event.Type == "error" {
streamWriter.writeTerminalPayload(event)
return
}
streamWriter.writePayload(event)
}
// Execute with streaming
defer func() {
if !streamWriter.disconnected() {
doneEvent := ai.StreamEvent{Type: "done"}
streamWriter.writeTerminalPayload(doneEvent)
}
}()
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."}}
streamWriter.writeTerminalPayload(errEvent)
return
}
// Send completion event
finalEvent := aiExecuteStreamCompleteEvent{
Type: "complete",
Model: resp.Model,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
ToolCalls: resp.ToolCalls,
}.NormalizeCollections()
streamWriter.writeTerminalPayload(finalEvent)
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)
}
}
const unsupportedAnthropicOAuthMessage = "Anthropic subscription OAuth is not supported by Pulse Assistant. Configure an Anthropic API key or another supported provider."
func writeAnthropicOAuthUnsupported(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotImplemented)
if err := utils.WriteJSONResponse(w, map[string]interface{}{
"success": false,
"error": "unsupported_anthropic_oauth",
"message": unsupportedAnthropicOAuthMessage,
}); err != nil {
log.Error().Err(err).Msg("Failed to write unsupported Anthropic OAuth response")
}
}
// HandleOAuthStart rejects legacy Anthropic subscription OAuth setup.
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
}
writeAnthropicOAuthUnsupported(w)
}
// HandleOAuthExchange rejects legacy Anthropic subscription OAuth setup.
func (h *AISettingsHandler) HandleOAuthExchange(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
writeAnthropicOAuthUnsupported(w)
}
// HandleOAuthCallback rejects stale legacy OAuth redirects without saving tokens.
func (h *AISettingsHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
errParam := r.URL.Query().Get("error")
if errParam != "" {
log.Info().Str("error", errParam).Msg("Unsupported Anthropic OAuth callback returned provider error")
http.Redirect(w, r, "/settings?ai_oauth_error="+url.QueryEscape(errParam), http.StatusTemporaryRedirect)
return
}
http.Redirect(w, r, "/settings?ai_oauth_error=unsupported", 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 Intelligence 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("Legacy Anthropic 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, "Assistant runtime service", "Pulse Assistant 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.", "")
if ai.IsDemoMode() {
// Demo/mock runtimes simulate Patrol's provider path end to end, so
// the provider-dependent checks report a simulated pass instead of
// steering visitors into provider setup.
addCheck("settings", patrolReadinessReady, ai.PatrolFailureCauseNone, "Settings persistence", "Demo mode uses the built-in demo dataset.", "")
addCheck("enabled", patrolReadinessReady, ai.PatrolFailureCauseNone, "Assistant enabled", "Demo mode simulates Pulse Assistant; no provider key is required.", "")
addCheck("provider", patrolReadinessReady, ai.PatrolFailureCauseNone, "Provider configured", "Demo mode uses the simulated demo provider.", "")
addCheck("model", patrolReadinessReady, ai.PatrolFailureCauseNone, "Patrol model", "Demo mode uses the simulated demo model.", "")
addCheck("tools", patrolReadinessReady, ai.PatrolFailureCauseNone, "Patrol tools", "Demo mode simulates Patrol's tool-backed analysis.", "")
return summarizePatrolReadiness(ai.DemoPatrolProvider, ai.DemoPatrolModel, checks)
}
cfg, err := h.loadAIConfig(ctx)
if err != nil || cfg == nil {
addCheck("settings", patrolReadinessNotReady, ai.PatrolFailureCauseSettingsPersistence, "Settings persistence", "Pulse Intelligence settings could not be loaded from persistence.", "open_provider_settings")
return summarizePatrolReadiness("", "", checks)
}
addCheck("settings", patrolReadinessReady, ai.PatrolFailureCauseNone, "Settings persistence", "Pulse Intelligence settings are readable.", "")
if !cfg.Enabled {
addCheck("enabled", patrolReadinessNotReady, ai.PatrolFailureCauseAssistantDisabled, "Assistant enabled", "Pulse Intelligence is turned off, so Patrol cannot run.", "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 yet. Add a provider API key or an Ollama server on the Provider & Models settings page.", "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 control",
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 writePatrolAgentUnavailableResponse(w http.ResponseWriter) {
writeJSONError(w, http.StatusServiceUnavailable, agentcapabilities.AgentErrCodePatrolUnavailable, "Pulse Patrol service not available")
}
func writeFindingInvalidRequestResponse(w http.ResponseWriter, message string, details map[string]string) {
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidFindingRequest, message, details)
}
func writeFindingNotFoundResponse(w http.ResponseWriter, message string) {
writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeFindingNotFound, message)
}
func writeFindingActionNotAllowedResponse(w http.ResponseWriter, message string) {
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeFindingActionNotAllowed, message)
}
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]
}
h.applyResourceCriticalityToPatrolFindings(r.Context(), findings)
if err := utils.WriteJSONResponse(w, findings); err != nil {
log.Error().Err(err).Msg("Failed to write patrol findings response")
}
}
func (h *AISettingsHandler) applyResourceCriticalityToPatrolFindings(ctx context.Context, findings []*ai.Finding) {
if h == nil || len(findings) == 0 {
return
}
h.stateMu.RLock()
provider := h.resourceStoreProvider
h.stateMu.RUnlock()
if provider == nil {
return
}
store, err := provider(GetOrgID(ctx))
if err != nil || store == nil {
if err != nil {
log.Debug().Err(err).Msg("Failed to resolve resource store for Patrol finding priority enrichment")
}
return
}
criticalityByResourceID := make(map[string]string)
for _, finding := range findings {
if finding == nil {
continue
}
resourceID := strings.TrimSpace(finding.ResourceID)
if resourceID == "" {
continue
}
if _, exists := criticalityByResourceID[resourceID]; exists {
continue
}
state, found, fetchErr := store.GetResourceOperatorState(resourceID)
if fetchErr != nil {
log.Debug().Err(fetchErr).Str("resource_id", resourceID).Msg("Failed to load resource operator priority for Patrol finding")
continue
}
if !found {
criticalityByResourceID[resourceID] = ""
continue
}
criticalityByResourceID[resourceID] = normalizePatrolResourceCriticality(string(state.Criticality))
}
for _, finding := range findings {
if finding == nil {
continue
}
resourceID := strings.TrimSpace(finding.ResourceID)
if criticality, ok := criticalityByResourceID[resourceID]; ok {
finding.ResourceCriticality = criticality
}
}
}
func normalizePatrolResourceCriticality(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
if !unifiedresources.IsValidCriticality(normalized) {
return ""
}
return normalized
}
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
}
// manualScopedPatrolRequest is the optional body for POST /api/ai/patrol/run.
// When resource_ids or resource_types are present, the run is scoped to those
// resources as a manual Targeted check over the same scoped engine the
// automatic alert path uses, instead of a fleet-wide Patrol check.
type manualScopedPatrolRequest struct {
ResourceIDs []string `json:"resource_ids,omitempty"`
ResourceTypes []string `json:"resource_types,omitempty"`
AlertIdentifier string `json:"alert_identifier,omitempty"`
AlertType string `json:"alert_type,omitempty"`
}
// buildManualScopedPatrolScope maps an optional scoped-run request body to a
// manual PatrolScope. It returns hasScope=false when no resource ids or types
// remain after trimming, so the caller falls back to a full fleet run.
func buildManualScopedPatrolScope(req manualScopedPatrolRequest) (ai.PatrolScope, bool) {
resourceIDs := make([]string, 0, len(req.ResourceIDs))
for _, id := range req.ResourceIDs {
if trimmed := strings.TrimSpace(id); trimmed != "" {
resourceIDs = append(resourceIDs, trimmed)
}
}
resourceTypes := make([]string, 0, len(req.ResourceTypes))
for _, t := range req.ResourceTypes {
if trimmed := strings.TrimSpace(t); trimmed != "" {
resourceTypes = append(resourceTypes, trimmed)
}
}
if len(resourceIDs) == 0 && len(resourceTypes) == 0 {
return ai.PatrolScope{}, false
}
scope := ai.PatrolScope{
ResourceIDs: resourceIDs,
ResourceTypes: resourceTypes,
Depth: ai.PatrolDepthQuick,
Reason: ai.TriggerReasonManual,
AlertIdentifier: strings.TrimSpace(req.AlertIdentifier),
}
scope.Context = "Manual targeted check"
if alertType := strings.TrimSpace(req.AlertType); alertType != "" {
scope.Context = "Manual targeted check for alert: " + alertType
}
return scope, true
}
// 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
}
// Optional scope: resource_ids/resource_types turn this into a manual
// Targeted check over the same scoped engine the automatic alert path uses,
// instead of a fleet-wide Patrol check. An empty or absent body keeps the
// legacy full-run behaviour. Scoped runs bypass the full-run cadence gate
// (consistent with automatic scoped runs) but still honour readiness above.
var scopedReq manualScopedPatrolRequest
if r.Body != nil {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
decodeErr := decoder.Decode(&scopedReq)
if decodeErr == nil {
var trailing any
decodeErr = decoder.Decode(&trailing)
if errors.Is(decodeErr, io.EOF) {
decodeErr = nil
} else if decodeErr == nil {
decodeErr = errors.New("multiple JSON values")
}
}
if decodeErr != nil && !errors.Is(decodeErr, io.EOF) {
writeErrorResponse(w, http.StatusBadRequest, "invalid_patrol_scope",
"The Patrol run body must contain only valid resource_ids, resource_types, alert_identifier, and alert_type fields.", nil)
return
}
}
if scope, hasScope := buildManualScopedPatrolScope(scopedReq); hasScope {
_, resolution := patrol.ResolvePatrolScope(scope)
if len(resolution.UnmatchedResourceIDs) > 0 || len(resolution.AmbiguousResourceIDs) > 0 ||
(len(resolution.RequestedResourceIDs) > 0 && len(resolution.EffectiveResourceIDs) == 0) {
writeErrorResponse(w, http.StatusUnprocessableEntity, "patrol_scope_unresolved",
"One or more requested resource identities could not be resolved exactly to the current Patrol collection state.",
map[string]string{
"unmatched_resource_ids": strings.Join(resolution.UnmatchedResourceIDs, ","),
"ambiguous_resource_ids": strings.Join(resolution.AmbiguousResourceIDs, ","),
})
return
}
go patrol.TriggerScopedPatrol(context.WithoutCancel(r.Context()), scope)
response := map[string]interface{}{
"success": true,
"message": "Triggered targeted Patrol check",
"scope_resolution": resolution,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write scoped patrol response")
}
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
}
// The service owns the route-aware deadline. API providers keep the tight
// preflight budget while local subscription agents receive enough time for
// bounded CLI startup and structured-output assembly.
preflight := aiService.RunPatrolToolPreflight(r.Context(), 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 {
writePatrolAgentUnavailableResponse(w)
return
}
var req struct {
FindingID string `json:"finding_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeFindingInvalidRequestResponse(w, "Invalid request body", nil)
return
}
if req.FindingID == "" {
writeFindingInvalidRequestResponse(w, "finding_id is required", map[string]string{"finding_id": "required"})
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 {
writeFindingNotFoundResponse(w, "Finding not found")
return
}
} else if !foundInPatrol {
writeFindingNotFoundResponse(w, "Finding not found")
return
}
if foundInPatrol {
if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "acknowledged"); err != nil {
writeFindingActionNotAllowedResponse(w, err.Error())
return
}
}
// Acknowledge in patrol findings if it exists there
if foundInPatrol {
if !findings.Acknowledge(req.FindingID) {
writeFindingNotFoundResponse(w, "Finding not found")
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 {
writePatrolAgentUnavailableResponse(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 {
writeFindingInvalidRequestResponse(w, "Invalid request body", nil)
return
}
if req.FindingID == "" {
writeFindingInvalidRequestResponse(w, "finding_id is required", map[string]string{"finding_id": "required"})
return
}
if req.DurationHours <= 0 {
writeFindingInvalidRequestResponse(w, "duration_hours must be positive", map[string]string{"duration_hours": "must be positive"})
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 {
writeFindingNotFoundResponse(w, "Finding not found or already resolved")
return
}
} else if !foundInPatrol {
writeFindingNotFoundResponse(w, "Finding not found or already resolved")
return
}
if foundInPatrol {
if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "snoozed"); err != nil {
writeFindingActionNotAllowedResponse(w, err.Error())
return
}
}
// Snooze in patrol findings if it exists there
if foundInPatrol {
if !findings.Snooze(req.FindingID, duration) {
writeFindingNotFoundResponse(w, "Finding not found or already resolved")
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 {
writePatrolAgentUnavailableResponse(w)
return
}
var req struct {
FindingID string `json:"finding_id"`
ResolutionNote string `json:"resolution_note"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeFindingInvalidRequestResponse(w, "Invalid request body", nil)
return
}
req.ResolutionNote = strings.TrimSpace(req.ResolutionNote)
if req.FindingID == "" {
writeFindingInvalidRequestResponse(w, "finding_id is required", map[string]string{"finding_id": "required"})
return
}
findings := patrol.GetFindings()
// Get finding details before resolving (for learning/analytics)
finding := findings.Get(req.FindingID)
if finding == nil {
writeFindingNotFoundResponse(w, "Finding not found or already resolved")
return
}
if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "resolved"); err != nil {
writeFindingActionNotAllowedResponse(w, err.Error())
return
}
if req.ResolutionNote != "" {
findings.SetUserNote(req.FindingID, req.ResolutionNote)
}
// 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) {
writeFindingNotFoundResponse(w, "Finding not found or already resolved")
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),
UserNote: req.ResolutionNote,
})
}
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 {
writePatrolAgentUnavailableResponse(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 {
writeFindingInvalidRequestResponse(w, "Invalid request body", nil)
return
}
if req.FindingID == "" {
writeFindingInvalidRequestResponse(w, "finding_id is required", map[string]string{"finding_id": "required"})
return
}
if req.Reason == "" {
writeFindingInvalidRequestResponse(w, "reason is required", map[string]string{"reason": "required"})
return
}
// Validate reason
validReasons := map[string]bool{
"not_an_issue": true,
"expected_behavior": true,
"will_fix_later": true,
}
if req.Reason != "" && !validReasons[req.Reason] {
writeFindingInvalidRequestResponse(w, "Invalid reason. Valid values: not_an_issue, expected_behavior, will_fix_later", map[string]string{"reason": "must be one of not_an_issue, expected_behavior, will_fix_later"})
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 {
writeFindingNotFoundResponse(w, "Finding not found")
return
}
} else if !foundInPatrol {
writeFindingNotFoundResponse(w, "Finding not found")
return
}
if foundInPatrol {
if err := patrol.RejectManualActionForRuntimeFinding(req.FindingID, "dismissed"); err != nil {
writeFindingActionNotAllowedResponse(w, err.Error())
return
}
}
// Dismiss in patrol findings if it exists there
if foundInPatrol {
if !findings.Dismiss(req.FindingID, req.Reason, req.Note) {
writeFindingNotFoundResponse(w, "Finding not found")
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 {
response := map[string]interface{}{
"approvals": []approval.ApprovalRequest{},
"stats": emptyApprovalStats(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
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)
}
func emptyApprovalStats() map[string]int {
return map[string]int{
"pending": 0,
"approved": 0,
"denied": 0,
"expired": 0,
"executions": 0,
}
}
// 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, "Patrol fix actions 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.
// cleanTargetHost and the agent command/tool executor adapters have been
// moved to router_routes_ai_relay.go as package-level helpers 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 existing := findingsStore.Get(findingID); existing != nil && existing.InvestigationOutcome == outcome {
return
}
if !findingsStore.UpdateInvestigationOutcome(findingID, outcome) {
log.Warn().Str("findingID", findingID).Msg("Finding not found for outcome update")
return
}
patrol.PublishFindingLifecycleUpdate(findingID)
log.Info().Str("findingID", findingID).Str("outcome", outcome).Msg("Updated finding investigation outcome")
}
func (h *AISettingsHandler) actionAuditStoreForApprovalDecision(ctx context.Context) unifiedresources.ResourceStore {
if h == nil {
return nil
}
h.stateMu.RLock()
chatHandler := h.chatHandler
h.stateMu.RUnlock()
if chatHandler == nil {
return nil
}
service := chatHandler.GetService(ctx)
provider, ok := service.(actionAuditStoreProvider)
if !ok {
return nil
}
return provider.GetActionAuditStore()
}
// 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
}
reason := strings.TrimSpace(body.Reason)
if reason == "" {
reason = "approval denied"
}
tools.RecordApprovalDecision(
h.actionAuditStoreForApprovalDecision(r.Context()),
req.ID,
unifiedresources.ActionStateRejected,
username,
reason,
)
if req.ToolID == "investigation_fix" && strings.EqualFold(strings.TrimSpace(req.TargetType), "investigation") && strings.TrimSpace(req.TargetID) != "" {
h.updateFindingOutcome(r.Context(), orgID, strings.TrimSpace(req.TargetID), string(ai.InvestigationOutcomeFixRejected))
}
// 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.
// FullModeUnlocked is a legacy compatibility field only; it is never acknowledgement authority.
type PatrolAutonomySettings struct {
AutonomyLevel string `json:"autonomy_level"` // "monitor", "approval", "assisted", "full"
FullModeUnlocked *bool `json:"full_mode_unlocked,omitempty"` // Deprecated compatibility label (nil = preserve existing)
AcknowledgementID string `json:"acknowledgement_id,omitempty"`
InvestigationBudget int `json:"investigation_budget"` // Max evidence calls 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"`
RequestedAutonomyLevel string `json:"requested_autonomy_level"`
EffectiveAutonomyLevel string `json:"effective_autonomy_level"`
FullModeUnlocked bool `json:"full_mode_unlocked"`
AutopilotStatus unifiedresources.PatrolAutopilotStatus `json:"autopilot_acknowledgement"`
InvestigationBudget int `json:"investigation_budget"`
InvestigationTimeoutSec int `json:"investigation_timeout_sec"`
}
type patrolAutopilotAcknowledgementRequest struct {
AcknowledgementID string `json:"acknowledgement_id"`
}
type patrolAutopilotRevocationRequest struct {
Reason string `json:"reason,omitempty"`
}
type patrolAutopilotActivationRequest struct {
AcknowledgementID string
Actor unifiedresources.ActionActor
}
type patrolAutopilotActivationContextKey struct{}
func patrolAutopilotActivationFromContext(ctx context.Context) (patrolAutopilotActivationRequest, bool) {
request, ok := ctx.Value(patrolAutopilotActivationContextKey{}).(patrolAutopilotActivationRequest)
return request, ok
}
func decodeStrictPatrolAutopilotJSONBody(w http.ResponseWriter, r *http.Request, target any, maxBytes int64) error {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
data, err := io.ReadAll(r.Body)
if err != nil {
return err
}
return decodeStrictPatrolAutopilotJSONBytes(data, target)
}
func decodeStrictPatrolAutopilotJSONBytes(data []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
}
return err
}
return nil
}
func cloneAIConfigForPatrolAutopilot(cfg *config.AIConfig) (*config.AIConfig, error) {
if cfg == nil {
return nil, fmt.Errorf("Pulse Patrol config not configured")
}
data, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
var cloned config.AIConfig
if err := json.Unmarshal(data, &cloned); err != nil {
return nil, err
}
return &cloned, nil
}
func (h *AISettingsHandler) mutatePatrolAutopilotConfig(ctx context.Context, mutate func(*config.AIConfig, unifiedresources.PatrolAutopilotServerPolicy) (bool, error)) error {
if h == nil {
return fmt.Errorf("Pulse Patrol service not available")
}
persistence := h.getPersistence(ctx)
service := h.GetAIService(ctx)
if persistence == nil || service == nil {
return fmt.Errorf("Pulse Patrol config persistence unavailable")
}
write := func() error {
current, err := h.loadAIConfig(ctx)
if err != nil {
return err
}
next, err := cloneAIConfigForPatrolAutopilot(current)
if err != nil {
return err
}
changed, err := mutate(next, h.currentPatrolAutopilotServerPolicy())
if err != nil || !changed {
return err
}
if err := persistence.SaveAIConfig(*next); err != nil {
return err
}
return service.ApplyPatrolAutonomyConfig(next)
}
h.stateMu.RLock()
coordinator := h.policyMutation
h.stateMu.RUnlock()
if coordinator != nil {
return coordinator(write)
}
return write()
}
func patrolAutonomyResponseForConfig(cfg *config.AIConfig, orgID string, policy unifiedresources.PatrolAutopilotServerPolicy, hasAutoFix bool) PatrolAutonomyResponse {
requested := cfg.GetPatrolAutonomyLevel()
effective, status := cfg.GetEffectivePatrolAutonomyWithPolicy(orgID, policy)
if !hasAutoFix {
effective = config.PatrolAutonomyMonitor
status.Active = false
status.Code = unifiedresources.PatrolAutopilotStatusLicenseRequired
}
return PatrolAutonomyResponse{
AutonomyLevel: effective,
RequestedAutonomyLevel: requested,
EffectiveAutonomyLevel: effective,
FullModeUnlocked: effective == config.PatrolAutonomyFull && status.Active,
AutopilotStatus: status,
InvestigationBudget: cfg.GetPatrolInvestigationBudget(),
InvestigationTimeoutSec: int(cfg.GetPatrolInvestigationTimeout().Seconds()),
}
}
// 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
}
hasAutoFix := aiService.HasLicenseFeature(featureAIAutoFixValue)
settings := patrolAutonomyResponseForConfig(cfg, GetOrgID(r.Context()), h.currentPatrolAutopilotServerPolicy(), hasAutoFix)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(settings)
}
func (h *AISettingsHandler) HandleCreatePatrolAutopilotAcknowledgement(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var request patrolAutopilotAcknowledgementRequest
if err := decodeStrictPatrolAutopilotJSONBody(w, r, &request, 16*1024); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid acknowledgement request", nil)
return
}
orgID := strings.TrimSpace(GetOrgID(r.Context()))
if orgID == "" {
orgID = "default"
}
actor, err := actionActorForRequest(h.defaultConfig, r, orgID)
if err != nil || actor.Kind != unifiedresources.ActionActorUser {
writeErrorResponse(w, http.StatusForbidden, unifiedresources.PatrolAutopilotStatusUserRequired, "Autopilot acknowledgement requires an authenticated human session", nil)
return
}
var acknowledgement unifiedresources.PatrolAutopilotAcknowledgement
created := false
err = h.mutatePatrolAutopilotConfig(r.Context(), func(cfg *config.AIConfig, policy unifiedresources.PatrolAutopilotServerPolicy) (bool, error) {
var issueErr error
acknowledgement, created, issueErr = unifiedresources.IssuePatrolAutopilotAcknowledgement(cfg.PatrolAutopilotAcknowledgements, request.AcknowledgementID, actor, policy)
if issueErr != nil || !created {
return false, issueErr
}
cfg.PatrolAutopilotAcknowledgements = append(cfg.PatrolAutopilotAcknowledgements, acknowledgement)
return true, nil
})
if err != nil {
code := unifiedresources.PatrolAutopilotErrorCode(err)
if code == "" {
code = unifiedresources.PatrolAutopilotStatusStoreUnavailable
}
status := http.StatusConflict
if code == unifiedresources.PatrolAutopilotStatusStoreUnavailable {
status = http.StatusServiceUnavailable
}
writeErrorResponse(w, status, code, "Autopilot acknowledgement was not recorded", nil)
return
}
_, acknowledgementStatus := unifiedresources.ValidatePatrolAutopilotAcknowledgement(
[]unifiedresources.PatrolAutopilotAcknowledgement{acknowledgement}, nil, acknowledgement.ID, orgID, &actor, h.currentPatrolAutopilotServerPolicy(),
)
statusCode := http.StatusOK
if created {
statusCode = http.StatusCreated
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(map[string]any{"created": created, "acknowledgement": acknowledgementStatus})
}
func (h *AISettingsHandler) HandleRevokePatrolAutopilotAcknowledgement(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
acknowledgementID := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/autonomy/acknowledgements/"))
if acknowledgementID == "" || strings.Contains(acknowledgementID, "/") {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Acknowledgement id is required", nil)
return
}
var request patrolAutopilotRevocationRequest
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
body, err := io.ReadAll(r.Body)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid revocation request", nil)
return
}
if len(bytes.TrimSpace(body)) > 0 {
if err := decodeStrictPatrolAutopilotJSONBytes(body, &request); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid revocation request", nil)
return
}
}
orgID := strings.TrimSpace(GetOrgID(r.Context()))
if orgID == "" {
orgID = "default"
}
actor, err := actionActorForRequest(h.defaultConfig, r, orgID)
if err != nil || actor.Kind != unifiedresources.ActionActorUser {
writeErrorResponse(w, http.StatusForbidden, unifiedresources.PatrolAutopilotStatusUserRequired, "Autopilot revocation requires an authenticated human session", nil)
return
}
created := false
err = h.mutatePatrolAutopilotConfig(r.Context(), func(cfg *config.AIConfig, policy unifiedresources.PatrolAutopilotServerPolicy) (bool, error) {
revocation, wasCreated, revokeErr := unifiedresources.RevokePatrolAutopilotAcknowledgement(cfg.PatrolAutopilotAcknowledgements, cfg.PatrolAutopilotRevocations, acknowledgementID, actor, request.Reason, policy)
if revokeErr != nil || !wasCreated {
created = false
return false, revokeErr
}
created = true
cfg.PatrolAutopilotRevocations = append(cfg.PatrolAutopilotRevocations, revocation)
cfg.PatrolFullModeUnlocked = false
return true, nil
})
if err != nil {
code := unifiedresources.PatrolAutopilotErrorCode(err)
if code == "" {
code = unifiedresources.PatrolAutopilotStatusStoreUnavailable
}
status := http.StatusConflict
if code == unifiedresources.PatrolAutopilotStatusStoreUnavailable {
status = http.StatusServiceUnavailable
}
writeErrorResponse(w, status, code, "Autopilot acknowledgement was not revoked", nil)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"revoked": true, "created": created, "acknowledgement_id": acknowledgementID})
}
func (h *AISettingsHandler) GatePatrolAutonomyUpdate(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
next(w, r)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
body, err := io.ReadAll(r.Body)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid Patrol autonomy request", nil)
return
}
var settings PatrolAutonomySettings
if err := decodeStrictPatrolAutopilotJSONBytes(body, &settings); err != nil {
writeErrorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid Patrol autonomy request", nil)
return
}
settings.AcknowledgementID = strings.TrimSpace(settings.AcknowledgementID)
if settings.AutonomyLevel != config.PatrolAutonomyFull {
if settings.AcknowledgementID != "" {
writeErrorResponse(w, http.StatusConflict, unifiedresources.PatrolAutopilotStatusNotRequested, "Acknowledgement is only valid for an explicit Autopilot activation", nil)
return
}
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
next(w, r)
return
}
if settings.AcknowledgementID == "" {
writeErrorResponse(w, http.StatusConflict, unifiedresources.PatrolAutopilotStatusAcknowledgementRequired, "Autopilot activation requires a current acknowledgement", nil)
return
}
orgID := strings.TrimSpace(GetOrgID(r.Context()))
if orgID == "" {
orgID = "default"
}
actor, err := actionActorForRequest(h.defaultConfig, r, orgID)
if err != nil || actor.Kind != unifiedresources.ActionActorUser {
writeErrorResponse(w, http.StatusForbidden, unifiedresources.PatrolAutopilotStatusUserRequired, "Autopilot activation requires the human session that owns the acknowledgement", nil)
return
}
cfg, err := h.loadAIConfig(r.Context())
if err != nil {
writeErrorResponse(w, http.StatusServiceUnavailable, unifiedresources.PatrolAutopilotStatusStoreUnavailable, "Autopilot acknowledgement store is unavailable", nil)
return
}
_, status := unifiedresources.ValidatePatrolAutopilotAcknowledgement(cfg.PatrolAutopilotAcknowledgements, cfg.PatrolAutopilotRevocations, settings.AcknowledgementID, orgID, &actor, h.currentPatrolAutopilotServerPolicy())
if !status.Active {
writeErrorResponse(w, http.StatusConflict, status.Code, "Autopilot acknowledgement is not eligible for activation", nil)
return
}
compatUnlocked := true
settings.FullModeUnlocked = &compatUnlocked
normalizedBody, err := json.Marshal(settings)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "encoding_failed", "Failed to prepare Patrol autonomy request", nil)
return
}
r.Body = io.NopCloser(bytes.NewReader(normalizedBody))
r.ContentLength = int64(len(normalizedBody))
activationRequest := patrolAutopilotActivationRequest{AcknowledgementID: settings.AcknowledgementID, Actor: actor}
next(w, r.WithContext(context.WithValue(r.Context(), patrolAutopilotActivationContextKey{}, activationRequest)))
}
}
// 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
}
if err := h.mutatePatrolAutopilotConfig(r.Context(), func(current *config.AIConfig, _ unifiedresources.PatrolAutopilotServerPolicy) (bool, error) {
changed := current.PatrolAutonomyLevel != config.PatrolAutonomyMonitor || current.PatrolFullModeUnlocked || current.PatrolAutopilotActivation != nil ||
current.PatrolInvestigationBudget != req.InvestigationBudget || current.PatrolInvestigationTimeoutSec != req.InvestigationTimeoutSec
current.PatrolAutonomyLevel = config.PatrolAutonomyMonitor
current.PatrolFullModeUnlocked = false
current.PatrolAutopilotActivation = nil
current.PatrolInvestigationBudget = req.InvestigationBudget
current.PatrolInvestigationTimeoutSec = req.InvestigationTimeoutSec
return changed, nil
}); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save Pulse Patrol autonomy settings", nil)
return
}
settings := patrolAutonomyResponseForConfig(aiService.GetConfig(), GetOrgID(r.Context()), h.currentPatrolAutopilotServerPolicy(), aiService.HasLicenseFeature(featureAIAutoFixValue))
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
}
orgID := GetOrgID(r.Context())
investigation = h.hydratePatrolInvestigationAction(orgID, investigation)
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.