Gate Patrol readiness across runtime entrypoints

Refs #1463
This commit is contained in:
rcourtman 2026-05-07 18:24:47 +01:00
parent 371ce99694
commit 9d1fabef03
16 changed files with 579 additions and 57 deletions

View file

@ -2424,7 +2424,7 @@
},
{
"id": "RA27",
"summary": "Patrol status owns server-authored runtime readiness: provider, model, settings-persistence, and tool-calling prerequisites must travel as structured `/api/ai/patrol/status` checks, and known not-ready states must block manual Patrol runs before they become generic interrupted-analysis failures.",
"summary": "Patrol readiness owns server-authored runtime and configuration safety: provider, model, settings-persistence, and tool-calling prerequisites must travel as structured `/api/ai/patrol/status`, settings-save, and Patrol action errors, and known not-ready states must block readiness-sensitive settings saves plus manual, scheduled, and scoped Patrol runs before they become generic interrupted-analysis failures.",
"kind": "invariant",
"blocking_level": "repo-ready",
"proof_type": "automated",
@ -2438,6 +2438,21 @@
],
"release_gate_ids": [],
"proof_commands": [
{
"id": "patrol-readiness-browser-contract-tests",
"run": [
"/usr/bin/env",
"PULSE_E2E_USE_HOT_DEV=1",
"PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1",
"npm",
"--prefix",
"tests/integration",
"test",
"--",
"tests/18-patrol-runtime-state.spec.ts",
"--project=chromium"
]
},
{
"id": "patrol-readiness-frontend-tests",
"run": [
@ -2451,6 +2466,28 @@
"src/pages/__tests__/AIIntelligence.test.tsx"
]
},
{
"id": "patrol-readiness-runtime-gate-tests",
"run": [
"go",
"test",
"./internal/ai",
"-run",
"RunPatrol_BlocksNotReadyRuntimeBeforeLLM|RunScopedPatrol_BlocksNotReadyRuntimeBeforeLLM",
"-count=1"
]
},
{
"id": "patrol-readiness-settings-save-tests",
"run": [
"go",
"test",
"./internal/api",
"-run",
"UpdateSettingsRejectsNotReadyPatrolModel|UpdateSettingsDoesNotLockUnrelatedSavesBehindExistingPatrolReadiness|HandleForcePatrol_BlocksNotReadyPatrolModel",
"-count=1"
]
},
{
"id": "patrol-readiness-status-contract-tests",
"run": [
@ -2509,11 +2546,41 @@
"path": "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_coverage_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_init.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_readiness.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_run.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/service.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/ai_handlers.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/ai_handlers_patrol_actions_additional_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/ai_handlers_test.go",
@ -2528,6 +2595,11 @@
"repo": "pulse",
"path": "internal/api/contract_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "tests/integration/tests/18-patrol-runtime-state.spec.ts",
"kind": "file"
}
]
}

View file

@ -110,7 +110,11 @@ runtime cost control, and shared AI transport surfaces.
Patrol status must also carry server-authored readiness for provider,
model, settings-persistence, and tool-calling prerequisites so the UI can
block known-bad manual Patrol runs before they become generic runtime
failures.
failures. The same `internal/ai` readiness evaluation must gate Patrol
runtime admission directly: readiness-sensitive settings saves, manual run
requests, scheduled ticks, and scoped alert/anomaly runs must fail or skip
before LLM execution when the selected Patrol model/provider is known
not-ready.
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
External provider-bound unified-resource context must enforce the same

View file

@ -114,6 +114,10 @@ product API routes free of maintainer commercial analytics.
The Patrol status payload owns Patrol readiness as structured API state:
provider/model/settings/tool prerequisites must travel as bounded readiness
checks instead of frontend-only heuristics or generic analysis-failed text.
`/api/settings/ai/update` and `/api/ai/patrol/run` must use that same
`patrol_readiness_not_ready` error taxonomy when they reject a known-bad
Patrol runtime configuration, with bounded `status`, `provider`, and
`model` details where available.
7. `frontend-modern/src/api/rbac.ts` shared with `organization-settings`: the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary.
8. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.

View file

