mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
fix(hosted): auto-bootstrap AI quickstart for hosted tenants
This commit is contained in:
parent
00a3817d9e
commit
2ac8a1fef9
14 changed files with 514 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
102
internal/api/ai_hosted_runtime.go
Normal file
102
internal/api/ai_hosted_runtime.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
143
internal/api/ai_hosted_runtime_test.go
Normal file
143
internal/api/ai_hosted_runtime_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue