Pulse/internal/api/ai_handlers.go

5876 lines
191 KiB
Go

package api
import (
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/circuit"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/cost"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/forecast"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/investigation"
"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/remediation"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/unified"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/license"
"github.com/rcourtman/pulse-go-rewrite/internal/metrics"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
// AISettingsHandler handles AI settings endpoints
type AISettingsHandler struct {
mtPersistence *config.MultiTenantPersistence
mtMonitor *monitoring.MultiTenantMonitor
legacyConfig *config.Config
legacyPersistence *config.ConfigPersistence
legacyAIService *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
resourceProvider ai.ResourceProvider
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 *remediation.Engine // 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
// Investigation orchestration (Patrol Autonomy)
chatHandler *AIHandler // Chat service handler for investigations
investigationStores map[string]*investigation.Store // Investigation stores per org
investigationMu sync.RWMutex
// Discovery store for deep infrastructure discovery
discoveryStore *servicediscovery.Store
}
// 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
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
}
}
if defaultPersistence != nil {
defaultAIService = ai.NewService(defaultPersistence, agentServer)
if err := defaultAIService.LoadConfig(); err != nil {
log.Warn().Err(err).Msg("Failed to load AI config on startup")
}
}
return &AISettingsHandler{
mtPersistence: mtp,
mtMonitor: mtm,
legacyConfig: defaultConfig,
legacyPersistence: defaultPersistence,
legacyAIService: defaultAIService,
aiServices: make(map[string]*ai.Service),
agentServer: agentServer,
}
}
func (h *AISettingsHandler) ensureLegacyAIService() *ai.Service {
if h.legacyAIService != nil || h.legacyPersistence == nil {
return h.legacyAIService
}
h.legacyAIService = ai.NewService(h.legacyPersistence, h.agentServer)
if err := h.legacyAIService.LoadConfig(); err != nil {
log.Warn().Err(err).Msg("Failed to load AI config on startup")
}
return h.legacyAIService
}
// GetAIService returns the underlying AI service
func (h *AISettingsHandler) GetAIService(ctx context.Context) *ai.Service {
orgID := GetOrgID(ctx)
if orgID == "default" || orgID == "" {
return h.ensureLegacyAIService()
}
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
if h.mtPersistence == nil {
return h.ensureLegacyAIService()
}
persistence, err := h.mtPersistence.GetPersistence(orgID)
if err != nil {
log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to get persistence for AI service")
return h.legacyAIService
}
svc = ai.NewService(persistence, h.agentServer)
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
if h.stateProvider != nil {
svc.SetStateProvider(h.stateProvider)
}
if h.resourceProvider != nil {
svc.SetResourceProvider(h.resourceProvider)
}
if h.metadataProvider != nil {
svc.SetMetadataProvider(h.metadataProvider)
}
if h.patrolThresholdProvider != nil {
svc.SetPatrolThresholdProvider(h.patrolThresholdProvider)
}
if h.metricsHistoryProvider != nil {
svc.SetMetricsHistoryProvider(h.metricsHistoryProvider)
}
if h.baselineStore != nil {
svc.SetBaselineStore(h.baselineStore)
}
if h.changeDetector != nil {
svc.SetChangeDetector(h.changeDetector)
}
if h.remediationLog != nil {
svc.SetRemediationLog(h.remediationLog)
}
if h.incidentStore != nil {
svc.SetIncidentStore(h.incidentStore)
}
if h.patternDetector != nil {
svc.SetPatternDetector(h.patternDetector)
}
if h.correlationDetector != nil {
svc.SetCorrelationDetector(h.correlationDetector)
}
if h.discoveryStore != nil {
svc.SetDiscoveryStore(h.discoveryStore)
}
// Set license checker if handler available
if h.licenseHandlers != nil {
// Used context to resolve tenant license service
if licSvc, _, err := h.licenseHandlers.getTenantComponents(ctx); err == nil {
svc.SetLicenseChecker(licSvc)
}
}
// Set up investigation orchestrator if chat handler is available
if h.chatHandler != nil {
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) {
if orgID == "default" || orgID == "" {
return
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
delete(h.aiServices, orgID)
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 {
orgID := GetOrgID(ctx)
if h.mtMonitor != nil {
if m, err := h.mtMonitor.GetMonitor(orgID); err == nil && m != nil {
return m.GetConfig()
}
}
return h.legacyConfig
}
// GetPersistence returns the persistence for the current context
func (h *AISettingsHandler) getPersistence(ctx context.Context) *config.ConfigPersistence {
orgID := GetOrgID(ctx)
if h.mtPersistence != nil {
if p, err := h.mtPersistence.GetPersistence(orgID); err == nil {
return p
}
}
return h.legacyPersistence
}
// SetMultiTenantPersistence updates the persistence manager
func (h *AISettingsHandler) SetMultiTenantPersistence(mtp *config.MultiTenantPersistence) {
h.mtPersistence = mtp
}
// SetMultiTenantMonitor updates the monitor manager
func (h *AISettingsHandler) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) {
h.mtMonitor = mtm
}
// SetConfig updates the configuration reference used by the handler.
func (h *AISettingsHandler) SetConfig(cfg *config.Config) {
if cfg == nil {
return
}
h.legacyConfig = cfg
}
// SetLegacyRuntime wires the single-tenant runtime config and persistence explicitly.
func (h *AISettingsHandler) SetLegacyRuntime(cfg *config.Config, persistence *config.ConfigPersistence) {
if cfg != nil {
h.legacyConfig = cfg
}
if persistence != nil {
h.legacyPersistence = persistence
}
h.ensureLegacyAIService()
}
// 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
}
if allowed == "*" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
} else {
for _, o := range strings.Split(allowed, ",") {
if strings.TrimSpace(o) == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
break
}
}
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Cookie")
w.Header().Set("Vary", "Origin")
}
// SetStateProvider sets the state provider for infrastructure context
func (h *AISettingsHandler) SetStateProvider(sp ai.StateProvider) {
h.stateProvider = sp
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetStateProvider(sp)
}
h.aiServicesMu.Lock()
for _, svc := range h.aiServices {
svc.SetStateProvider(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 h.chatHandler != nil {
h.setupInvestigationOrchestrator("default", h.legacyAIService)
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 {
return h.stateProvider
}
// SetResourceProvider sets the resource provider for unified infrastructure context (Phase 2)
func (h *AISettingsHandler) SetResourceProvider(rp ai.ResourceProvider) {
h.resourceProvider = rp
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetResourceProvider(rp)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetResourceProvider(rp)
}
}
// SetMetadataProvider sets the metadata provider for AI URL discovery
func (h *AISettingsHandler) SetMetadataProvider(mp ai.MetadataProvider) {
h.metadataProvider = mp
if svc := h.ensureLegacyAIService(); svc != nil {
svc.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.patrolThresholdProvider = provider
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetPatrolThresholdProvider(provider)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetPatrolThresholdProvider(provider)
}
}
// SetPatrolFindingsPersistence enables findings persistence for the patrol service
func (h *AISettingsHandler) SetPatrolFindingsPersistence(persistence ai.FindingsPersistence) error {
var firstErr error
if svc := h.ensureLegacyAIService(); svc != nil {
if patrol := svc.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 svc := h.ensureLegacyAIService(); svc != nil {
if patrol := svc.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.metricsHistoryProvider = provider
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetMetricsHistoryProvider(provider)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetMetricsHistoryProvider(provider)
}
}
// SetBaselineStore sets the baseline store for anomaly detection
func (h *AISettingsHandler) SetBaselineStore(store *ai.BaselineStore) {
h.baselineStore = store
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetBaselineStore(store)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetBaselineStore(store)
}
}
// SetChangeDetector sets the change detector for operational memory
func (h *AISettingsHandler) SetChangeDetector(detector *ai.ChangeDetector) {
h.changeDetector = detector
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetChangeDetector(detector)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetChangeDetector(detector)
}
}
// SetRemediationLog sets the remediation log for tracking fix attempts
func (h *AISettingsHandler) SetRemediationLog(remLog *ai.RemediationLog) {
h.remediationLog = remLog
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetRemediationLog(remLog)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetRemediationLog(remLog)
}
}
// SetIncidentStore sets the incident store for alert timelines.
func (h *AISettingsHandler) SetIncidentStore(store *memory.IncidentStore) {
h.incidentStore = store
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetIncidentStore(store)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetIncidentStore(store)
}
}
// SetPatternDetector sets the pattern detector for failure prediction
func (h *AISettingsHandler) SetPatternDetector(detector *ai.PatternDetector) {
h.patternDetector = detector
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetPatternDetector(detector)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetPatternDetector(detector)
}
}
// SetCorrelationDetector sets the correlation detector for multi-resource correlation
func (h *AISettingsHandler) SetCorrelationDetector(detector *ai.CorrelationDetector) {
h.correlationDetector = detector
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetCorrelationDetector(detector)
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.SetCorrelationDetector(detector)
}
}
// SetCircuitBreaker sets the circuit breaker for resilient patrol
func (h *AISettingsHandler) SetCircuitBreaker(breaker *circuit.Breaker) {
h.circuitBreaker = breaker
}
// GetCircuitBreaker returns the circuit breaker
func (h *AISettingsHandler) GetCircuitBreaker() *circuit.Breaker {
return h.circuitBreaker
}
// SetLearningStore sets the learning store for feedback learning
func (h *AISettingsHandler) SetLearningStore(store *learning.LearningStore) {
h.learningStore = store
}
// GetLearningStore returns the learning store
func (h *AISettingsHandler) GetLearningStore() *learning.LearningStore {
return h.learningStore
}
// SetForecastService sets the forecast service for trend forecasting
func (h *AISettingsHandler) SetForecastService(svc *forecast.Service) {
h.forecastService = svc
}
// GetForecastService returns the forecast service
func (h *AISettingsHandler) GetForecastService() *forecast.Service {
return h.forecastService
}
// SetProxmoxCorrelator sets the Proxmox event correlator
func (h *AISettingsHandler) SetProxmoxCorrelator(correlator *proxmox.EventCorrelator) {
h.proxmoxCorrelator = correlator
}
// GetProxmoxCorrelator returns the Proxmox event correlator
func (h *AISettingsHandler) GetProxmoxCorrelator() *proxmox.EventCorrelator {
return h.proxmoxCorrelator
}
// SetRemediationEngine sets the remediation engine for AI-guided fixes
func (h *AISettingsHandler) SetRemediationEngine(engine *remediation.Engine) {
h.remediationEngine = engine
}
// GetRemediationEngine returns the remediation engine
func (h *AISettingsHandler) GetRemediationEngine() *remediation.Engine {
return h.remediationEngine
}
// SetUnifiedStore sets the unified store
func (h *AISettingsHandler) SetUnifiedStore(store *unified.UnifiedStore) {
h.unifiedStore = store
}
// GetUnifiedStore returns the unified store
func (h *AISettingsHandler) GetUnifiedStore() *unified.UnifiedStore {
return h.unifiedStore
}
// SetDiscoveryStore sets the discovery store for deep infrastructure discovery
func (h *AISettingsHandler) SetDiscoveryStore(store *servicediscovery.Store) {
h.discoveryStore = store
// Also set on legacy service if it exists
if svc := h.ensureLegacyAIService(); svc != nil {
svc.SetDiscoveryStore(store)
}
// Set on all existing tenant services
h.aiServicesMu.RLock()
defer h.aiServicesMu.RUnlock()
for _, svc := range h.aiServices {
svc.SetDiscoveryStore(store)
}
}
// GetDiscoveryStore returns the discovery store
func (h *AISettingsHandler) GetDiscoveryStore() *servicediscovery.Store {
return h.discoveryStore
}
// SetAlertBridge sets the alert bridge
func (h *AISettingsHandler) SetAlertBridge(bridge *unified.AlertBridge) {
h.alertBridge = bridge
}
// GetAlertBridge returns the alert bridge
func (h *AISettingsHandler) GetAlertBridge() *unified.AlertBridge {
return h.alertBridge
}
// SetTriggerManager sets the event-driven patrol trigger manager
func (h *AISettingsHandler) SetTriggerManager(tm *ai.TriggerManager) {
h.triggerManager = tm
}
// GetTriggerManager returns the event-driven patrol trigger manager
func (h *AISettingsHandler) GetTriggerManager() *ai.TriggerManager {
return h.triggerManager
}
// SetIncidentCoordinator sets the incident recording coordinator
func (h *AISettingsHandler) SetIncidentCoordinator(coordinator *ai.IncidentCoordinator) {
h.incidentCoordinator = coordinator
}
// GetIncidentCoordinator returns the incident recording coordinator
func (h *AISettingsHandler) GetIncidentCoordinator() *ai.IncidentCoordinator {
return h.incidentCoordinator
}
// SetIncidentRecorder sets the high-frequency incident recorder
func (h *AISettingsHandler) SetIncidentRecorder(recorder *metrics.IncidentRecorder) {
h.incidentRecorder = recorder
}
// GetIncidentRecorder returns the high-frequency incident recorder
func (h *AISettingsHandler) GetIncidentRecorder() *metrics.IncidentRecorder {
return h.incidentRecorder
}
// StopPatrol stops the background AI patrol service
func (h *AISettingsHandler) StopPatrol() {
if svc := h.ensureLegacyAIService(); svc != nil {
svc.StopPatrol()
}
h.aiServicesMu.Lock()
defer h.aiServicesMu.Unlock()
for _, svc := range h.aiServices {
svc.StopPatrol()
}
}
// GetAlertTriggeredAnalyzer returns the alert-triggered analyzer for wiring into alert callbacks
func (h *AISettingsHandler) GetAlertTriggeredAnalyzer(ctx context.Context) *ai.AlertTriggeredAnalyzer {
if svc := h.GetAIService(ctx); svc != nil {
return svc.GetAlertTriggeredAnalyzer()
}
return nil
}
// SetLicenseHandlers sets the license handlers for Pro feature gating
func (h *AISettingsHandler) SetLicenseHandlers(handlers *LicenseHandlers) {
h.licenseHandlers = handlers
// Update legacy service?
// legacy service needs a legacy/default license checker?
// We can try to get it using background context (default tenant)
if handlers == nil {
return
}
if svc, _, err := handlers.getTenantComponents(context.Background()); err == nil {
if legacy := h.ensureLegacyAIService(); legacy != nil {
legacy.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.Provider != nil ||
req.APIKey != nil ||
req.Model != nil ||
req.ChatModel != nil ||
req.PatrolModel != nil ||
req.AutoFixModel != nil ||
req.BaseURL != nil ||
req.AuthMethod != nil ||
req.AnthropicAPIKey != nil ||
req.OpenAIAPIKey != nil ||
req.DeepSeekAPIKey != nil ||
req.GeminiAPIKey != nil ||
req.OllamaBaseURL != nil ||
req.OpenAIBaseURL != nil ||
req.ClearAnthropicKey != nil ||
req.ClearOpenAIKey != nil ||
req.ClearDeepSeekKey != nil ||
req.ClearGeminiKey != nil ||
req.ClearOllamaURL != nil
}
// SetOnControlSettingsChange sets a callback to be invoked when control settings change
// Used by Router to update MCP tool visibility without restarting AI chat
func (h *AISettingsHandler) SetOnControlSettingsChange(callback func()) {
h.onControlSettingsChange = callback
}
// 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.chatHandler = chatHandler
h.investigationMu.Lock()
if h.investigationStores == nil {
h.investigationStores = make(map[string]*investigation.Store)
}
h.investigationMu.Unlock()
// Wire up orchestrator for the legacy service
// Note: This usually fails because chat service isn't started yet.
// The orchestrator will be wired via WireOrchestratorAfterChatStart() instead.
if h.legacyAIService != nil {
h.setupInvestigationOrchestrator("default", h.legacyAIService)
}
// Wire up orchestrator for any existing services
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 h.chatHandler == nil {
log.Warn().Msg("WireOrchestratorAfterChatStart called but chatHandler is nil")
return
}
// Wire up orchestrator for the legacy service
if h.legacyAIService != nil {
h.setupInvestigationOrchestrator("default", h.legacyAIService)
}
// 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) {
if h.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
if h.legacyPersistence != nil && orgID == "default" {
dataDir = h.legacyPersistence.DataDir()
} else if h.mtPersistence != nil {
if p, err := h.mtPersistence.GetPersistence(orgID); err == nil {
dataDir = p.DataDir()
}
}
store = investigation.NewStore(dataDir)
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 := h.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
}
chatAdapter := investigation.NewChatServiceAdapter(chatService)
// Create findings store adapter
findingsStore := patrol.GetFindings()
if findingsStore == nil {
log.Warn().Str("orgID", orgID).Msg("Findings store not available for orchestrator")
return
}
findingsStoreWrapper := &findingsStoreWrapper{store: findingsStore}
findingsAdapter := investigation.NewFindingsStoreAdapter(findingsStoreWrapper)
// Create approval adapter from the global approval store
var approvalAdapter *investigation.ApprovalAdapter
if approvalStore := approval.GetStore(); approvalStore != nil {
approvalAdapter = investigation.NewApprovalAdapter(approvalStore)
}
// Get config for investigation settings
cfg := svc.GetConfig()
invConfig := investigation.DefaultConfig()
if cfg != nil {
invConfig.MaxTurns = cfg.GetPatrolInvestigationBudget()
invConfig.Timeout = cfg.GetPatrolInvestigationTimeout()
}
// Create orchestrator
orchestrator := investigation.NewOrchestrator(
chatAdapter,
store,
findingsAdapter,
approvalAdapter,
invConfig,
)
// Set command executor for auto-executing fixes in full autonomy mode
// The chatAdapter implements both ChatService and CommandExecutor interfaces
orchestrator.SetCommandExecutor(chatAdapter)
// Set autonomy level provider for re-checking before fix execution
// This handles cases where user changes autonomy level during an investigation
orchestrator.SetAutonomyLevelProvider(&autonomyLevelProviderAdapter{svc: svc})
// Set infrastructure context provider for CLI access information
// This enables investigations to know where services run (Docker, systemd, native)
// and propose correct commands (e.g., 'docker exec pbs proxmox-backup-manager ...')
if knowledgeStore := svc.GetKnowledgeStore(); knowledgeStore != nil {
// Wire up discovery context to the knowledge store
// This unifies deep-scanned discovery data with legacy knowledge notes
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)
})
}
orchestrator.SetInfrastructureContextProvider(knowledgeStore)
}
// Create adapter to bridge investigation.Orchestrator to ai.InvestigationOrchestrator interface
adapter := ai.NewInvestigationOrchestratorAdapter(orchestrator)
// Set on patrol service
patrol.SetInvestigationOrchestrator(adapter)
// Wire up fix verification: patrol re-checks resources after fixes are executed
adapter.SetFixVerifier(patrol)
// Wire up Prometheus metrics for investigation outcomes and fix verification
adapter.SetMetricsCallback()
// Wire up license checker for defense-in-depth autonomy clamping
// This prevents auto-fix execution even if autonomy level was somehow set to assisted/full without Pro
adapter.SetLicenseChecker(&licenseCheckerForOrchestrator{svc: svc})
log.Info().Str("orgID", orgID).Msg("Investigation orchestrator configured for patrol service")
}
// licenseCheckerForOrchestrator adapts *ai.Service to investigation.LicenseChecker
type licenseCheckerForOrchestrator struct {
svc *ai.Service
}
func (l *licenseCheckerForOrchestrator) HasFeature(feature string) bool {
return l.svc.HasLicenseFeature(feature)
}
// findingsStoreWrapper wraps *ai.FindingsStore to implement investigation.AIFindingsStore
type findingsStoreWrapper struct {
store *ai.FindingsStore
}
func (w *findingsStoreWrapper) Get(id string) investigation.AIFinding {
if w.store == nil {
return nil
}
f := w.store.Get(id)
if f == nil {
return nil
}
return f
}
func (w *findingsStoreWrapper) UpdateInvestigation(id, sessionID, status, outcome string, lastInvestigatedAt *time.Time, attempts int) bool {
if w.store == nil {
return false
}
return w.store.UpdateInvestigation(id, sessionID, status, outcome, lastInvestigatedAt, attempts)
}
// autonomyLevelProviderAdapter provides current autonomy level from config for re-checking before fix execution
type autonomyLevelProviderAdapter struct {
svc *ai.Service
}
func (a *autonomyLevelProviderAdapter) GetCurrentAutonomyLevel() string {
if a.svc == nil {
return config.PatrolAutonomyMonitor
}
cfg := a.svc.GetConfig()
if cfg == nil {
return config.PatrolAutonomyMonitor
}
return cfg.GetPatrolAutonomyLevel()
}
func (a *autonomyLevelProviderAdapter) IsFullModeUnlocked() bool {
if a.svc == nil {
return false
}
cfg := a.svc.GetConfig()
if cfg == nil {
return false
}
return cfg.PatrolFullModeUnlocked
}
// AISettingsResponse is returned by GET /api/settings/ai
// API keys are masked for security
type AISettingsResponse struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"` // DEPRECATED: legacy single provider
APIKeySet bool `json:"api_key_set"` // DEPRECATED: true if legacy API key is configured
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)
BaseURL string `json:"base_url,omitempty"` // DEPRECATED: legacy base URL
Configured bool `json:"configured"` // true if AI is ready to use
AutonomousMode bool `json:"autonomous_mode"` // true if AI can execute without approval
CustomContext string `json:"custom_context"` // user-provided infrastructure context
// OAuth fields for Claude Pro/Max subscription authentication
AuthMethod string `json:"auth_method"` // "api_key" or "oauth"
OAuthConnected bool `json:"oauth_connected"` // true if OAuth tokens are configured
// Patrol settings for token efficiency
PatrolSchedulePreset string `json:"patrol_schedule_preset"` // DEPRECATED: legacy preset
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
UseProactiveThresholds bool `json:"use_proactive_thresholds"` // true if patrol warns before thresholds (false = use exact thresholds)
AvailableModels []providers.ModelInfo `json:"available_models"` // List of models for current provider
// Multi-provider credentials - shows which providers are configured
AnthropicConfigured bool `json:"anthropic_configured"` // true if Anthropic API key or OAuth is set
OpenAIConfigured bool `json:"openai_configured"` // true if OpenAI API key is set
DeepSeekConfigured bool `json:"deepseek_configured"` // true if DeepSeek API key is set
GeminiConfigured bool `json:"gemini_configured"` // true if Gemini API key is set
OllamaConfigured bool `json:"ollama_configured"` // true (always available for attempt)
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
// Cost controls
CostBudgetUSD30d float64 `json:"cost_budget_usd_30d,omitempty"`
// Request timeout (seconds) - for slow hardware running local models
RequestTimeoutSeconds int `json:"request_timeout_seconds,omitempty"`
// Infrastructure control settings
ControlLevel string `json:"control_level"` // "read_only", "controlled", "autonomous"
ProtectedGuests []string `json:"protected_guests,omitempty"` // 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)
}
// AISettingsUpdateRequest is the request body for PUT /api/settings/ai
type AISettingsUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"`
Provider *string `json:"provider,omitempty"` // DEPRECATED: use model selection instead
APIKey *string `json:"api_key,omitempty"` // DEPRECATED: use per-provider keys
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
BaseURL *string `json:"base_url,omitempty"` // DEPRECATED: use per-provider URLs
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
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
PatrolSchedulePreset *string `json:"patrol_schedule_preset,omitempty"` // DEPRECATED: use patrol_interval_minutes
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
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
DeepSeekAPIKey *string `json:"deepseek_api_key,omitempty"` // Set DeepSeek API key
GeminiAPIKey *string `json:"gemini_api_key,omitempty"` // Set Gemini API key
OllamaBaseURL *string `json:"ollama_base_url,omitempty"` // Set Ollama server URL
OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL
// Clear flags for removing credentials
ClearAnthropicKey *bool `json:"clear_anthropic_key,omitempty"` // Clear Anthropic API key
ClearOpenAIKey *bool `json:"clear_openai_key,omitempty"` // Clear OpenAI API key
ClearDeepSeekKey *bool `json:"clear_deepseek_key,omitempty"` // Clear DeepSeek API key
ClearGeminiKey *bool `json:"clear_gemini_key,omitempty"` // Clear Gemini API key
ClearOllamaURL *bool `json:"clear_ollama_url,omitempty"` // Clear Ollama URL
// 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)
}
// 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
}
ctx := r.Context()
persistence := h.getPersistence(ctx)
settings, err := persistence.LoadAIConfig()
if err != nil {
log.Error().Err(err).Msg("Failed to load Pulse Assistant settings")
http.Error(w, "Failed to load Pulse Assistant settings", http.StatusInternalServerError)
return
}
if settings == nil {
settings = config.NewDefaultAIConfig()
}
// Determine auth method string
authMethod := string(settings.AuthMethod)
if authMethod == "" {
authMethod = string(config.AuthMethodAPIKey)
}
// Determine if running in demo mode
isDemo := strings.EqualFold(os.Getenv("PULSE_MOCK_MODE"), "true")
response := AISettingsResponse{
Enabled: settings.Enabled || isDemo,
Provider: settings.Provider,
APIKeySet: settings.APIKey != "",
Model: settings.GetModel(),
ChatModel: settings.ChatModel,
PatrolModel: settings.PatrolModel,
AutoFixModel: settings.AutoFixModel,
BaseURL: settings.BaseURL,
Configured: settings.IsConfigured() || isDemo,
AutonomousMode: settings.IsAutonomous(), // Derived from control_level
CustomContext: settings.CustomContext,
AuthMethod: authMethod,
OAuthConnected: settings.OAuthAccessToken != "",
// Patrol settings
PatrolSchedulePreset: settings.PatrolSchedulePreset,
PatrolIntervalMinutes: settings.PatrolIntervalMinutes,
PatrolEnabled: settings.PatrolEnabled,
PatrolAutoFix: settings.PatrolAutoFix,
AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis,
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),
DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek),
GeminiConfigured: settings.HasProvider(config.AIProviderGemini),
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OpenAIBaseURL: settings.OpenAIBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
CostBudgetUSD30d: settings.CostBudgetUSD30d,
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
ControlLevel: settings.GetControlLevel(),
ProtectedGuests: settings.GetProtectedGuests(),
DiscoveryEnabled: settings.IsDiscoveryEnabled(),
DiscoveryIntervalHours: settings.DiscoveryIntervalHours,
}
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
}
}
// Load existing settings
settings, err := h.getPersistence(r.Context()).LoadAIConfig()
if err != nil {
log.Error().Err(err).Msg("Failed to load existing AI settings")
settings = config.NewDefaultAIConfig()
}
if settings == nil {
settings = config.NewDefaultAIConfig()
}
// 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.Provider != nil {
provider := strings.ToLower(strings.TrimSpace(*req.Provider))
switch provider {
case config.AIProviderAnthropic, config.AIProviderOpenAI, config.AIProviderOllama, config.AIProviderDeepSeek, config.AIProviderGemini:
settings.Provider = provider
default:
http.Error(w, "Invalid provider. Must be 'anthropic', 'openai', 'ollama', 'deepseek', or 'gemini'", http.StatusBadRequest)
return
}
}
if req.APIKey != nil {
// Empty string clears the API key
settings.APIKey = strings.TrimSpace(*req.APIKey)
}
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 {
// Auto-fix requires Pro license with ai_autofix feature
if *req.PatrolAutoFix && !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": "Pulse Patrol Auto-Fix requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulserelay.pro/",
})
return
}
settings.PatrolAutoFix = *req.PatrolAutoFix
}
if req.UseProactiveThresholds != nil {
settings.UseProactiveThresholds = *req.UseProactiveThresholds
}
if req.BaseURL != nil {
settings.BaseURL = strings.TrimSpace(*req.BaseURL)
}
if req.AutonomousMode != nil {
// Legacy: autonomous_mode now maps to control_level for backwards compatibility
if *req.AutonomousMode {
// Autonomous mode requires Pro license with ai_autofix feature
if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": "Autonomous Mode requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulserelay.pro/",
})
return
}
settings.ControlLevel = config.ControlLevelAutonomous
settings.AutonomousMode = true
} else if settings.GetControlLevel() == config.ControlLevelAutonomous {
// Only downgrade from autonomous to controlled; preserve other levels
// (e.g., don't change read_only to controlled)
settings.ControlLevel = config.ControlLevelControlled
settings.AutonomousMode = false
}
}
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.ClearDeepSeekKey != nil && *req.ClearDeepSeekKey {
settings.DeepSeekAPIKey = ""
} else if req.DeepSeekAPIKey != nil {
settings.DeepSeekAPIKey = strings.TrimSpace(*req.DeepSeekAPIKey)
}
if req.ClearGeminiKey != nil && *req.ClearGeminiKey {
settings.GeminiAPIKey = ""
} else if req.GeminiAPIKey != nil {
settings.GeminiAPIKey = strings.TrimSpace(*req.GeminiAPIKey)
}
if req.ClearOllamaURL != nil && *req.ClearOllamaURL {
settings.OllamaBaseURL = ""
} else if req.OllamaBaseURL != nil {
settings.OllamaBaseURL = strings.TrimSpace(*req.OllamaBaseURL)
}
if req.OpenAIBaseURL != nil {
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
}
if req.Enabled != nil {
// Only allow enabling if at least one provider is configured
if *req.Enabled {
configuredProviders := settings.GetConfiguredProviders()
if len(configuredProviders) == 0 {
// No providers configured - give a helpful error
http.Error(w, "Please configure a provider (API key or Ollama URL) before enabling Pulse Assistant", http.StatusBadRequest)
return
}
// If we have configured providers, we're good to enable
}
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
settings.PatrolSchedulePreset = "" // Clear preset when using custom minutes
if minutes > 0 {
settings.PatrolEnabled = true // Enable patrol when setting custom interval
} else {
settings.PatrolEnabled = false // Disable patrol when setting interval to 0
}
} else if req.PatrolSchedulePreset != nil {
// Legacy preset support
preset := strings.ToLower(strings.TrimSpace(*req.PatrolSchedulePreset))
switch preset {
case "15min", "1hr", "6hr", "12hr", "daily":
settings.PatrolSchedulePreset = preset
settings.PatrolIntervalMinutes = config.PresetToMinutes(preset)
settings.PatrolEnabled = true // Enable patrol when setting schedule preset
case "disabled":
settings.PatrolSchedulePreset = preset
settings.PatrolIntervalMinutes = 0
settings.PatrolEnabled = false // Disable patrol when using disabled preset
default:
http.Error(w, "Invalid patrol_schedule_preset. Must be '15min', '1hr', '6hr', '12hr', 'daily', or 'disabled'", http.StatusBadRequest)
return
}
}
if req.PatrolEnabled != nil && req.PatrolIntervalMinutes == nil && req.PatrolSchedulePreset == nil {
settings.PatrolEnabled = *req.PatrolEnabled
if *req.PatrolEnabled {
// Re-enable if legacy preset was disabled
if strings.EqualFold(settings.PatrolSchedulePreset, "disabled") {
settings.PatrolSchedulePreset = ""
}
// 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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": "Pulse Alert Analysis requires Pulse Pro",
"feature": ai.FeatureAIAlerts,
"upgrade_url": "https://pulserelay.pro/",
})
return
}
settings.AlertTriggeredAnalysis = *req.AlertTriggeredAnalysis
}
// 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 level == "suggest" {
level = config.ControlLevelControlled
}
if !config.IsValidControlLevel(level) {
http.Error(w, "invalid control_level: must be read_only, controlled, or autonomous", http.StatusBadRequest)
return
}
// "autonomous" requires Pro license (same as autonomous_mode)
if level == config.ControlLevelAutonomous {
if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": "Autonomous control requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulserelay.pro/",
})
return
}
}
settings.ControlLevel = level
// Keep legacy AutonomousMode in sync to prevent fallback issues
settings.AutonomousMode = (level == config.ControlLevelAutonomous)
}
// 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 to 24h when enabled with no interval set.
// Without this, enabling discovery with interval=0 silently stays in manual-only mode.
if settings.DiscoveryEnabled && settings.DiscoveryIntervalHours == 0 {
settings.DiscoveryIntervalHours = 24
}
// Save settings
if err := h.getPersistence(r.Context()).SaveAIConfig(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save AI settings")
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
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)
}
// Trigger AI chat service restart if model changed
// This ensures the new model is picked up by the service
// Trigger AI chat service restart if model changed or AI enabled
// This ensures the new model is picked up by the service
if h.onModelChange != nil && shouldRestartAIChat(req) {
h.onModelChange()
}
// Update MCP control settings if control level or protected guests changed
// This updates tool visibility without restarting AI chat
// Note: req.AutonomousMode also maps to control_level for backwards compatibility
if h.onControlSettingsChange != nil && (req.ControlLevel != nil || req.ProtectedGuests != nil || req.AutonomousMode != nil) {
h.onControlSettingsChange()
}
log.Info().
Bool("enabled", settings.Enabled).
Str("provider", settings.Provider).
Str("model", settings.GetModel()).
Str("chatModel", settings.ChatModel).
Str("patrolModel", settings.PatrolModel).
Str("patrolPreset", settings.PatrolSchedulePreset).
Bool("alertTriggeredAnalysis", settings.AlertTriggeredAnalysis).
Msg("AI settings updated")
// Determine auth method for response
authMethod := string(settings.AuthMethod)
if authMethod == "" {
authMethod = string(config.AuthMethodAPIKey)
}
// Return updated settings
response := AISettingsResponse{
Enabled: settings.Enabled,
Provider: settings.Provider,
APIKeySet: settings.APIKey != "",
Model: settings.GetModel(),
ChatModel: settings.ChatModel,
PatrolModel: settings.PatrolModel,
AutoFixModel: settings.AutoFixModel,
BaseURL: settings.BaseURL,
Configured: settings.IsConfigured(),
AutonomousMode: settings.IsAutonomous(), // Derived from control_level
CustomContext: settings.CustomContext,
AuthMethod: authMethod,
OAuthConnected: settings.OAuthAccessToken != "",
PatrolSchedulePreset: settings.PatrolSchedulePreset,
PatrolIntervalMinutes: settings.PatrolIntervalMinutes,
PatrolAutoFix: settings.PatrolAutoFix,
AlertTriggeredAnalysis: settings.AlertTriggeredAnalysis,
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),
DeepSeekConfigured: settings.HasProvider(config.AIProviderDeepSeek),
GeminiConfigured: settings.HasProvider(config.AIProviderGemini),
OllamaConfigured: settings.HasProvider(config.AIProviderOllama),
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
OpenAIBaseURL: settings.OpenAIBaseURL,
ConfiguredProviders: settings.GetConfiguredProviders(),
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
ControlLevel: settings.GetControlLevel(),
ProtectedGuests: settings.GetProtectedGuests(),
DiscoveryEnabled: settings.DiscoveryEnabled,
DiscoveryIntervalHours: settings.DiscoveryIntervalHours,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write AI settings update response")
}
}
// HandleTestAIConnection tests the AI provider connection (POST /api/ai/test)
func (h *AISettingsHandler) HandleTestAIConnection(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
}
// 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 AI connection test")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Admin privileges required"})
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"`
}
err := h.GetAIService(r.Context()).TestConnection(ctx)
if err != nil {
testResult.Success = false
testResult.Message = err.Error()
} else {
cfg := h.GetAIService(r.Context()).GetConfig()
testResult.Success = true
testResult.Message = "Connection successful"
if cfg != nil {
testResult.Model = cfg.GetModel()
}
}
if err := utils.WriteJSONResponse(w, testResult); err != nil {
log.Error().Err(err).Msg("Failed to write AI test response")
}
}
// HandleTestProvider tests a specific AI provider connection (POST /api/ai/test/:provider)
func (h *AISettingsHandler) HandleTestProvider(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
}
// 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 AI provider test")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Admin privileges required"})
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
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var testResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Provider string `json:"provider"`
}
testResult.Provider = provider
// Load config and create provider for testing
cfg := h.GetAIService(r.Context()).GetConfig()
if cfg == nil {
testResult.Success = false
testResult.Message = "Pulse Assistant not configured"
utils.WriteJSONResponse(w, testResult)
return
}
// Check if provider is configured
if !cfg.HasProvider(provider) {
testResult.Success = false
testResult.Message = "Provider not configured"
utils.WriteJSONResponse(w, testResult)
return
}
// Create provider and test connection
testProvider, err := providers.NewForProvider(cfg, provider, cfg.GetModel())
if err != nil {
testResult.Success = false
testResult.Message = fmt.Sprintf("Failed to create provider: %v", err)
utils.WriteJSONResponse(w, testResult)
return
}
err = testProvider.TestConnection(ctx)
if err != nil {
testResult.Success = false
testResult.Message = err.Error()
} else {
testResult.Success = true
testResult.Message = "Connection successful"
}
if err := utils.WriteJSONResponse(w, testResult); err != nil {
log.Error().Err(err).Msg("Failed to write provider test response")
}
}
// 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
}
// Require authentication
if !CheckAuth(h.getConfig(r.Context()), w, r) {
return
}
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
resp := Response{
Models: []ModelInfo{},
Error: err.Error(),
}
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"` // "host", "container", "vm", "node"
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"
}
// 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,omitempty"` // Commands that were executed
PendingApprovals []ai.ApprovalNeededData `json:"pending_approvals,omitempty"` // Commands that require approval (non-streaming)
}
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
}
// 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, 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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": "Pulse Patrol Auto-Fix requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulserelay.pro/",
})
return
}
}
if strings.TrimSpace(req.Prompt) == "" {
http.Error(w, "Prompt is required", 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: req.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,
}
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
}
// 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, 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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": "Pulse Patrol Auto-Fix requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulserelay.pro/",
})
return
}
}
if strings.TrimSpace(req.Prompt) == "" {
http.Error(w, "Prompt is required", http.StatusBadRequest)
return
}
log.Info().
Int("prompt_len", len(req.Prompt)).
Str("target_type", req.TargetType).
Str("target_id", req.TargetID).
Msg("AI streaming request started")
// Set up SSE headers
// IMPORTANT: Set headers BEFORE any writes to prevent Go from auto-adding Transfer-Encoding: chunked
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
// Prevent chunked encoding which causes "Invalid character in chunk size" errors in Vite proxy
w.Header().Set("Transfer-Encoding", "identity")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
// Disable the server's write deadline for this SSE connection
// This is critical for long-running AI requests that can take several minutes
rc := http.NewResponseController(w)
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
log.Warn().Err(err).Msg("Failed to disable write deadline for SSE")
}
// Also disable read deadline
if err := rc.SetReadDeadline(time.Time{}); err != nil {
log.Warn().Err(err).Msg("Failed to disable read deadline for SSE")
}
// Flush headers immediately
flusher.Flush()
// Create context with timeout (15 minutes for complex analysis with multiple tool calls)
// Use background context to avoid browser disconnect canceling the request
// DeepSeek reasoning models + multiple tool executions can easily take 5+ minutes
ctx, cancel := context.WithTimeout(context.Background(), 900*time.Second)
defer cancel()
// Set up heartbeat to keep connection alive during long tool executions
// NOTE: We don't check r.Context().Done() because Vite proxy may close
// the request context prematurely. We detect real disconnection via write failures.
heartbeatDone := make(chan struct{})
var clientDisconnected atomic.Bool
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Extend write deadline before heartbeat
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
// Send SSE comment as heartbeat
_, err := w.Write([]byte(": heartbeat\n\n"))
if err != nil {
log.Debug().Err(err).Msg("Heartbeat write failed, stopping heartbeat (AI continues)")
clientDisconnected.Store(true)
// Don't cancel the AI request - let it complete with its own timeout
// The SSE connection may have issues but the AI work can still finish
return
}
flusher.Flush()
log.Debug().Msg("Sent SSE heartbeat")
case <-heartbeatDone:
return
}
}
}()
defer close(heartbeatDone)
// Helper to safely write SSE events, tracking if client disconnected
safeWrite := func(data []byte) bool {
if clientDisconnected.Load() {
return false
}
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
_, err := w.Write(data)
if err != nil {
log.Debug().Err(err).Msg("Failed to write SSE event (client may have disconnected)")
clientDisconnected.Store(true)
return false
}
flusher.Flush()
return true
}
// Stream callback - write SSE events
callback := func(event ai.StreamEvent) {
// Skip the 'done' event from service - we'll send our own at the end
// This ensures 'complete' comes before 'done'
if event.Type == "done" {
log.Debug().Msg("Skipping service 'done' event - will send final 'done' after 'complete'")
return
}
data, err := json.Marshal(event)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal stream event")
return
}
log.Debug().
Str("event_type", event.Type).
Msg("Streaming AI event")
// SSE format: data: <json>\n\n
safeWrite([]byte("data: " + string(data) + "\n\n"))
}
// Convert history from API type to service type
var history []ai.ConversationMessage
for _, msg := range req.History {
history = append(history, ai.ConversationMessage{
Role: msg.Role,
Content: msg.Content,
})
}
if useCase == "" {
useCase = "chat"
}
// Ensure we always send a final 'done' event
defer func() {
if !clientDisconnected.Load() {
doneEvent := ai.StreamEvent{Type: "done"}
data, _ := json.Marshal(doneEvent)
safeWrite([]byte("data: " + string(data) + "\n\n"))
log.Debug().Msg("Sent final 'done' event")
}
}()
// Execute with streaming
resp, err := h.GetAIService(r.Context()).ExecuteStream(ctx, ai.ExecuteRequest{
Prompt: req.Prompt,
TargetType: req.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: "AI request failed. Please try again."}
data, _ := json.Marshal(errEvent)
safeWrite([]byte("data: " + string(data) + "\n\n"))
return
}
log.Info().
Str("model", resp.Model).
Int("input_tokens", resp.InputTokens).
Int("output_tokens", resp.OutputTokens).
Int("tool_calls", len(resp.ToolCalls)).
Msg("AI streaming request completed")
// Send final response with metadata (before 'done')
finalEvent := 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,omitempty"`
}{
Type: "complete",
Model: resp.Model,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
ToolCalls: resp.ToolCalls,
}
data, _ := json.Marshal(finalEvent)
safeWrite([]byte("data: " + string(data) + "\n\n"))
// 'done' event is sent by the defer above
}
// AIRunCommandRequest is the request body for POST /api/ai/run-command
type AIRunCommandRequest struct {
Command string `json:"command"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
RunOnHost bool `json:"run_on_host"`
VMID string `json:"vmid,omitempty"`
TargetHost string `json:"target_host,omitempty"` // Explicit host for routing
}
// HandleRunCommand executes a single approved command (POST /api/ai/run-command)
func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require authentication
if !CheckAuth(h.getConfig(r.Context()), w, r) {
return
}
// Gated for AI Auto-Fix (Pro feature)
if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "license_required",
"message": "Pulse Patrol Auto-Fix requires Pulse Pro",
"feature": ai.FeatureAIAutoFix,
"upgrade_url": "https://pulserelay.pro/",
})
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).Str("body", string(bodyBytes)).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
}
log.Info().
Str("command", req.Command).
Str("target_type", req.TargetType).
Str("target_id", req.TargetID).
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,
TargetType: req.TargetType,
TargetID: req.TargetID,
RunOnHost: req.RunOnHost,
VMID: req.VMID,
TargetHost: req.TargetHost,
})
if err != nil {
log.Error().Err(err).Msg("Failed to execute command")
http.Error(w, "Failed to execute command: "+err.Error(), http.StatusInternalServerError)
return
}
if err := utils.WriteJSONResponse(w, resp); err != nil {
log.Error().Err(err).Msg("Failed to write run command response")
}
}
// HandleGetGuestKnowledge returns all notes for a guest
func (h *AISettingsHandler) HandleGetGuestKnowledge(w http.ResponseWriter, r *http.Request) {
guestID := r.URL.Query().Get("guest_id")
if guestID == "" {
http.Error(w, "guest_id is required", http.StatusBadRequest)
return
}
knowledge, err := h.GetAIService(r.Context()).GetGuestKnowledge(guestID)
if err != nil {
http.Error(w, "Failed to get knowledge: "+err.Error(), 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) {
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 err := h.GetAIService(r.Context()).SaveGuestNote(req.GuestID, req.GuestName, req.GuestType, req.Category, req.Title, req.Content); err != nil {
http.Error(w, "Failed to save note: "+err.Error(), 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) {
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 err := h.GetAIService(r.Context()).DeleteGuestNote(req.GuestID, req.NoteID); err != nil {
http.Error(w, "Failed to delete note: "+err.Error(), 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) {
guestID := r.URL.Query().Get("guest_id")
if guestID == "" {
http.Error(w, "guest_id is required", http.StatusBadRequest)
return
}
knowledge, err := h.GetAIService(r.Context()).GetGuestKnowledge(guestID)
if err != nil {
http.Error(w, "Failed to get knowledge: "+err.Error(), http.StatusInternalServerError)
return
}
// Set headers for file download
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", "attachment; filename=\"pulse-notes-"+guestID+".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: "+err.Error(), http.StatusBadRequest)
return
}
if importData.GuestID == "" {
http.Error(w, "guest_id is required in import data", http.StatusBadRequest)
return
}
if len(importData.Notes) == 0 {
http.Error(w, "No 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 note.Category == "" || note.Title == "" || note.Content == "" {
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
}
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 !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, "Failed to get knowledge: "+err.Error(), 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 hosts
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 a host 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 {
AlertID string `json:"alert_id"`
ResourceID string `json:"resource_id"`
ResourceName string `json:"resource_name"`
ResourceType string `json:"resource_type"` // guest, node, storage, docker
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"`
}
// 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
investigationPrompt := ai.GenerateAlertInvestigationPrompt(ai.AlertInvestigationRequest{
AlertID: req.AlertID,
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_id", req.AlertID).
Str("resource", req.ResourceName).
Str("type", req.AlertType).
Msg("AI alert investigation started")
// Set up SSE streaming
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Transfer-Encoding", "identity")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
return
}
// Disable write/read deadlines for SSE
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Time{})
_ = rc.SetReadDeadline(time.Time{})
flusher.Flush()
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second)
defer cancel()
// Heartbeat routine
heartbeatDone := make(chan struct{})
var clientDisconnected atomic.Bool
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
_, err := w.Write([]byte(": heartbeat\n\n"))
if err != nil {
clientDisconnected.Store(true)
return
}
flusher.Flush()
case <-heartbeatDone:
return
}
}
}()
defer close(heartbeatDone)
safeWrite := func(data []byte) bool {
if clientDisconnected.Load() {
return false
}
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
_, err := w.Write(data)
if err != nil {
clientDisconnected.Store(true)
return false
}
flusher.Flush()
return true
}
// Determine target type and ID from alert info
targetType := req.ResourceType
targetID := req.ResourceID
// Map resource type to expected target type format
switch req.ResourceType {
case "guest":
// Could be VM or container - try to determine from VMID
if req.VMID > 0 {
targetType = "container" // Default to container, AI will figure it out
}
case "docker":
targetType = "docker_container"
}
// Stream callback
callback := func(event ai.StreamEvent) {
if event.Type == "done" {
return
}
data, err := json.Marshal(event)
if err != nil {
return
}
safeWrite([]byte("data: " + string(data) + "\n\n"))
}
// Execute with streaming
defer func() {
if !clientDisconnected.Load() {
doneEvent := ai.StreamEvent{Type: "done"}
data, _ := json.Marshal(doneEvent)
safeWrite([]byte("data: " + string(data) + "\n\n"))
}
}()
resp, err := h.GetAIService(r.Context()).ExecuteStream(ctx, ai.ExecuteRequest{
Prompt: investigationPrompt,
TargetType: targetType,
TargetID: targetID,
Context: map[string]interface{}{
"alertId": req.AlertID,
"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: "Alert investigation failed. Please try again."}
data, _ := json.Marshal(errEvent)
safeWrite([]byte("data: " + string(data) + "\n\n"))
return
}
// Send completion event
finalEvent := 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,omitempty"`
}{
Type: "complete",
Model: resp.Model,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
ToolCalls: resp.ToolCalls,
}
data, _ := json.Marshal(finalEvent)
safeWrite([]byte("data: " + string(data) + "\n\n"))
if req.AlertID != "" {
h.GetAIService(r.Context()).RecordIncidentAnalysis(req.AlertID, "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_id", req.AlertID).
Str("model", resp.Model).
Int("tool_calls", len(resp.ToolCalls)).
Msg("AI alert investigation completed")
if req.AlertID != "" {
h.GetAIService(r.Context()).RecordIncidentAnalysis(req.AlertID, "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 legacy service and all tenant services to ensure multi-tenant support.
func (h *AISettingsHandler) SetAlertProvider(ap ai.AlertProvider) {
h.legacyAIService.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 legacy service and all tenant services to ensure multi-tenant support.
func (h *AISettingsHandler) SetAlertResolver(resolver ai.AlertResolver) {
h.legacyAIService.SetAlertResolver(resolver)
h.aiServicesMu.RLock()
defer h.aiServicesMu.RUnlock()
for _, svc := range h.aiServices {
svc.SetAlertResolver(resolver)
}
}
// oauthSessions stores active OAuth sessions (state -> session)
// In production, consider using a more robust session store with expiry
var oauthSessions = make(map[string]*providers.OAuthSession)
var oauthSessionsMu sync.Mutex
// HandleOAuthStart initiates the OAuth flow for Claude Pro/Max subscription (POST /api/ai/oauth/start)
// Returns an authorization URL for the user to visit manually
func (h *AISettingsHandler) HandleOAuthStart(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Generate OAuth session (redirect URI is not used since we use Anthropic's callback)
session, err := providers.GenerateOAuthSession("")
if err != nil {
log.Error().Err(err).Msg("Failed to generate OAuth session")
http.Error(w, "Failed to start OAuth flow", http.StatusInternalServerError)
return
}
// Store session (with cleanup of old sessions)
oauthSessionsMu.Lock()
// Clean up sessions older than 15 minutes
for state, s := range oauthSessions {
if time.Since(s.CreatedAt) > 15*time.Minute {
delete(oauthSessions, state)
}
}
oauthSessions[session.State] = session
oauthSessionsMu.Unlock()
// Get authorization URL
authURL := providers.GetAuthorizationURL(session)
log.Info().
Str("state", safePrefixForLog(session.State, 8)+"...").
Str("verifier_len", fmt.Sprintf("%d", len(session.CodeVerifier))).
Str("auth_url", authURL).
Msg("Starting Claude OAuth flow - user must visit URL and paste code back")
// Return the URL for the user to visit
response := map[string]string{
"auth_url": authURL,
"state": session.State,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write OAuth start response")
}
}
// HandleOAuthExchange exchanges a manually-pasted authorization code for tokens (POST /api/ai/oauth/exchange)
func (h *AISettingsHandler) HandleOAuthExchange(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse request body
var req struct {
Code string `json:"code"`
State string `json:"state"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Code == "" || req.State == "" {
http.Error(w, "Missing code or state", http.StatusBadRequest)
return
}
// Trim any whitespace from the code (user might have copied extra spaces)
code := strings.TrimSpace(req.Code)
// Anthropic's callback page displays the code as "code#state"
// We need to extract just the code part before the #
if idx := strings.Index(code, "#"); idx > 0 {
code = code[:idx]
}
log.Debug().
Str("code_len", fmt.Sprintf("%d", len(code))).
Str("code_prefix", code[:min(20, len(code))]).
Str("state_prefix", req.State[:min(8, len(req.State))]).
Msg("Processing OAuth code exchange")
// Look up session
oauthSessionsMu.Lock()
session, ok := oauthSessions[req.State]
if ok {
delete(oauthSessions, req.State) // One-time use
}
oauthSessionsMu.Unlock()
if !ok {
log.Error().Str("state", req.State[:min(8, len(req.State))]+"...").Msg("OAuth exchange with unknown state")
http.Error(w, "Invalid or expired session. Please start the OAuth flow again.", http.StatusBadRequest)
return
}
// Exchange code for tokens
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
tokens, err := providers.ExchangeCodeForTokens(ctx, code, session)
if err != nil {
log.Error().Err(err).Msg("Failed to exchange OAuth code for tokens")
http.Error(w, "Failed to exchange authorization code: "+err.Error(), http.StatusBadRequest)
return
}
// Try to create an API key from the OAuth access token
// Team/Enterprise users get org:create_api_key scope and can create API keys
// Pro/Max users don't have this scope and will use OAuth tokens directly
apiKey, err := providers.CreateAPIKeyFromOAuth(ctx, tokens.AccessToken)
if err != nil {
// Check if it's a permission error (Pro/Max users)
if strings.Contains(err.Error(), "org:create_api_key") || strings.Contains(err.Error(), "403") {
log.Info().Msg("User doesn't have org:create_api_key permission - will use OAuth tokens directly")
// This is fine for Pro/Max users - they'll use OAuth tokens
} else {
log.Error().Err(err).Msg("Failed to create API key from OAuth token")
http.Error(w, "Failed to create API key: "+err.Error(), http.StatusBadRequest)
return
}
}
if apiKey != "" {
log.Info().Msg("Successfully created API key from OAuth - using subscription-based billing")
}
// Load existing settings
settings, err := h.getPersistence(r.Context()).LoadAIConfig()
if err != nil {
log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth")
settings = config.NewDefaultAIConfig()
}
if settings == nil {
settings = config.NewDefaultAIConfig()
}
// Update settings
settings.Provider = config.AIProviderAnthropic
settings.AuthMethod = config.AuthMethodOAuth
settings.OAuthAccessToken = tokens.AccessToken
settings.OAuthRefreshToken = tokens.RefreshToken
settings.OAuthExpiresAt = tokens.ExpiresAt
settings.Enabled = true
// If we got an API key, use it; otherwise use OAuth tokens directly
if apiKey != "" {
settings.APIKey = apiKey
} else {
// Pro/Max users: clear any old API key, will use OAuth client
settings.ClearAPIKey()
}
// Save settings
if err := h.getPersistence(r.Context()).SaveAIConfig(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save OAuth tokens")
http.Error(w, "Failed to save OAuth credentials", http.StatusInternalServerError)
return
}
// Reload the AI service with new settings
if err := h.GetAIService(r.Context()).Reload(); err != nil {
log.Warn().Err(err).Msg("Failed to reload AI service after OAuth setup")
}
log.Info().Msg("Claude OAuth authentication successful")
response := map[string]interface{}{
"success": true,
"message": "Successfully connected to Claude with your subscription",
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write OAuth exchange response")
}
}
// HandleOAuthCallback handles the OAuth callback (GET /api/ai/oauth/callback)
// This is kept for backwards compatibility but mainly serves as a fallback
func (h *AISettingsHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get code and state from query params
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
errParam := r.URL.Query().Get("error")
errDesc := r.URL.Query().Get("error_description")
// Check for OAuth error
if errParam != "" {
log.Error().
Str("error", errParam).
Str("description", errDesc).
Msg("OAuth authorization failed")
// Redirect to settings page with error
http.Redirect(w, r, "/settings?ai_oauth_error="+errParam, http.StatusTemporaryRedirect)
return
}
if code == "" || state == "" {
log.Error().Msg("OAuth callback missing code or state")
http.Redirect(w, r, "/settings?ai_oauth_error=missing_params", http.StatusTemporaryRedirect)
return
}
// Look up session
oauthSessionsMu.Lock()
session, ok := oauthSessions[state]
if ok {
delete(oauthSessions, state) // One-time use
}
oauthSessionsMu.Unlock()
if !ok {
log.Error().Str("state", state).Msg("OAuth callback with unknown state")
http.Redirect(w, r, "/settings?ai_oauth_error=invalid_state", http.StatusTemporaryRedirect)
return
}
// Exchange code for tokens
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
tokens, err := providers.ExchangeCodeForTokens(ctx, code, session)
if err != nil {
log.Error().Err(err).Msg("Failed to exchange OAuth code for tokens")
http.Redirect(w, r, "/settings?ai_oauth_error=token_exchange_failed", http.StatusTemporaryRedirect)
return
}
// Load existing settings
settings, err := h.getPersistence(r.Context()).LoadAIConfig()
if err != nil {
log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth")
settings = config.NewDefaultAIConfig()
}
if settings == nil {
settings = config.NewDefaultAIConfig()
}
// Update settings with OAuth tokens
settings.Provider = config.AIProviderAnthropic
settings.AuthMethod = config.AuthMethodOAuth
settings.OAuthAccessToken = tokens.AccessToken
settings.OAuthRefreshToken = tokens.RefreshToken
settings.OAuthExpiresAt = tokens.ExpiresAt
settings.Enabled = true
// Clear API key since we're using OAuth
settings.ClearAPIKey()
// Save settings
if err := h.getPersistence(r.Context()).SaveAIConfig(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save OAuth tokens")
http.Redirect(w, r, "/settings?ai_oauth_error=save_failed", http.StatusTemporaryRedirect)
return
}
// Reload the AI service with new settings
if err := h.GetAIService(r.Context()).Reload(); err != nil {
log.Warn().Err(err).Msg("Failed to reload AI service after OAuth setup")
}
log.Info().Msg("Claude OAuth authentication successful")
// Redirect to settings page with success
http.Redirect(w, r, "/settings?ai_oauth_success=true", http.StatusTemporaryRedirect)
}
// HandleOAuthDisconnect disconnects OAuth and clears tokens (POST /api/ai/oauth/disconnect)
func (h *AISettingsHandler) HandleOAuthDisconnect(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Require admin authentication
if !CheckAuth(h.getConfig(r.Context()), w, r) {
return
}
// Load existing settings
settings, err := h.getPersistence(r.Context()).LoadAIConfig()
if err != nil {
log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth disconnect")
http.Error(w, "Failed to load settings", http.StatusInternalServerError)
return
}
if settings == nil {
settings = config.NewDefaultAIConfig()
}
// Clear OAuth tokens
settings.ClearOAuthTokens()
settings.AuthMethod = config.AuthMethodAPIKey
// Save settings
if err := h.getPersistence(r.Context()).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 err := h.GetAIService(r.Context()).Reload(); err != nil {
log.Warn().Err(err).Msg("Failed to reload AI service after OAuth disconnect")
}
log.Info().Msg("Claude OAuth disconnected")
response := map[string]interface{}{
"success": true,
"message": "OAuth disconnected successfully",
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write OAuth disconnect response")
}
}
// PatrolStatusResponse is the response for GET /api/ai/patrol/status
type PatrolStatusResponse struct {
Running bool `json:"running"`
Enabled bool `json:"enabled"`
LastPatrolAt *time.Time `json:"last_patrol_at,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 auto-fixed by Patrol
BlockedReason string `json:"blocked_reason,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"`
}
// 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
}
patrol := h.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
// Patrol not initialized
response := PatrolStatusResponse{
Running: false,
Enabled: false,
Healthy: true,
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write patrol status response")
}
return
}
status := patrol.GetStatus()
summary := patrol.GetFindingsSummary()
// Determine license status for Pro feature gating
// GetLicenseState returns accurate state: none, active, expired, grace_period
licenseStatus, _ := h.GetAIService(r.Context()).GetLicenseState()
// Check for auto-fix feature - patrol itself is free, auto-fix requires Pro
hasAutoFixFeature := h.GetAIService(r.Context()).HasLicenseFeature(license.FeatureAIAutoFix)
// Get fixed count from investigation orchestrator
fixedCount := 0
if orchestrator := patrol.GetInvestigationOrchestrator(); orchestrator != nil {
fixedCount = orchestrator.GetFixedCount()
}
response := PatrolStatusResponse{
Running: status.Running,
Enabled: status.Enabled,
LastPatrolAt: status.LastPatrolAt,
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,
BlockedAt: status.BlockedAt,
LicenseRequired: !hasAutoFixFeature,
LicenseStatus: licenseStatus,
}
if !hasAutoFixFeature {
response.UpgradeURL = "https://pulserelay.pro/"
}
response.Summary.Critical = summary.Critical
response.Summary.Warning = summary.Warning
response.Summary.Watch = summary.Watch
response.Summary.Info = summary.Info
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
}
patrol := h.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
// Return empty intelligence when not initialized
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 != "" {
// 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
}
patrol := h.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
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")
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.
fmt.Fprintf(w, ": connected\n\n")
flusher.Flush()
// Subscribe to patrol stream
// Note: SubscribeToStream already sends the current buffered output to the channel
ch := patrol.SubscribeToStream()
defer patrol.UnsubscribeFromStream(ch)
// Stream events until client disconnects
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case event, ok := <-ch:
if !ok {
return
}
data, err := json.Marshal(event)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", data)
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
}
patrol := h.GetAIService(r.Context()).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")
var findings []*ai.Finding
if resourceID != "" {
findings = patrol.GetFindingsForResource(resourceID)
} else {
findings = patrol.GetAllFindings()
}
if err := utils.WriteJSONResponse(w, findings); err != nil {
log.Error().Err(err).Msg("Failed to write patrol findings response")
}
}
// 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
}
patrol := h.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
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")
}
}
// 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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
findings := patrol.GetFindings()
// Try patrol findings store first
var detectedAt time.Time
var category, severity, resourceID, findingKey string
foundInPatrol := false
finding := findings.Get(req.FindingID)
if finding != nil {
foundInPatrol = true
detectedAt = finding.DetectedAt
category = string(finding.Category)
severity = string(finding.Severity)
resourceID = finding.ResourceID
findingKey = finding.Key
}
// If not in patrol findings, check the unified store (for threshold alerts)
unifiedStore := h.GetUnifiedStore()
if !foundInPatrol && unifiedStore != nil {
unifiedFinding := unifiedStore.Get(req.FindingID)
if unifiedFinding != nil {
detectedAt = unifiedFinding.DetectedAt
category = string(unifiedFinding.Category)
severity = string(unifiedFinding.Severity)
resourceID = unifiedFinding.ResourceID
findingKey = unifiedFinding.ID
} else {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
} else if !foundInPatrol {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
// Acknowledge in patrol findings if it exists there
if foundInPatrol {
if !findings.Acknowledge(req.FindingID) {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
}
// Acknowledge in unified store (for both patrol and threshold alerts)
if unifiedStore != nil {
unifiedStore.Acknowledge(req.FindingID)
}
// Record to learning store
if h.learningStore != nil {
h.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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
DurationHours int `json:"duration_hours"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
if req.DurationHours <= 0 {
http.Error(w, "duration_hours must be positive", http.StatusBadRequest)
return
}
// Cap snooze duration at 7 days
if req.DurationHours > 168 {
req.DurationHours = 168
}
findings := patrol.GetFindings()
duration := time.Duration(req.DurationHours) * time.Hour
// Try patrol findings store first
var detectedAt time.Time
var category, severity, resourceID, findingKey string
foundInPatrol := false
finding := findings.Get(req.FindingID)
if finding != nil {
foundInPatrol = true
detectedAt = finding.DetectedAt
category = string(finding.Category)
severity = string(finding.Severity)
resourceID = finding.ResourceID
findingKey = finding.Key
}
// If not in patrol findings, check the unified store (for threshold alerts)
unifiedStore := h.GetUnifiedStore()
if !foundInPatrol && unifiedStore != nil {
unifiedFinding := unifiedStore.Get(req.FindingID)
if unifiedFinding != nil {
detectedAt = unifiedFinding.DetectedAt
category = string(unifiedFinding.Category)
severity = string(unifiedFinding.Severity)
resourceID = unifiedFinding.ResourceID
findingKey = unifiedFinding.ID
} else {
http.Error(w, "Finding not found or already resolved", http.StatusNotFound)
return
}
} else if !foundInPatrol {
http.Error(w, "Finding not found or already resolved", http.StatusNotFound)
return
}
// Snooze in patrol findings if it exists there
if foundInPatrol {
if !findings.Snooze(req.FindingID, duration) {
http.Error(w, "Finding not found or already resolved", http.StatusNotFound)
return
}
}
// Snooze in unified store (for both patrol and threshold alerts)
if unifiedStore != nil {
unifiedStore.Snooze(req.FindingID, duration)
}
// Record to learning store
if h.learningStore != nil {
h.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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
findings := patrol.GetFindings()
// Get finding details before resolving (for learning/analytics)
finding := findings.Get(req.FindingID)
if finding == nil {
http.Error(w, "Finding not found or already resolved", http.StatusNotFound)
return
}
// Capture details before action
detectedAt := finding.DetectedAt
category := string(finding.Category)
severity := string(finding.Severity)
resourceID := finding.ResourceID
// Mark as manually resolved (auto=false since user did it)
if !findings.Resolve(req.FindingID, false) {
http.Error(w, "Finding not found or already resolved", http.StatusNotFound)
return
}
// Mirror into unified store for consistent UI state
if store := h.GetUnifiedStore(); store != nil {
store.Resolve(req.FindingID)
}
// Record to learning store - manual resolve = user fixed the issue
if h.learningStore != nil {
h.learningStore.RecordFeedback(learning.FeedbackRecord{
FindingID: req.FindingID,
FindingKey: finding.Key,
ResourceID: resourceID,
Category: category,
Severity: severity,
Action: learning.ActionQuickFix, // Manual resolve means user took action to fix
TimeToAction: time.Since(detectedAt),
})
}
log.Info().
Str("finding_id", req.FindingID).
Msg("AI Patrol: Finding manually resolved by user")
response := map[string]interface{}{
"success": true,
"message": "Finding marked as resolved",
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write resolve response")
}
}
// HandleSetFindingNote sets or updates a user note on a finding (POST /api/ai/patrol/findings/note)
// Notes provide context that Patrol sees on future runs (e.g., "PBS server was decommissioned").
func (h *AISettingsHandler) HandleSetFindingNote(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !CheckAuth(h.getConfig(r.Context()), w, r) {
return
}
patrol := h.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
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.GetUnifiedStore(); 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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
FindingID string `json:"finding_id"`
Reason string `json:"reason"` // "not_an_issue", "expected_behavior", "will_fix_later"
Note string `json:"note"` // Optional freeform note
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.FindingID == "" {
http.Error(w, "finding_id is required", http.StatusBadRequest)
return
}
if req.Reason == "" {
http.Error(w, "reason is required", http.StatusBadRequest)
return
}
// Validate reason
validReasons := map[string]bool{
"not_an_issue": true,
"expected_behavior": true,
"will_fix_later": true,
}
if req.Reason != "" && !validReasons[req.Reason] {
http.Error(w, "Invalid reason. Valid values: not_an_issue, expected_behavior, will_fix_later", http.StatusBadRequest)
return
}
findings := patrol.GetFindings()
// Try patrol findings store first
var detectedAt time.Time
var category, severity, resourceID, findingKey string
foundInPatrol := false
finding := findings.Get(req.FindingID)
if finding != nil {
foundInPatrol = true
detectedAt = finding.DetectedAt
category = string(finding.Category)
severity = string(finding.Severity)
resourceID = finding.ResourceID
findingKey = finding.Key
}
// If not in patrol findings, check the unified store (for threshold alerts)
unifiedStore := h.GetUnifiedStore()
if !foundInPatrol && unifiedStore != nil {
unifiedFinding := unifiedStore.Get(req.FindingID)
if unifiedFinding != nil {
detectedAt = unifiedFinding.DetectedAt
category = string(unifiedFinding.Category)
severity = string(unifiedFinding.Severity)
resourceID = unifiedFinding.ResourceID
findingKey = unifiedFinding.ID // Use ID as key for unified findings
} else {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
} else if !foundInPatrol {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
// Dismiss in patrol findings if it exists there
if foundInPatrol {
if !findings.Dismiss(req.FindingID, req.Reason, req.Note) {
http.Error(w, "Finding not found", http.StatusNotFound)
return
}
}
// Dismiss in unified store (for both patrol and threshold alerts)
if unifiedStore != nil {
unifiedStore.Dismiss(req.FindingID, req.Reason, req.Note)
}
// Map dismiss reason to learning action
var learningAction learning.UserAction
switch req.Reason {
case "not_an_issue":
learningAction = learning.ActionDismissNotAnIssue
case "expected_behavior":
learningAction = learning.ActionDismissExpected
case "will_fix_later":
learningAction = learning.ActionDismissWillFixLater
default:
learningAction = learning.ActionDismissNotAnIssue // Default
}
// Record to learning store
if h.learningStore != nil {
h.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")
}
}
// HandleUndismissFinding reverts a dismissed/suppressed finding back to active state (POST /api/ai/patrol/undismiss)
func (h *AISettingsHandler) HandleUndismissFinding(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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
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()
ok := findings.Undismiss(req.FindingID)
if store := h.GetUnifiedStore(); store != nil {
if store.Undismiss(req.FindingID) {
ok = true
}
}
if !ok {
http.Error(w, "Finding not found or not dismissed", http.StatusNotFound)
return
}
log.Info().
Str("finding_id", req.FindingID).
Msg("AI Patrol: Finding undismissed by user")
response := map[string]interface{}{
"success": true,
"message": "Finding undismissed",
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write undismiss 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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
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
}
// 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.GetUnifiedStore(); 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 h.learningStore != nil {
h.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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
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.GetAIService(r.Context()).GetPatrolService()
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
}
patrol := h.GetAIService(r.Context()).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: 50)
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if l, err := fmt.Sscanf(limitStr, "%d", &limit); err == nil && l > 0 {
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")
}
}
// 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 != "" {
if _, err := fmt.Sscanf(daysStr, "%d", &days); err == nil && days > 0 {
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.Summary{
Days: days,
PricingAsOf: cost.PricingAsOf(),
ProviderModels: []cost.ProviderModelSummary{},
DailyTotals: []cost.DailySummary{},
Totals: cost.ProviderModelSummary{Provider: "all"},
}
}
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
}
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.GetAIService(r.Context()).GetPatrolService()
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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
return
}
var req struct {
ResourceID string `json:"resource_id"` // Can be empty for "any resource"
ResourceName string `json:"resource_name"` // Human-readable name
Category string `json:"category"` // Can be empty for "any category"
Description string `json:"description"` // Required - user's reason
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Description == "" {
http.Error(w, "description is required", 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.GetAIService(r.Context()).GetPatrolService()
if patrol == nil {
http.Error(w, "Patrol service not available", http.StatusServiceUnavailable)
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.GetAIService(r.Context()).GetPatrolService()
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")
}
}
// ============================================
// AI Chat Sessions API
// ============================================
// HandleListAIChatSessions lists all chat sessions for the current user (GET /api/ai/chat/sessions)
func (h *AISettingsHandler) HandleListAIChatSessions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !CheckAuth(h.getConfig(r.Context()), w, r) {
return
}
// Get username from auth context
username := getAuthUsername(h.getConfig(r.Context()), r)
sessions, err := h.getPersistence(r.Context()).GetAIChatSessionsForUser(username)
if err != nil {
log.Error().Err(err).Msg("Failed to load chat sessions")
http.Error(w, "Failed to load chat sessions", http.StatusInternalServerError)
return
}
// Return summary (without full messages) for list view
type sessionSummary struct {
ID string `json:"id"`
Title string `json:"title"`
MessageCount int `json:"message_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
summaries := make([]sessionSummary, 0, len(sessions))
for _, s := range sessions {
summaries = append(summaries, sessionSummary{
ID: s.ID,
Title: s.Title,
MessageCount: len(s.Messages),
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
})
}
if err := utils.WriteJSONResponse(w, summaries); err != nil {
log.Error().Err(err).Msg("Failed to write chat sessions response")
}
}
// HandleGetAIChatSession returns a specific chat session (GET /api/ai/chat/sessions/{id})
func (h *AISettingsHandler) HandleGetAIChatSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !CheckAuth(h.getConfig(r.Context()), w, r) {
return
}
// Extract session ID from URL
sessionID := strings.TrimPrefix(r.URL.Path, "/api/ai/chat/sessions/")
if sessionID == "" {
http.Error(w, "Session ID required", http.StatusBadRequest)
return
}
username := getAuthUsername(h.getConfig(r.Context()), r)
sessionsData, err := h.getPersistence(r.Context()).LoadAIChatSessions()
if err != nil {
log.Error().Err(err).Msg("Failed to load chat sessions")
http.Error(w, "Failed to load chat sessions", http.StatusInternalServerError)
return
}
session, exists := sessionsData.Sessions[sessionID]
if !exists {
http.Error(w, "Session not found", http.StatusNotFound)
return
}
// Check ownership (allow access if single-user or username matches)
if session.Username != "" && session.Username != username {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
if err := utils.WriteJSONResponse(w, session); err != nil {
log.Error().Err(err).Msg("Failed to write chat session response")
}
}
// HandleSaveAIChatSession creates or updates a chat session (PUT /api/ai/chat/sessions/{id})
func (h *AISettingsHandler) HandleSaveAIChatSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !CheckAuth(h.getConfig(r.Context()), w, r) {
return
}
// Extract session ID from URL
sessionID := strings.TrimPrefix(r.URL.Path, "/api/ai/chat/sessions/")
if sessionID == "" {
http.Error(w, "Session ID required", http.StatusBadRequest)
return
}
username := getAuthUsername(h.getConfig(r.Context()), r)
// Parse request body
var session config.AIChatSession
if err := json.NewDecoder(r.Body).Decode(&session); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Ensure session ID matches URL
session.ID = sessionID
// Check ownership if session exists
existingData, err := h.getPersistence(r.Context()).LoadAIChatSessions()
if err != nil {
log.Error().Err(err).Msg("Failed to load chat sessions")
http.Error(w, "Failed to load chat sessions", http.StatusInternalServerError)
return
}
if existing, exists := existingData.Sessions[sessionID]; exists {
// Check ownership
if existing.Username != "" && existing.Username != username {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Preserve original creation time and username
session.CreatedAt = existing.CreatedAt
session.Username = existing.Username
} else {
// New session - set creation time and username
session.CreatedAt = time.Now()
session.Username = username
}
// Auto-generate title from first user message if not set
if session.Title == "" && len(session.Messages) > 0 {
for _, msg := range session.Messages {
if msg.Role == "user" {
title := msg.Content
if len(title) > 50 {
title = title[:47] + "..."
}
session.Title = title
break
}
}
}
if session.Title == "" {
session.Title = "New conversation"
}
if err := h.getPersistence(r.Context()).SaveAIChatSession(&session); err != nil {
log.Error().Err(err).Msg("Failed to save chat session")
http.Error(w, "Failed to save chat session", http.StatusInternalServerError)
return
}
log.Debug().
Str("session_id", sessionID).
Str("username", username).
Int("messages", len(session.Messages)).
Msg("Chat session saved")
if err := utils.WriteJSONResponse(w, session); err != nil {
log.Error().Err(err).Msg("Failed to write save chat session response")
}
}
// HandleDeleteAIChatSession deletes a chat session (DELETE /api/ai/chat/sessions/{id})
func (h *AISettingsHandler) HandleDeleteAIChatSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !CheckAuth(h.getConfig(r.Context()), w, r) {
return
}
// Extract session ID from URL
sessionID := strings.TrimPrefix(r.URL.Path, "/api/ai/chat/sessions/")
if sessionID == "" {
http.Error(w, "Session ID required", http.StatusBadRequest)
return
}
username := getAuthUsername(h.getConfig(r.Context()), r)
// Check ownership
existingData, err := h.getPersistence(r.Context()).LoadAIChatSessions()
if err != nil {
log.Error().Err(err).Msg("Failed to load chat sessions")
http.Error(w, "Failed to load chat sessions", http.StatusInternalServerError)
return
}
if existing, exists := existingData.Sessions[sessionID]; exists {
if existing.Username != "" && existing.Username != username {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
}
if err := h.getPersistence(r.Context()).DeleteAIChatSession(sessionID); err != nil {
log.Error().Err(err).Msg("Failed to delete chat session")
http.Error(w, "Failed to delete chat session", http.StatusInternalServerError)
return
}
log.Info().
Str("session_id", sessionID).
Str("username", username).
Msg("Chat session deleted")
w.WriteHeader(http.StatusNoContent)
}
// getAuthUsername extracts the username from the current auth context
func getAuthUsername(cfg *config.Config, r *http.Request) string {
// Check OIDC session first
if cookie, err := r.Cookie("pulse_session"); 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 (Pro Feature)
// ============================================================================
// HandleListApprovals returns all pending approval requests.
func (h *AISettingsHandler) HandleListApprovals(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check license
licenseState, hasFeatures := h.GetAIService(r.Context()).GetLicenseState()
if !h.GetAIService(r.Context()).HasLicenseFeature(license.FeatureAIAutoFix) {
log.Warn().
Str("license_state", licenseState).
Bool("license_features_available", hasFeatures).
Str("feature", license.FeatureAIAutoFix).
Str("data_path", h.getConfig(r.Context()).DataPath).
Msg("AI Auto-Fix feature not available for approvals")
writeErrorResponse(w, http.StatusForbidden, "license_required", "Pulse Patrol Auto-Fix feature requires Pro license", map[string]string{
"license_state": licenseState,
"license_features_available": strconv.FormatBool(hasFeatures),
"feature": license.FeatureAIAutoFix,
})
return
}
store := approval.GetStore()
if store == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil)
return
}
approvals := store.GetPendingApprovals()
if approvals == nil {
approvals = []*approval.ApprovalRequest{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"approvals": approvals,
"stats": store.GetStats(),
})
}
// 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}
id := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/")
id = strings.TrimSuffix(id, "/")
id = strings.Split(id, "/")[0] // Handle /approve or /deny suffixes
if id == "" {
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
}
req, ok := store.GetApproval(id)
if !ok {
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
}
// Check license
licenseState, hasFeatures := h.GetAIService(r.Context()).GetLicenseState()
if !h.GetAIService(r.Context()).HasLicenseFeature(license.FeatureAIAutoFix) {
log.Warn().
Str("license_state", licenseState).
Bool("license_features_available", hasFeatures).
Str("feature", license.FeatureAIAutoFix).
Str("data_path", h.getConfig(r.Context()).DataPath).
Msg("AI Auto-Fix feature not available for approvals")
writeErrorResponse(w, http.StatusForbidden, "license_required", "Pulse Patrol Auto-Fix feature requires Pro license", map[string]string{
"license_state": licenseState,
"license_features_available": strconv.FormatBool(hasFeatures),
"feature": license.FeatureAIAutoFix,
})
return
}
// SECURITY: Validating command execution scope
if !ensureScope(w, r, config.ScopeAIExecute) {
return
}
// Extract ID from path: /api/ai/approvals/{id}/approve
path := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/")
path = strings.TrimSuffix(path, "/approve")
id := strings.TrimSuffix(path, "/")
if id == "" {
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
}
username := getAuthUsername(h.getConfig(r.Context()), r)
if username == "" {
username = "anonymous"
}
req, err := store.Approve(id, 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 investigation fixes, execute the command directly since there's no active agentic loop
if req.ToolID == "investigation_fix" {
h.executeInvestigationFix(w, r, req)
return
}
// 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)
}
// executeInvestigationFix executes an approved investigation fix directly.
// This is needed because Patrol investigations complete before approval, so there's no
// active agentic loop to execute the command when approved.
func (h *AISettingsHandler) executeInvestigationFix(w http.ResponseWriter, r *http.Request, req *approval.ApprovalRequest) {
ctx := r.Context()
findingID := req.TargetID
// Get the investigation store for this org
orgID := GetOrgID(ctx)
h.investigationMu.RLock()
invStore := h.investigationStores[orgID]
h.investigationMu.RUnlock()
if invStore == nil {
log.Warn().Str("orgID", orgID).Msg("Investigation store not found for org")
writeErrorResponse(w, http.StatusServiceUnavailable, "no_investigation_store", "Investigation store not available", nil)
return
}
// Get the latest investigation for this finding to get the target host
session := invStore.GetLatestByFinding(findingID)
if session == nil {
log.Warn().Str("findingID", findingID).Msg("No investigation found for finding")
writeErrorResponse(w, http.StatusNotFound, "no_investigation", "No investigation found for this finding", nil)
return
}
// Get target host from the proposed fix and clean it up
var targetHost string
if session.ProposedFix != nil {
targetHost = h.cleanTargetHost(session.ProposedFix.TargetHost)
}
var output string
var exitCode int
var execErr string
var err error
// Check if this is an MCP tool call or a shell command
if h.isMCPToolCall(req.Command) {
// Execute via chat service tool executor
// Pass the approval ID so the executor knows this is pre-approved
output, exitCode, err = h.executeMCPToolFix(ctx, req.Command, req.ID)
if err != nil {
execErr = err.Error()
log.Error().Err(err).Str("findingID", findingID).Str("command", req.Command).Msg("Failed to execute MCP tool fix")
}
} else {
// Execute via agent server (shell command)
if h.agentServer == nil {
log.Warn().Msg("No agent server available for fix execution")
writeErrorResponse(w, http.StatusServiceUnavailable, "no_agent_server", "No agent server available", nil)
return
}
// Find the appropriate agent
agentID := h.findAgentForTarget(targetHost)
if agentID == "" {
log.Warn().Str("targetHost", targetHost).Msg("No agent found for target host")
writeErrorResponse(w, http.StatusServiceUnavailable, "no_agent", "No agent available for target host", nil)
return
}
log.Info().
Str("findingID", findingID).
Str("command", req.Command).
Str("targetHost", targetHost).
Str("agentID", agentID).
Msg("Executing approved investigation fix via agent")
// Execute the command
var result *agentexec.CommandResultPayload
result, err = h.agentServer.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{
Command: req.Command,
TargetType: "host",
TargetID: "",
})
if err != nil {
execErr = err.Error()
log.Error().Err(err).Str("findingID", findingID).Msg("Failed to execute investigation fix")
} else {
output = result.Stdout
if result.Stderr != "" {
output += "\n" + result.Stderr
}
exitCode = result.ExitCode
}
}
// Update the investigation outcome
newOutcome := investigation.OutcomeFixExecuted
if err != nil || exitCode != 0 {
newOutcome = investigation.OutcomeFixFailed
}
invStore.Complete(session.ID, newOutcome, fmt.Sprintf("Fix executed with exit code %d", exitCode), session.ProposedFix)
// Update the finding outcome
h.updateFindingOutcome(ctx, orgID, findingID, string(newOutcome))
// Log audit event for execution
success := err == nil && exitCode == 0
LogAuditEvent("ai_fix_executed", getAuthUsername(h.getConfig(ctx), r), GetClientIP(r), r.URL.Path, success,
fmt.Sprintf("Executed fix for finding %s: %s (exit code: %d)", findingID, truncateForLog(req.Command, 100), exitCode))
// Launch background verification if fix executed successfully
if success {
aiSvc := h.GetAIService(ctx)
go func() {
time.Sleep(30 * time.Second)
patrol := aiSvc.GetPatrolService()
if patrol == nil {
log.Warn().Str("findingID", findingID).Msg("Post-fix verification skipped: no patrol service")
return
}
finding := patrol.GetFindings().Get(findingID)
if finding == nil {
log.Warn().Str("findingID", findingID).Msg("Post-fix verification skipped: finding not found")
return
}
bgCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
verified, verifyErr := patrol.VerifyFixResolved(bgCtx, finding.ResourceID, finding.ResourceType, finding.Key, finding.ID)
if verifyErr != nil {
log.Error().Err(verifyErr).Str("findingID", findingID).Msg("Post-fix verification failed with error")
invStore.Complete(session.ID, investigation.OutcomeFixVerificationFailed, fmt.Sprintf("Fix executed but verification error: %v", verifyErr), session.ProposedFix)
} else if !verified {
log.Warn().Str("findingID", findingID).Msg("Post-fix verification: issue persists")
invStore.Complete(session.ID, investigation.OutcomeFixVerificationFailed, "Fix executed but issue persists after verification.", session.ProposedFix)
} else {
log.Info().Str("findingID", findingID).Msg("Post-fix verification: issue resolved")
invStore.Complete(session.ID, investigation.OutcomeFixVerified, "Fix executed and verified - issue resolved.", session.ProposedFix)
}
h.updateFindingOutcome(bgCtx, orgID, findingID, string(invStore.GetLatestByFinding(findingID).Outcome))
}()
}
// Return response
response := map[string]interface{}{
"approved": true,
"executed": true,
"success": success,
"output": output,
"exit_code": exitCode,
"error": execErr,
"finding_id": findingID,
"message": "Fix executed.",
}
if !success {
response["message"] = fmt.Sprintf("Fix execution failed (exit code %d)", exitCode)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// isMCPToolCall checks if a command is an MCP tool call (vs a shell command)
func (h *AISettingsHandler) isMCPToolCall(command string) bool {
// MCP tool calls look like: pulse_control_guest(...) or default_api:pulse_control_guest(...)
return strings.HasPrefix(command, "pulse_") ||
strings.HasPrefix(command, "default_api:") ||
strings.Contains(command, "pulse_control_guest") ||
strings.Contains(command, "pulse_run_command") ||
strings.Contains(command, "pulse_get_resource")
}
// cleanTargetHost extracts just the hostname from a target host string
// Handles cases like "delly (The container's host is 'delly')" -> "delly"
func (h *AISettingsHandler) cleanTargetHost(targetHost string) string {
if targetHost == "" {
return ""
}
// If it contains a space and parenthesis, extract the first word
if idx := strings.Index(targetHost, " ("); idx > 0 {
return strings.TrimSpace(targetHost[:idx])
}
// If it contains a space, take the first word
if idx := strings.Index(targetHost, " "); idx > 0 {
return strings.TrimSpace(targetHost[:idx])
}
return strings.TrimSpace(targetHost)
}
// executeMCPToolFix executes an MCP tool call via the chat service
// The approvalID is passed to mark this execution as pre-approved
func (h *AISettingsHandler) executeMCPToolFix(ctx context.Context, command string, approvalID string) (output string, exitCode int, err error) {
// Get the chat service
if h.chatHandler == nil {
return "", -1, fmt.Errorf("chat handler not available")
}
chatSvc := h.chatHandler.GetService(ctx)
if chatSvc == nil {
return "", -1, fmt.Errorf("chat service not available")
}
// Cast to *chat.Service to access ExecuteMCPTool
chatService, ok := chatSvc.(*chat.Service)
if !ok {
return "", -1, fmt.Errorf("chat service type mismatch")
}
// Parse the tool call
toolName, args, parseErr := h.parseMCPToolCall(command)
if parseErr != nil {
return "", -1, fmt.Errorf("failed to parse tool call: %w", parseErr)
}
// Add the approval ID to mark this as pre-approved
// The tool executor will check this and skip the approval flow
if approvalID != "" {
args["_approval_id"] = approvalID
}
log.Info().
Str("tool", toolName).
Str("approvalID", approvalID).
Interface("args", args).
Msg("Executing MCP tool fix with pre-approval")
// Execute the tool
result, toolErr := chatService.ExecuteMCPTool(ctx, toolName, args)
if toolErr != nil {
return result, 1, toolErr // Return partial result if any
}
return result, 0, nil
}
// parseMCPToolCall parses an MCP tool call string into tool name and arguments
// Handles formats like:
// - pulse_control_guest(action='start', guest_id='102')
// - default_api:pulse_control_guest(guest_id="102", action="start")
func (h *AISettingsHandler) parseMCPToolCall(command string) (string, map[string]interface{}, error) {
// Remove default_api: prefix if present
command = strings.TrimPrefix(command, "default_api:")
// Find the opening parenthesis
openParen := strings.Index(command, "(")
if openParen == -1 {
return "", nil, fmt.Errorf("no opening parenthesis in tool call")
}
toolName := strings.TrimSpace(command[:openParen])
// Find the closing parenthesis
closeParen := strings.LastIndex(command, ")")
if closeParen == -1 || closeParen <= openParen {
return "", nil, fmt.Errorf("no closing parenthesis in tool call")
}
argsStr := command[openParen+1 : closeParen]
args := make(map[string]interface{})
if strings.TrimSpace(argsStr) == "" {
return toolName, args, nil
}
// Parse key=value pairs (handles both 'value' and "value" formats)
// This is a simple parser - for complex cases we might need something more robust
pairs := h.splitToolArgs(argsStr)
for _, pair := range pairs {
parts := strings.SplitN(pair, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
// Remove quotes from value
value = strings.Trim(value, "'\"")
args[key] = value
}
return toolName, args, nil
}
// splitToolArgs splits tool arguments respecting quoted strings
func (h *AISettingsHandler) splitToolArgs(argsStr string) []string {
var result []string
var current strings.Builder
var inQuote rune
var escaped bool
for _, r := range argsStr {
if escaped {
current.WriteRune(r)
escaped = false
continue
}
if r == '\\' {
escaped = true
current.WriteRune(r)
continue
}
if inQuote != 0 {
current.WriteRune(r)
if r == inQuote {
inQuote = 0
}
continue
}
if r == '\'' || r == '"' {
inQuote = r
current.WriteRune(r)
continue
}
if r == ',' {
if s := strings.TrimSpace(current.String()); s != "" {
result = append(result, s)
}
current.Reset()
continue
}
current.WriteRune(r)
}
if s := strings.TrimSpace(current.String()); s != "" {
result = append(result, s)
}
return result
}
// findAgentForTarget finds an agent that can execute commands on the target host
func (h *AISettingsHandler) findAgentForTarget(targetHost string) string {
if h.agentServer == nil {
return ""
}
agents := h.agentServer.GetConnectedAgents()
if len(agents) == 0 {
return ""
}
// If a specific target host is requested, find that agent
if targetHost != "" {
for _, agent := range agents {
if agent.Hostname == targetHost || agent.AgentID == targetHost {
return agent.AgentID
}
}
}
// If only one agent is connected, use it
if len(agents) == 1 {
return agents[0].AgentID
}
// Multiple agents and no specific target - return empty (caller should handle)
return ""
}
// updateFindingOutcome updates the investigation outcome on a finding
func (h *AISettingsHandler) updateFindingOutcome(ctx context.Context, orgID, findingID, outcome string) {
// Get AI service for this org
svc := h.GetAIService(ctx)
if svc == nil {
log.Warn().Str("orgID", orgID).Msg("AI service not available for finding update")
return
}
patrol := svc.GetPatrolService()
if patrol == nil {
log.Warn().Str("orgID", orgID).Msg("Patrol service not available for finding update")
return
}
findingsStore := patrol.GetFindings()
if findingsStore == nil {
log.Warn().Str("orgID", orgID).Msg("Findings store not available for finding update")
return
}
if !findingsStore.UpdateInvestigationOutcome(findingID, outcome) {
log.Warn().Str("findingID", findingID).Msg("Finding not found for outcome update")
return
}
log.Info().Str("findingID", findingID).Str("outcome", outcome).Msg("Updated finding investigation outcome")
}
// HandleDenyCommand denies a pending command.
func (h *AISettingsHandler) HandleDenyCommand(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract ID from path: /api/ai/approvals/{id}/deny
path := strings.TrimPrefix(r.URL.Path, "/api/ai/approvals/")
path = strings.TrimSuffix(path, "/deny")
id := strings.TrimSuffix(path, "/")
if id == "" {
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 {
json.NewDecoder(r.Body).Decode(&body)
}
store := approval.GetStore()
if store == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil)
return
}
username := getAuthUsername(h.getConfig(r.Context()), r)
if username == "" {
username = "anonymous"
}
req, err := store.Deny(id, username, body.Reason)
if err != nil {
writeErrorResponse(w, http.StatusBadRequest, "denial_failed", err.Error(), nil)
return
}
// Log audit event
LogAuditEvent("ai_command_denied", username, GetClientIP(r), r.URL.Path, true,
fmt.Sprintf("Denied command: %s (reason: %s)", truncateForLog(req.Command, 100), body.Reason))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"denied": true,
"request": req,
"message": "Command denied.",
})
}
// truncateForLog truncates a string for logging purposes.
func truncateForLog(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// PatrolAutonomySettings represents the patrol autonomy configuration for API requests
// Uses pointer for FullModeUnlocked to distinguish "not sent" from "sent as false"
type PatrolAutonomySettings struct {
AutonomyLevel string `json:"autonomy_level"` // "monitor", "approval", "assisted", "full"
FullModeUnlocked *bool `json:"full_mode_unlocked,omitempty"` // User has acknowledged Full mode risks (nil = preserve existing)
InvestigationBudget int `json:"investigation_budget"` // Max turns per investigation (5-30)
InvestigationTimeoutSec int `json:"investigation_timeout_sec"` // Max seconds per investigation (60-1800)
}
// PatrolAutonomyResponse represents the patrol autonomy configuration for API responses
// Uses plain bool for FullModeUnlocked since responses always include the actual value
type PatrolAutonomyResponse struct {
AutonomyLevel string `json:"autonomy_level"`
FullModeUnlocked bool `json:"full_mode_unlocked"`
InvestigationBudget int `json:"investigation_budget"`
InvestigationTimeoutSec int `json:"investigation_timeout_sec"`
}
// HandleGetPatrolAutonomy returns the current patrol autonomy settings (GET /api/ai/patrol/autonomy)
func (h *AISettingsHandler) HandleGetPatrolAutonomy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
aiService := h.GetAIService(r.Context())
cfg := aiService.GetConfig()
if cfg == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "Pulse Patrol not configured", nil)
return
}
autonomyLevel := cfg.GetPatrolAutonomyLevel()
// Clamp for free tier: assisted/full require Pro (ai_autofix)
hasAutoFix := aiService.HasLicenseFeature(license.FeatureAIAutoFix)
if !hasAutoFix && (autonomyLevel == config.PatrolAutonomyAssisted || autonomyLevel == config.PatrolAutonomyFull) {
autonomyLevel = config.PatrolAutonomyApproval
}
settings := PatrolAutonomyResponse{
AutonomyLevel: autonomyLevel,
FullModeUnlocked: cfg.PatrolFullModeUnlocked,
InvestigationBudget: cfg.GetPatrolInvestigationBudget(),
InvestigationTimeoutSec: int(cfg.GetPatrolInvestigationTimeout().Seconds()),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(settings)
}
// HandleUpdatePatrolAutonomy updates the patrol autonomy settings (PUT /api/ai/patrol/autonomy)
func (h *AISettingsHandler) HandleUpdatePatrolAutonomy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
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
}
// Validate autonomy level
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
}
// License-based autonomy clamping: assisted/full require Pro (ai_autofix)
hasAutoFix := h.GetAIService(r.Context()).HasLicenseFeature(license.FeatureAIAutoFix)
if !hasAutoFix && (req.AutonomyLevel == config.PatrolAutonomyAssisted || req.AutonomyLevel == config.PatrolAutonomyFull) {
writeErrorResponse(w, http.StatusPaymentRequired, "license_required",
"Auto-fix requires Pulse Pro. Free tier supports Monitor and Investigate modes.",
map[string]string{
"feature": license.FeatureAIAutoFix,
"upgrade_url": "https://pulserelay.pro/",
"allowed_modes": "monitor,approval",
})
return
}
// Validate budget (5-30)
if req.InvestigationBudget < 5 {
req.InvestigationBudget = 5
}
if req.InvestigationBudget > 30 {
req.InvestigationBudget = 30
}
// Validate timeout (60-1800 seconds / 30 minutes)
if req.InvestigationTimeoutSec < 60 {
req.InvestigationTimeoutSec = 60
}
if req.InvestigationTimeoutSec > 1800 {
req.InvestigationTimeoutSec = 1800
}
aiService := h.GetAIService(r.Context())
cfg := aiService.GetConfig()
if cfg == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "Pulse Patrol not configured", nil)
return
}
// Determine effective unlock value: use request value if provided, else preserve existing
effectiveUnlocked := cfg.PatrolFullModeUnlocked
if req.FullModeUnlocked != nil {
effectiveUnlocked = *req.FullModeUnlocked
}
// Handle auto-downgrade FIRST: if turning off unlock while currently in full mode AND
// request still asks for "full", downgrade to assisted. If user explicitly requested a
// lower level (monitor, approval, assisted), honor that instead.
finalAutonomyLevel := req.AutonomyLevel
currentLevel := cfg.GetPatrolAutonomyLevel() // Use getter to handle legacy "autonomous" migration
if !effectiveUnlocked && currentLevel == config.PatrolAutonomyFull && req.AutonomyLevel == config.PatrolAutonomyFull {
finalAutonomyLevel = config.PatrolAutonomyAssisted
}
// Validate the FINAL level: can't use "full" mode unless unlocked
if finalAutonomyLevel == config.PatrolAutonomyFull && !effectiveUnlocked {
writeErrorResponse(w, http.StatusForbidden, "full_mode_locked",
"Auto-fixing critical issues requires acknowledgment. Enable 'Auto-fix critical issues' in settings first.", nil)
return
}
// Now safe to update config (all validation passed)
cfg.PatrolFullModeUnlocked = effectiveUnlocked
cfg.PatrolAutonomyLevel = finalAutonomyLevel
cfg.PatrolInvestigationBudget = req.InvestigationBudget
cfg.PatrolInvestigationTimeoutSec = req.InvestigationTimeoutSec
// Save config via persistence layer
if err := h.getPersistence(r.Context()).SaveAIConfig(*cfg); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "save_failed", "Failed to save Pulse Assistant config", nil)
return
}
// Reload config to update in-memory state
if err := aiService.LoadConfig(); err != nil {
// Log but don't fail - config was saved successfully
LogAuditEvent("patrol_autonomy_reload_warning", "", "", r.URL.Path, false,
fmt.Sprintf("Config saved but failed to reload: %v", err))
}
// Log audit event with actual saved values
username := getAuthUsername(h.getConfig(r.Context()), r)
LogAuditEvent("patrol_autonomy_updated", username, GetClientIP(r), r.URL.Path, true,
fmt.Sprintf("Updated patrol autonomy: level=%s, unlocked=%v, budget=%d, timeout=%ds",
finalAutonomyLevel, effectiveUnlocked, req.InvestigationBudget, req.InvestigationTimeoutSec))
// Return actual saved values (may differ from request due to auto-downgrade)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"settings": map[string]interface{}{
"autonomy_level": finalAutonomyLevel,
"full_mode_unlocked": effectiveUnlocked,
"investigation_budget": req.InvestigationBudget,
"investigation_timeout_sec": req.InvestigationTimeoutSec,
},
})
}
// 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
}
aiService := h.GetAIService(r.Context())
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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(investigation)
}
// HandleReapproveInvestigationFix creates a new approval from an investigation's proposed fix (POST /api/ai/findings/{id}/reapprove)
// This is useful when the original approval has expired but the user still wants to execute the fix.
func (h *AISettingsHandler) HandleReapproveInvestigationFix(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check license
if !h.GetAIService(r.Context()).HasLicenseFeature(license.FeatureAIAutoFix) {
writeErrorResponse(w, http.StatusForbidden, "license_required", "Pulse Patrol Auto-Fix feature requires Pro license", nil)
return
}
// Extract finding ID from path
findingID := strings.TrimPrefix(r.URL.Path, "/api/ai/findings/")
findingID = strings.TrimSuffix(findingID, "/reapprove")
if findingID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Finding ID is required", nil)
return
}
aiService := h.GetAIService(r.Context())
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
}
inv := orchestrator.GetInvestigationByFinding(findingID)
if inv == nil {
writeErrorResponse(w, http.StatusNotFound, "not_found", "No investigation found for this finding", nil)
return
}
// Check if investigation has a proposed fix
if inv.ProposedFix == nil || len(inv.ProposedFix.Commands) == 0 {
writeErrorResponse(w, http.StatusBadRequest, "no_fix", "Investigation has no proposed fix", nil)
return
}
// Check approval store
store := approval.GetStore()
if store == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Approval store not initialized", nil)
return
}
// Create new approval request
req := &approval.ApprovalRequest{
ToolID: "investigation_fix",
Command: inv.ProposedFix.Commands[0],
TargetType: "investigation",
TargetID: findingID,
TargetName: inv.ProposedFix.Description,
Context: fmt.Sprintf("Re-approval of fix from investigation: %s", inv.ProposedFix.Description),
RiskLevel: approval.AssessRiskLevel(inv.ProposedFix.Commands[0], "investigation"),
}
if err := store.CreateApproval(req); err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create approval: "+err.Error(), nil)
return
}
log.Info().
Str("finding_id", findingID).
Str("approval_id", req.ID).
Str("command", truncateForLog(req.Command, 100)).
Msg("Re-created approval for investigation fix")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"approval_id": req.ID,
"message": "Approval created. You can now approve and execute the fix.",
})
}
// 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
}
aiService := h.GetAIService(r.Context())
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
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"investigation_id": investigation.ID,
"session_id": investigation.SessionID,
"messages": messages,
})
}
// HandleReinvestigateFinding triggers a re-investigation of a finding (POST /api/ai/findings/{id}/reinvestigate)
func (h *AISettingsHandler) HandleReinvestigateFinding(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
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, "/reinvestigate")
if findingID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_id", "Finding ID is required", nil)
return
}
aiService := h.GetAIService(r.Context())
cfg := aiService.GetConfig()
if cfg == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_configured", "Pulse Patrol not configured", nil)
return
}
autonomyLevel := cfg.GetPatrolAutonomyLevel()
if autonomyLevel == config.PatrolAutonomyMonitor {
writeErrorResponse(w, http.StatusBadRequest, "autonomy_disabled",
"Patrol autonomy is set to 'monitor' mode. Enable 'approval', 'assisted', or 'full' mode to investigate findings.", nil)
return
}
patrol := aiService.GetPatrolService()
if patrol == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Patrol service not initialized", nil)
return
}
orchestrator := patrol.GetInvestigationOrchestrator()
if orchestrator == nil {
// Try lazy initialization if chat handler is available
if h.chatHandler != nil {
log.Debug().Msg("Attempting lazy orchestrator initialization for reinvestigation")
h.setupInvestigationOrchestrator("default", aiService)
orchestrator = patrol.GetInvestigationOrchestrator()
}
if orchestrator == nil {
writeErrorResponse(w, http.StatusServiceUnavailable, "not_initialized", "Investigation orchestrator not initialized", nil)
return
}
}
// Check for already-running investigation (return 409 Conflict)
orgID := GetOrgID(r.Context())
h.investigationMu.RLock()
invStore := h.investigationStores[orgID]
h.investigationMu.RUnlock()
if invStore != nil {
if latest := invStore.GetLatestByFinding(findingID); latest != nil && latest.Status == investigation.StatusRunning {
writeErrorResponse(w, http.StatusConflict, "investigation_running",
"An investigation is already running for this finding", nil)
return
}
}
// Trigger re-investigation in background
go func() {
ctx := context.Background()
if err := orchestrator.ReinvestigateFinding(ctx, findingID, autonomyLevel); err != nil {
log.Error().Err(err).Str("finding_id", findingID).Msg("Re-investigation failed")
}
}()
// Log audit event
username := getAuthUsername(h.getConfig(r.Context()), r)
LogAuditEvent("finding_reinvestigation", username, GetClientIP(r), r.URL.Path, true,
fmt.Sprintf("Triggered re-investigation for finding: %s", findingID))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"finding_id": findingID,
"message": "Re-investigation started",
})
}