Pulse/internal/api/ai_handler.go
rcourtman 52669128e6 Drop redundant policy gates in resource-link routing
Tail of the operator-local-UI redaction sweep (abdde303a, a17f879a1).

resolveKubernetesContextForResource gated on requiresGovernedResourceDisplay
to choose between getPreferredInfrastructureDisplayName and a manual
displayName-or-name fallback. Both branches produce a raw infra name
once we trust that displayName never carries a redacted summary in
local rendering, so the gate is dead complexity. Collapse to a single
call and drop the now-unused requiresGovernedResourceDisplay import.

problemResourcePresentation.getProblemResourceDisplayName has no
production consumers today, but it still routes through the governed
helper. Reclassify it now (same as every other operator-local helper)
so the rule is consistent across the codebase if the surface ever gets
adopted.
2026-05-09 21:31:45 +01:00

3096 lines
99 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"fmt"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
airuntime "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/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/unified"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
"github.com/rs/zerolog/log"
)
// AIPersistence interface for loading/saving AI config
type AIPersistence interface {
LoadAIConfig() (*config.AIConfig, error)
DataDir() string
}
// AIService interface for the AI chat service - enables mocking in tests
type AIService interface {
Start(ctx context.Context) error
Stop(ctx context.Context) error
Restart(ctx context.Context, newCfg *config.AIConfig) error
IsRunning() bool
Execute(ctx context.Context, req chat.ExecuteRequest) (map[string]interface{}, error)
ExecuteStream(ctx context.Context, req chat.ExecuteRequest, callback chat.StreamCallback) error
ListSessions(ctx context.Context) ([]chat.Session, error)
CreateSession(ctx context.Context) (*chat.Session, error)
DeleteSession(ctx context.Context, sessionID string) error
GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error)
GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error)
GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error)
ClearModelHandoffContext(ctx context.Context, sessionID string) error
AbortSession(ctx context.Context, sessionID string) error
SummarizeSession(ctx context.Context, sessionID string) (map[string]interface{}, error)
GetSessionDiff(ctx context.Context, sessionID string) (map[string]interface{}, error)
ForkSession(ctx context.Context, sessionID string) (*chat.Session, error)
RevertSession(ctx context.Context, sessionID string) (map[string]interface{}, error)
UnrevertSession(ctx context.Context, sessionID string) (map[string]interface{}, error)
AnswerQuestion(ctx context.Context, questionID string, answers []chat.QuestionAnswer) error
SetAlertProvider(provider chat.MCPAlertProvider)
SetFindingsProvider(provider chat.MCPFindingsProvider)
SetBaselineProvider(provider chat.MCPBaselineProvider)
SetPatternProvider(provider chat.MCPPatternProvider)
SetMetricsHistory(provider chat.MCPMetricsHistoryProvider)
SetAgentProfileManager(manager chat.AgentProfileManager)
SetGuestConfigProvider(provider chat.MCPGuestConfigProvider)
SetAppContainerConfigProvider(provider chat.MCPAppContainerConfigProvider)
SetBackupProvider(provider chat.MCPBackupProvider)
SetDiskHealthProvider(provider chat.MCPDiskHealthProvider)
SetUpdatesProvider(provider chat.MCPUpdatesProvider)
SetFindingsManager(manager chat.FindingsManager)
SetMetadataUpdater(updater chat.MetadataUpdater)
SetKnowledgeStoreProvider(provider chat.KnowledgeStoreProvider)
SetIncidentRecorderProvider(provider chat.IncidentRecorderProvider)
SetEventCorrelatorProvider(provider chat.EventCorrelatorProvider)
SetDiscoveryProvider(provider chat.MCPDiscoveryProvider)
SetUnifiedResourceProvider(provider chat.MCPUnifiedResourceProvider)
SetAppContainerActionProvider(provider chat.MCPAppContainerActionProvider)
SetAppContainerReadProvider(provider chat.MCPAppContainerReadProvider)
UpdateControlSettings(cfg *config.AIConfig)
GetBaseURL() string
}
type patrolRunHandoffProvider func(context.Context, string) (airuntime.PatrolRunRecord, bool)
// AIHandler handles all AI endpoints using direct AI integration
type AIHandler struct {
stateMu sync.RWMutex
approvalStoreMu sync.Mutex
mtPersistence *config.MultiTenantPersistence
mtMonitor *monitoring.MultiTenantMonitor
defaultConfig *config.Config
defaultPersistence AIPersistence
hostedMode bool
defaultService AIService
agentServer *agentexec.Server
services map[string]AIService
servicesMu sync.RWMutex
serviceInitMu sync.RWMutex
serviceInit func(ctx context.Context, svc AIService)
defaultMonitor *monitoring.Monitor
unifiedStoreMu sync.RWMutex
unifiedStore *unified.UnifiedStore
unifiedStores map[string]*unified.UnifiedStore
readState unifiedresources.ReadState
recoveryManager *recoverymanager.Manager
approvalStore *approval.Store
approvalStoreDir string
approvalStoreStop context.CancelFunc
// approvalCreatedCallback is registered via SetApprovalCreatedCallback
// before the first approval store is built. ensureApprovalStore
// re-installs it on every freshly created store so multi-tenant
// re-keying or data-dir changes keep the agent SSE stream wired.
approvalCreatedCallback func(*approval.ApprovalRequest)
controlLevelResolver func(context.Context, *config.AIConfig) string
patrolRunProvider patrolRunHandoffProvider
}
// newChatService is the factory function for creating the AI service.
// Can be swapped in tests for mocking.
var newChatService = func(cfg chat.Config) AIService {
return chat.NewService(cfg)
}
// NewAIHandler creates a new AI handler
func NewAIHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenantMonitor, agentServer *agentexec.Server) *AIHandler {
var defaultConfig *config.Config
var defaultPersistence AIPersistence
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
}
}
return &AIHandler{
mtPersistence: mtp,
mtMonitor: mtm,
defaultConfig: defaultConfig,
defaultPersistence: defaultPersistence,
hostedMode: hostedModeEnabledFromEnv(),
agentServer: agentServer,
services: make(map[string]AIService),
unifiedStores: make(map[string]*unified.UnifiedStore),
}
}
// SetPatrolRunHandoffProvider wires the Patrol history owner into Assistant
// chat handoffs without making the browser reconstruct model context.
func (h *AIHandler) SetPatrolRunHandoffProvider(provider patrolRunHandoffProvider) {
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.patrolRunProvider = provider
}
func (h *AIHandler) getPatrolRunForHandoff(ctx context.Context, runID string) (airuntime.PatrolRunRecord, bool) {
runID = strings.TrimSpace(runID)
if runID == "" {
return airuntime.PatrolRunRecord{}, false
}
h.stateMu.RLock()
provider := h.patrolRunProvider
h.stateMu.RUnlock()
if provider == nil {
return airuntime.PatrolRunRecord{}, false
}
return provider(ctx, runID)
}
func (h *AIHandler) stateRefs() (
*config.MultiTenantPersistence,
*monitoring.MultiTenantMonitor,
*config.Config,
AIPersistence,
unifiedresources.ReadState,
*recoverymanager.Manager,
) {
h.stateMu.RLock()
defer h.stateMu.RUnlock()
return h.mtPersistence, h.mtMonitor, h.defaultConfig, h.defaultPersistence, h.readState, h.recoveryManager
}
func (h *AIHandler) getDefaultService() AIService {
if h == nil {
return nil
}
h.servicesMu.RLock()
defer h.servicesMu.RUnlock()
return h.defaultService
}
func normalizeAIChatOrgID(orgID string) string {
orgID = strings.TrimSpace(orgID)
if orgID == "" {
return "default"
}
return orgID
}
// SetUnifiedStore sets the unified store for finding context lookup in the default org "Discuss" flow.
func (h *AIHandler) SetUnifiedStore(store *unified.UnifiedStore) {
h.SetUnifiedStoreForOrg("default", store)
}
// SetUnifiedStoreForOrg sets the unified store for finding context lookup in an org-specific "Discuss" flow.
func (h *AIHandler) SetUnifiedStoreForOrg(orgID string, store *unified.UnifiedStore) {
orgID = normalizeAIChatOrgID(orgID)
h.unifiedStoreMu.Lock()
if h.unifiedStores == nil {
h.unifiedStores = make(map[string]*unified.UnifiedStore)
}
if store == nil {
delete(h.unifiedStores, orgID)
} else {
h.unifiedStores[orgID] = store
}
if orgID == "default" {
h.unifiedStore = store
}
h.unifiedStoreMu.Unlock()
}
// GetUnifiedStoreForOrg returns the unified store for finding context lookup for a specific org.
func (h *AIHandler) GetUnifiedStoreForOrg(orgID string) *unified.UnifiedStore {
if h == nil {
return nil
}
orgID = normalizeAIChatOrgID(orgID)
h.unifiedStoreMu.RLock()
if h.unifiedStores != nil {
if store := h.unifiedStores[orgID]; store != nil {
h.unifiedStoreMu.RUnlock()
return store
}
}
store := h.unifiedStore
h.unifiedStoreMu.RUnlock()
if orgID == "default" {
return store
}
return nil
}
// SetReadState stores a unified read-state provider for injection into newly created chat services.
func (h *AIHandler) SetReadState(rs unifiedresources.ReadState) {
if h == nil {
return
}
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.readState = rs
}
// SetRecoveryManager stores a recovery manager for injection into newly created chat services.
func (h *AIHandler) SetRecoveryManager(manager *recoverymanager.Manager) {
if h == nil {
return
}
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.recoveryManager = manager
}
func (h *AIHandler) applyServiceInitializer(ctx context.Context, svc AIService) {
if h == nil || svc == nil {
return
}
h.serviceInitMu.RLock()
initializer := h.serviceInit
h.serviceInitMu.RUnlock()
if initializer == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
initializer(ctx, svc)
}
// SetServiceInitializer configures an initializer that runs whenever a chat
// service is returned or created, allowing router-level org-specific wiring.
func (h *AIHandler) SetServiceInitializer(initializer func(ctx context.Context, svc AIService)) {
if h == nil {
return
}
h.serviceInitMu.Lock()
h.serviceInit = initializer
h.serviceInitMu.Unlock()
if initializer == nil {
return
}
orgServices := make(map[string]AIService)
h.servicesMu.RLock()
defaultSvc := h.defaultService
for orgID, svc := range h.services {
if svc != nil {
orgServices[orgID] = svc
}
}
h.servicesMu.RUnlock()
if defaultSvc != nil {
defaultCtx := context.WithValue(context.Background(), OrgIDContextKey, "default")
initializer(defaultCtx, defaultSvc)
}
for orgID, svc := range orgServices {
ctx := context.WithValue(context.Background(), OrgIDContextKey, orgID)
initializer(ctx, svc)
}
}
// SetControlLevelResolver configures the entitlement-aware control-level
// resolver used by chat services when applying Assistant tool permissions.
func (h *AIHandler) SetControlLevelResolver(
resolver func(context.Context, *config.AIConfig) string,
) {
if h == nil {
return
}
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.controlLevelResolver = resolver
}
func (h *AIHandler) resolveControlLevel(ctx context.Context, cfg *config.AIConfig) string {
if h == nil {
if cfg == nil {
return config.ControlLevelReadOnly
}
return cfg.GetControlLevel()
}
h.stateMu.RLock()
resolver := h.controlLevelResolver
h.stateMu.RUnlock()
if resolver != nil {
if level := strings.TrimSpace(resolver(ctx, cfg)); config.IsValidControlLevel(level) {
return level
}
}
if cfg == nil {
return config.ControlLevelReadOnly
}
return cfg.GetControlLevel()
}
// GetService returns the AI service for the current context
func (h *AIHandler) GetService(ctx context.Context) AIService {
orgID := GetOrgID(ctx)
if orgID == "default" || orgID == "" {
svc := h.getDefaultService()
if svc != nil {
defaultCtx := ctx
if strings.TrimSpace(GetOrgID(defaultCtx)) == "" {
defaultCtx = context.WithValue(context.Background(), OrgIDContextKey, "default")
}
h.applyServiceInitializer(defaultCtx, svc)
}
return svc
}
h.servicesMu.RLock()
svc, exists := h.services[orgID]
h.servicesMu.RUnlock()
if exists {
h.applyServiceInitializer(ctx, svc)
return svc
}
h.servicesMu.Lock()
defer h.servicesMu.Unlock()
// Double check
if svc, exists = h.services[orgID]; exists {
return svc
}
// Create and start service for this tenant
svc = h.initTenantService(ctx, orgID)
if svc != nil {
h.applyServiceInitializer(ctx, svc)
h.services[orgID] = svc
}
return svc
}
// RemoveTenantService stops and removes the AI service for a specific tenant.
// This should be called when a tenant is offboarded to free resources.
func (h *AIHandler) RemoveTenantService(ctx context.Context, orgID string) error {
orgID = normalizeAIChatOrgID(orgID)
if orgID == "default" {
return nil // Don't remove the default-org service.
}
// Clear org-scoped finding context store even if the chat service was never created.
h.SetUnifiedStoreForOrg(orgID, nil)
h.servicesMu.Lock()
defer h.servicesMu.Unlock()
svc, exists := h.services[orgID]
if !exists {
return nil // Nothing to remove
}
if svc != nil {
if err := svc.Stop(ctx); err != nil {
log.Warn().Str("orgID", orgID).Err(err).Msg("Error stopping AI service for removed tenant")
}
}
delete(h.services, orgID)
log.Info().Str("orgID", orgID).Msg("Removed AI service for tenant")
return nil
}
func (h *AIHandler) initTenantService(ctx context.Context, orgID string) AIService {
mtPersistence, mtMonitor, _, _, _, recoveryManager := h.stateRefs()
if mtPersistence == nil {
return nil
}
tenantCtx := context.WithValue(backgroundContext(ctx), OrgIDContextKey, orgID)
persistence, err := mtPersistence.GetPersistence(orgID)
if err != nil {
log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to get persistence for AI service")
return nil
}
// Tenant chat startup must use the same hosted-aware config path as
// /api/settings/ai so hosted orgs do not race into a synthetic disabled/default config.
aiCfg := h.loadAIConfig(tenantCtx)
if aiCfg == nil {
log.Info().Str("orgID", orgID).Msg("AI config is nil for tenant service initialization")
return nil
}
if !aiCfg.Enabled {
log.Info().Str("orgID", orgID).Bool("enabled", aiCfg.Enabled).Msg("AI is disabled in tenant config")
return nil
}
dataDir := h.getDataDir(aiCfg, persistence.DataDir())
// Create chat config
chatCfg := chat.Config{
AIConfig: aiCfg,
DataDir: dataDir,
AgentServer: h.agentServer,
ReadState: h.readStateForOrg(orgID),
OrgID: orgID,
ControlLevelResolver: func(next *config.AIConfig) string {
return h.resolveControlLevel(tenantCtx, next)
},
}
if recoveryManager != nil {
chatCfg.RecoveryPointsProvider = tools.NewRecoveryPointsMCPAdapter(recoveryManager, orgID)
}
// Get monitor for state provider
if mtMonitor != nil {
if m, err := mtMonitor.GetMonitor(orgID); err == nil && m != nil {
chatCfg.StateProvider = m
}
}
svc := newChatService(chatCfg)
if err := svc.Start(ctx); err != nil {
log.Error().Str("orgID", orgID).Err(err).Msg("Failed to start AI service for tenant")
return nil
}
return svc
}
func (h *AIHandler) getDataDir(aiCfg *config.AIConfig, baseDir string) string {
dataDir := baseDir
if dataDir == "" {
dataDir = "data"
}
return dataDir
}
func (h *AIHandler) readStateForOrg(orgID string) unifiedresources.ReadState {
if h == nil {
return nil
}
_, mtMonitor, _, _, fallbackReadState, _ := h.stateRefs()
orgID = strings.TrimSpace(orgID)
if orgID == "" {
orgID = "default"
}
if mtMonitor != nil {
if monitor, err := mtMonitor.GetMonitor(orgID); err == nil && monitor != nil {
if readState := monitor.GetUnifiedReadState(); readState != nil {
return readState
}
}
if orgID != "default" {
// Security: never fall back to default-org read state for non-default orgs.
return nil
}
}
return fallbackReadState
}
func (h *AIHandler) getConfig(ctx context.Context) *config.Config {
_, mtMonitor, defaultConfig, _, _, _ := h.stateRefs()
orgID := strings.TrimSpace(GetOrgID(ctx))
if orgID == "" || orgID == "default" {
return defaultConfig
}
if mtMonitor != nil {
if m, err := mtMonitor.GetMonitor(orgID); err == nil && m != nil {
return m.GetConfig()
}
// Security: never fall back to default config for non-default orgs.
return nil
}
return defaultConfig
}
func (h *AIHandler) getPersistence(ctx context.Context) AIPersistence {
mtPersistence, _, _, defaultPersistence, _, _ := h.stateRefs()
orgID := strings.TrimSpace(GetOrgID(ctx))
if orgID == "" || orgID == "default" {
return defaultPersistence
}
if mtPersistence != nil {
if p, err := mtPersistence.GetPersistence(orgID); err == nil && p != nil {
return p
}
// Security: never fall back to default persistence for non-default orgs.
return nil
}
return defaultPersistence
}
// loadAIConfig loads AI config for the current context
func (h *AIHandler) loadAIConfig(ctx context.Context) *config.AIConfig {
p := h.getPersistence(ctx)
if p == nil {
return nil
}
if persistence, ok := p.(*config.ConfigPersistence); ok {
billingBaseDir := persistence.DataDir()
orgID := strings.TrimSpace(GetOrgID(ctx))
if orgID == "" {
orgID = "default"
}
if h.mtPersistence != nil {
billingBaseDir = h.mtPersistence.BaseDataDir()
}
cfg, err := loadHostedAwareAIConfig(h.hostedMode, billingBaseDir, orgID, persistence)
if err == nil {
return cfg
}
log.Warn().Err(err).Str("org_id", orgID).Msg("Failed to load hosted-aware Pulse Assistant config")
}
cfg, err := p.LoadAIConfig()
if err != nil {
return nil
}
return cfg
}
// SetMultiTenantPersistence updates the persistence manager
func (h *AIHandler) SetMultiTenantPersistence(mtp *config.MultiTenantPersistence) {
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.mtPersistence = mtp
}
// SetMultiTenantMonitor updates the monitor manager
func (h *AIHandler) SetMultiTenantMonitor(mtm *monitoring.MultiTenantMonitor) {
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.mtMonitor = mtm
}
// SetApprovalCreatedCallback registers a fire-and-forget callback that
// runs after every successful approval creation. The callback is
// installed on the active approval store and re-installed on any
// future store rebuilt by ensureApprovalStore. Pass nil to clear.
//
// This is the seam the router uses to bridge approval creation into
// the agent SSE stream — see AgentEventBroadcaster.PublishApprovalPending.
func (h *AIHandler) SetApprovalCreatedCallback(cb func(*approval.ApprovalRequest)) {
h.approvalStoreMu.Lock()
defer h.approvalStoreMu.Unlock()
h.approvalCreatedCallback = cb
if h.approvalStore != nil {
h.approvalStore.SetOnApprovalCreated(cb)
}
}
func (h *AIHandler) ensureApprovalStore(dataDir string) {
if strings.TrimSpace(dataDir) == "" {
return
}
h.approvalStoreMu.Lock()
defer h.approvalStoreMu.Unlock()
if h.approvalStore != nil && h.approvalStoreDir == dataDir {
approval.SetStore(h.approvalStore)
return
}
if h.approvalStoreStop != nil {
h.approvalStoreStop()
h.approvalStoreStop = nil
}
approvalStore, err := approval.NewStore(approval.StoreConfig{
DataDir: dataDir,
DefaultTimeout: 5 * time.Minute,
MaxApprovals: 100,
})
if err != nil {
h.approvalStore = nil
h.approvalStoreDir = ""
approval.SetStore(nil)
log.Warn().Err(err).Msg("Failed to create approval store, approvals will not be persisted")
return
}
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
approvalStore.StartCleanup(cleanupCtx)
approval.SetStore(approvalStore)
h.approvalStore = approvalStore
h.approvalStoreDir = dataDir
h.approvalStoreStop = cleanupCancel
if h.approvalCreatedCallback != nil {
approvalStore.SetOnApprovalCreated(h.approvalCreatedCallback)
}
log.Info().Str("data_dir", dataDir).Msg("Approval store initialized")
}
func (h *AIHandler) clearApprovalStore() {
h.approvalStoreMu.Lock()
defer h.approvalStoreMu.Unlock()
if h.approvalStoreStop != nil {
h.approvalStoreStop()
h.approvalStoreStop = nil
}
h.approvalStore = nil
h.approvalStoreDir = ""
approval.SetStore(nil)
}
// Start initializes and starts the AI chat service.
// The monitor parameter provides state snapshots to the chat service (satisfies chat.StateProvider).
func (h *AIHandler) Start(ctx context.Context, monitor *monitoring.Monitor) error {
log.Info().Msg("AIHandler.Start called")
aiCfg := h.loadAIConfig(ctx)
if aiCfg == nil {
log.Info().Msg("AI config is nil, AI is disabled")
return nil
}
if !aiCfg.Enabled {
log.Info().Bool("enabled", aiCfg.Enabled).Msg("AI is disabled in config")
return nil
}
// Determine data directory
persistence := h.getPersistence(ctx)
dataDir := h.getDataDir(aiCfg, persistence.DataDir())
orgID := GetOrgID(ctx)
if orgID == "" {
orgID = "default"
}
serviceCtx := context.WithValue(backgroundContext(ctx), OrgIDContextKey, orgID)
// Cache the monitor for use by Restart().
h.stateMu.Lock()
h.defaultMonitor = monitor
h.stateMu.Unlock()
// Create chat config
chatCfg := chat.Config{
AIConfig: aiCfg,
DataDir: dataDir,
StateProvider: monitor,
AgentServer: h.agentServer,
ReadState: h.readStateForOrg(orgID),
OrgID: orgID,
ControlLevelResolver: func(next *config.AIConfig) string {
return h.resolveControlLevel(serviceCtx, next)
},
}
_, _, _, _, _, recoveryManager := h.stateRefs()
if recoveryManager != nil {
chatCfg.RecoveryPointsProvider = tools.NewRecoveryPointsMCPAdapter(recoveryManager, orgID)
}
svc := newChatService(chatCfg)
if err := svc.Start(ctx); err != nil {
return fmt.Errorf("start AI chat service: %w", err)
}
h.servicesMu.Lock()
h.defaultService = svc
h.servicesMu.Unlock()
h.applyServiceInitializer(serviceCtx, svc)
// Initialize approval store for command approval workflow.
h.ensureApprovalStore(dataDir)
log.Info().Msg("Pulse AI started (direct integration)")
return nil
}
// Stop stops the AI chat service
func (h *AIHandler) Stop(ctx context.Context) error {
defer h.clearApprovalStore()
if svc := h.getDefaultService(); svc != nil {
return svc.Stop(ctx)
}
return nil
}
// Restart restarts the AI chat service with updated configuration
// Call this when model or other settings change
func (h *AIHandler) Restart(ctx context.Context) error {
// Load fresh config from persistence to get latest settings
newCfg := h.loadAIConfig(ctx)
svc := h.getDefaultService()
if newCfg == nil || !newCfg.Enabled {
h.clearApprovalStore()
if svc == nil {
return nil
}
return svc.Restart(ctx, newCfg)
}
// If enabled but not started yet, recover the monitor and bootstrap now.
if svc == nil {
log.Info().Msg("Starting AI service via restart trigger")
h.stateMu.RLock()
m := h.defaultMonitor
mtm := h.mtMonitor
h.stateMu.RUnlock()
if m == nil && mtm != nil {
m, _ = mtm.GetMonitor("default")
}
return h.Start(ctx, m)
}
if !svc.IsRunning() {
log.Info().Msg("Starting AI service via restart trigger")
// Recover the monitor: prefer cached default-org monitor, fall back to mtMonitor.
h.stateMu.RLock()
m := h.defaultMonitor
mtm := h.mtMonitor
h.stateMu.RUnlock()
if m == nil && mtm != nil {
m, _ = mtm.GetMonitor("default")
}
// Reuse start logic
return h.Start(ctx, m)
}
if err := svc.Restart(ctx, newCfg); err != nil {
return err
}
persistence := h.getPersistence(ctx)
if persistence == nil {
return nil
}
dataDir := h.getDataDir(newCfg, persistence.DataDir())
h.ensureApprovalStore(dataDir)
return nil
}
// IsRunning returns whether AI is running
// GetAIConfig returns the current AI configuration
func (h *AIHandler) GetAIConfig(ctx context.Context) *config.AIConfig {
return h.loadAIConfig(ctx)
}
// IsRunning returns true if the AI chat service is running
func (h *AIHandler) IsRunning(ctx context.Context) bool {
svc := h.GetService(ctx)
return svc != nil && svc.IsRunning()
}
// ChatMention represents a resource tagged via @ mention in the chat UI
type ChatMention struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Node string `json:"node,omitempty"`
}
func canonicalizeChatMentionType(raw string) string {
normalized := normalizeAITransportResourceType(raw)
switch normalized {
case "vm", "node", "agent", "system-container", "app-container", "docker-host", "k8s-cluster", "k8s-node", "k8s-pod", "k8s-deployment", "storage", "disk", "pbs", "pmg", "proxmox", "ceph", "oci-container":
return normalized
default:
return ""
}
}
// ChatRequest represents a chat request
type ChatRequest struct {
Prompt string `json:"prompt"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
Mentions []ChatMention `json:"mentions,omitempty"`
FindingID string `json:"finding_id,omitempty"`
HandoffContext string `json:"handoff_context,omitempty"`
HandoffResources []chat.HandoffResource `json:"handoff_resources,omitempty"`
HandoffActions []chat.HandoffAction `json:"handoff_actions,omitempty"`
HandoffMetadata chat.HandoffMetadata `json:"handoff_metadata,omitempty"`
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
}
const (
chatRequestHandoffContextMaxBytes = 16 * 1024
chatRequestHandoffResourceLimit = 8
chatRequestHandoffActionLimit = 4
)
func chatAutonomousModeForScopedHandoff(requested *bool, handoffContext string, handoffResources []chat.HandoffResource, handoffActions []chat.HandoffAction, handoffMetadata chat.HandoffMetadata) *bool {
if strings.TrimSpace(handoffContext) == "" && len(handoffResources) == 0 && len(handoffActions) == 0 && chat.NormalizeHandoffMetadata(handoffMetadata) == (chat.HandoffMetadata{}) {
return requested
}
return chatApprovalRequiredAutonomousMode()
}
func chatApprovalRequiredAutonomousMode() *bool {
approvalRequired := false
return &approvalRequired
}
func chatAutonomousModeForFindingHandoff(requested *bool, findingID, handoffContext string, handoffResources []chat.HandoffResource, handoffActions []chat.HandoffAction, handoffMetadata chat.HandoffMetadata) *bool {
if strings.TrimSpace(findingID) != "" {
return chatApprovalRequiredAutonomousMode()
}
return chatAutonomousModeForScopedHandoff(requested, handoffContext, handoffResources, handoffActions, handoffMetadata)
}
func normalizeChatRequestHandoffContext(raw string) string {
context := strings.TrimSpace(raw)
if len(context) <= chatRequestHandoffContextMaxBytes {
return context
}
return strings.TrimSpace(context[:chatRequestHandoffContextMaxBytes]) + "\n[Handoff Context Truncated]"
}
func normalizeChatRequestHandoffResources(raw []chat.HandoffResource) []chat.HandoffResource {
resources := make([]chat.HandoffResource, 0, min(len(raw), chatRequestHandoffResourceLimit))
for _, resource := range raw {
if len(resources) >= chatRequestHandoffResourceLimit {
break
}
id := trimChatHandoffField(resource.ID, 256)
name := trimChatHandoffField(resource.Name, 256)
resourceType := canonicalizeChatMentionType(resource.Type)
node := trimChatHandoffField(resource.Node, 256)
if id == "" && name == "" {
continue
}
resources = append(resources, chat.HandoffResource{
ID: id,
Name: name,
Type: resourceType,
Node: node,
})
}
return resources
}
func normalizeChatRequestHandoffActions(raw []chat.HandoffAction) []chat.HandoffAction {
actions := make([]chat.HandoffAction, 0, min(len(raw), chatRequestHandoffActionLimit))
for _, action := range raw {
if len(actions) >= chatRequestHandoffActionLimit {
break
}
normalized := chat.HandoffAction{
FindingID: trimChatHandoffField(action.FindingID, 256),
RecordID: trimChatHandoffField(action.RecordID, 256),
ApprovalID: trimChatHandoffField(action.ApprovalID, 256),
ApprovalStatus: trimChatHandoffField(action.ApprovalStatus, 64),
ApprovalRequestedAt: trimChatHandoffField(action.ApprovalRequestedAt, 64),
ApprovalExpiresAt: trimChatHandoffField(action.ApprovalExpiresAt, 64),
ApprovalDecidedAt: trimChatHandoffField(action.ApprovalDecidedAt, 64),
ApprovalConsumed: action.ApprovalConsumed,
ActionID: trimChatHandoffField(action.ActionID, 256),
ActionState: trimChatHandoffField(action.ActionState, 64),
ActionUpdatedAt: trimChatHandoffField(action.ActionUpdatedAt, 64),
ActionRequestedBy: trimChatHandoffField(action.ActionRequestedBy, 128),
ActionCapability: trimChatHandoffField(action.ActionCapability, 128),
ActionApprovalPolicy: trimChatHandoffField(action.ActionApprovalPolicy, 64),
ActionRequiresApproval: action.ActionRequiresApproval,
ActionPlanExpiresAt: trimChatHandoffField(action.ActionPlanExpiresAt, 64),
ActionPlanMessage: trimChatHandoffField(action.ActionPlanMessage, 512),
ActionPreflight: trimChatHandoffField(action.ActionPreflight, 512),
ActionDryRunSummary: trimChatHandoffField(action.ActionDryRunSummary, 512),
ActionResult: trimChatHandoffField(action.ActionResult, 64),
FixID: trimChatHandoffField(action.FixID, 256),
Description: trimChatHandoffField(action.Description, 512),
RiskLevel: trimChatHandoffField(action.RiskLevel, 64),
Destructive: action.Destructive,
TargetHost: trimChatHandoffField(action.TargetHost, 256),
TargetResourceID: trimChatHandoffField(action.TargetResourceID, 256),
TargetResourceName: trimChatHandoffField(action.TargetResourceName, 256),
TargetResourceType: canonicalizeChatMentionType(action.TargetResourceType),
TargetNode: trimChatHandoffField(action.TargetNode, 256),
}
if handoffActionHasBriefingValue(normalized) ||
strings.TrimSpace(normalized.FindingID) != "" ||
strings.TrimSpace(normalized.TargetResourceID) != "" {
actions = append(actions, normalized)
}
}
return actions
}
func trimChatHandoffField(raw string, limit int) string {
value := strings.TrimSpace(raw)
if len(value) <= limit {
return value
}
return strings.TrimSpace(value[:limit])
}
const findingChatContextListLimit = 5
type unifiedFindingLookup interface {
Get(findingID string) *unified.UnifiedFinding
}
type unifiedRelatedFindingContext struct {
Label string
Finding *unified.UnifiedFinding
}
func buildUnifiedFindingChatContext(f *unified.UnifiedFinding, lookup unifiedFindingLookup, handoffActions []chat.HandoffAction) string {
if f == nil {
return ""
}
var b strings.Builder
appendUnifiedFindingOperatorBriefingContext(&b, f, handoffActions)
appendChatContextLine(&b, "[Finding Context]", "")
appendChatContextLine(&b, "ID", f.ID)
appendChatContextLine(&b, "Title", f.Title)
appendChatContextLine(&b, "Finding Status", unifiedFindingChatStatus(f, time.Now()))
appendChatContextLine(&b, "Source", string(f.Source))
appendChatContextLine(&b, "Severity", string(f.Severity))
appendChatContextLine(&b, "Category", string(f.Category))
appendChatContextLine(&b, "Resource", formatChatResource(f.ResourceName, f.ResourceType))
appendChatContextLine(&b, "Resource ID", f.ResourceID)
if !f.DetectedAt.IsZero() {
appendChatContextLine(&b, "Finding Detected At", f.DetectedAt.Format(time.RFC3339))
}
if !f.LastSeenAt.IsZero() {
appendChatContextLine(&b, "Finding Last Seen At", f.LastSeenAt.Format(time.RFC3339))
}
if f.ResolvedAt != nil {
appendChatContextLine(&b, "Finding Resolved At", f.ResolvedAt.Format(time.RFC3339))
}
if f.SnoozedUntil != nil {
appendChatContextLine(&b, "Finding Snoozed Until", f.SnoozedUntil.Format(time.RFC3339))
}
if strings.TrimSpace(f.DismissedReason) != "" {
appendChatContextLine(&b, "Finding Dismissed Reason", f.DismissedReason)
}
if f.Suppressed {
appendChatContextLine(&b, "Finding Suppressed", "true")
}
if f.TimesRaised > 0 {
appendChatContextLine(&b, "Finding Times Raised", strconv.Itoa(f.TimesRaised))
}
appendChatContextLine(&b, "Description", f.Description)
appendChatContextLine(&b, "Recommendation", f.Recommendation)
appendChatContextLine(&b, "Evidence", f.Evidence)
appendChatContextLine(&b, "AI Context", f.AIContext)
if f.AIConfidence > 0 {
appendChatContextLine(&b, "AI Confidence", fmt.Sprintf("%.2f", f.AIConfidence))
}
if f.AIEnhancedAt != nil {
appendChatContextLine(&b, "AI Enhanced At", f.AIEnhancedAt.Format(time.RFC3339))
}
appendChatContextLine(&b, "Root Cause ID", f.RootCauseID)
appendStringListChatContext(&b, "Correlated Finding", f.CorrelatedIDs)
appendUnifiedFindingRelatedContext(&b, f, lookup)
appendChatContextLine(&b, "Remediation ID", f.RemediationID)
appendChatContextLine(&b, "Investigation Status", f.InvestigationStatus)
appendChatContextLine(&b, "Investigation Outcome", f.InvestigationOutcome)
if f.LastInvestigatedAt != nil {
appendChatContextLine(&b, "Last Investigated At", f.LastInvestigatedAt.Format(time.RFC3339))
}
if f.InvestigationAttempts > 0 {
appendChatContextLine(&b, "Investigation Attempts", strconv.Itoa(f.InvestigationAttempts))
}
appendChatContextLine(&b, "Loop State", f.LoopState)
if f.RegressionCount > 0 {
appendChatContextLine(&b, "Regression Count", strconv.Itoa(f.RegressionCount))
}
if f.LastRegressionAt != nil {
appendChatContextLine(&b, "Last Regression At", f.LastRegressionAt.Format(time.RFC3339))
}
// Surface the prior successful fix as operational memory so Assistant
// and any AI investigation can reason about what worked previously
// instead of treating each regression as a blank-slate diagnosis.
if strings.TrimSpace(f.PreviousResolvedFixSummary) != "" {
appendChatContextLine(&b, "Previous Resolved Fix", f.PreviousResolvedFixSummary)
}
appendChatContextLine(&b, "User Note", f.UserNote)
if f.AcknowledgedAt != nil {
appendChatContextLine(&b, "Finding Acknowledged At", f.AcknowledgedAt.Format(time.RFC3339))
}
appendChatContextLine(&b, "Node", f.Node)
appendUnifiedFindingLifecycleEventContext(&b, f.Lifecycle)
appendInvestigationRecordChatContext(&b, f.InvestigationRecord)
return b.String()
}
func mergeUnifiedFindingRequestHandoffContext(canonicalContext, requestContext, findingID string) string {
canonicalContext = strings.TrimSpace(canonicalContext)
requestContext = safePatrolFindingRequestHandoffContext(requestContext, findingID)
switch {
case canonicalContext == "":
return requestContext
case requestContext == "":
return canonicalContext
default:
var b strings.Builder
b.WriteString(canonicalContext)
appendChatContextLine(&b, "", "")
appendChatContextLine(&b, "[Product Handoff Context]", "")
b.WriteString(requestContext)
if !strings.HasSuffix(requestContext, "\n") {
b.WriteByte('\n')
}
appendChatContextLine(&b, "Product Handoff Boundary", "This product-originated Patrol handoff is secondary to backend-refreshed canonical finding context; use it for explanation and operator review only, not approval or execution authority.")
return strings.TrimSpace(b.String())
}
}
func safePatrolFindingRequestHandoffContext(raw, findingID string) string {
raw = strings.TrimSpace(raw)
findingID = strings.TrimSpace(findingID)
if raw == "" || findingID == "" {
return ""
}
lines := strings.Split(raw, "\n")
safeLines := make([]string, 0, len(lines))
hasPatrolFindingHeader := false
hasPatrolFindingSource := false
matchesFinding := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if patrolFindingRequestHandoffLineHasRawCommandPayload(line) {
continue
}
switch {
case line == "[Patrol Finding Context]":
hasPatrolFindingHeader = true
safeLines = append(safeLines, line)
case line == "Source: Pulse Patrol finding handoff":
hasPatrolFindingSource = true
safeLines = append(safeLines, line)
case isSafePatrolFindingRequestHandoffLine(line):
if strings.HasPrefix(line, "Finding ID:") && strings.EqualFold(strings.TrimSpace(strings.TrimPrefix(line, "Finding ID:")), findingID) {
matchesFinding = true
}
safeLines = append(safeLines, line)
}
}
if !hasPatrolFindingHeader || !hasPatrolFindingSource || !matchesFinding || len(safeLines) == 0 {
return ""
}
return strings.Join(safeLines, "\n")
}
func patrolFindingRequestHandoffLineHasRawCommandPayload(line string) bool {
normalized := strings.ToLower(strings.TrimSpace(line))
if normalized == "" {
return false
}
for _, marker := range []string{
"systemctl restart ",
"systemctl stop ",
"systemctl start ",
"systemctl reload ",
"sudo systemctl ",
"kubectl delete ",
"kubectl apply ",
"rm -rf ",
} {
if strings.Contains(normalized, marker) {
return true
}
}
return false
}
func isSafePatrolFindingRequestHandoffLine(line string) bool {
label, _, ok := strings.Cut(line, ":")
if !ok {
return false
}
label = strings.TrimSpace(label)
if label == "" {
return false
}
if strings.HasPrefix(label, "Evidence ") || strings.HasPrefix(label, "Verification ") {
return true
}
switch label {
case "Finding",
"Finding ID",
"Subject",
"Resource",
"Status",
"Detected At",
"Last Seen At",
"Recurrence",
"Description",
"Attention",
"Investigation Record",
"Investigation Status",
"Investigation Outcome",
"Investigation Confidence",
"Conclusion",
"Recommended Action",
"Tools Used",
"Approval",
"Approval Status",
"Approval Risk",
"Approval Target",
"Approval Requested At",
"Approval Expires At",
"Approval Policy",
"Approval Plan Expires At",
"Action Plan Summary",
"Action Preflight",
"Dry-Run Posture",
"Proposed Fix",
"Operator Decision",
"Command Boundary",
"Operator Boundary":
return true
default:
return false
}
}
func mergeUnifiedFindingRequestHandoffResources(f *unified.UnifiedFinding, canonicalResources, requestResources []chat.HandoffResource) []chat.HandoffResource {
merged := append([]chat.HandoffResource{}, canonicalResources...)
for _, resource := range requestResources {
if !requestHandoffResourceMatchesUnifiedFinding(f, resource) {
continue
}
if handoffResourceExists(merged, resource) {
continue
}
merged = append(merged, resource)
}
return merged
}
func requestHandoffResourceMatchesUnifiedFinding(f *unified.UnifiedFinding, resource chat.HandoffResource) bool {
if f == nil {
return false
}
resourceID := strings.TrimSpace(resource.ID)
if resourceID == "" {
return false
}
return strings.EqualFold(resourceID, strings.TrimSpace(f.ResourceID))
}
func handoffResourceExists(resources []chat.HandoffResource, candidate chat.HandoffResource) bool {
candidateID := strings.TrimSpace(candidate.ID)
for _, resource := range resources {
if candidateID != "" && strings.EqualFold(strings.TrimSpace(resource.ID), candidateID) {
return true
}
}
return false
}
func mergeUnifiedFindingRequestHandoffActions(f *unified.UnifiedFinding, canonicalActions, requestActions []chat.HandoffAction) []chat.HandoffAction {
merged := append([]chat.HandoffAction{}, canonicalActions...)
for _, action := range requestActions {
if !requestHandoffActionMatchesUnifiedFinding(f, action) || !handoffActionHasBriefingValue(action) {
continue
}
if idx := matchingHandoffActionIndex(merged, action); idx >= 0 {
merged[idx] = mergeHandoffActionSafeFields(merged[idx], action)
continue
}
merged = append(merged, action)
}
return merged
}
func requestHandoffActionMatchesUnifiedFinding(f *unified.UnifiedFinding, action chat.HandoffAction) bool {
if f == nil {
return false
}
actionFindingID := strings.TrimSpace(action.FindingID)
if actionFindingID == "" {
return false
}
return strings.EqualFold(actionFindingID, strings.TrimSpace(f.ID))
}
func matchingHandoffActionIndex(actions []chat.HandoffAction, candidate chat.HandoffAction) int {
for idx, action := range actions {
if sameNonEmptyHandoffActionID(action.ApprovalID, candidate.ApprovalID) ||
sameNonEmptyHandoffActionID(action.ActionID, candidate.ActionID) ||
sameNonEmptyHandoffActionID(action.FixID, candidate.FixID) {
return idx
}
}
return -1
}
func sameNonEmptyHandoffActionID(left, right string) bool {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
return left != "" && right != "" && strings.EqualFold(left, right)
}
func mergeHandoffActionSafeFields(canonical, requested chat.HandoffAction) chat.HandoffAction {
fillString := func(target *string, value string) {
if strings.TrimSpace(*target) == "" {
*target = value
}
}
fillBool := func(target *bool, value bool) {
if !*target {
*target = value
}
}
fillString(&canonical.FindingID, requested.FindingID)
fillString(&canonical.RecordID, requested.RecordID)
fillString(&canonical.ApprovalID, requested.ApprovalID)
fillString(&canonical.ApprovalStatus, requested.ApprovalStatus)
fillString(&canonical.ApprovalRequestedAt, requested.ApprovalRequestedAt)
fillString(&canonical.ApprovalExpiresAt, requested.ApprovalExpiresAt)
fillString(&canonical.ApprovalDecidedAt, requested.ApprovalDecidedAt)
fillString(&canonical.ActionID, requested.ActionID)
fillString(&canonical.ActionState, requested.ActionState)
fillString(&canonical.ActionUpdatedAt, requested.ActionUpdatedAt)
fillString(&canonical.ActionRequestedBy, requested.ActionRequestedBy)
fillString(&canonical.ActionCapability, requested.ActionCapability)
fillString(&canonical.ActionApprovalPolicy, requested.ActionApprovalPolicy)
fillString(&canonical.ActionPlanExpiresAt, requested.ActionPlanExpiresAt)
fillString(&canonical.ActionPlanMessage, requested.ActionPlanMessage)
fillString(&canonical.ActionPreflight, requested.ActionPreflight)
fillString(&canonical.ActionDryRunSummary, requested.ActionDryRunSummary)
fillString(&canonical.ActionResult, requested.ActionResult)
fillString(&canonical.FixID, requested.FixID)
fillString(&canonical.Description, requested.Description)
fillString(&canonical.RiskLevel, requested.RiskLevel)
fillString(&canonical.TargetHost, requested.TargetHost)
fillString(&canonical.TargetResourceID, requested.TargetResourceID)
fillString(&canonical.TargetResourceName, requested.TargetResourceName)
fillString(&canonical.TargetResourceType, requested.TargetResourceType)
fillString(&canonical.TargetNode, requested.TargetNode)
fillBool(&canonical.ApprovalConsumed, requested.ApprovalConsumed)
fillBool(&canonical.ActionRequiresApproval, requested.ActionRequiresApproval)
fillBool(&canonical.Destructive, requested.Destructive)
return canonical
}
func appendUnifiedFindingRelatedContext(b *strings.Builder, f *unified.UnifiedFinding, lookup unifiedFindingLookup) {
if b == nil || f == nil || lookup == nil {
return
}
related := resolveUnifiedFindingRelatedContext(f, lookup)
if len(related) == 0 {
return
}
appendChatContextLine(b, "", "")
appendChatContextLine(b, "[Related Finding Context]", "")
for _, item := range related {
if item.Finding == nil {
continue
}
appendChatContextLine(b, item.Label, formatUnifiedRelatedFindingSummary(item.Finding))
}
appendChatContextLine(b, "Related Finding Boundary", "Related findings are current unified finding context for explanation only; they do not grant approval or execution authority.")
}
func resolveUnifiedFindingRelatedContext(f *unified.UnifiedFinding, lookup unifiedFindingLookup) []unifiedRelatedFindingContext {
if f == nil || lookup == nil {
return nil
}
related := make([]unifiedRelatedFindingContext, 0, findingChatContextListLimit)
seen := map[string]struct{}{
strings.ToLower(strings.TrimSpace(f.ID)): {},
}
add := func(label, findingID string) {
if len(related) >= findingChatContextListLimit {
return
}
findingID = strings.TrimSpace(findingID)
if findingID == "" {
return
}
key := strings.ToLower(findingID)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
relatedFinding := lookup.Get(findingID)
if relatedFinding == nil {
return
}
related = append(related, unifiedRelatedFindingContext{
Label: label,
Finding: relatedFinding,
})
}
add("Root Cause Finding", f.RootCauseID)
correlatedCount := 0
for _, findingID := range f.CorrelatedIDs {
correlatedCount++
add(fmt.Sprintf("Correlated Finding %d", correlatedCount), findingID)
if len(related) >= findingChatContextListLimit {
break
}
}
return related
}
func formatUnifiedRelatedFindingSummary(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
parts := make([]string, 0, 5)
if id := strings.TrimSpace(f.ID); id != "" {
parts = append(parts, id)
}
if finding := formatUnifiedFindingBriefingFinding(f); finding != "" {
parts = append(parts, finding)
}
if resource := formatUnifiedFindingBriefingResource(f); resource != "" {
parts = append(parts, "resource "+resource)
}
if recency := formatUnifiedFindingBriefingRecency(f); recency != "" {
parts = append(parts, "recency "+recency)
}
if investigation := formatUnifiedFindingBriefingInvestigation(f); investigation != "" {
parts = append(parts, "investigation "+investigation)
}
if conclusion := unifiedFindingBriefingConclusion(f); conclusion != "" {
parts = append(parts, "conclusion "+conclusion)
}
return strings.Join(parts, " | ")
}
func appendUnifiedFindingOperatorBriefingContext(b *strings.Builder, f *unified.UnifiedFinding, handoffActions []chat.HandoffAction) {
if b == nil || f == nil {
return
}
briefingSource := "Product structured finding"
if f.Source == unified.SourceAIPatrol {
briefingSource = "Pulse Patrol structured finding"
}
appendChatContextLine(b, "[Operator Briefing]", "")
appendChatContextLine(b, "Briefing Source", briefingSource)
appendChatContextLine(b, "Operator Role", "Explain the investigation, highlight current risk, and guide the operator through evidence, approval, or continued monitoring.")
appendChatContextLine(b, "Finding", formatUnifiedFindingBriefingFinding(f))
appendChatContextLine(b, "Resource", formatUnifiedFindingBriefingResource(f))
appendChatContextLine(b, "Priority", formatUnifiedFindingBriefingPriority(f))
if attention := unifiedFindingBriefingAttention(f); attention != "" {
appendChatContextLine(b, "Attention Reason", attention)
}
appendChatContextLine(b, "Recency", formatUnifiedFindingBriefingRecencyFacts(f))
appendChatContextLine(b, "Investigation", formatUnifiedFindingBriefingInvestigation(f))
if evidence := unifiedFindingBriefingEvidence(f); evidence != "" {
appendChatContextLine(b, "Evidence Snapshot", evidence)
}
if verification := unifiedFindingBriefingVerification(f); verification != "" {
appendChatContextLine(b, "Verification", verification)
}
if latestLifecycle := formatUnifiedFindingLatestLifecycleEvent(f.Lifecycle); latestLifecycle != "" {
appendChatContextLine(b, "Latest Lifecycle Event", latestLifecycle)
}
appendChatContextLine(b, "Current Conclusion", unifiedFindingBriefingConclusion(f))
appendChatContextLine(b, "Recommended Next Step", unifiedFindingBriefingNextStep(f))
if decision := unifiedFindingBriefingOperatorDecision(f, handoffActions); decision != "" {
appendChatContextLine(b, "Operator Decision", decision)
}
appendChatContextLine(b, "Action Posture", unifiedFindingBriefingActionPosture(f, handoffActions))
appendChatContextLine(b, "Operator Boundary", "Treat Patrol data as product context for explanation and review; do not invent commands, expose raw command text, or treat chat as approval or execution authority.")
appendChatContextLine(b, "", "")
}
func formatUnifiedFindingBriefingFinding(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
title := strings.TrimSpace(f.Title)
if title == "" {
title = strings.TrimSpace(f.ID)
}
qualifiers := nonEmptyStrings(
string(f.Severity),
string(f.Category),
unifiedFindingChatStatus(f, time.Now()),
)
if len(qualifiers) == 0 {
return title
}
if title == "" {
return strings.Join(qualifiers, ", ")
}
return title + " (" + strings.Join(qualifiers, ", ") + ")"
}
func formatUnifiedFindingBriefingResource(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
resource := formatChatResource(f.ResourceName, f.ResourceType)
resourceID := strings.TrimSpace(f.ResourceID)
if resourceID != "" {
if resource == "" {
resource = resourceID
} else {
resource += " [" + resourceID + "]"
}
}
if node := strings.TrimSpace(f.Node); node != "" {
if resource == "" {
resource = "node " + node
} else {
resource += " on " + node
}
}
return resource
}
func formatUnifiedFindingBriefingPriority(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
parts := nonEmptyStrings(
strings.TrimSpace(string(f.Severity)+" "+string(f.Category)),
"status "+unifiedFindingChatStatus(f, time.Now()),
)
if loopState := strings.TrimSpace(f.LoopState); loopState != "" {
parts = append(parts, "loop "+loopState)
}
if f.TimesRaised > 0 {
parts = append(parts, fmt.Sprintf("raised %d times", f.TimesRaised))
}
if f.RegressionCount > 0 {
parts = append(parts, fmt.Sprintf("regressed %d times", f.RegressionCount))
}
return strings.Join(parts, "; ")
}
func unifiedFindingBriefingAttention(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
now := time.Now()
parts := make([]string, 0, 8)
status := unifiedFindingChatStatus(f, now)
switch status {
case "active":
if severity := strings.TrimSpace(string(f.Severity)); severity != "" {
parts = append(parts, "active "+severity+" finding")
} else {
parts = append(parts, "active finding")
}
case "resolved":
if f.RegressionCount > 0 {
parts = append(parts, "resolved after prior regression")
} else {
parts = append(parts, "resolved finding")
}
case "snoozed":
parts = append(parts, "snoozed finding")
case "dismissed":
parts = append(parts, "dismissed finding")
case "suppressed":
parts = append(parts, "suppressed finding")
}
if f.RegressionCount > 0 {
parts = append(parts, fmt.Sprintf("regressed %d times", f.RegressionCount))
} else if f.TimesRaised > 1 {
parts = append(parts, fmt.Sprintf("raised %d times", f.TimesRaised))
}
if f.LastRegressionAt != nil {
parts = append(parts, "last regression "+f.LastRegressionAt.Format(time.RFC3339))
}
if loopState := strings.TrimSpace(f.LoopState); loopState != "" {
parts = append(parts, "loop "+loopState)
}
rec := f.InvestigationRecord
outcome := strings.TrimSpace(f.InvestigationOutcome)
if rec != nil {
outcome = strings.TrimSpace(string(rec.Outcome))
if approvalID := strings.TrimSpace(rec.ApprovalID); approvalID != "" {
parts = append(parts, "approval "+approvalID)
}
if rec.ProposedFix != nil && rec.ProposedFix.Destructive {
parts = append(parts, "destructive proposed fix")
}
}
switch aicontracts.InvestigationOutcome(outcome) {
case aicontracts.OutcomeFixQueued:
parts = append(parts, "fix queued for governed review")
case aicontracts.OutcomeFixExecuted:
parts = append(parts, "fix executed awaiting verification")
case aicontracts.OutcomeFixFailed:
parts = append(parts, "fix failed")
case aicontracts.OutcomeFixVerificationFailed:
parts = append(parts, "verification failed")
case aicontracts.OutcomeFixVerificationUnknown:
parts = append(parts, "verification inconclusive")
case aicontracts.OutcomeNeedsAttention:
parts = append(parts, "needs operator attention")
case aicontracts.OutcomeCannotFix:
parts = append(parts, "Patrol cannot safely fix")
case aicontracts.OutcomeTimedOut:
parts = append(parts, "Patrol timed out")
}
return formatBriefingStringList(parts, 8, "attention facts")
}
func formatUnifiedFindingBriefingRecency(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
parts := nonEmptyStrings(formatUnifiedFindingBriefingRecencyFacts(f))
if latestLifecycle := formatUnifiedFindingLatestLifecycleEvent(f.Lifecycle); latestLifecycle != "" {
parts = append(parts, "latest lifecycle "+latestLifecycle)
}
return strings.Join(parts, "; ")
}
func formatUnifiedFindingBriefingRecencyFacts(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
parts := make([]string, 0, 8)
if !f.DetectedAt.IsZero() {
parts = append(parts, "detected "+f.DetectedAt.Format(time.RFC3339))
}
if !f.LastSeenAt.IsZero() {
parts = append(parts, "last seen "+f.LastSeenAt.Format(time.RFC3339))
}
if f.ResolvedAt != nil {
parts = append(parts, "resolved "+f.ResolvedAt.Format(time.RFC3339))
}
if f.SnoozedUntil != nil {
parts = append(parts, "snoozed until "+f.SnoozedUntil.Format(time.RFC3339))
}
if strings.TrimSpace(f.DismissedReason) != "" {
parts = append(parts, "dismissed "+strings.TrimSpace(f.DismissedReason))
}
if f.Suppressed {
parts = append(parts, "suppressed")
}
if f.TimesRaised > 1 {
parts = append(parts, fmt.Sprintf("raised %d times", f.TimesRaised))
}
if f.RegressionCount > 0 {
parts = append(parts, fmt.Sprintf("regressed %d times", f.RegressionCount))
}
if f.LastRegressionAt != nil {
parts = append(parts, "last regression "+f.LastRegressionAt.Format(time.RFC3339))
}
if f.AcknowledgedAt != nil {
parts = append(parts, "acknowledged "+f.AcknowledgedAt.Format(time.RFC3339))
}
return strings.Join(parts, "; ")
}
func formatUnifiedFindingBriefingInvestigation(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
rec := f.InvestigationRecord
parts := make([]string, 0, 5)
if rec != nil {
parts = append(parts, nonEmptyStrings(
string(rec.Status),
briefingLabelValue("outcome", string(rec.Outcome)),
briefingLabelValue("confidence", string(rec.Confidence)),
)...)
} else {
parts = append(parts, nonEmptyStrings(
f.InvestigationStatus,
briefingLabelValue("outcome", f.InvestigationOutcome),
)...)
}
if f.InvestigationAttempts > 0 {
parts = append(parts, fmt.Sprintf("attempts %d", f.InvestigationAttempts))
}
if f.AIConfidence > 0 && (rec == nil || strings.TrimSpace(string(rec.Confidence)) == "") {
parts = append(parts, fmt.Sprintf("ai confidence %.2f", f.AIConfidence))
}
return strings.Join(parts, "; ")
}
func unifiedFindingBriefingConclusion(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
if f.InvestigationRecord != nil {
if conclusion := strings.TrimSpace(f.InvestigationRecord.Conclusion); conclusion != "" {
return conclusion
}
}
for _, value := range []string{f.AIContext, f.Description, f.Evidence} {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}
func unifiedFindingBriefingEvidence(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
if f.InvestigationRecord != nil {
if evidence := formatInvestigationRecordEvidenceBriefing(f.InvestigationRecord.Evidence, 2); evidence != "" {
return evidence
}
}
return strings.TrimSpace(f.Evidence)
}
func unifiedFindingBriefingVerification(f *unified.UnifiedFinding) string {
if f == nil || f.InvestigationRecord == nil {
return ""
}
return formatBriefingStringList(f.InvestigationRecord.Verification, 2, "verification")
}
func unifiedFindingBriefingNextStep(f *unified.UnifiedFinding) string {
if f == nil {
return ""
}
if unifiedFindingChatStatus(f, time.Now()) == "resolved" {
return "Explain the resolution and any monitoring follow-up; do not propose execution unless a new active finding is raised."
}
if f.InvestigationRecord != nil {
if next := strings.TrimSpace(f.InvestigationRecord.RecommendedAction); next != "" {
return next
}
if f.InvestigationRecord.ProposedFix != nil {
if description := strings.TrimSpace(f.InvestigationRecord.ProposedFix.Description); description != "" {
return "Review proposed fix: " + description
}
}
}
if recommendation := strings.TrimSpace(f.Recommendation); recommendation != "" {
return recommendation
}
return "Explain what Patrol knows and identify the next evidence to verify before remediation."
}
func unifiedFindingBriefingOperatorDecision(f *unified.UnifiedFinding, handoffActions []chat.HandoffAction) string {
if f == nil {
return ""
}
if unifiedFindingChatStatus(f, time.Now()) == "resolved" {
return "Finding is resolved; explain the resolution and monitoring follow-up without proposing execution."
}
if action, ok := unifiedFindingPrimaryHandoffAction(f, handoffActions); ok {
if decision := unifiedFindingHandoffActionOperatorDecision(action); decision != "" {
return decision
}
}
rec := f.InvestigationRecord
if rec != nil {
if approvalID := strings.TrimSpace(rec.ApprovalID); approvalID != "" {
parts := []string{"review governed approval " + approvalID + " before execution"}
if rec.ProposedFix != nil {
if fixID := strings.TrimSpace(rec.ProposedFix.ID); fixID != "" {
parts = append(parts, "proposed fix "+fixID)
} else if description := strings.TrimSpace(rec.ProposedFix.Description); description != "" {
parts = append(parts, "proposed fix recorded")
}
if risk := strings.TrimSpace(rec.ProposedFix.RiskLevel); risk != "" {
parts = append(parts, "risk "+risk)
}
if rec.ProposedFix.Destructive {
parts = append(parts, "destructive true")
}
}
return strings.Join(parts, "; ")
}
switch rec.Outcome {
case aicontracts.OutcomeFixQueued:
return "Review the proposed fix in the governed approval or remediation flow before execution."
case aicontracts.OutcomeFixExecuted:
return "Verify the execution result before closing or resolving the finding."
case aicontracts.OutcomeFixFailed, aicontracts.OutcomeFixVerificationFailed:
return "Review failed remediation evidence before retrying or escalating."
case aicontracts.OutcomeFixVerificationUnknown:
return "Gather verification evidence before closing or retrying remediation."
case aicontracts.OutcomeNeedsAttention, aicontracts.OutcomeCannotFix:
return "Operator intervention is required; use the evidence to choose the next manual step."
case aicontracts.OutcomeTimedOut:
return "Patrol timed out; rerun investigation or gather more evidence before remediation."
}
switch rec.Status {
case aicontracts.InvestigationStatusPending, aicontracts.InvestigationStatusRunning:
return "Wait for Patrol to finish the investigation before approving remediation."
case aicontracts.InvestigationStatusFailed:
return "Review the Patrol investigation failure and gather evidence before remediation."
case aicontracts.InvestigationStatusNeedsAttention:
return "Operator intervention is required; use the evidence to choose the next manual step."
}
}
if remediationID := strings.TrimSpace(f.RemediationID); remediationID != "" {
return "Review governed remediation " + remediationID + " before execution."
}
loopState := strings.ToLower(strings.TrimSpace(f.LoopState))
switch {
case strings.Contains(loopState, "approval"):
return "Review the governed approval flow before execution."
case strings.Contains(loopState, "investigat"):
return "Wait for Patrol to finish the investigation before approving remediation."
}
if unifiedFindingChatStatus(f, time.Now()) == "active" {
return "Continue investigation or monitoring; no governed action reference is ready."
}
return ""
}
func formatInvestigationRecordEvidenceBriefing(evidence []aicontracts.InvestigationRecordEvidence, limit int) string {
if limit <= 0 || len(evidence) == 0 {
return ""
}
parts := make([]string, 0, limit+1)
total := 0
for _, item := range evidence {
summaryParts := make([]string, 0, 3)
if kind := strings.TrimSpace(item.Kind); kind != "" {
summaryParts = append(summaryParts, kind)
}
if id := strings.TrimSpace(item.ID); id != "" {
summaryParts = append(summaryParts, id)
}
if summary := strings.TrimSpace(item.Summary); summary != "" {
summaryParts = append(summaryParts, summary)
}
if len(summaryParts) == 0 {
continue
}
total++
if len(parts) < limit {
parts = append(parts, strings.Join(summaryParts, ": "))
}
}
if len(parts) == 0 {
return ""
}
if remaining := total - len(parts); remaining > 0 {
parts = append(parts, fmt.Sprintf("%d more evidence items", remaining))
}
return strings.Join(parts, "; ")
}
func formatBriefingStringList(values []string, limit int, itemName string) string {
if limit <= 0 || len(values) == 0 {
return ""
}
parts := make([]string, 0, limit+1)
total := 0
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
total++
if len(parts) < limit {
parts = append(parts, normalized)
}
}
}
if len(parts) == 0 {
return ""
}
if remaining := total - len(parts); remaining > 0 {
itemName = strings.TrimSpace(itemName)
if itemName == "" {
itemName = "items"
}
parts = append(parts, fmt.Sprintf("%d more %s", remaining, itemName))
}
return strings.Join(parts, "; ")
}
func unifiedFindingBriefingActionPosture(f *unified.UnifiedFinding, handoffActions []chat.HandoffAction) string {
if f == nil {
return ""
}
if action, ok := unifiedFindingPrimaryHandoffAction(f, handoffActions); ok {
if posture := unifiedFindingHandoffActionPosture(action); posture != "" {
parts := []string{posture}
if remediationID := strings.TrimSpace(f.RemediationID); remediationID != "" {
parts = append(parts, "remediation "+remediationID)
}
return strings.Join(parts, "; ")
}
}
rec := f.InvestigationRecord
parts := make([]string, 0, 5)
if rec != nil {
if approvalID := strings.TrimSpace(rec.ApprovalID); approvalID != "" {
parts = append(parts, "approval "+approvalID)
}
if rec.ProposedFix != nil {
if fixID := strings.TrimSpace(rec.ProposedFix.ID); fixID != "" {
parts = append(parts, "proposed fix "+fixID)
} else if description := strings.TrimSpace(rec.ProposedFix.Description); description != "" {
parts = append(parts, "proposed fix recorded")
}
if risk := strings.TrimSpace(rec.ProposedFix.RiskLevel); risk != "" {
parts = append(parts, "risk "+risk)
}
if rec.ProposedFix.Destructive {
parts = append(parts, "destructive true")
}
}
}
if remediationID := strings.TrimSpace(f.RemediationID); remediationID != "" {
parts = append(parts, "remediation "+remediationID)
}
if len(parts) == 0 {
return "No governed action is ready; keep the response investigative."
}
return strings.Join(parts, "; ")
}
func unifiedFindingPrimaryHandoffAction(f *unified.UnifiedFinding, handoffActions []chat.HandoffAction) (chat.HandoffAction, bool) {
if len(handoffActions) == 0 {
return chat.HandoffAction{}, false
}
findingID := ""
if f != nil {
findingID = strings.TrimSpace(f.ID)
}
for _, action := range handoffActions {
if !handoffActionHasBriefingValue(action) {
continue
}
actionFindingID := strings.TrimSpace(action.FindingID)
if findingID == "" || strings.EqualFold(actionFindingID, findingID) {
return action, true
}
}
return chat.HandoffAction{}, false
}
func handoffActionHasBriefingValue(action chat.HandoffAction) bool {
for _, value := range []string{
action.ApprovalID,
action.ApprovalStatus,
action.ActionID,
action.ActionState,
action.FixID,
action.Description,
} {
if strings.TrimSpace(value) != "" {
return true
}
}
return action.Destructive || action.ApprovalConsumed || action.ActionRequiresApproval
}
func unifiedFindingHandoffActionOperatorDecision(action chat.HandoffAction) string {
parts := make([]string, 0, 6)
if approvalID := strings.TrimSpace(action.ApprovalID); approvalID != "" {
parts = append(parts, "review governed approval "+approvalID+" before execution")
} else if actionID := strings.TrimSpace(action.ActionID); actionID != "" {
parts = append(parts, "review governed action "+actionID+" before execution")
} else if strings.TrimSpace(action.FixID) != "" || strings.TrimSpace(action.Description) != "" {
parts = append(parts, "review the proposed fix before execution")
}
if status := strings.TrimSpace(action.ApprovalStatus); status != "" {
parts = append(parts, "approval status "+status)
}
if expiresAt := strings.TrimSpace(action.ApprovalExpiresAt); expiresAt != "" {
parts = append(parts, "approval expires "+expiresAt)
}
if action.ApprovalConsumed {
parts = append(parts, "approval consumed true")
}
if actionID := strings.TrimSpace(action.ActionID); actionID != "" && strings.TrimSpace(action.ApprovalID) != "" {
parts = append(parts, "action "+actionID)
}
if state := strings.TrimSpace(action.ActionState); state != "" {
parts = append(parts, "action state "+state)
}
if requestedBy := strings.TrimSpace(action.ActionRequestedBy); requestedBy != "" {
parts = append(parts, "requested by "+requestedBy)
}
if policy := strings.TrimSpace(action.ActionApprovalPolicy); policy != "" {
parts = append(parts, "approval policy "+policy)
}
if planExpiresAt := strings.TrimSpace(action.ActionPlanExpiresAt); planExpiresAt != "" {
parts = append(parts, "plan expires "+planExpiresAt)
}
if fixID := strings.TrimSpace(action.FixID); fixID != "" {
parts = append(parts, "proposed fix "+fixID)
} else if description := strings.TrimSpace(action.Description); description != "" {
parts = append(parts, "proposed fix recorded")
}
if risk := strings.TrimSpace(action.RiskLevel); risk != "" {
parts = append(parts, "risk "+risk)
}
if action.Destructive {
parts = append(parts, "destructive true")
}
return strings.Join(parts, "; ")
}
func unifiedFindingHandoffActionPosture(action chat.HandoffAction) string {
parts := make([]string, 0, 8)
if approvalID := strings.TrimSpace(action.ApprovalID); approvalID != "" {
parts = append(parts, "approval "+approvalID)
}
if status := strings.TrimSpace(action.ApprovalStatus); status != "" {
parts = append(parts, "approval status "+status)
}
if requestedAt := strings.TrimSpace(action.ApprovalRequestedAt); requestedAt != "" {
parts = append(parts, "approval requested "+requestedAt)
}
if expiresAt := strings.TrimSpace(action.ApprovalExpiresAt); expiresAt != "" {
parts = append(parts, "approval expires "+expiresAt)
}
if decidedAt := strings.TrimSpace(action.ApprovalDecidedAt); decidedAt != "" {
parts = append(parts, "approval decided "+decidedAt)
}
if action.ApprovalConsumed {
parts = append(parts, "approval consumed true")
}
if actionID := strings.TrimSpace(action.ActionID); actionID != "" {
parts = append(parts, "action "+actionID)
}
if state := strings.TrimSpace(action.ActionState); state != "" {
parts = append(parts, "action state "+state)
}
if requestedBy := strings.TrimSpace(action.ActionRequestedBy); requestedBy != "" {
parts = append(parts, "requested by "+requestedBy)
}
if policy := strings.TrimSpace(action.ActionApprovalPolicy); policy != "" {
parts = append(parts, "approval policy "+policy)
}
if action.ActionRequiresApproval {
parts = append(parts, "action requires approval true")
}
if planExpiresAt := strings.TrimSpace(action.ActionPlanExpiresAt); planExpiresAt != "" {
parts = append(parts, "plan expires "+planExpiresAt)
}
if fixID := strings.TrimSpace(action.FixID); fixID != "" {
parts = append(parts, "proposed fix "+fixID)
} else if description := strings.TrimSpace(action.Description); description != "" {
parts = append(parts, "proposed fix recorded")
}
if risk := strings.TrimSpace(action.RiskLevel); risk != "" {
parts = append(parts, "risk "+risk)
}
if action.Destructive {
parts = append(parts, "destructive true")
}
return strings.Join(parts, "; ")
}
func unifiedFindingChatStatus(f *unified.UnifiedFinding, now time.Time) string {
if f == nil {
return ""
}
if f.ResolvedAt != nil {
return "resolved"
}
if f.SnoozedUntil != nil && (now.IsZero() || now.Before(*f.SnoozedUntil)) {
return "snoozed"
}
if f.Suppressed {
return "suppressed"
}
if strings.TrimSpace(f.DismissedReason) != "" {
return "dismissed"
}
return "active"
}
func appendUnifiedFindingLifecycleEventContext(b *strings.Builder, events []unified.UnifiedFindingLifecycleEvent) {
if len(events) == 0 {
return
}
start := 0
if len(events) > findingChatContextListLimit {
start = len(events) - findingChatContextListLimit
}
type formattedLifecycleEvent struct {
Label string
Summary string
}
formatted := make([]formattedLifecycleEvent, 0, findingChatContextListLimit)
for idx, event := range events[start:] {
summary := formatUnifiedFindingLifecycleEvent(event)
if summary == "" {
continue
}
formatted = append(formatted, formattedLifecycleEvent{
Label: fmt.Sprintf("Lifecycle Event %d", idx+1),
Summary: summary,
})
}
if len(formatted) == 0 {
return
}
appendChatContextLine(b, "", "")
appendChatContextLine(b, "[Finding Lifecycle Context]", "")
for _, event := range formatted {
appendChatContextLine(b, event.Label, event.Summary)
}
if start > 0 {
appendChatContextLine(b, "Lifecycle Additional Count", strconv.Itoa(start))
}
appendChatContextLine(b, "Lifecycle Boundary", "Finding lifecycle events are current Patrol review context only; they do not grant approval or execution authority.")
}
func formatUnifiedFindingLatestLifecycleEvent(events []unified.UnifiedFindingLifecycleEvent) string {
for idx := len(events) - 1; idx >= 0; idx-- {
if summary := formatUnifiedFindingLifecycleEvent(events[idx]); summary != "" {
return summary
}
}
return ""
}
func formatUnifiedFindingLifecycleEvent(event unified.UnifiedFindingLifecycleEvent) string {
parts := make([]string, 0, 4)
if !event.At.IsZero() {
parts = append(parts, event.At.Format(time.RFC3339))
}
eventType := strings.TrimSpace(event.Type)
if eventType != "" {
parts = append(parts, eventType)
}
message := strings.TrimSpace(event.Message)
if message != "" {
parts = append(parts, message)
}
from := strings.TrimSpace(event.From)
to := strings.TrimSpace(event.To)
if from != "" || to != "" {
parts = append(parts, from+" -> "+to)
}
return strings.Join(parts, " | ")
}
func appendInvestigationRecordChatContext(b *strings.Builder, rec *aicontracts.InvestigationRecord) {
if rec == nil {
return
}
appendChatContextLine(b, "", "")
appendChatContextLine(b, "[Investigation Record]", "")
appendChatContextLine(b, "Record ID", rec.ID)
appendChatContextLine(b, "Session ID", rec.SessionID)
appendChatContextLine(b, "Status", string(rec.Status))
appendChatContextLine(b, "Outcome", string(rec.Outcome))
appendChatContextLine(b, "Confidence", string(rec.Confidence))
appendChatContextLine(b, "Subject Resource", formatChatResource(rec.Subject.ResourceName, rec.Subject.ResourceType))
appendChatContextLine(b, "Subject Resource ID", rec.Subject.ResourceID)
appendChatContextLine(b, "Subject Node", rec.Subject.Node)
appendChatContextLine(b, "Conclusion", rec.Conclusion)
appendChatContextLine(b, "Recommended Action", rec.RecommendedAction)
appendChatContextLine(b, "Trigger", rec.Trigger.Title)
appendChatContextLine(b, "Trigger Description", rec.Trigger.Description)
if !rec.Trigger.DetectedAt.IsZero() {
appendChatContextLine(b, "Detected At", rec.Trigger.DetectedAt.Format(time.RFC3339))
}
if !rec.StartedAt.IsZero() {
appendChatContextLine(b, "Investigation Started At", rec.StartedAt.Format(time.RFC3339))
}
if rec.CompletedAt != nil {
appendChatContextLine(b, "Investigation Completed At", rec.CompletedAt.Format(time.RFC3339))
}
appendChatContextLine(b, "Approval ID", rec.ApprovalID)
appendChatContextLine(b, "Error", rec.Error)
appendInvestigationRecordEvidenceContext(b, rec.Evidence)
appendStringListChatContext(b, "Verification", rec.Verification)
appendStringListChatContext(b, "Tools Used", rec.ToolsUsed)
if rec.ProposedFix != nil {
appendChatContextLine(b, "Proposed Fix", rec.ProposedFix.Description)
appendChatContextLine(b, "Proposed Fix Risk", rec.ProposedFix.RiskLevel)
appendChatContextLine(b, "Proposed Fix Target Host", rec.ProposedFix.TargetHost)
appendChatContextLine(b, "Proposed Fix Rationale", rec.ProposedFix.Rationale)
if rec.ProposedFix.Destructive {
appendChatContextLine(b, "Proposed Fix Destructive", "true")
}
switch len(rec.ProposedFix.Commands) {
case 0:
case 1:
appendChatContextLine(b, "Proposed Fix Commands", "1 command recorded for approval context")
default:
appendChatContextLine(b, "Proposed Fix Commands", fmt.Sprintf("%d commands recorded for approval context", len(rec.ProposedFix.Commands)))
}
}
}
func buildUnifiedFindingHandoffResources(f *unified.UnifiedFinding, lookup unifiedFindingLookup) []chat.HandoffResource {
if f == nil {
return nil
}
resources := make([]chat.HandoffResource, 0, 2+findingChatContextListLimit)
add := func(resource chat.HandoffResource) {
resource.ID = strings.TrimSpace(resource.ID)
resource.Name = strings.TrimSpace(resource.Name)
resource.Type = strings.TrimSpace(resource.Type)
resource.Node = strings.TrimSpace(resource.Node)
if resource.ID == "" && resource.Name == "" {
return
}
key := strings.ToLower(resource.Type + "\x00" + resource.ID + "\x00" + resource.Name + "\x00" + resource.Node)
for _, existing := range resources {
existingKey := strings.ToLower(strings.TrimSpace(existing.Type) + "\x00" + strings.TrimSpace(existing.ID) + "\x00" + strings.TrimSpace(existing.Name) + "\x00" + strings.TrimSpace(existing.Node))
if existingKey == key {
return
}
}
resources = append(resources, resource)
}
add(chat.HandoffResource{
ID: f.ResourceID,
Name: f.ResourceName,
Type: f.ResourceType,
Node: f.Node,
})
if f.InvestigationRecord != nil {
add(chat.HandoffResource{
ID: f.InvestigationRecord.Subject.ResourceID,
Name: f.InvestigationRecord.Subject.ResourceName,
Type: f.InvestigationRecord.Subject.ResourceType,
Node: f.InvestigationRecord.Subject.Node,
})
}
for _, related := range resolveUnifiedFindingRelatedContext(f, lookup) {
relatedFinding := related.Finding
if relatedFinding == nil {
continue
}
add(chat.HandoffResource{
ID: relatedFinding.ResourceID,
Name: relatedFinding.ResourceName,
Type: relatedFinding.ResourceType,
Node: relatedFinding.Node,
})
if relatedFinding.InvestigationRecord != nil {
add(chat.HandoffResource{
ID: relatedFinding.InvestigationRecord.Subject.ResourceID,
Name: relatedFinding.InvestigationRecord.Subject.ResourceName,
Type: relatedFinding.InvestigationRecord.Subject.ResourceType,
Node: relatedFinding.InvestigationRecord.Subject.Node,
})
}
}
return resources
}
func buildUnifiedFindingHandoffActions(f *unified.UnifiedFinding, orgID string) []chat.HandoffAction {
if f == nil {
return nil
}
rec := f.InvestigationRecord
liveApproval := livePatrolApprovalForFinding(f.ID, orgID)
if rec == nil && liveApproval == nil {
return nil
}
action := chat.HandoffAction{
FindingID: f.ID,
TargetResourceID: f.ResourceID,
TargetResourceName: f.ResourceName,
TargetResourceType: f.ResourceType,
TargetNode: f.Node,
}
if rec != nil {
action.RecordID = rec.ID
action.ApprovalID = rec.ApprovalID
action.TargetResourceID = firstNonEmptyString(rec.Subject.ResourceID, action.TargetResourceID)
action.TargetResourceName = firstNonEmptyString(rec.Subject.ResourceName, action.TargetResourceName)
action.TargetResourceType = firstNonEmptyString(rec.Subject.ResourceType, action.TargetResourceType)
action.TargetNode = firstNonEmptyString(rec.Subject.Node, action.TargetNode)
}
if liveApproval != nil {
targetResourceIDBeforeApproval := strings.TrimSpace(action.TargetResourceID)
targetResourceTypeBeforeApproval := strings.TrimSpace(action.TargetResourceType)
chat.HydrateHandoffActionFromApproval(&action, liveApproval)
if liveTargetID := strings.TrimSpace(liveApproval.TargetID); liveTargetID != "" && strings.EqualFold(liveTargetID, strings.TrimSpace(f.ID)) {
if targetResourceIDBeforeApproval == "" && strings.EqualFold(strings.TrimSpace(action.TargetResourceID), liveTargetID) {
action.TargetResourceID = ""
}
if targetResourceTypeBeforeApproval == "" && strings.EqualFold(strings.TrimSpace(action.TargetResourceType), strings.TrimSpace(liveApproval.TargetType)) {
action.TargetResourceType = ""
}
}
}
if rec != nil && rec.ProposedFix != nil {
action.FixID = rec.ProposedFix.ID
action.Description = rec.ProposedFix.Description
if strings.TrimSpace(action.RiskLevel) == "" {
action.RiskLevel = rec.ProposedFix.RiskLevel
}
action.Destructive = rec.ProposedFix.Destructive
action.TargetHost = rec.ProposedFix.TargetHost
}
if strings.TrimSpace(action.ApprovalID) == "" &&
strings.TrimSpace(action.FixID) == "" &&
strings.TrimSpace(action.Description) == "" {
return nil
}
return []chat.HandoffAction{action}
}
func livePatrolApprovalForFinding(findingID, orgID string) *approval.ApprovalRequest {
findingID = strings.TrimSpace(findingID)
if findingID == "" {
return nil
}
store := approval.GetStore()
if store == nil {
return nil
}
var selected *approval.ApprovalRequest
for _, req := range store.GetPendingApprovalsForOrg(orgID) {
if req == nil {
continue
}
if !strings.EqualFold(strings.TrimSpace(req.ToolID), "investigation_fix") {
continue
}
if strings.TrimSpace(req.TargetID) != findingID {
continue
}
if selected == nil ||
req.RequestedAt.After(selected.RequestedAt) ||
(req.RequestedAt.Equal(selected.RequestedAt) && strings.TrimSpace(req.ID) > strings.TrimSpace(selected.ID)) {
selected = req
}
}
return selected
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}
func formatChatResource(name, resourceType string) string {
name = strings.TrimSpace(name)
resourceType = strings.TrimSpace(resourceType)
switch {
case name != "" && resourceType != "":
return fmt.Sprintf("%s (%s)", name, resourceType)
case name != "":
return name
case resourceType != "":
return resourceType
default:
return ""
}
}
func appendInvestigationRecordEvidenceContext(b *strings.Builder, evidence []aicontracts.InvestigationRecordEvidence) {
if len(evidence) == 0 {
return
}
count := 0
for _, item := range evidence {
if count >= findingChatContextListLimit {
break
}
parts := make([]string, 0, 3)
if strings.TrimSpace(item.Kind) != "" {
parts = append(parts, strings.TrimSpace(item.Kind))
}
if strings.TrimSpace(item.ID) != "" {
parts = append(parts, strings.TrimSpace(item.ID))
}
if strings.TrimSpace(item.Summary) != "" {
parts = append(parts, strings.TrimSpace(item.Summary))
}
if len(parts) == 0 {
continue
}
count++
appendChatContextLine(b, fmt.Sprintf("Evidence %d", count), strings.Join(parts, ": "))
}
if remaining := len(evidence) - count; remaining > 0 {
appendChatContextLine(b, "Evidence Additional Count", fmt.Sprintf("%d", remaining))
}
}
func appendStringListChatContext(b *strings.Builder, label string, values []string) {
count := 0
for _, value := range values {
normalized := strings.TrimSpace(value)
if normalized == "" {
continue
}
if count >= findingChatContextListLimit {
break
}
count++
appendChatContextLine(b, fmt.Sprintf("%s %d", label, count), normalized)
}
if remaining := len(values) - count; remaining > 0 {
appendChatContextLine(b, fmt.Sprintf("%s Additional Count", label), fmt.Sprintf("%d", remaining))
}
}
func nonEmptyStrings(values ...string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
out = append(out, normalized)
}
}
return out
}
func briefingLabelValue(label, value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
label = strings.TrimSpace(label)
if label == "" {
return value
}
return label + " " + value
}
func appendChatContextLine(b *strings.Builder, label string, value string) {
label = strings.TrimSpace(label)
value = strings.TrimSpace(value)
if label == "" && value == "" {
b.WriteByte('\n')
return
}
if label == "" {
b.WriteString(value)
b.WriteByte('\n')
return
}
if value == "" {
b.WriteString(label)
b.WriteByte('\n')
return
}
b.WriteString(label)
b.WriteString(": ")
b.WriteString(value)
b.WriteByte('\n')
}
// HandleChat handles POST /api/ai/chat - streaming chat
func (h *AIHandler) HandleChat(w http.ResponseWriter, r *http.Request) {
// CORS
if cfg := h.getConfig(r.Context()); cfg != nil {
applyConfiguredCORSHeaders(
w,
r.Header.Get("Origin"),
cfg.AllowedOrigins,
"POST, OPTIONS",
"Content-Type, Accept, Cookie",
)
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Auth already handled by RequireAuth wrapper - no need to check again
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
// Parse request
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
preview := req.Prompt
if len(preview) > 100 {
preview = preview[:100] + "..."
}
log.Info().
Str("sessionId", req.SessionID).
Str("prompt_preview", preview).
Msg("AIHandler: Received chat request")
// Set up SSE
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 timeouts
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Time{})
_ = rc.SetReadDeadline(time.Time{})
flusher.Flush()
// Keep assistant execution bound to the client request so disconnects cancel
// backend work instead of letting it continue until the hard timeout expires.
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute)
defer cancel()
// Heartbeat
heartbeatDone := make(chan struct{})
var clientDisconnected atomic.Bool
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
clientDisconnected.Store(true)
return
case <-ticker.C:
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
_, err := w.Write([]byte(": heartbeat\n\n"))
if err != nil {
clientDisconnected.Store(true)
cancel()
return
}
flusher.Flush()
case <-heartbeatDone:
return
}
}
}()
defer close(heartbeatDone)
// Write helper
writeEvent := func(event chat.StreamEvent) {
if clientDisconnected.Load() {
return
}
data, err := json.Marshal(event)
if err != nil {
return
}
_ = rc.SetWriteDeadline(time.Now().Add(10 * time.Second))
_, err = w.Write([]byte("data: " + string(data) + "\n\n"))
if err != nil {
clientDisconnected.Store(true)
cancel()
return
}
flusher.Flush()
}
// Convert API mentions to chat mentions
var chatMentions []chat.StructuredMention
for _, m := range req.Mentions {
mentionType := canonicalizeChatMentionType(m.Type)
if mentionType == "" {
log.Warn().
Str("mention_type", m.Type).
Str("mention_name", m.Name).
Msg("Ignoring unsupported chat mention type")
continue
}
chatMentions = append(chatMentions, chat.StructuredMention{
ID: m.ID,
Name: m.Name,
Type: mentionType,
Node: m.Node,
})
}
// Build model-only finding context when discussing a specific finding. The
// chat service injects this into the current model turn without persisting it
// as the user's authored prompt, so conversation history stays readable.
handoffContext := normalizeChatRequestHandoffContext(req.HandoffContext)
handoffResources := normalizeChatRequestHandoffResources(req.HandoffResources)
handoffActions := normalizeChatRequestHandoffActions(req.HandoffActions)
handoffMetadata := chat.NormalizeHandoffMetadata(req.HandoffMetadata)
requestHandoffContext := handoffContext
requestHandoffResources := handoffResources
requestHandoffActions := handoffActions
findingID := strings.TrimSpace(req.FindingID)
if findingID == "" && strings.TrimSpace(req.SessionID) != "" {
if storedFindingID, err := svc.GetModelHandoffFindingID(ctx, req.SessionID); err != nil {
log.Debug().Err(err).Str("session_id", req.SessionID).Msg("Unable to load stored Assistant finding handoff reference")
} else {
findingID = strings.TrimSpace(storedFindingID)
}
}
if findingID == "" && handoffMetadata == (chat.HandoffMetadata{}) && strings.TrimSpace(req.SessionID) != "" {
if storedMetadata, err := svc.GetModelHandoffMetadata(ctx, req.SessionID); err != nil {
log.Debug().Err(err).Str("session_id", req.SessionID).Msg("Unable to load stored Assistant handoff metadata")
} else {
handoffMetadata = chat.NormalizeHandoffMetadata(storedMetadata)
}
}
if findingID == "" && handoffMetadata.Kind == "patrol_run" {
handoffContext = ""
handoffResources = nil
handoffActions = nil
if run, ok := h.getPatrolRunForHandoff(ctx, handoffMetadata.RunID); ok {
runHandoff := airuntime.BuildPatrolRunAssistantHandoff(run)
handoffContext = runHandoff.Context
handoffResources = runHandoff.Resources
handoffMetadata = chat.NormalizeHandoffMetadata(runHandoff.Metadata)
}
}
if findingID != "" {
findingResolved := false
orgID := GetOrgID(ctx)
store := h.GetUnifiedStoreForOrg(orgID)
if store != nil {
if f := store.Get(findingID); f != nil {
findingResolved = true
handoffActions = mergeUnifiedFindingRequestHandoffActions(f, buildUnifiedFindingHandoffActions(f, orgID), requestHandoffActions)
handoffContext = mergeUnifiedFindingRequestHandoffContext(buildUnifiedFindingChatContext(f, store, handoffActions), requestHandoffContext, f.ID)
handoffResources = mergeUnifiedFindingRequestHandoffResources(f, buildUnifiedFindingHandoffResources(f, store), requestHandoffResources)
handoffMetadata = chat.HandoffMetadata{}
}
}
if !findingResolved {
if sessionID := strings.TrimSpace(req.SessionID); sessionID != "" {
if err := svc.ClearModelHandoffContext(ctx, sessionID); err != nil {
log.Debug().Err(err).Str("session_id", sessionID).Str("finding_id", findingID).Msg("Unable to clear stale Assistant finding handoff context")
}
}
findingID = ""
handoffContext = ""
handoffResources = nil
handoffActions = nil
handoffMetadata = chat.HandoffMetadata{}
}
}
// Stream from AI chat service
serviceSentDone := false
err := svc.ExecuteStream(ctx, chat.ExecuteRequest{
Prompt: req.Prompt,
SessionID: req.SessionID,
Model: req.Model,
Mentions: chatMentions,
FindingID: findingID,
HandoffContext: handoffContext,
HandoffResources: handoffResources,
HandoffActions: handoffActions,
HandoffMetadata: handoffMetadata,
AutonomousMode: chatAutonomousModeForFindingHandoff(req.AutonomousMode, findingID, handoffContext, handoffResources, handoffActions, handoffMetadata),
}, func(event chat.StreamEvent) {
if event.Type == "done" {
serviceSentDone = true
}
writeEvent(event)
})
if err != nil {
log.Error().Err(err).Msg("Chat stream error")
errData, _ := json.Marshal(chat.ErrorData{Message: "An error occurred while processing your request"})
writeEvent(chat.StreamEvent{Type: "error", Data: errData})
}
// Send done
if !serviceSentDone {
writeEvent(chat.StreamEvent{Type: "done", Data: nil})
}
}
// HandleSessions handles GET /api/ai/sessions - list sessions
func (h *AIHandler) HandleSessions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
sessions, err := svc.ListSessions(ctx)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
// Optional limit parameter (for relay proxy clients with body size constraints)
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit < len(sessions) {
sessions = sessions[:limit]
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(sessions)
}
// HandleCreateSession handles POST /api/ai/sessions - create session
func (h *AIHandler) HandleCreateSession(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
session, err := svc.CreateSession(ctx)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
// HandleDeleteSession handles DELETE /api/ai/sessions/{id}
func (h *AIHandler) HandleDeleteSession(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
if err := svc.DeleteSession(ctx, sessionID); err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleMessages handles GET /api/ai/sessions/{id}/messages
func (h *AIHandler) HandleMessages(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
messages, err := svc.GetMessages(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
// Optional limit parameter — returns the LAST N messages (most recent).
// Used by relay proxy clients with body size constraints.
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit < len(messages) {
messages = messages[len(messages)-limit:]
}
}
for i := range messages {
messages[i] = messages[i].NormalizeCollections()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
// HandleAbort handles POST /api/ai/sessions/{id}/abort
func (h *AIHandler) HandleAbort(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
if err := svc.AbortSession(ctx, sessionID); err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleStatus handles GET /api/ai/status
func (h *AIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
status := map[string]interface{}{
"running": h.IsRunning(r.Context()),
"engine": "direct",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// HandleSummarize handles POST /api/ai/sessions/{id}/summarize
// Compresses context when nearing model limits
func (h *AIHandler) HandleSummarize(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
result, err := svc.SummarizeSession(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// HandleDiff handles GET /api/ai/sessions/{id}/diff
// Returns file changes made during the session
func (h *AIHandler) HandleDiff(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
diff, err := svc.GetSessionDiff(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(diff)
}
// HandleFork handles POST /api/ai/sessions/{id}/fork
// Creates a branch point in the conversation
func (h *AIHandler) HandleFork(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
session, err := svc.ForkSession(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(session)
}
// HandleRevert handles POST /api/ai/sessions/{id}/revert
// Reverts file changes from the session
func (h *AIHandler) HandleRevert(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
result, err := svc.RevertSession(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// HandleUnrevert handles POST /api/ai/sessions/{id}/unrevert
// Restores previously reverted changes
func (h *AIHandler) HandleUnrevert(w http.ResponseWriter, r *http.Request, sessionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
result, err := svc.UnrevertSession(ctx, sessionID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
// AnswerQuestionRequest represents a request to answer a question
type AnswerQuestionRequest struct {
Answers []struct {
ID string `json:"id"`
Value string `json:"value"`
} `json:"answers"`
}
// HandleAnswerQuestion handles POST /api/ai/question/{questionID}/answer
func (h *AIHandler) HandleAnswerQuestion(w http.ResponseWriter, r *http.Request, questionID string) {
ctx := r.Context()
if !h.IsRunning(ctx) {
http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable)
return
}
var req AnswerQuestionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Convert to chat.QuestionAnswer
answers := make([]chat.QuestionAnswer, len(req.Answers))
for i, a := range req.Answers {
answers[i] = chat.QuestionAnswer{
ID: a.ID,
Value: a.Value,
}
}
log.Info().
Str("questionID", questionID).
Int("answers_count", len(answers)).
Msg("AIHandler: Received answer to question")
svc := h.GetService(ctx)
if svc == nil {
http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable)
return
}
if err := svc.AnswerQuestion(ctx, questionID, answers); err != nil {
log.Error().Err(err).Str("questionID", questionID).Msg("Failed to answer question")
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// SetAlertProvider sets the alert provider for MCP tools
func (h *AIHandler) SetAlertProvider(provider chat.MCPAlertProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetAlertProvider(provider)
}
}
// SetFindingsProvider sets the findings provider for MCP tools
func (h *AIHandler) SetFindingsProvider(provider chat.MCPFindingsProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetFindingsProvider(provider)
}
}
// SetBaselineProvider sets the baseline provider for MCP tools
func (h *AIHandler) SetBaselineProvider(provider chat.MCPBaselineProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetBaselineProvider(provider)
}
}
// SetPatternProvider sets the pattern provider for MCP tools
func (h *AIHandler) SetPatternProvider(provider chat.MCPPatternProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetPatternProvider(provider)
}
}
// SetMetricsHistory sets the metrics history provider for MCP tools
func (h *AIHandler) SetMetricsHistory(provider chat.MCPMetricsHistoryProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetMetricsHistory(provider)
}
}
// SetAgentProfileManager sets the agent profile manager for MCP tools
func (h *AIHandler) SetAgentProfileManager(manager chat.AgentProfileManager) {
if svc := h.getDefaultService(); svc != nil {
svc.SetAgentProfileManager(manager)
}
}
// SetGuestConfigProvider sets the guest config provider for MCP tools
func (h *AIHandler) SetGuestConfigProvider(provider chat.MCPGuestConfigProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetGuestConfigProvider(provider)
}
}
// SetAppContainerConfigProvider sets the native app-container config provider for MCP tools
func (h *AIHandler) SetAppContainerConfigProvider(provider chat.MCPAppContainerConfigProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetAppContainerConfigProvider(provider)
}
}
// SetBackupProvider sets the backup provider for MCP tools
func (h *AIHandler) SetBackupProvider(provider chat.MCPBackupProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetBackupProvider(provider)
}
}
// SetDiskHealthProvider sets the disk health provider for MCP tools
func (h *AIHandler) SetDiskHealthProvider(provider chat.MCPDiskHealthProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetDiskHealthProvider(provider)
}
}
// SetUpdatesProvider sets the updates provider for MCP tools
func (h *AIHandler) SetUpdatesProvider(provider chat.MCPUpdatesProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetUpdatesProvider(provider)
}
}
// SetFindingsManager sets the findings manager for MCP tools
func (h *AIHandler) SetFindingsManager(manager chat.FindingsManager) {
if svc := h.getDefaultService(); svc != nil {
svc.SetFindingsManager(manager)
}
}
// SetMetadataUpdater sets the metadata updater for MCP tools
func (h *AIHandler) SetMetadataUpdater(updater chat.MetadataUpdater) {
if svc := h.getDefaultService(); svc != nil {
svc.SetMetadataUpdater(updater)
}
}
// SetUnifiedResourceProvider sets the unified resource provider for MCP tools
func (h *AIHandler) SetUnifiedResourceProvider(provider chat.MCPUnifiedResourceProvider) {
if svc := h.getDefaultService(); svc != nil {
svc.SetUnifiedResourceProvider(provider)
}
}
// UpdateControlSettings updates control settings in the service
func (h *AIHandler) UpdateControlSettings(cfg *config.AIConfig) {
if svc := h.getDefaultService(); svc != nil {
svc.UpdateControlSettings(cfg)
}
}