mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user