Align AI discovery lifecycle with discovery settings

Refs #1425
This commit is contained in:
rcourtman
2026-04-21 16:06:49 +01:00
parent fd5c14b049
commit a170692683
5 changed files with 328 additions and 138 deletions
@@ -78,19 +78,20 @@ runtime cost control, and shared AI transport surfaces.
1. Update this contract when canonical AI runtime or transport entry points move
2. Keep AI runtime and shared API proof routing aligned in `registry.json`
3. Preserve explicit coverage for chat, Patrol, remediation, and cost-control behavior when AI runtime changes
4. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider
5. Keep AI resource and incident context aligned with the canonical unified-resource timeline before falling back to patrol-local change detectors
6. Keep platform assistant read/control claims aligned with
4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops
5. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider
6. Keep AI resource and incident context aligned with the canonical unified-resource timeline before falling back to patrol-local change detectors
7. Keep platform assistant read/control claims aligned with
`docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md`. New
platform-native reads or writes must extend the shared Assistant tool
contracts, and read-only or augmentation-only platforms must stay explicit
there instead of drifting into provider-local tools.
7. Keep self-hosted Patrol quickstart messaging aligned with backend runtime
8. Keep self-hosted Patrol quickstart messaging aligned with backend runtime
truth: the governed quickstart contract is Patrol-only first-run
acceleration on activated or trial-backed installs with server-authoritative
run inventory, not a general hosted chat entitlement or a replacement for
BYOK once Patrol leaves the quickstart path.
8. Keep discovery-analysis prompt bounds and response budgets aligned across
9. Keep discovery-analysis prompt bounds and response budgets aligned across
`internal/ai/service.go` and the shared service-discovery prompt builders:
the runtime must reserve enough output tokens for structured discovery JSON,
and discovery prompts must cap fact/path/port fan-out explicitly instead of
+34 -1
View File
@@ -87,6 +87,7 @@ type Service struct {
mu sync.RWMutex
lastRun time.Time
interval time.Duration
intervalCh chan time.Duration
stopCh chan struct{}
lifecycleCtx context.Context
lifecycleStop context.CancelFunc
@@ -210,6 +211,7 @@ func NewService(knowledgeStore *knowledge.Store, cfg Config) *Service {
return &Service{
knowledgeStore: knowledgeStore,
interval: cfg.Interval,
intervalCh: make(chan time.Duration, 1),
cacheExpiry: cfg.CacheExpiry,
aiAnalysisTimeout: cfg.AIAnalysisTimeout,
stopCh: make(chan struct{}),
@@ -311,6 +313,30 @@ func (s *Service) Start(ctx context.Context) {
}()
}
// SetInterval updates the discovery interval. Takes effect immediately if the
// service is already running.
func (s *Service) SetInterval(interval time.Duration) {
cfg := normalizeConfig(Config{
Interval: interval,
CacheExpiry: s.cacheExpiry,
AIAnalysisTimeout: s.aiAnalysisTimeout,
})
s.mu.Lock()
s.interval = cfg.Interval
running := s.running
s.mu.Unlock()
if running {
select {
case s.intervalCh <- cfg.Interval:
log.Info().Dur("interval", cfg.Interval).Msg("Infrastructure discovery interval updated (live)")
default:
log.Debug().Dur("interval", cfg.Interval).Msg("Infrastructure discovery interval updated (pending)")
}
}
}
// Stop stops the background discovery service.
func (s *Service) Stop() {
s.mu.Lock()
@@ -366,13 +392,20 @@ func (s *Service) discoveryLoop(ctx context.Context) {
s.mu.Unlock()
}()
ticker := time.NewTicker(s.interval)
s.mu.RLock()
currentInterval := s.interval
s.mu.RUnlock()
ticker := time.NewTicker(currentInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.RunDiscovery(ctx)
case newInterval := <-s.intervalCh:
ticker.Stop()
ticker = time.NewTicker(newInterval)
log.Info().Dur("interval", newInterval).Msg("Infrastructure discovery interval reset")
case <-s.stopCh:
log.Info().Msg("Stopping infrastructure discovery service")
return
@@ -100,6 +100,30 @@ func TestForceRefreshUpdatesLastRun(t *testing.T) {
})
}
func TestSetIntervalUpdatesStatusSnapshot(t *testing.T) {
service := NewService(nil, Config{
Interval: 10 * time.Millisecond,
CacheExpiry: time.Millisecond,
})
service.SetReadState(&mockReadState{})
service.SetAIAnalyzer(&mockAIAnalyzer{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service.Start(ctx)
defer service.Stop()
waitFor(t, 500*time.Millisecond, func() bool {
return service.GetStatusSnapshot().Running
})
service.SetInterval(2 * time.Second)
status := service.GetStatusSnapshot()
if status.Interval != 2*time.Second {
t.Fatalf("expected interval 2s after update, got %v", status.Interval)
}
}
func TestSaveDiscoveriesWritesKnowledge(t *testing.T) {
store, err := knowledge.NewStore(t.TempDir())
if err != nil {
+177 -132
View File
@@ -201,7 +201,6 @@ type Service struct {
// Infrastructure discovery service - detects apps running on hosts
infraDiscoveryService *infradiscovery.Service
infraDiscoveryCancel context.CancelFunc
// AI-powered deep discovery store - detailed service analysis with commands
discoveryStore *servicediscovery.Store
@@ -468,10 +467,11 @@ func (s *Service) CheckBudget(useCase string) error {
// This is forwarded to PatrolService and DiscoveryService when available.
func (s *Service) SetReadState(rs unifiedresources.ReadState) {
s.mu.Lock()
defer s.mu.Unlock()
s.readState = rs
cfg := s.cfg
s.initPatrolServiceLocked()
s.initInfraDiscoveryServiceLocked()
if s.patrolService != nil {
s.patrolService.SetReadState(rs)
@@ -487,6 +487,9 @@ func (s *Service) SetReadState(rs unifiedresources.ReadState) {
// and SetReadState may be called after SetStateProvider (which sets up
// the discoveryStore). Try to create the service if both are now available.
s.initDiscoveryServiceLocked()
s.mu.Unlock()
s.updateInfraDiscoverySettings(cfg)
}
// initPatrolServiceLocked creates the patrol service once any canonical patrol
@@ -520,6 +523,31 @@ func (s *Service) initPatrolServiceLocked() {
}
}
// initInfraDiscoveryServiceLocked creates the lightweight infrastructure
// discovery service when the canonical runtime dependencies are available.
// Must be called while holding s.mu.
func (s *Service) initInfraDiscoveryServiceLocked() {
if s.infraDiscoveryService != nil || s.knowledgeStore == nil {
return
}
discoveryCfg := infradiscovery.DefaultConfig()
if s.cfg != nil {
if interval := s.cfg.GetDiscoveryInterval(); interval > 0 {
discoveryCfg.Interval = interval
}
}
s.infraDiscoveryService = infradiscovery.NewService(
s.knowledgeStore,
discoveryCfg,
)
s.infraDiscoveryService.SetAIAnalyzer(s)
if s.readState != nil {
s.infraDiscoveryService.SetReadState(s.readState)
}
}
// initDiscoveryServiceLocked creates the deep discovery service if all
// dependencies are available. Must be called while holding s.mu.
func (s *Service) initDiscoveryServiceLocked() {
@@ -564,39 +592,18 @@ func (s *Service) initDiscoveryServiceLocked() {
// SetStateProvider sets the state provider for infrastructure context
func (s *Service) SetStateProvider(sp StateProvider) {
s.mu.Lock()
defer s.mu.Unlock()
s.stateProvider = sp
cfg := s.cfg
s.initPatrolServiceLocked()
if s.patrolService != nil {
s.patrolService.SetStateProvider(sp)
}
// Initialize infrastructure discovery service if not already done
// This uses AI to detect applications running in Docker containers
// and saves discoveries to the knowledge store for Patrol to use when proposing commands
if s.infraDiscoveryService == nil && s.knowledgeStore != nil {
s.infraDiscoveryService = infradiscovery.NewService(
s.knowledgeStore,
infradiscovery.DefaultConfig(),
)
// Wire the AI service as the analyzer (implements infradiscovery.AIAnalyzer)
s.infraDiscoveryService.SetAIAnalyzer(s)
// Forward unified ReadState if already configured.
if s.readState != nil {
s.infraDiscoveryService.SetReadState(s.readState)
}
// Only start if AI is enabled
if s.cfg != nil && s.cfg.Enabled {
ctx, cancel := context.WithCancel(context.Background())
s.infraDiscoveryCancel = cancel
s.infraDiscoveryService.Start(ctx)
log.Info().Msg("AI-powered infrastructure discovery service started")
} else {
log.Info().Msg("AI-powered infrastructure discovery initialized (stopped - AI disabled)")
}
}
// Initialize infrastructure discovery service if not already done.
// This uses AI to detect applications running in Docker containers and is
// governed by the canonical discovery settings in AIConfig.
s.initInfraDiscoveryServiceLocked()
// Attempt lazy init — discovery service requires ReadState + discoveryStore.
s.initDiscoveryServiceLocked()
@@ -609,6 +616,9 @@ func (s *Service) SetStateProvider(sp StateProvider) {
}
s.alertTriggeredAnalyzer = s.alertAnalyzerFactory(deps)
}
s.mu.Unlock()
s.updateInfraDiscoverySettings(cfg)
}
// GetStateProvider returns the state provider for infrastructure context
@@ -821,6 +831,31 @@ func (s *Service) GetDiscoveryService() *servicediscovery.Service {
return s.discoveryService
}
// updateInfraDiscoverySettings updates the lightweight infrastructure discovery
// service based on config changes.
func (s *Service) updateInfraDiscoverySettings(cfg *config.AIConfig) {
if s.infraDiscoveryService == nil || cfg == nil {
return
}
enabled := s.IsEnabled() && cfg.IsDiscoveryEnabled()
interval := cfg.GetDiscoveryInterval()
if enabled && interval > 0 {
s.infraDiscoveryService.SetInterval(interval)
s.infraDiscoveryService.Start(context.Background())
log.Info().
Bool("enabled", enabled).
Dur("interval", interval).
Msg("Infrastructure discovery service updated: automatic scanning enabled")
} else {
s.infraDiscoveryService.Stop()
log.Info().
Bool("enabled", enabled).
Msg("Infrastructure discovery service updated: manual mode (background scanning stopped)")
}
}
// updateDiscoverySettings updates the discovery service based on config changes
// Note: caller must NOT hold s.mu lock
func (s *Service) updateDiscoverySettings(cfg *config.AIConfig) {
@@ -828,7 +863,7 @@ func (s *Service) updateDiscoverySettings(cfg *config.AIConfig) {
return
}
enabled := cfg.IsDiscoveryEnabled()
enabled := s.IsEnabled() && cfg.IsDiscoveryEnabled()
interval := cfg.GetDiscoveryInterval()
if enabled && interval > 0 {
@@ -1034,6 +1069,8 @@ func (s *Service) Stop() {
s.mu.Lock()
store := s.resourceExportStore
incidentStore := s.incidentStore
infraDiscovery := s.infraDiscoveryService
discoveryService := s.discoveryService
s.resourceExportStore = nil
s.resourceExportStoreOrgID = ""
defer s.mu.Unlock()
@@ -1042,12 +1079,11 @@ func (s *Service) Stop() {
incidentStore.SetResourceTimelineStore(nil)
}
if s.infraDiscoveryCancel != nil {
s.infraDiscoveryCancel()
s.infraDiscoveryCancel = nil
if infraDiscovery != nil {
infraDiscovery.Stop()
}
if s.discoveryService != nil {
s.discoveryService.Stop()
if discoveryService != nil {
discoveryService.Stop()
}
if store != nil {
@@ -1476,139 +1512,148 @@ func approvalNeededFromToolCall(req ExecuteRequest, tc providers.ToolCall, resul
// LoadConfig loads the AI configuration and initializes the provider
func (s *Service) LoadConfig() error {
s.mu.Lock()
defer s.mu.Unlock()
s.mu.RLock()
persistence := s.persistence
quickstartCredits := s.quickstartCredits
orgID := s.orgID
s.mu.RUnlock()
if s.persistence == nil {
if persistence == nil {
s.mu.Lock()
s.provider = nil
s.cfg = nil
s.usingQuickstart = false
s.quickstartBlockedReason = ""
s.mu.Unlock()
return fmt.Errorf("Pulse Assistant config persistence unavailable")
}
cfg, err := s.persistence.LoadAIConfig()
cfg, err := persistence.LoadAIConfig()
if err != nil {
return fmt.Errorf("failed to load Pulse Assistant config: %w", err)
}
s.cfg = cfg
s.usingQuickstart = false
s.quickstartBlockedReason = ""
modelResolutionCtx := context.Background()
var providerClient providers.Provider
usingQuickstart := false
blockedReason := ""
// Don't initialize provider if AI is not enabled or not configured
if cfg == nil || !cfg.Enabled || !cfg.IsConfigured() {
// Check if quickstart can fill the Patrol gap (enabled but no BYOK).
if cfg != nil && cfg.Enabled && s.quickstartCredits != nil {
if err := s.quickstartCredits.EnsureBootstrap(context.Background()); err != nil {
s.quickstartBlockedReason = quickstartBlockedReasonFromError(err)
log.Warn().Err(err).Str("orgID", s.orgID).Msg("Quickstart bootstrap failed during AI service load")
if cfg != nil && cfg.Enabled && quickstartCredits != nil {
if err := quickstartCredits.EnsureBootstrap(context.Background()); err != nil {
blockedReason = quickstartBlockedReasonFromError(err)
log.Warn().Err(err).Str("orgID", orgID).Msg("Quickstart bootstrap failed during AI service load")
}
qp := s.quickstartCredits.GetProvider()
qp := quickstartCredits.GetProvider()
if qp != nil {
s.provider = qp
s.usingQuickstart = true
providerClient = qp
usingQuickstart = true
// Force all model strings to quickstart so chat.Service creates the right provider.
quickstartModelStr := config.DefaultModelForProvider(config.AIProviderQuickstart)
cfg.Model = quickstartModelStr
cfg.PatrolModel = quickstartModelStr
cfg.ChatModel = quickstartModelStr
log.Info().
Int("credits_remaining", s.quickstartCredits.CreditsRemaining()).
Int("credits_remaining", quickstartCredits.CreditsRemaining()).
Msg("AI service initialized via quickstart credits (no BYOK)")
return nil
}
if s.quickstartBlockedReason == "" && !s.quickstartCredits.HasCredits() {
s.quickstartBlockedReason = patrolQuickstartCreditsExhaustedReason
} else if s.quickstartBlockedReason == "" {
s.quickstartBlockedReason = patrolQuickstartUnavailableReason
if providerClient == nil && blockedReason == "" && !quickstartCredits.HasCredits() {
blockedReason = patrolQuickstartCreditsExhaustedReason
} else if providerClient == nil && blockedReason == "" {
blockedReason = patrolQuickstartUnavailableReason
}
}
s.provider = nil
return nil
}
selectedModel, err := ResolveConfiguredModel(modelResolutionCtx, cfg)
if err != nil {
log.Warn().Err(err).Str("orgID", s.orgID).Msg("AI enabled but no effective provider model could be resolved")
s.provider = nil
return nil
}
cfg.Model = selectedModel
selectedProvider, _ := config.ParseModelString(selectedModel)
// BYOK transition: if the user added their own API key while the model
// was still set to quickstart, switch to the BYOK provider's default.
if selectedProvider == config.AIProviderQuickstart && cfg.IsConfigured() {
var byokDefault string
configuredProviders := cfg.GetConfiguredProviders()
if len(configuredProviders) > 0 {
byokDefault, _ = ResolveConfiguredProviderModel(modelResolutionCtx, cfg, configuredProviders[0])
}
if byokDefault != "" {
log.Info().
Str("from", selectedModel).
Str("to", byokDefault).
Msg("AI service: BYOK configured, switching from quickstart model")
selectedModel = byokDefault
selectedProvider, _ = config.ParseModelString(selectedModel)
} else {
selectedModel, resolveErr := ResolveConfiguredModel(modelResolutionCtx, cfg)
if resolveErr != nil {
log.Warn().Err(resolveErr).Str("orgID", orgID).Msg("AI enabled but no effective provider model could be resolved")
} else {
cfg.Model = selectedModel
cfg.PatrolModel = "" // Let it inherit from Model
cfg.ChatModel = "" // Let it inherit from Model
}
}
selectedProvider, _ := config.ParseModelString(selectedModel)
providerClient, err := providers.NewForModel(cfg, selectedModel)
if err != nil {
// Smart fallback: if selected provider isn't configured but OTHER providers are,
// automatically switch to a model from a configured provider.
// This prevents confusing errors when the user has e.g. DeepSeek configured
// but the model is still set to an Anthropic model.
configuredProviders := cfg.GetConfiguredProviders()
if len(configuredProviders) > 0 {
fallbackProvider := configuredProviders[0]
fallbackModel, _ := ResolveConfiguredProviderModel(modelResolutionCtx, cfg, fallbackProvider)
if fallbackModel != "" {
log.Warn().
Str("selected_model", selectedModel).
Str("selected_provider", selectedProvider).
Str("fallback_model", fallbackModel).
Str("fallback_provider", fallbackProvider).
Msg("Selected provider not configured - automatically falling back to configured provider")
providerClient, err = providers.NewForModel(cfg, fallbackModel)
if err == nil {
selectedModel = fallbackModel
selectedProvider = fallbackProvider
} else {
log.Error().Err(err).Str("fallback_model", fallbackModel).Msg("failed to create fallback provider")
s.provider = nil
return nil
// BYOK transition: if the user added their own API key while the model
// was still set to quickstart, switch to the BYOK provider's default.
if selectedProvider == config.AIProviderQuickstart && cfg.IsConfigured() {
var byokDefault string
configuredProviders := cfg.GetConfiguredProviders()
if len(configuredProviders) > 0 {
byokDefault, _ = ResolveConfiguredProviderModel(modelResolutionCtx, cfg, configuredProviders[0])
}
if byokDefault != "" {
log.Info().
Str("from", selectedModel).
Str("to", byokDefault).
Msg("AI service: BYOK configured, switching from quickstart model")
selectedModel = byokDefault
selectedProvider, _ = config.ParseModelString(selectedModel)
cfg.Model = selectedModel
cfg.PatrolModel = "" // Let it inherit from Model
cfg.ChatModel = "" // Let it inherit from Model
}
}
}
if providerClient == nil {
log.Warn().
Err(err).
Str("selected_model", selectedModel).
Str("selected_provider", selectedProvider).
Strs("configured_providers", cfg.GetConfiguredProviders()).
Msg("AI enabled but no providers configured")
s.provider = nil
return nil
nextProvider, providerErr := providers.NewForModel(cfg, selectedModel)
if providerErr != nil {
// Smart fallback: if selected provider isn't configured but OTHER providers are,
// automatically switch to a model from a configured provider.
// This prevents confusing errors when the user has e.g. DeepSeek configured
// but the model is still set to an Anthropic model.
configuredProviders := cfg.GetConfiguredProviders()
if len(configuredProviders) > 0 {
fallbackProvider := configuredProviders[0]
fallbackModel, _ := ResolveConfiguredProviderModel(modelResolutionCtx, cfg, fallbackProvider)
if fallbackModel != "" {
log.Warn().
Str("selected_model", selectedModel).
Str("selected_provider", selectedProvider).
Str("fallback_model", fallbackModel).
Str("fallback_provider", fallbackProvider).
Msg("Selected provider not configured - automatically falling back to configured provider")
nextProvider, providerErr = providers.NewForModel(cfg, fallbackModel)
if providerErr == nil {
selectedModel = fallbackModel
selectedProvider = fallbackProvider
} else {
log.Error().Err(providerErr).Str("fallback_model", fallbackModel).Msg("failed to create fallback provider")
}
}
}
if nextProvider == nil {
log.Warn().
Err(providerErr).
Str("selected_model", selectedModel).
Str("selected_provider", selectedProvider).
Strs("configured_providers", cfg.GetConfiguredProviders()).
Msg("AI enabled but no providers configured")
}
}
providerClient = nextProvider
if providerClient != nil {
log.Info().
Str("provider", selectedProvider).
Str("model", selectedModel).
Str("control_level", cfg.GetControlLevel()).
Bool("autonomous", cfg.IsAutonomous()).
Msg("AI service initialized")
}
}
}
s.mu.Lock()
s.cfg = cfg
s.provider = providerClient
log.Info().
Str("provider", selectedProvider).
Str("model", selectedModel).
Str("control_level", cfg.GetControlLevel()).
Bool("autonomous", cfg.IsAutonomous()).
Msg("AI service initialized")
s.usingQuickstart = usingQuickstart
s.quickstartBlockedReason = blockedReason
s.initInfraDiscoveryServiceLocked()
s.initDiscoveryServiceLocked()
s.mu.Unlock()
// Update discovery service settings based on config
s.updateInfraDiscoverySettings(cfg)
s.updateDiscoverySettings(cfg)
return nil
+87
View File
@@ -172,6 +172,93 @@ func TestService_SetStateProvider(t *testing.T) {
}
}
func TestService_LoadConfig_SyncsInfraDiscoveryLifecycle(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "pulse-infra-discovery-lifecycle-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
persistence := config.NewConfigPersistence(tmpDir)
svc := NewService(persistence, nil)
defer svc.Stop()
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{})
svc.SetReadState(registry)
saveConfig := func(cfg config.AIConfig) {
t.Helper()
if err := persistence.SaveAIConfig(cfg); err != nil {
t.Fatalf("save ai config: %v", err)
}
if err := svc.LoadConfig(); err != nil {
t.Fatalf("LoadConfig(): %v", err)
}
if svc.infraDiscoveryService == nil {
t.Fatal("expected infra discovery service to be initialized")
}
}
saveConfig(config.AIConfig{
Enabled: true,
Model: "ollama:llama3.2",
OllamaBaseURL: "http://localhost:11434",
DiscoveryEnabled: false,
DiscoveryIntervalHours: 0,
})
status := svc.infraDiscoveryService.GetStatusSnapshot()
if status.Running {
t.Fatalf("expected infra discovery to stay stopped when discovery is disabled")
}
saveConfig(config.AIConfig{
Enabled: true,
Model: "ollama:llama3.2",
OllamaBaseURL: "http://localhost:11434",
DiscoveryEnabled: true,
DiscoveryIntervalHours: 2,
})
status = svc.infraDiscoveryService.GetStatusSnapshot()
if !status.Running {
t.Fatalf("expected infra discovery to start when discovery is enabled")
}
if status.Interval != 2*time.Hour {
t.Fatalf("expected infra discovery interval 2h, got %v", status.Interval)
}
saveConfig(config.AIConfig{
Enabled: true,
Model: "ollama:llama3.2",
OllamaBaseURL: "http://localhost:11434",
DiscoveryEnabled: true,
DiscoveryIntervalHours: 4,
})
status = svc.infraDiscoveryService.GetStatusSnapshot()
if !status.Running {
t.Fatalf("expected infra discovery to remain running after interval update")
}
if status.Interval != 4*time.Hour {
t.Fatalf("expected infra discovery interval 4h after reload, got %v", status.Interval)
}
saveConfig(config.AIConfig{
Enabled: true,
Model: "ollama:llama3.2",
OllamaBaseURL: "http://localhost:11434",
DiscoveryEnabled: true,
DiscoveryIntervalHours: 0,
})
status = svc.infraDiscoveryService.GetStatusSnapshot()
if status.Running {
t.Fatalf("expected infra discovery to stop in manual discovery mode")
}
}
func TestService_GetCostSummary_NoStore(t *testing.T) {
svc := NewService(nil, nil)
svc.costStore = nil