@ -84,8 +84,9 @@ Patrol-specific presentation helpers.
managed credits or account-backed AI access.
Server-authored Patrol readiness from the status payload is part of the
Patrol product surface: warnings must be visible before a run starts, and
known not-ready states must disable manual Patrol runs instead of letting
operators discover provider/model/tool incompatibility through a failed run.
known not-ready states must block readiness-sensitive settings saves plus
manual, scheduled, and scoped Patrol runs instead of letting operators
discover provider/model/tool incompatibility through a failed run.
5. Keep customer-facing Patrol naming product-first: page titles, route chrome,
summary copy, actions, and empty states should lead with `Patrol` or
`Pulse Patrol` rather than generic `AI` branding. Reserve `AI` terminology
@ -350,6 +351,12 @@ The advanced Patrol panel must stay within the desktop viewport, scroll its
own contents to the Apply control, and surface the backend's concrete
license/validation reason when a save is rejected instead of replacing it with
a generic `Failed to save advanced settings` toast.
The readiness contract now applies before Patrol work is admitted, not only
after a page render: readiness-sensitive settings saves must reject known-bad
Patrol runtime configurations, manual run requests must return the structured
readiness reason if a stale UI still submits, and scheduled or scoped
alert/anomaly runs must skip before calling the model while preserving the
blocked reason in Patrol status.
That same Patrol-owned presentation rule also applies to the findings empty
state: `frontend-modern/src/components/AI/FindingsPanel.tsx` must not treat
`0 active findings` as equivalent to "your infrastructure looks healthy" when

View file

@ -31,6 +31,9 @@ import { buildPatrolInvestigationContextSummary } from './patrolInvestigationCon
type PatrolTab = 'findings' | 'history';
const patrolErrorMessage = (error: unknown, fallback: string) =>
error instanceof Error && error.message.trim() ? error.message : fallback;
export function usePatrolIntelligenceState() {
const [initialSurfaceReady, setInitialSurfaceReady] = createSignal(false);
const [activeTab, setActiveTab] = createSignal<PatrolTab>('findings');
@ -190,7 +193,7 @@ export function usePatrolIntelligenceState() {
} catch (err) {
console.error('Failed to toggle patrol:', err);
setPatrolEnabledLocal(previousValue);
notificationStore.error('Failed to toggle patrol');
notificationStore.error(patrolErrorMessage(err, 'Failed to toggle patrol'));
} finally {
setIsTogglingPatrol(false);
}
@ -223,7 +226,7 @@ export function usePatrolIntelligenceState() {
} catch (err) {
console.error('Failed to trigger patrol run:', err);
setManualRunRequested(false);
notificationStore.error('Failed to start patrol run');
notificationStore.error(patrolErrorMessage(err, 'Failed to start patrol run'));
clearSafetyTimer();
} finally {
setIsTriggeringPatrol(false);
@ -239,7 +242,7 @@ export function usePatrolIntelligenceState() {
setPatrolModel(updated.patrol_model || modelId);
} catch (err) {
console.error('Failed to update patrol model:', err);
notificationStore.error('Failed to update patrol model');
notificationStore.error(patrolErrorMessage(err, 'Failed to update patrol model'));
} finally {
setIsUpdatingSettings(false);
}
@ -256,7 +259,7 @@ export function usePatrolIntelligenceState() {
refetchPatrolStatus();
} catch (err) {
console.error('Failed to update patrol interval:', err);
notificationStore.error('Failed to update patrol schedule');
notificationStore.error(patrolErrorMessage(err, 'Failed to update patrol schedule'));
} finally {
setIsUpdatingSettings(false);
}
@ -273,7 +276,7 @@ export function usePatrolIntelligenceState() {
} catch (err) {
console.error('Failed to update alert-triggered analysis:', err);
setAlertTriggeredAnalysis(previous);
notificationStore.error('Failed to update alert analysis setting');
notificationStore.error(patrolErrorMessage(err, 'Failed to update alert analysis setting'));
} finally {
setIsUpdatingSettings(false);
}
@ -290,7 +293,9 @@ export function usePatrolIntelligenceState() {
} catch (err) {
console.error('Failed to update alert-triggered patrols:', err);
setPatrolAlertTriggers(previous);
notificationStore.error('Failed to update alert-triggered Patrol setting');
notificationStore.error(
patrolErrorMessage(err, 'Failed to update alert-triggered Patrol setting'),
);
} finally {
setIsUpdatingSettings(false);
}
@ -307,7 +312,9 @@ export function usePatrolIntelligenceState() {
} catch (err) {
console.error('Failed to update anomaly-triggered patrols:', err);
setPatrolAnomalyTriggers(previous);
notificationStore.error('Failed to update anomaly-triggered Patrol setting');
notificationStore.error(
patrolErrorMessage(err, 'Failed to update anomaly-triggered Patrol setting'),
);
} finally {
setIsUpdatingSettings(false);
}

View file

@ -491,6 +491,28 @@ describe('AIIntelligence entitlement gating', () => {
});
});
it('surfaces backend readiness rejection when a stale manual run request reaches the server', async () => {
triggerPatrolRunMock.mockRejectedValue(
new Error(
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
),
);
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument();
});
fireEvent.click(screen.getAllByRole('button', { name: /Run Patrol/i })[0]);
await waitFor(() => {
expect(notificationErrorMock).toHaveBeenCalledWith(
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
);
});
});
it('keeps legacy Patrol status payloads without readiness compatible', async () => {
getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ readiness: undefined }));

View file

@ -3,6 +3,7 @@ package ai
import (
"context"
"fmt"
"strings"
"testing"
"time"
@ -221,3 +222,91 @@ func TestPatrol_RunPatrol_AIUnavailableUsesProviderSettingsGuidance(t *testing.T
t.Fatalf("blocked reason = %q, want %q", status.BlockedReason, patrolProviderNotConfiguredReason)
}
}
func TestPatrol_RunPatrol_BlocksNotReadyRuntimeBeforeLLM(t *testing.T) {
stateProvider := &mockStateProvider{
state: models.StateSnapshot{
Nodes: []models.Node{
{ID: "node1", Name: "node1", Status: "online"},
},
},
}
svc := NewService(nil, nil)
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
svc.cfg = &config.AIConfig{
Enabled: true,
Model: model,
PatrolModel: model,
PatrolEnabled: true,
OllamaBaseURL: "http://127.0.0.1:11434",
}
svc.provider = &mockProvider{}
svc.SetChatService(&mockChatService{
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
t.Fatalf("ExecutePatrolStream should not be called when Patrol readiness blocks the run")
return nil, nil
},
})
ps := NewPatrolService(svc, stateProvider)
svc.patrolService = ps
svc.ReconfigurePatrol()
ps.runPatrol(context.Background())
status := ps.GetStatus()
if status.BlockedReason == "" {
t.Fatal("expected blocked reason")
}
if !strings.Contains(status.BlockedReason, "reasoning-only model family") {
t.Fatalf("blocked reason = %q", status.BlockedReason)
}
if status.ErrorCount != 0 {
t.Fatalf("expected readiness block not to record runtime errors, got %d", status.ErrorCount)
}
}
func TestPatrol_RunScopedPatrol_BlocksNotReadyRuntimeBeforeLLM(t *testing.T) {
stateProvider := &mockStateProvider{
state: models.StateSnapshot{
Nodes: []models.Node{
{ID: "node1", Name: "node1", Status: "online"},
},
},
}
svc := NewService(nil, nil)
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
svc.cfg = &config.AIConfig{
Enabled: true,
Model: model,
PatrolModel: model,
PatrolEnabled: true,
OllamaBaseURL: "http://127.0.0.1:11434",
}
svc.provider = &mockProvider{}
svc.SetChatService(&mockChatService{
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
t.Fatalf("ExecutePatrolStream should not be called when scoped Patrol readiness blocks the run")
return nil, nil
},
})
ps := NewPatrolService(svc, stateProvider)
svc.patrolService = ps
svc.ReconfigurePatrol()
ps.runScopedPatrol(context.Background(), PatrolScope{
Reason: TriggerReasonAlertFired,
ResourceIDs: []string{"node1"},
})
status := ps.GetStatus()
if !strings.Contains(status.BlockedReason, "reasoning-only model family") {
t.Fatalf("blocked reason = %q", status.BlockedReason)
}
if status.ErrorCount != 0 {
t.Fatalf("expected readiness block not to record runtime errors, got %d", status.ErrorCount)
}
}

View file

@ -141,6 +141,9 @@ type PatrolConfig struct {
AnalyzeKubernetes bool `json:"analyze_kubernetes"`
// AnalyzePMG controls whether to analyze Proxmox Mail Gateway instances
AnalyzePMG bool `json:"analyze_pmg"`
// RuntimeBlockedReason explains why the configured Patrol runtime must not
// execute model-backed runs even though the schedule may be enabled.
RuntimeBlockedReason string `json:"runtime_blocked_reason,omitempty"`
}
// GetInterval returns the effective patrol interval, handling migration from old config
@ -292,9 +295,19 @@ func (p *PatrolService) SetUnifiedFindingResolver(cb func(findingID string)) {
func (p *PatrolService) SetConfig(cfg PatrolConfig) {
p.mu.Lock()
oldInterval := p.config.GetInterval()
oldBlockedReason := strings.TrimSpace(p.config.RuntimeBlockedReason)
p.config = cfg
newInterval := cfg.GetInterval()
configCh := p.configChanged
newBlockedReason := strings.TrimSpace(cfg.RuntimeBlockedReason)
switch {
case newBlockedReason != "":
p.lastBlockedReason = newBlockedReason
p.lastBlockedAt = time.Now()
case oldBlockedReason != "" && p.lastBlockedReason == oldBlockedReason:
p.lastBlockedReason = ""
p.lastBlockedAt = time.Time{}
}
p.mu.Unlock()
// Signal config change if patrol is running and interval changed

View file

@ -0,0 +1,92 @@
package ai
import (
"fmt"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
const (
PatrolReadinessReady = "ready"
PatrolReadinessWarning = "warning"
PatrolReadinessNotReady = "not_ready"
)
type PatrolConfigReadiness struct {
Status string
Ready bool
Summary string
Provider string
Model string
}
func EvaluatePatrolConfigReadiness(cfg *config.AIConfig) PatrolConfigReadiness {
if cfg == nil {
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "Pulse Assistant settings could not be loaded from persistence.")
}
if !cfg.Enabled {
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.")
}
if !cfg.IsConfigured() {
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "No AI provider is configured for Patrol.")
}
model := strings.TrimSpace(cfg.GetPatrolModel())
if model == "" {
model = strings.TrimSpace(cfg.GetChatModel())
}
provider, _ := config.ParseModelString(model)
if model == "" || provider == "" || provider == config.AIProviderQuickstart {
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, "No concrete Patrol model is selected.")
}
if !cfg.HasProvider(provider) {
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", provider))
}
status, message := PatrolToolReadinessForModel(provider, model)
if status == PatrolReadinessReady {
message = "Patrol is ready to run tool-backed verification."
}
return patrolConfigReadiness(provider, model, status, message)
}
func PatrolToolReadinessForModel(provider, model string) (string, string) {
normalizedModel := strings.ToLower(strings.TrimSpace(model))
switch {
case strings.Contains(normalizedModel, "deepseek-r1") ||
strings.Contains(normalizedModel, "/r1") ||
strings.Contains(normalizedModel, ":r1") ||
strings.Contains(normalizedModel, "reasoner") ||
strings.Contains(normalizedModel, "qwq"):
return PatrolReadinessNotReady, "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings."
case provider == config.AIProviderOpenRouter:
return PatrolReadinessWarning, "OpenRouter routes vary by model and endpoint. Patrol will fail closed if the routed model rejects tools or tool_choice."
case provider == config.AIProviderOllama:
return PatrolReadinessWarning, "Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification."
case provider == config.AIProviderDeepSeek:
return PatrolReadinessWarning, "DeepSeek model capability varies by model. Patrol requires a model that supports tool calling."
default:
return PatrolReadinessReady, "The selected provider path supports Patrol's tool-backed analysis contract."
}
}
func patrolConfigReadiness(provider, model, status, summary string) PatrolConfigReadiness {
return PatrolConfigReadiness{
Status: status,
Ready: status != PatrolReadinessNotReady,
Summary: summary,
Provider: provider,
Model: model,
}
}
func (s *Service) PatrolRuntimeReadiness() PatrolConfigReadiness {
if s == nil {
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "Pulse AI runtime service is not available.")
}
s.mu.RLock()
cfg := s.cfg
s.mu.RUnlock()
return EvaluatePatrolConfigReadiness(cfg)
}

