diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index c397c9109..14e3a816b 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/internal/ai/infradiscovery/service.go b/internal/ai/infradiscovery/service.go index c0cf701d1..067e6680a 100644 --- a/internal/ai/infradiscovery/service.go +++ b/internal/ai/infradiscovery/service.go @@ -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 diff --git a/internal/ai/infradiscovery/service_additional_test.go b/internal/ai/infradiscovery/service_additional_test.go index 309b9f3ba..0e9962a0f 100644 --- a/internal/ai/infradiscovery/service_additional_test.go +++ b/internal/ai/infradiscovery/service_additional_test.go @@ -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 { diff --git a/internal/ai/service.go b/internal/ai/service.go index 0f7632bd1..cbf14ac71 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -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 diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index 9cf4d5d36..64ab0deed 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -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