diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index c7cfab9960..e0c0019990 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -223,6 +223,13 @@ before any API-only token fallback or optional-auth anonymous fallback so operators can mint relay-mobile credentials and continue onboarding from the hosted runtime itself even after that tenant has already minted managed API tokens. +That same lifecycle-adjacent hosted setup path also depends on AI bootstrap +staying canonical before the first settings write. Hosted operators may land +in Chat, Patrol-backed setup hints, or AI-dependent remediation surfaces +before anyone has visited AI Settings, so the shared `internal/api/` +hosted-AI bootstrap helper must persist the machine-owned quickstart-backed +`ai.enc` for entitled tenants instead of leaving lifecycle-adjacent hosted +flows in a fake `AI disabled` state that disappears only after a manual save. That same lifecycle-adjacent hosted entitlement path must also preserve the trial quickstart grant fields already seeded into billing state. Hosted setup and pairing may depend on the shared `internal/api/` entitlement refresh, but diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 0620cbf389..ee8e77576f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -9,7 +9,7 @@ "contract_file": "docs/release-control/v6/internal/subsystems/ai-runtime.md", "status_file": "docs/release-control/v6/internal/status.json", "registry_file": "docs/release-control/v6/internal/subsystems/registry.json", - "dependency_subsystem_ids": ["frontend-primitives"] + "dependency_subsystem_ids": ["api-contracts", "frontend-primitives"] } ``` @@ -23,19 +23,20 @@ runtime cost control, and shared AI transport surfaces. 1. `internal/ai/` 2. `internal/api/ai_handler.go` 3. `internal/api/ai_handlers.go` -4. `internal/api/ai_intelligence_handlers.go` -5. `frontend-modern/src/api/ai.ts` -6. `frontend-modern/src/api/patrol.ts` -7. `frontend-modern/src/components/AI/AICostDashboard.tsx` -8. `frontend-modern/src/components/AI/Chat/` -9. `frontend-modern/src/utils/aiChatPresentation.ts` -10. `frontend-modern/src/utils/aiControlLevelPresentation.ts` -11. `frontend-modern/src/utils/aiCostPresentation.ts` -12. `frontend-modern/src/utils/aiExplorePresentation.ts` -13. `frontend-modern/src/utils/aiProviderHealthPresentation.ts` -14. `frontend-modern/src/utils/aiProviderPresentation.ts` -15. `frontend-modern/src/utils/aiSessionDiffPresentation.ts` -16. `frontend-modern/src/utils/textPresentation.ts` +4. `internal/api/ai_hosted_runtime.go` +5. `internal/api/ai_intelligence_handlers.go` +6. `frontend-modern/src/api/ai.ts` +7. `frontend-modern/src/api/patrol.ts` +8. `frontend-modern/src/components/AI/AICostDashboard.tsx` +9. `frontend-modern/src/components/AI/Chat/` +10. `frontend-modern/src/utils/aiChatPresentation.ts` +11. `frontend-modern/src/utils/aiControlLevelPresentation.ts` +12. `frontend-modern/src/utils/aiCostPresentation.ts` +13. `frontend-modern/src/utils/aiExplorePresentation.ts` +14. `frontend-modern/src/utils/aiProviderHealthPresentation.ts` +15. `frontend-modern/src/utils/aiProviderPresentation.ts` +16. `frontend-modern/src/utils/aiSessionDiffPresentation.ts` +17. `frontend-modern/src/utils/textPresentation.ts` ## Shared Boundaries @@ -105,6 +106,7 @@ it. `frontend-modern/src/api/ai.ts`, `frontend-modern/src/api/patrol.ts`, `internal/api/ai_handler.go`, `internal/api/ai_handlers.go`, and +`internal/api/ai_hosted_runtime.go`, and `internal/api/ai_intelligence_handlers.go` are runtime control surfaces for the AI product while also remaining canonical payload contract boundaries. That same AI transport boundary now also defines the narrow Pulse Mobile @@ -114,6 +116,16 @@ mobile runtime routes may accept that scope as a compatibility alias alongside legacy `ai:chat` or `ai:execute` mobile tokens. Broader AI runtime surfaces must stay on their canonical AI scopes instead of treating the mobile relay capability as a general-purpose AI permission. +That same shared AI transport boundary now also owns hosted AI bootstrap. +When Pulse Cloud runs in hosted mode and no explicit `ai.enc` exists yet, +`internal/api/ai_hosted_runtime.go`, `internal/api/ai_handler.go`, and +`internal/api/ai_handlers.go` must derive the initial runtime config from the +canonical hosted billing state instead of falling back to a synthetic +`enabled=false` default. Entitled hosted tenants that carry AI capabilities +must persist one machine-owned quickstart-backed AI config with the canonical +`quickstart:minimax-2.5m` runtime models so Chat and Patrol can start before +the operator visits AI Settings, while any explicitly written AI config +remains authoritative and must not be overwritten by hosted bootstrap. The same ownership includes the Pulse query tool schema under `internal/ai/tools/`: topology-query input names must stay canonical inside the AI runtime itself, so new tool arguments such as `max_proxmox_nodes` diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 51585de5e5..f0c4e18827 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -56,6 +56,7 @@ Own canonical runtime payload shapes between backend and frontend. 33. `frontend-modern/src/utils/infrastructureSettingsPresentation.ts` 34. `internal/api/router_routes_auth_security.go` 35. `internal/api/relay_hosted_runtime.go` +36. `internal/api/ai_hosted_runtime.go` ## Shared Boundaries @@ -143,6 +144,7 @@ Own canonical runtime payload shapes between backend and frontend. 18. Keep tenant settings-scope authorization aligned with org management: `internal/api/security_setup_fix.go`, `internal/api/contract_test.go`, and settings-bound hosted callers must allow the current non-default org owner/admin membership to exercise privileged tenant routes, rather than requiring a separate configured local admin identity after hosted handoff. 19. Keep mobile onboarding payload reads aligned with the server-owned relay-mobile credential: `internal/api/router_routes_ai_relay.go`, `internal/api/onboarding_handlers.go`, and `internal/api/contract_test.go` must allow the dedicated `relay:mobile:access` scope to reach the governed QR, deep-link, and connection-validation payloads without reintroducing a broader `settings:read` requirement for token-authenticated pairing clients. 20. Keep hosted billing-state quickstart payload fields on the shared API contract: `internal/api/hosted_entitlement_refresh.go`, `internal/api/subscription_state_handlers.go`, and `internal/api/contract_test.go` must preserve `quickstart_credits_granted`, `quickstart_credits_used`, and `quickstart_credits_granted_at` through hosted signup, hosted lease refresh, and billing-state reads instead of letting lease rewrites silently erase seeded quickstart inventory. +21. Keep hosted AI settings bootstrap on the shared API contract: `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handlers.go`, `internal/api/ai_handler.go`, and `internal/api/contract_test.go` must treat a missing `ai.enc` in hosted mode as a canonical bootstrap condition, persist one machine-owned quickstart-backed AI config when hosted entitlements grant AI capability, and preserve that configured settings payload as the same public contract that Chat, Patrol, and AI Settings consume. ## Forbidden Paths @@ -1630,3 +1632,14 @@ generated relay identity metadata instead of requiring a prior manual `PUT /api/settings/relay`. The API response contract must continue to expose only public relay fields while omitting the hosted instance secret and private key. +That same shared backend API contract now also owns hosted AI bootstrap +reads. `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handler.go`, +`internal/api/ai_handlers.go`, and `internal/api/contract_test.go` must derive +`/api/settings/ai` and the initial hosted AI runtime from the same runtime +helper. In hosted mode, when no explicit `ai.enc` exists but the default +hosted billing lease grants AI capability and carries hosted entitlement +proof, those read surfaces must persist a canonical quickstart-backed AI +config with the governed hosted default model instead of returning a synthetic +`enabled=false` payload that leaves Chat and Patrol unavailable until the +operator manually saves settings. Once a real AI config exists, that explicit +operator-owned state must remain authoritative over hosted bootstrap. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 25708fd826..e440478741 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -877,6 +877,14 @@ surfaces may run without local auth configured, but a valid tenant the anonymous optional-auth fallback so hosted recovery, onboarding, and support flows do not silently degrade into unauthenticated state or bearer- token-only mode after cloud handoff. +That same shared `internal/api/` boundary also owns hosted AI bootstrap +continuity. Storage- and recovery-adjacent hosted flows may surface Patrol- +backed investigation or AI-assisted recovery guidance before an operator has +ever opened AI Settings, so the shared hosted runtime helper must persist the +canonical quickstart-backed `ai.enc` from entitled billing state when no +explicit AI config exists. Adjacent recovery surfaces must not invent their +own "AI disabled until configured" fallback when the hosted runtime already +has enough entitlement proof to bootstrap the machine-owned default. That same shared settings helper layer must then preserve canonical org-management privilege for non-default tenant requests. Storage- and recovery-adjacent hosted flows that reuse settings-bound helpers must allow diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 0dca1b09f5..0cc0e25b09 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -77,6 +77,7 @@ type AIHandler struct { mtMonitor *monitoring.MultiTenantMonitor defaultConfig *config.Config defaultPersistence AIPersistence + hostedMode bool defaultService AIService agentServer *agentexec.Server services map[string]AIService @@ -118,6 +119,7 @@ func NewAIHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.MultiTenan mtMonitor: mtm, defaultConfig: defaultConfig, defaultPersistence: defaultPersistence, + hostedMode: hostedModeEnabledFromEnv(), agentServer: agentServer, services: make(map[string]AIService), unifiedStores: make(map[string]*unified.UnifiedStore), @@ -461,6 +463,21 @@ func (h *AIHandler) loadAIConfig(ctx context.Context) *config.AIConfig { 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 diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 9353706972..9cf44668dd 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -48,6 +48,7 @@ type AISettingsHandler struct { mtMonitor *monitoring.MultiTenantMonitor defaultConfig *config.Config defaultPersistence *config.ConfigPersistence + hostedMode bool defaultAIService *ai.Service aiServices map[string]*ai.Service aiServicesMu sync.RWMutex @@ -186,6 +187,7 @@ func NewAISettingsHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.Mu var defaultConfig *config.Config var defaultPersistence *config.ConfigPersistence var defaultAIService *ai.Service + hostedMode := hostedModeEnabledFromEnv() if mtm != nil { if m, err := mtm.GetMonitor("default"); err == nil && m != nil { @@ -198,36 +200,12 @@ func NewAISettingsHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.Mu } } - defaultAIService = ai.NewService(defaultPersistence, agentServer) - defaultAIService.SetOrgID("default") - defaultAIService.SetAlertAnalyzerFactory(getCreateAlertAnalyzer()) - // Wire quickstart credit manager before LoadConfig so the quickstart - // provider path is available during initial configuration. - if defaultPersistence != nil { - billingStore := config.NewFileBillingStore(defaultPersistence.DataDir()) - qsMgr := ai.NewFileQuickstartCreditManager( - billingStore, - "default", - func() *config.AIConfig { - cfg, _ := defaultPersistence.LoadAIConfig() - return cfg - }, - "default", - ) - defaultAIService.SetQuickstartCredits(qsMgr) - } - if defaultPersistence != nil { - if err := defaultAIService.LoadConfig(); err != nil { - log.Warn().Err(err).Msg("Failed to load AI config on startup") - } - } - - return &AISettingsHandler{ + handler := &AISettingsHandler{ mtPersistence: mtp, mtMonitor: mtm, defaultConfig: defaultConfig, defaultPersistence: defaultPersistence, - defaultAIService: defaultAIService, + hostedMode: hostedMode, aiServices: make(map[string]*ai.Service), agentServer: agentServer, proxmoxCorrelators: make(map[string]*proxmox.EventCorrelator), @@ -243,6 +221,57 @@ func NewAISettingsHandler(mtp *config.MultiTenantPersistence, mtm *monitoring.Mu incidentCoordinators: make(map[string]*ai.IncidentCoordinator), incidentRecorders: make(map[string]*metrics.IncidentRecorder), } + + defaultAIService = ai.NewService(defaultPersistence, agentServer) + defaultAIService.SetOrgID("default") + defaultAIService.SetAlertAnalyzerFactory(getCreateAlertAnalyzer()) + // Wire quickstart credit manager before LoadConfig so the quickstart + // provider path is available during initial configuration. + if defaultPersistence != nil { + billingStore := config.NewFileBillingStore(defaultPersistence.DataDir()) + qsMgr := ai.NewFileQuickstartCreditManager( + billingStore, + "default", + func() *config.AIConfig { + cfg, _ := handler.loadAIConfigForPersistence(context.Background(), "default", defaultPersistence) + return cfg + }, + "default", + ) + defaultAIService.SetQuickstartCredits(qsMgr) + if _, err := handler.loadAIConfigForPersistence(context.Background(), "default", defaultPersistence); err != nil { + log.Warn().Err(err).Msg("Failed to bootstrap Pulse Assistant config on startup") + } + if err := defaultAIService.LoadConfig(); err != nil { + log.Warn().Err(err).Msg("Failed to load AI config on startup") + } + } + handler.defaultAIService = defaultAIService + + return handler +} + +func (h *AISettingsHandler) loadAIConfigForPersistence(_ context.Context, orgID string, persistence *config.ConfigPersistence) (*config.AIConfig, error) { + if persistence == nil { + return nil, fmt.Errorf("Pulse Assistant config persistence unavailable") + } + billingBaseDir := persistence.DataDir() + if h != nil && h.mtPersistence != nil { + billingBaseDir = h.mtPersistence.BaseDataDir() + } + return loadHostedAwareAIConfig(h != nil && h.hostedMode, billingBaseDir, orgID, persistence) +} + +func (h *AISettingsHandler) loadAIConfig(ctx context.Context) (*config.AIConfig, error) { + persistence := h.getPersistence(ctx) + if persistence == nil { + return nil, fmt.Errorf("Pulse Assistant config persistence unavailable") + } + orgID := strings.TrimSpace(GetOrgID(ctx)) + if orgID == "" { + orgID = "default" + } + return h.loadAIConfigForPersistence(ctx, orgID, persistence) } // GetAIService returns the underlying AI service @@ -303,12 +332,15 @@ func (h *AISettingsHandler) GetAIService(ctx context.Context) *ai.Service { billingStore, orgID, func() *config.AIConfig { - cfg, _ := persistence.LoadAIConfig() + cfg, _ := h.loadAIConfigForPersistence(context.Background(), orgID, persistence) return cfg }, orgID, ) svc.SetQuickstartCredits(qsMgr) + if _, err := h.loadAIConfigForPersistence(context.Background(), orgID, persistence); err != nil { + log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to bootstrap Pulse Assistant config for tenant") + } if err := svc.LoadConfig(); err != nil { log.Warn().Str("orgID", orgID).Err(err).Msg("Failed to load AI config for tenant") } @@ -2242,8 +2274,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R } ctx := r.Context() - persistence := h.getPersistence(ctx) - settings, err := persistence.LoadAIConfig() + settings, err := h.loadAIConfig(ctx) if err != nil { log.Error().Err(err).Msg("Failed to load Pulse Assistant settings") http.Error(w, "Failed to load Pulse Assistant settings", http.StatusInternalServerError) @@ -2344,7 +2375,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt } // Load existing settings - settings, err := h.getPersistence(r.Context()).LoadAIConfig() + settings, err := h.loadAIConfig(r.Context()) if err != nil { log.Error().Err(err).Msg("Failed to load existing AI settings") settings = config.NewDefaultAIConfig() @@ -4457,7 +4488,7 @@ func (h *AISettingsHandler) HandleOAuthExchange(w http.ResponseWriter, r *http.R http.Error(w, "Tenant persistence unavailable", http.StatusInternalServerError) return } - settings, err := persistence.LoadAIConfig() + settings, err := h.loadAIConfig(oauthCtx) if err != nil { log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth") settings = config.NewDefaultAIConfig() @@ -4566,7 +4597,7 @@ func (h *AISettingsHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.R http.Redirect(w, r, "/settings?ai_oauth_error=save_failed", http.StatusTemporaryRedirect) return } - settings, err := persistence.LoadAIConfig() + settings, err := h.loadAIConfig(oauthCtx) if err != nil { log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth") settings = config.NewDefaultAIConfig() @@ -4623,7 +4654,7 @@ func (h *AISettingsHandler) HandleOAuthDisconnect(w http.ResponseWriter, r *http http.Error(w, "Failed to load settings", http.StatusInternalServerError) return } - settings, err := persistence.LoadAIConfig() + settings, err := h.loadAIConfig(r.Context()) if err != nil { log.Error().Err(err).Msg("Failed to load Pulse Assistant settings for OAuth disconnect") http.Error(w, "Failed to load settings", http.StatusInternalServerError) diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 74e87e6bb0..1826491ce8 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -191,6 +191,36 @@ func TestAISettingsResponse_UsesCanonicalEmptyCollections(t *testing.T) { } } +func TestAISettingsHandler_GetHostedSettings_AutoBootstrapsQuickstart(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "true") + + tmp := t.TempDir() + mtp := config.NewMultiTenantPersistence(tmp) + persistence, err := mtp.GetPersistence("default") + require.NoError(t, err) + + seedHostedAIBillingState(t, mtp, "default") + + handler := NewAISettingsHandler(mtp, nil, nil) + handler.defaultConfig = &config.Config{DataPath: tmp} + + req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil) + rec := httptest.NewRecorder() + handler.HandleGetAISettings(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + var resp AISettingsResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.True(t, resp.Enabled) + assert.True(t, resp.Configured) + assert.Equal(t, config.DefaultModelForProvider(config.AIProviderQuickstart), resp.Model) + assert.Equal(t, 25, resp.QuickstartCreditsTotal) + assert.Equal(t, 25, resp.QuickstartCreditsRemaining) + assert.True(t, resp.QuickstartCreditsAvailable) + assert.True(t, persistence.HasAIConfig()) +} + func TestAISettingsHandler_UpdateSettings_OpenRouterKeySetAndClear(t *testing.T) { t.Parallel() diff --git a/internal/api/ai_hosted_runtime.go b/internal/api/ai_hosted_runtime.go new file mode 100644 index 0000000000..d4a1c93e5e --- /dev/null +++ b/internal/api/ai_hosted_runtime.go @@ -0,0 +1,102 @@ +package api + +import ( + "fmt" + "os" + "strings" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" +) + +func loadHostedAwareAIConfig(hostedMode bool, billingBaseDir, orgID string, persistence *config.ConfigPersistence) (*config.AIConfig, error) { + if persistence == nil { + return nil, fmt.Errorf("Pulse Assistant config persistence unavailable") + } + + cfg, err := persistence.LoadAIConfig() + if err != nil { + return nil, err + } + if !shouldAutoBootstrapHostedAIConfig(hostedMode, persistence) { + return cfg, nil + } + + state, err := ensureHostedAIQuickstartBillingState(billingBaseDir, orgID) + if err != nil { + return nil, err + } + if !hostedAIAutoBootstrapEligible(state) { + return cfg, nil + } + + bootstrapped := config.NewDefaultAIConfig() + bootstrapped.Enabled = true + quickstartModel := config.DefaultModelForProvider(config.AIProviderQuickstart) + bootstrapped.Model = quickstartModel + bootstrapped.ChatModel = quickstartModel + bootstrapped.PatrolModel = quickstartModel + if err := persistence.SaveAIConfig(*bootstrapped); err != nil { + return nil, fmt.Errorf("persist hosted Pulse Assistant bootstrap config: %w", err) + } + return bootstrapped, nil +} + +func shouldAutoBootstrapHostedAIConfig(hostedMode bool, persistence *config.ConfigPersistence) bool { + return hostedMode && persistence != nil && !persistence.HasAIConfig() +} + +func ensureHostedAIQuickstartBillingState(billingBaseDir, orgID string) (*billingState, error) { + billingBaseDir = strings.TrimSpace(billingBaseDir) + if billingBaseDir == "" { + return nil, nil + } + orgID = normalizeHostedEntitlementOrgID(orgID) + + billingStore := config.NewFileBillingStore(billingBaseDir) + state, err := billingStore.GetBillingState(orgID) + if err != nil || state == nil { + return state, err + } + if !hostedAIAutoBootstrapEligible(state) || state.QuickstartCreditsGranted { + return state, nil + } + + updated := normalizeBillingStateFromLicensing(state) + updated.GrantQuickstartCredits() + if err := billingStore.SaveBillingState(orgID, updated); err != nil { + return nil, fmt.Errorf("save hosted Pulse Assistant quickstart billing state: %w", err) + } + return updated, nil +} + +func hostedAIAutoBootstrapEligible(state *billingState) bool { + if state == nil { + return false + } + if strings.TrimSpace(state.EntitlementJWT) == "" && strings.TrimSpace(state.EntitlementRefreshToken) == "" { + return false + } + switch state.SubscriptionState { + case subscriptionStateActiveValue, subscriptionStateGraceValue, pkglicensing.SubStateTrial: + default: + return false + } + return hasBillingCapability(state, featureAIPatrolValue) || hasBillingCapability(state, featureAIAutoFixValue) +} + +func hasBillingCapability(state *billingState, feature string) bool { + if state == nil || strings.TrimSpace(feature) == "" { + return false + } + for _, capability := range state.Capabilities { + if capability == feature { + return true + } + } + return false +} + +func hostedModeEnabledFromEnv() bool { + return os.Getenv("PULSE_HOSTED_MODE") == "true" +} diff --git a/internal/api/ai_hosted_runtime_test.go b/internal/api/ai_hosted_runtime_test.go new file mode 100644 index 0000000000..7e53541674 --- /dev/null +++ b/internal/api/ai_hosted_runtime_test.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/chat" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" + "github.com/stretchr/testify/mock" +) + +func TestLoadHostedAwareAIConfig_AutoBootstrapsHostedQuickstart(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + persistence, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("GetPersistence(default): %v", err) + } + + seedHostedAIBillingState(t, mtp, "default") + + loaded, err := loadHostedAwareAIConfig(true, mtp.BaseDataDir(), "default", persistence) + if err != nil { + t.Fatalf("loadHostedAwareAIConfig(): %v", err) + } + if loaded == nil { + t.Fatal("expected hosted Pulse Assistant config") + } + if !loaded.Enabled { + t.Fatal("expected hosted Pulse Assistant config to be enabled") + } + quickstartModel := config.DefaultModelForProvider(config.AIProviderQuickstart) + if loaded.Model != quickstartModel || loaded.ChatModel != quickstartModel || loaded.PatrolModel != quickstartModel { + t.Fatalf("expected quickstart models, got model=%q chat=%q patrol=%q", loaded.Model, loaded.ChatModel, loaded.PatrolModel) + } + if !persistence.HasAIConfig() { + t.Fatal("expected hosted AI bootstrap to persist ai config") + } + + billingStore := config.NewFileBillingStore(mtp.BaseDataDir()) + state, err := billingStore.GetBillingState("default") + if err != nil { + t.Fatalf("GetBillingState(default): %v", err) + } + if state == nil || !state.QuickstartCreditsGranted { + t.Fatal("expected hosted AI bootstrap to backfill quickstart credits") + } +} + +func TestLoadHostedAwareAIConfig_DoesNotOverrideExplicitAIConfig(t *testing.T) { + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + persistence, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("GetPersistence(default): %v", err) + } + + seedHostedAIBillingState(t, mtp, "default") + + explicit := config.NewDefaultAIConfig() + explicit.Enabled = false + explicit.Model = config.DefaultModelForProvider(config.AIProviderAnthropic) + if err := persistence.SaveAIConfig(*explicit); err != nil { + t.Fatalf("SaveAIConfig(): %v", err) + } + + loaded, err := loadHostedAwareAIConfig(true, mtp.BaseDataDir(), "default", persistence) + if err != nil { + t.Fatalf("loadHostedAwareAIConfig(): %v", err) + } + if loaded == nil { + t.Fatal("expected explicit Pulse Assistant config") + } + if loaded.Enabled { + t.Fatal("expected explicit disabled Pulse Assistant config to remain disabled") + } + if loaded.Model != explicit.Model { + t.Fatalf("model=%q, want %q", loaded.Model, explicit.Model) + } +} + +func TestAIHandlerStart_HostedAutoBootstrapStartsService(t *testing.T) { + oldNewService := newChatService + defer func() { newChatService = oldNewService }() + + mockSvc := new(MockAIService) + newChatService = func(cfg chat.Config) AIService { + return mockSvc + } + + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + persistence, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("GetPersistence(default): %v", err) + } + seedHostedAIBillingState(t, mtp, "default") + + handler := NewAIHandler(mtp, nil, nil) + handler.defaultPersistence = persistence + handler.hostedMode = true + + mockSvc.On("Start", mock.Anything).Return(nil).Once() + + if err := handler.Start(context.Background(), nil); err != nil { + t.Fatalf("Start(): %v", err) + } + if handler.defaultService != mockSvc { + t.Fatal("expected hosted auto-bootstrap to start AI service") + } +} + +func seedHostedAIBillingState(t *testing.T, mtp *config.MultiTenantPersistence, orgID string) { + t.Helper() + + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generate entitlement keypair: %v", err) + } + t.Setenv(pkglicensing.TrialActivationPublicKeyEnvVar, base64.StdEncoding.EncodeToString(pub)) + + entitlementJWT, err := pkglicensing.SignEntitlementLeaseToken(priv, pkglicensing.EntitlementLeaseClaims{ + OrgID: orgID, + InstanceHost: "t-hostedai.cloud.pulserelay.pro", + PlanVersion: "msp_starter", + SubscriptionState: pkglicensing.SubStateActive, + Capabilities: []string{pkglicensing.FeatureAIPatrol, pkglicensing.FeatureAIAutoFix}, + }) + if err != nil { + t.Fatalf("SignEntitlementLeaseToken(): %v", err) + } + + billingStore := config.NewFileBillingStore(mtp.BaseDataDir()) + if err := billingStore.SaveBillingState(orgID, &billingState{ + EntitlementJWT: entitlementJWT, + EntitlementRefreshToken: "etr_hosted_test_bootstrap", + }); err != nil { + t.Fatalf("SaveBillingState(): %v", err) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index a1f39f5523..4e0857853d 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -243,6 +243,69 @@ func TestContract_BillingStateQuickstartJSONSnapshot(t *testing.T) { assertJSONSnapshot(t, got, want) } +func TestContract_HostedAISettingsAutoBootstrapJSONSnapshot(t *testing.T) { + t.Setenv("PULSE_HOSTED_MODE", "true") + + baseDir := t.TempDir() + mtp := config.NewMultiTenantPersistence(baseDir) + persistence, err := mtp.GetPersistence("default") + if err != nil { + t.Fatalf("GetPersistence(default): %v", err) + } + + seedHostedAIBillingState(t, mtp, "default") + + handler := NewAISettingsHandler(mtp, nil, nil) + handler.defaultConfig = &config.Config{DataPath: baseDir} + + req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil) + rec := httptest.NewRecorder() + handler.HandleGetAISettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !persistence.HasAIConfig() { + t.Fatal("expected hosted AI settings contract to persist canonical ai.enc bootstrap") + } + + const want = `{ + "enabled":true, + "model":"quickstart:minimax-2.5m", + "chat_model":"quickstart:minimax-2.5m", + "patrol_model":"quickstart:minimax-2.5m", + "configured":true, + "custom_context":"", + "auth_method":"api_key", + "oauth_connected":false, + "patrol_interval_minutes":360, + "patrol_enabled":true, + "patrol_auto_fix":false, + "alert_triggered_analysis":true, + "patrol_event_triggers_enabled":true, + "use_proactive_thresholds":false, + "available_models":[], + "anthropic_configured":false, + "openai_configured":false, + "openrouter_configured":false, + "deepseek_configured":false, + "gemini_configured":false, + "ollama_configured":false, + "ollama_base_url":"http://localhost:11434", + "configured_providers":[], + "control_level":"read_only", + "protected_guests":[], + "discovery_enabled":false, + "quickstart_credits_total":25, + "quickstart_credits_used":0, + "quickstart_credits_remaining":25, + "quickstart_credits_available":true, + "using_quickstart":true + }` + + assertJSONSnapshot(t, rec.Body.Bytes(), want) +} + func TestContract_StripeWebhookHandlersUseCanonicalRuntimeDataDir(t *testing.T) { envDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", envDir) diff --git a/internal/config/ai.go b/internal/config/ai.go index e896fc3d19..a0854efeec 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -131,6 +131,7 @@ const ( DefaultAIModelOllama = "llama3" DefaultAIModelDeepSeek = "deepseek-chat" // V3.2 with tool-use support DefaultAIModelGemini = "gemini-2.5-flash" // Latest stable Gemini model + DefaultAIModelQuickstart = "minimax-2.5m" DefaultOllamaBaseURL = "http://localhost:11434" DefaultOpenRouterBaseURL = "https://openrouter.ai/api/v1/chat/completions" DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions" @@ -331,6 +332,8 @@ func DefaultModelForProvider(provider string) string { return FormatModelString(AIProviderGemini, DefaultAIModelGemini) case AIProviderOllama: return FormatModelString(AIProviderOllama, DefaultAIModelOllama) + case AIProviderQuickstart: + return FormatModelString(AIProviderQuickstart, DefaultAIModelQuickstart) default: return "" } diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go index 2f4110e4a1..8d80fc94c4 100644 --- a/internal/config/ai_config_test.go +++ b/internal/config/ai_config_test.go @@ -223,6 +223,14 @@ func TestAIConfig_GetConfiguredProviders(t *testing.T) { } } +func TestDefaultModelForProvider_Quickstart(t *testing.T) { + got := DefaultModelForProvider(AIProviderQuickstart) + want := FormatModelString(AIProviderQuickstart, DefaultAIModelQuickstart) + if got != want { + t.Fatalf("DefaultModelForProvider(%q) = %q, want %q", AIProviderQuickstart, got, want) + } +} + func TestAIConfig_GetAPIKeyForProvider(t *testing.T) { config := AIConfig{ AnthropicAPIKey: "anthropic-key", diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 783dcd3aa0..6e69ff82f4 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -1854,6 +1854,17 @@ func (c *ConfigPersistence) LoadAIConfig() (*AIConfig, error) { return settings, nil } +// HasAIConfig reports whether ai.enc exists on disk, which distinguishes +// explicit operator configuration from synthesized defaults returned by +// LoadAIConfig when the file is absent. +func (c *ConfigPersistence) HasAIConfig() bool { + if c == nil || c.fs == nil { + return false + } + _, err := c.fs.Stat(c.aiFile) + return err == nil +} + func applyLegacyAIConfigFields(settings *AIConfig, raw map[string]json.RawMessage) bool { if settings == nil || len(raw) == 0 { return false diff --git a/internal/config/persistence_ai_test.go b/internal/config/persistence_ai_test.go index 2a5567fded..3f58686a35 100644 --- a/internal/config/persistence_ai_test.go +++ b/internal/config/persistence_ai_test.go @@ -145,6 +145,25 @@ func TestPersistence_AIConfig(t *testing.T) { assert.Error(t, err) } +func TestPersistence_HasAIConfig(t *testing.T) { + tempDir := t.TempDir() + p := NewConfigPersistence(tempDir) + + if p.HasAIConfig() { + t.Fatal("expected HasAIConfig() to be false before ai config is saved") + } + + cfg := NewDefaultAIConfig() + cfg.Enabled = true + if err := p.SaveAIConfig(*cfg); err != nil { + t.Fatalf("SaveAIConfig(): %v", err) + } + + if !p.HasAIConfig() { + t.Fatal("expected HasAIConfig() to be true after ai config is saved") + } +} + func TestPersistence_AIConfig_MigratesSuggestControlLevel(t *testing.T) { tempDir := t.TempDir() p := NewConfigPersistence(tempDir)