View file

@ -250,6 +250,11 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge
if !cfg.Enabled {
return
}
if reason := strings.TrimSpace(cfg.RuntimeBlockedReason); reason != "" {
p.setBlockedReason(reason)
log.Info().Str("reason", reason).Msg("AI Patrol: Skipping run - runtime readiness blocked")
return
}
if !p.tryStartRun("full") {
return
@ -615,6 +620,11 @@ func (p *PatrolService) runScopedPatrol(ctx context.Context, scope PatrolScope)
if !cfg.Enabled {
return
}
if reason := strings.TrimSpace(cfg.RuntimeBlockedReason); reason != "" {
p.setBlockedReason(reason)
log.Info().Str("reason", reason).Msg("AI Patrol: Skipping scoped run - runtime readiness blocked")
return
}
if !p.tryStartRun("scoped") {
// Re-queue with backoff if retries remain

View file

@ -955,6 +955,28 @@ func (s *Service) getEffectivePatrolInterval(cfg *config.AIConfig) time.Duration
return interval
}
func (s *Service) patrolConfigFromAIConfig(cfg *config.AIConfig) PatrolConfig {
patrolCfg := DefaultPatrolConfig()
if cfg == nil {
patrolCfg.Enabled = false
patrolCfg.RuntimeBlockedReason = "Pulse Assistant settings could not be loaded from persistence."
return patrolCfg
}
patrolCfg.Enabled = cfg.IsPatrolEnabled()
patrolCfg.Interval = s.getEffectivePatrolInterval(cfg)
patrolCfg.AnalyzeNodes = cfg.PatrolAnalyzeNodes
patrolCfg.AnalyzeGuests = cfg.PatrolAnalyzeGuests
patrolCfg.AnalyzeDocker = cfg.PatrolAnalyzeDocker
patrolCfg.AnalyzeStorage = cfg.PatrolAnalyzeStorage
if patrolCfg.Enabled {
if readiness := EvaluatePatrolConfigReadiness(cfg); !readiness.Ready {
patrolCfg.RuntimeBlockedReason = readiness.Summary
}
}
return patrolCfg
}
// GetLicenseState returns the current license state and whether features are available
func (s *Service) GetLicenseState() (string, bool) {
s.mu.RLock()
@ -1000,14 +1022,8 @@ func (s *Service) StartPatrol(ctx context.Context) {
log.Info().Msg("Patrol safe remediation requires Pulse Pro license - fixes will require manual approval")
}
// Configure patrol from AI config (preserve defaults for resource types not in AI config)
patrolCfg := DefaultPatrolConfig()
patrolCfg.Enabled = true
patrolCfg.Interval = s.getEffectivePatrolInterval(cfg)
patrolCfg.AnalyzeNodes = cfg.PatrolAnalyzeNodes
patrolCfg.AnalyzeGuests = cfg.PatrolAnalyzeGuests
patrolCfg.AnalyzeDocker = cfg.PatrolAnalyzeDocker
patrolCfg.AnalyzeStorage = cfg.PatrolAnalyzeStorage
// Configure patrol from AI config (preserve defaults for resource types not in AI config).
patrolCfg := s.patrolConfigFromAIConfig(cfg)
patrol.SetConfig(patrolCfg)
patrol.SetProactiveMode(cfg.UseProactiveThresholds)
patrol.SetEventTriggerConfig(PatrolEventTriggerConfig{
@ -1104,14 +1120,8 @@ func (s *Service) ReconfigurePatrol() {
return
}
// Update patrol configuration (preserve defaults for resource types not in AI config)
patrolCfg := DefaultPatrolConfig()
patrolCfg.Enabled = cfg.IsPatrolEnabled()
patrolCfg.Interval = s.getEffectivePatrolInterval(cfg)
patrolCfg.AnalyzeNodes = cfg.PatrolAnalyzeNodes
patrolCfg.AnalyzeGuests = cfg.PatrolAnalyzeGuests
patrolCfg.AnalyzeDocker = cfg.PatrolAnalyzeDocker
patrolCfg.AnalyzeStorage = cfg.PatrolAnalyzeStorage
// Update patrol configuration (preserve defaults for resource types not in AI config).
patrolCfg := s.patrolConfigFromAIConfig(cfg)
patrol.SetConfig(patrolCfg)
// Update proactive threshold mode

View file

@ -1673,6 +1673,33 @@ func shouldRestartAIChat(req AISettingsUpdateRequest) bool {
req.ClearOllamaPassword != nil
}
func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool {
return req.Enabled != nil ||
req.Model != nil ||
req.ChatModel != nil ||
req.PatrolModel != nil ||
req.PatrolEnabled != nil ||
req.PatrolIntervalMinutes != nil ||
req.AuthMethod != nil ||
req.AnthropicAPIKey != nil ||
req.OpenAIAPIKey != nil ||
req.OpenRouterAPIKey != nil ||
req.DeepSeekAPIKey != nil ||
req.GeminiAPIKey != nil ||
req.OllamaBaseURL != nil ||
req.OllamaUsername != nil ||
req.OllamaPassword != nil ||
req.OpenAIBaseURL != nil ||
req.ClearAnthropicKey != nil ||
req.ClearOpenAIKey != nil ||
req.ClearOpenRouterKey != nil ||
req.ClearDeepSeekKey != nil ||
req.ClearGeminiKey != nil ||
req.ClearOllamaURL != nil ||
req.ClearOllamaUsername != nil ||
req.ClearOllamaPassword != nil
}
// SetOnControlSettingsChange sets a callback to be invoked when control settings change
// Used by Router to update MCP tool visibility without restarting AI chat
func (h *AISettingsHandler) SetOnControlSettingsChange(callback func()) {
@ -2695,6 +2722,13 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
settings.Model = resolvedModel
}
settings.NormalizeQuickstartModelAliases()
if settings.IsPatrolEnabled() && aiSettingsUpdateTouchesPatrolReadiness(req) {
readiness := ai.EvaluatePatrolConfigReadiness(settings)
if !readiness.Ready {
writePatrolReadinessNotReadyResponse(w, http.StatusBadRequest, readiness)
return
}
}
// Save settings
if err := h.getPersistence(r.Context()).SaveAIConfig(*settings); err != nil {
@ -4825,9 +4859,9 @@ type PatrolReadinessCheck struct {
}
const (
patrolReadinessReady = "ready"
patrolReadinessWarning = "warning"
patrolReadinessNotReady = "not_ready"
patrolReadinessReady = ai.PatrolReadinessReady
patrolReadinessWarning = ai.PatrolReadinessWarning
patrolReadinessNotReady = ai.PatrolReadinessNotReady
)
func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService *ai.Service, patrolAvailable bool) PatrolReadinessResponse {
@ -4886,7 +4920,7 @@ func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService
}
addCheck("model", patrolReadinessReady, "Patrol model", "Patrol has a model selected from a configured provider.", "")
toolStatus, toolMessage := patrolToolReadinessForModel(provider, model)
toolStatus, toolMessage := ai.PatrolToolReadinessForModel(provider, model)
toolAction := ""
if toolStatus != patrolReadinessReady {
toolAction = "open_provider_settings"
@ -4925,26 +4959,6 @@ func ptrToPatrolReadiness(readiness PatrolReadinessResponse) *PatrolReadinessRes
return &readiness
}
func patrolToolReadinessForModel(provider, model string) (string, string) {
normalizedModel := strings.ToLower(strings.TrimSpace(model))
switch {
case strings.Contains(normalizedModel, "deepseek-r1") ||
strings.Contains(normalizedModel, "/r1") ||
strings.Contains(normalizedModel, ":r1") ||
strings.Contains(normalizedModel, "reasoner") ||
strings.Contains(normalizedModel, "qwq"):
return patrolReadinessNotReady, "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings."
case provider == config.AIProviderOpenRouter:
return patrolReadinessWarning, "OpenRouter routes vary by model and endpoint. Patrol will fail closed if the routed model rejects tools or tool_choice."
case provider == config.AIProviderOllama:
return patrolReadinessWarning, "Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification."
case provider == config.AIProviderDeepSeek:
return patrolReadinessWarning, "DeepSeek model capability varies by model. Patrol requires a model that supports tool calling."
default:
return patrolReadinessReady, "The selected provider path supports Patrol's tool-backed analysis contract."
}
}
func (h *AISettingsHandler) getPatrolService(ctx context.Context) *ai.PatrolService {
aiService := h.GetAIService(ctx)
if aiService == nil {
@ -4957,6 +4971,17 @@ func writePatrolServiceUnavailableResponse(w http.ResponseWriter) {
writeErrorResponse(w, http.StatusServiceUnavailable, "service_unavailable", "Pulse Patrol service not available", nil)
}
func writePatrolReadinessNotReadyResponse(w http.ResponseWriter, statusCode int, readiness ai.PatrolConfigReadiness) {
details := map[string]string{"status": readiness.Status}
if readiness.Provider != "" {
details["provider"] = readiness.Provider
}
if readiness.Model != "" {
details["model"] = readiness.Model
}
writeErrorResponse(w, statusCode, "patrol_readiness_not_ready", readiness.Summary, details)
}
func retireQuickstartPatrolStatus(status ai.PatrolStatus) ai.PatrolStatus {
if isRetiredQuickstartBlockedReason(status.BlockedReason) {
status.BlockedReason = ""
@ -5319,6 +5344,10 @@ func (h *AISettingsHandler) HandleForcePatrol(w http.ResponseWriter, r *http.Req
writePatrolServiceUnavailableResponse(w)
return
}
if readiness := aiService.PatrolRuntimeReadiness(); !readiness.Ready {
writePatrolReadinessNotReadyResponse(w, http.StatusConflict, readiness)
return
}
// Cadence cap: Community tier is limited to 1 patrol run per hour.
// Patrol itself is free (ai_patrol), but higher cadence is gated behind Pro/Cloud.

View file

@ -642,8 +642,24 @@ func TestHandleGetFindingsHistory_StartTimeFilter(t *testing.T) {
}
}
func seedReadyAnthropicPatrolRuntime(t *testing.T, handler *AISettingsHandler) {
t.Helper()
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "anthropic:claude-3-5-sonnet-latest"
aiCfg.AnthropicAPIKey = "test-key"
if err := handler.defaultPersistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
if err := handler.defaultAIService.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
}
func TestHandleForcePatrol_ConfigDisabled(t *testing.T) {
handler, patrol, _, _ := setupAIHandlerWithPatrol(t)
seedReadyAnthropicPatrolRuntime(t, handler)
cfg := ai.DefaultPatrolConfig()
cfg.Enabled = false
@ -662,8 +678,48 @@ func TestHandleForcePatrol_ConfigDisabled(t *testing.T) {
}
}
func TestHandleForcePatrol_BlocksNotReadyPatrolModel(t *testing.T) {
handler, _, _, _ := setupAIHandlerWithPatrol(t)
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = model
aiCfg.PatrolModel = model
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
if err := handler.defaultPersistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
if err := handler.defaultAIService.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
req := newLoopbackRequest(http.MethodPost, "/api/ai/patrol/run", nil)
rec := httptest.NewRecorder()
handler.HandleForcePatrol(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409: %s", rec.Code, rec.Body.String())
}
var payload APIError
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode error payload: %v", err)
}
if payload.Code != "patrol_readiness_not_ready" {
t.Fatalf("code = %q, want patrol_readiness_not_ready", payload.Code)
}
if !strings.Contains(payload.ErrorMessage, "reasoning-only model family") {
t.Fatalf("error = %q", payload.ErrorMessage)
}
if payload.Details["model"] != model {
t.Fatalf("model detail = %q, want %q", payload.Details["model"], model)
}
}
func TestHandleForcePatrol_CommunityTierIgnoresRecentScopedActivityForFullPatrolRateLimit(t *testing.T) {
handler, patrol, _, _ := setupAIHandlerWithPatrol(t)
seedReadyAnthropicPatrolRuntime(t, handler)
handler.defaultAIService.SetLicenseChecker(communityLicenseChecker{})
setUnexportedField(t, patrol, "lastActivity", time.Now().Add(-10*time.Minute))

View file

@ -133,6 +133,68 @@ func TestAISettingsHandler_PatrolReadinessFlagsReasoningOnlyModel(t *testing.T)
require.Equal(t, patrolReadinessNotReady, toolCheck.Status)
}
func TestAISettingsHandler_UpdateSettingsRejectsNotReadyPatrolModel(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
body, err := json.Marshal(AISettingsUpdateRequest{
Enabled: ptr(true),
Model: ptr(model),
PatrolModel: ptr(model),
OllamaBaseURL: ptr("http://127.0.0.1:11434"),
})
require.NoError(t, err)
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
var payload APIError
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Equal(t, "patrol_readiness_not_ready", payload.Code)
require.Contains(t, payload.ErrorMessage, "reasoning-only model family")
require.Equal(t, patrolReadinessNotReady, payload.Details["status"])
require.Equal(t, "ollama", payload.Details["provider"])
require.Equal(t, model, payload.Details["model"])
persisted, err := persistence.LoadAIConfig()
require.NoError(t, err)
require.False(t, persisted.Enabled)
}
func TestAISettingsHandler_UpdateSettingsDoesNotLockUnrelatedSavesBehindExistingPatrolReadiness(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
model := "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = model
aiCfg.PatrolModel = model
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
require.NoError(t, persistence.SaveAIConfig(*aiCfg))
handler := newTestAISettingsHandler(cfg, persistence, nil)
body, err := json.Marshal(AISettingsUpdateRequest{
RequestTimeoutSeconds: ptr(120),
})
require.NoError(t, err)
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
persisted, err := persistence.LoadAIConfig()
require.NoError(t, err)
require.Equal(t, 120, persisted.RequestTimeoutSeconds)
require.Equal(t, model, persisted.PatrolModel)
}
func TestAISettingsHandler_PatrolReadinessBranches(t *testing.T) {
t.Parallel()

View file

@ -3624,12 +3624,12 @@ class SubsystemLookupTest(unittest.TestCase):
self.assertEqual(
api_match["matched_contract_references"],
[
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 186,
"heading_line": 106,
}
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 190,
"heading_line": 106,
}
],
)

View file

@ -6,6 +6,8 @@ const PATROL_BLOCK_REASON =
"Connect a provider to power Pulse Assistant and Patrol.";
const PATROL_AUTONOMY_PRO_REQUIRED =
"Investigation and auto-fix require Pulse Pro. Community tier is limited to Monitor (findings-only) autonomy.";
const PATROL_REASONING_ONLY_REJECTION =
"The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings.";
function todayAt(hours: number, minutes: number): string {
const value = new Date();
@ -638,6 +640,49 @@ test.describe("Patrol runtime-state browser contract", () => {
).toHaveCount(0);
});
test("shows the server reason when a stale manual Patrol run is rejected", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("mobile-"),
"Desktop-only Patrol runtime coverage",
);
let manualRunRequests = 0;
await ensureAuthenticated(page);
await mockScopedTriggerPatrolRuntimeState(page);
await page.route("**/api/ai/patrol/run", async (route) => {
manualRunRequests += 1;
expect(route.request().method()).toBe("POST");
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({
error: PATROL_REASONING_ONLY_REJECTION,
code: "patrol_readiness_not_ready",
status_code: 409,
timestamp: Math.floor(Date.now() / 1000),
details: {
status: "not_ready",
provider: "ollama",
model: "ollama:deepseek-r1:7b-llama-distill-q4_K_M",
},
}),
});
});
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
const runButton = page.getByRole("button", { name: "Run Patrol" });
await expect(runButton).toBeEnabled();
await runButton.click();
await expect.poll(() => manualRunRequests).toBe(1);
await expect(page.getByText(PATROL_REASONING_ONLY_REJECTION)).toBeVisible();
await expect(page.getByText("Failed to start patrol run")).toHaveCount(0);
});
test("surfaces scoped trigger context inside the summary and split trigger controls on the Patrol page", async ({
page,
}, testInfo) => {