Add Patrol runtime readiness contract

Refs #1463
This commit is contained in:
rcourtman 2026-05-07 17:51:40 +01:00
parent 0da15bc1bf
commit 371ce99694
14 changed files with 754 additions and 3 deletions

View file

@ -2421,6 +2421,115 @@
"kind": "file"
}
]
},
{
"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.",
"kind": "invariant",
"blocking_level": "repo-ready",
"proof_type": "automated",
"lane_ids": [
"L6"
],
"subsystem_ids": [
"ai-runtime",
"api-contracts",
"patrol-intelligence"
],
"release_gate_ids": [],
"proof_commands": [
{
"id": "patrol-readiness-frontend-tests",
"run": [
"npm",
"--prefix",
"frontend-modern",
"test",
"--",
"--run",
"src/api/__tests__/patrol.test.ts",
"src/pages/__tests__/AIIntelligence.test.tsx"
]
},
{
"id": "patrol-readiness-status-contract-tests",
"run": [
"go",
"test",
"./internal/api",
"-run",
"PatrolReadiness|PatrolStatus|Contract_PatrolStatusResponseJSONSnapshot",
"-count=1"
]
}
],
"evidence": [
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/status.json",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/ai-runtime.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/api-contracts.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/subsystems/patrol-intelligence.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/api/__tests__/patrol.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/api/patrol.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/ai_handlers.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/ai_handlers_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/ai_patrol_handlers_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/contract_test.go",
"kind": "file"
}
]
}
],
"evidence_reference_policy": {

View file

@ -107,6 +107,10 @@ runtime cost control, and shared AI transport surfaces.
Unavailable-provider blocked states must direct operators to Assistant &
Patrol provider settings and tool-capable Patrol model selection, not
legacy `Settings > Pulse Assistant` copy.
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.
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

@ -111,6 +111,9 @@ product API routes free of maintainer commercial analytics.
4. `frontend-modern/src/api/notifications.ts` shared with `notifications`: the notifications frontend client is both a notification delivery control surface and a canonical API payload contract boundary.
5. `frontend-modern/src/api/orgs.ts` shared with `organization-settings`: the organization frontend client is both an organization settings control surface and a canonical API payload contract boundary.
6. `frontend-modern/src/api/patrol.ts` shared with `ai-runtime`: the Patrol frontend client is both an AI runtime control surface and a canonical API payload contract boundary.
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.
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

@ -82,6 +82,10 @@ Patrol-specific presentation helpers.
header badges, runtime banners, empty states, and settings handoffs must
present provider setup as BYOK/local/self-managed rather than promising
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.
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

View file

@ -64,12 +64,43 @@ describe('patrol api', () => {
runtime_state: 'blocked',
blocked_reason: 'Connect a provider to power Pulse Assistant and Patrol.',
healthy: false,
readiness: {
status: 'not_ready',
ready: false,
summary:
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
provider: 'ollama',
model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M',
checks: [
{
id: 'tools',
status: 'not_ready',
label: 'Patrol tools',
message:
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
action: 'open_provider_settings',
},
],
},
} as any);
await expect(getPatrolStatus()).resolves.toMatchObject({
runtime_state: 'blocked',
blocked_reason: 'Connect a provider to power Pulse Assistant and Patrol.',
healthy: false,
readiness: {
status: 'not_ready',
ready: false,
provider: 'ollama',
model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M',
checks: [
{
id: 'tools',
status: 'not_ready',
action: 'open_provider_settings',
},
],
},
});
});

View file

@ -141,6 +141,24 @@ export interface FindingsSummary {
export type LicenseStatus = 'none' | 'active' | 'expired' | 'grace_period';
export type PatrolRuntimeState = 'unavailable' | 'disabled' | 'running' | 'blocked' | 'active';
export type PatrolReadinessStatus = 'ready' | 'warning' | 'not_ready';
export interface PatrolReadinessCheck {
id: string;
status: PatrolReadinessStatus;
label: string;
message: string;
action?: string;
}
export interface PatrolReadiness {
status: PatrolReadinessStatus;
ready: boolean;
summary: string;
provider?: string;
model?: string;
checks: PatrolReadinessCheck[];
}
export interface PatrolTriggerStatus {
running: boolean;
@ -172,6 +190,7 @@ export interface PatrolStatus {
license_required?: boolean;
license_status?: LicenseStatus;
upgrade_url?: string;
readiness?: PatrolReadiness;
summary: FindingsSummary;
}

View file

@ -52,6 +52,69 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
</div>
</Show>
<Show when={state.showReadinessBanner() && !state.showBlockedBanner()}>
<div
class={`flex-shrink-0 border-b px-4 py-3 ${
state.patrolReadiness()?.status === 'not_ready'
? 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-900'
: 'bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-800'
}`}
>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-start gap-3">
<div
class={`flex-shrink-0 p-1.5 rounded-md ${
state.patrolReadiness()?.status === 'not_ready'
? 'bg-red-100 dark:bg-red-900'
: 'bg-amber-100 dark:bg-amber-900'
}`}
>
<ShieldAlertIcon
class={`w-4 h-4 ${
state.patrolReadiness()?.status === 'not_ready'
? 'text-red-600 dark:text-red-300'
: 'text-amber-600 dark:text-amber-400'
}`}
/>
</div>
<div>
<p
class={`text-sm font-semibold ${
state.patrolReadiness()?.status === 'not_ready'
? 'text-red-900 dark:text-red-100'
: 'text-amber-900 dark:text-amber-100'
}`}
>
{state.patrolReadiness()?.status === 'not_ready'
? 'Patrol readiness issue'
: 'Patrol readiness warning'}
</p>
<p
class={`text-xs ${
state.patrolReadiness()?.status === 'not_ready'
? 'text-red-700 dark:text-red-200'
: 'text-amber-700 dark:text-amber-300'
}`}
>
{state.patrolReadiness()?.summary}
</p>
</div>
</div>
<a
href={PATROL_PROVIDER_SETTINGS_ACTION.href}
class={`inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-md border transition-colors ${
state.patrolReadiness()?.status === 'not_ready'
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900 border-red-200 dark:border-red-700 hover:bg-red-200 dark:hover:bg-red-900'
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900 border-amber-200 dark:border-amber-700 hover:bg-amber-200 dark:hover:bg-amber-900'
}`}
>
<SettingsIcon class="w-3.5 h-3.5" />
{PATROL_PROVIDER_SETTINGS_ACTION.label}
</a>
</div>
</div>
</Show>
<Show when={state.showBlockedBanner()}>
<div class="flex-shrink-0 bg-amber-50 dark:bg-amber-900 border-b border-amber-200 dark:border-amber-800 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">

View file

@ -336,13 +336,20 @@ export function usePatrolIntelligenceState() {
normalizePatrolRuntimeBlockedReason(patrolStatus()?.blocked_reason),
);
const blockedAt = createMemo(() => patrolStatus()?.blocked_at);
const patrolReadiness = createMemo(() => patrolStatus()?.readiness ?? null);
const readinessBlocksPatrol = createMemo(() => patrolReadiness()?.status === 'not_ready');
const showBlockedBanner = createMemo(() => runtimeState() === 'blocked');
const canTriggerPatrol = createMemo(() => runtimeState() === 'active');
const showReadinessBanner = createMemo(() => {
const readiness = patrolReadiness();
return runtimeState() === 'active' && readiness !== null && readiness.status !== 'ready';
});
const canTriggerPatrol = createMemo(() => runtimeState() === 'active' && !readinessBlocksPatrol());
const triggerPatrolDisabledReason = createMemo(() => {
if (runtimeState() === 'disabled') return 'Patrol is disabled';
if (runtimeState() === 'blocked') return blockedReason() || 'Patrol is paused';
if (runtimeState() === 'running') return 'Patrol is already running';
if (runtimeState() === 'unavailable') return 'Patrol service is unavailable';
if (readinessBlocksPatrol()) return patrolReadiness()?.summary || 'Patrol is not ready';
return '';
});
@ -683,6 +690,7 @@ export function usePatrolIntelligenceState() {
patrolAnomalyTriggers,
patrolInterval,
patrolModel,
patrolReadiness,
patrolRunHistory,
runtimeState,
patrolStatus,
@ -710,6 +718,7 @@ export function usePatrolIntelligenceState() {
},
showAdvancedSettings,
showBlockedBanner,
showReadinessBanner,
showInvestigationContext,
shouldSurfaceInvestigationContext,
summaryStats,

View file

@ -187,6 +187,9 @@ vi.mock('@/stores/aiIntelligence', () => {
get circuitBreakerStatus() {
return intelligenceState.circuitBreakerStatus;
},
get patrolPendingApprovals() {
return [];
},
get correlations() {
return correlationsState();
},
@ -408,6 +411,101 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.getByTestId('pulse-patrol-logo')).toHaveAttribute('aria-hidden', 'true');
});
it('surfaces Patrol readiness issues before a manual run can start', async () => {
getPatrolStatusMock.mockResolvedValue(
defaultPatrolStatus({
readiness: {
status: 'not_ready',
ready: false,
summary:
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
provider: 'ollama',
model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M',
checks: [
{
id: 'tools',
status: 'not_ready',
label: 'Patrol tools',
message:
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
action: 'open_provider_settings',
},
],
},
}),
);
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByText('Patrol readiness issue')).toBeInTheDocument();
});
expect(
screen.getByText(
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
),
).toBeInTheDocument();
for (const button of screen.getAllByRole('button', { name: /Run Patrol/i })) {
expect(button).toBeDisabled();
}
expect(triggerPatrolRunMock).not.toHaveBeenCalled();
});
it('surfaces Patrol readiness warnings without blocking manual runs', async () => {
getPatrolStatusMock.mockResolvedValue(
defaultPatrolStatus({
readiness: {
status: 'warning',
ready: true,
summary:
'Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification.',
provider: 'ollama',
model: 'ollama:llama3',
checks: [
{
id: 'tools',
status: 'warning',
label: 'Patrol tools',
message:
'Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification.',
action: 'open_provider_settings',
},
],
},
}),
);
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByText('Patrol readiness warning')).toBeInTheDocument();
});
const runButtons = screen.getAllByRole('button', { name: /Run Patrol/i });
for (const button of runButtons) {
expect(button).not.toBeDisabled();
}
fireEvent.click(runButtons[0]);
await waitFor(() => {
expect(triggerPatrolRunMock).toHaveBeenCalled();
});
});
it('keeps legacy Patrol status payloads without readiness compatible', async () => {
getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ readiness: undefined }));
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument();
});
expect(screen.queryByText('Patrol readiness issue')).not.toBeInTheDocument();
expect(screen.queryByText('Patrol readiness warning')).not.toBeInTheDocument();
for (const button of screen.getAllByRole('button', { name: /Run Patrol/i })) {
expect(button).not.toBeDisabled();
}
});
it('renders canonical learned correlations in the summary page through the correlation card', async () => {
hasFeatureMock.mockReturnValue(true);
licenseStatusMock.mockReturnValue({ subscription_state: 'active' });

View file

@ -4804,6 +4804,145 @@ type PatrolStatusResponse struct {
Watch int `json:"watch"`
Info int `json:"info"`
} `json:"summary"`
Readiness *PatrolReadinessResponse `json:"readiness,omitempty"`
}
type PatrolReadinessResponse struct {
Status string `json:"status"`
Ready bool `json:"ready"`
Summary string `json:"summary"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Checks []PatrolReadinessCheck `json:"checks"`
}
type PatrolReadinessCheck struct {
ID string `json:"id"`
Status string `json:"status"`
Label string `json:"label"`
Message string `json:"message"`
Action string `json:"action,omitempty"`
}
const (
patrolReadinessReady = "ready"
patrolReadinessWarning = "warning"
patrolReadinessNotReady = "not_ready"
)
func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService *ai.Service, patrolAvailable bool) PatrolReadinessResponse {
checks := make([]PatrolReadinessCheck, 0, 4)
addCheck := func(id, status, label, message, action string) {
checks = append(checks, PatrolReadinessCheck{
ID: id,
Status: status,
Label: label,
Message: message,
Action: action,
})
}
if aiService == nil {
addCheck("service", patrolReadinessNotReady, "AI runtime service", "Pulse AI runtime service is not available.", "restart_service")
return summarizePatrolReadiness("", "", checks)
}
if !patrolAvailable {
addCheck("service", patrolReadinessNotReady, "Patrol service", "Pulse Patrol service is not available.", "restart_service")
return summarizePatrolReadiness("", "", checks)
}
addCheck("service", patrolReadinessReady, "Patrol service", "Pulse Patrol service is available.", "")
cfg, err := h.loadAIConfig(ctx)
if err != nil || cfg == nil {
addCheck("settings", patrolReadinessNotReady, "Settings persistence", "Pulse Assistant settings could not be loaded from persistence.", "open_provider_settings")
return summarizePatrolReadiness("", "", checks)
}
addCheck("settings", patrolReadinessReady, "Settings persistence", "Pulse Assistant and Patrol settings are readable.", "")
if !cfg.Enabled {
addCheck("enabled", patrolReadinessNotReady, "Assistant enabled", "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.", "open_provider_settings")
} else {
addCheck("enabled", patrolReadinessReady, "Assistant enabled", "Pulse Assistant is enabled for Patrol verification.", "")
}
if !cfg.IsConfigured() {
addCheck("provider", patrolReadinessNotReady, "Provider configured", "No AI provider is configured for Patrol.", "open_provider_settings")
return summarizePatrolReadiness("", "", checks)
}
addCheck("provider", patrolReadinessReady, "Provider configured", "At least one AI provider is configured.", "")
model := strings.TrimSpace(cfg.GetPatrolModel())
if model == "" {
model = strings.TrimSpace(cfg.GetChatModel())
}
provider, _ := config.ParseModelString(model)
if model == "" || provider == "" || provider == config.AIProviderQuickstart {
addCheck("model", patrolReadinessNotReady, "Patrol model", "No concrete Patrol model is selected.", "open_provider_settings")
return summarizePatrolReadiness(provider, model, checks)
}
if !cfg.HasProvider(provider) {
addCheck("model", patrolReadinessNotReady, "Patrol model", fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", provider), "open_provider_settings")
return summarizePatrolReadiness(provider, model, checks)
}
addCheck("model", patrolReadinessReady, "Patrol model", "Patrol has a model selected from a configured provider.", "")
toolStatus, toolMessage := patrolToolReadinessForModel(provider, model)
toolAction := ""
if toolStatus != patrolReadinessReady {
toolAction = "open_provider_settings"
}
addCheck("tools", toolStatus, "Patrol tools", toolMessage, toolAction)
return summarizePatrolReadiness(provider, model, checks)
}
func summarizePatrolReadiness(provider, model string, checks []PatrolReadinessCheck) PatrolReadinessResponse {
status := patrolReadinessReady
summary := "Patrol is ready to run tool-backed verification."
for _, check := range checks {
if check.Status == patrolReadinessNotReady {
status = patrolReadinessNotReady
summary = check.Message
break
}
if check.Status == patrolReadinessWarning && status == patrolReadinessReady {
status = patrolReadinessWarning
summary = check.Message
}
}
return PatrolReadinessResponse{
Status: status,
Ready: status != patrolReadinessNotReady,
Summary: summary,
Provider: provider,
Model: model,
Checks: checks,
}
}
func ptrToPatrolReadiness(readiness PatrolReadinessResponse) *PatrolReadinessResponse {
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 {
@ -4858,6 +4997,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
LicenseRequired: true,
LicenseStatus: "none",
UpgradeURL: upgradeURLForFeatureFromLicensing(featureAIAutoFixValue),
Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), nil, false)),
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to write patrol status response (no AI service)")
@ -4877,6 +5017,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
Healthy: true,
LicenseRequired: !hasAutoFixFeature,
LicenseStatus: licenseStatus,
Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), aiService, false)),
}
if !hasAutoFixFeature {
response.UpgradeURL = upgradeURLForFeatureFromLicensing(featureAIAutoFixValue)
@ -4921,6 +5062,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
BlockedAt: status.BlockedAt,
LicenseRequired: !hasAutoFixFeature,
LicenseStatus: licenseStatus,
Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), aiService, true)),
}
if !hasAutoFixFeature {
response.UpgradeURL = upgradeURLForFeatureFromLicensing(featureAIAutoFixValue)

View file

@ -101,6 +101,195 @@ func TestAISettingsHandler_PatrolAutonomyMonitorOnlyAllowsMonitor(t *testing.T)
require.Contains(t, premiumRec.Body.String(), "limited to Monitor")
}
func TestAISettingsHandler_PatrolReadinessFlagsReasoningOnlyModel(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.PatrolModel = "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
require.NoError(t, persistence.SaveAIConfig(*aiCfg))
handler := newTestAISettingsHandler(cfg, persistence, nil)
readiness := handler.buildPatrolReadiness(context.Background(), handler.GetAIService(context.Background()), true)
require.Equal(t, patrolReadinessNotReady, readiness.Status)
require.False(t, readiness.Ready)
require.Equal(t, "ollama", readiness.Provider)
require.Equal(t, "ollama:deepseek-r1:7b-llama-distill-q4_K_M", readiness.Model)
require.Contains(t, readiness.Summary, "reasoning-only model family")
var toolCheck *PatrolReadinessCheck
for i := range readiness.Checks {
if readiness.Checks[i].ID == "tools" {
toolCheck = &readiness.Checks[i]
break
}
}
require.NotNil(t, toolCheck)
require.Equal(t, patrolReadinessNotReady, toolCheck.Status)
}
func TestAISettingsHandler_PatrolReadinessBranches(t *testing.T) {
t.Parallel()
tests := []struct {
name string
patrolAvailable bool
configure func(*config.AIConfig)
wantStatus string
wantReady bool
wantCheckID string
wantCheck string
wantSummary string
}{
{
name: "disabled assistant blocks patrol",
patrolAvailable: true,
wantStatus: patrolReadinessNotReady,
wantReady: false,
wantCheckID: "enabled",
wantCheck: patrolReadinessNotReady,
wantSummary: "disabled",
configure: func(aiCfg *config.AIConfig) {
aiCfg.Enabled = false
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
},
},
{
name: "provider configured without model blocks patrol",
patrolAvailable: true,
wantStatus: patrolReadinessNotReady,
wantReady: false,
wantCheckID: "model",
wantCheck: patrolReadinessNotReady,
wantSummary: "No concrete Patrol model",
configure: func(aiCfg *config.AIConfig) {
aiCfg.Enabled = true
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
},
},
{
name: "selected model provider must be configured",
patrolAvailable: true,
wantStatus: patrolReadinessNotReady,
wantReady: false,
wantCheckID: "model",
wantCheck: patrolReadinessNotReady,
wantSummary: "provider is not configured",
configure: func(aiCfg *config.AIConfig) {
aiCfg.Enabled = true
aiCfg.Model = "anthropic:claude-3-5-sonnet-latest"
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
},
},
{
name: "ollama tool support is a warning",
patrolAvailable: true,
wantStatus: patrolReadinessWarning,
wantReady: true,
wantCheckID: "tools",
wantCheck: patrolReadinessWarning,
wantSummary: "Ollama connectivity alone does not prove tool support",
configure: func(aiCfg *config.AIConfig) {
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
},
},
{
name: "anthropic model is ready",
patrolAvailable: true,
wantStatus: patrolReadinessReady,
wantReady: true,
wantCheckID: "tools",
wantCheck: patrolReadinessReady,
wantSummary: "ready to run",
configure: func(aiCfg *config.AIConfig) {
aiCfg.Enabled = true
aiCfg.Model = "anthropic:claude-3-5-sonnet-latest"
aiCfg.AnthropicAPIKey = "test-key"
},
},
{
name: "deepseek aliases warn instead of silently ready",
patrolAvailable: true,
wantStatus: patrolReadinessWarning,
wantReady: true,
wantCheckID: "tools",
wantCheck: patrolReadinessWarning,
wantSummary: "DeepSeek model capability varies",
configure: func(aiCfg *config.AIConfig) {
aiCfg.Enabled = true
aiCfg.Model = "deepseek:deepseek-v4-flush7pro"
aiCfg.DeepSeekAPIKey = "test-key"
},
},
{
name: "missing patrol service blocks before settings checks",
patrolAvailable: false,
wantStatus: patrolReadinessNotReady,
wantReady: false,
wantCheckID: "service",
wantCheck: patrolReadinessNotReady,
wantSummary: "Pulse Patrol service is not available",
configure: func(aiCfg *config.AIConfig) {
aiCfg.Enabled = true
aiCfg.Model = "anthropic:claude-3-5-sonnet-latest"
aiCfg.AnthropicAPIKey = "test-key"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
if tt.configure != nil {
tt.configure(aiCfg)
}
require.NoError(t, persistence.SaveAIConfig(*aiCfg))
handler := newTestAISettingsHandler(cfg, persistence, nil)
readiness := handler.buildPatrolReadiness(context.Background(), handler.GetAIService(context.Background()), tt.patrolAvailable)
require.Equal(t, tt.wantStatus, readiness.Status)
require.Equal(t, tt.wantReady, readiness.Ready)
require.Contains(t, readiness.Summary, tt.wantSummary)
check := requirePatrolReadinessCheck(t, readiness, tt.wantCheckID)
require.Equal(t, tt.wantCheck, check.Status)
})
}
}
func TestAISettingsHandler_PatrolReadinessNotReadyTakesPrecedenceOverWarnings(t *testing.T) {
readiness := summarizePatrolReadiness("ollama", "ollama:llama3", []PatrolReadinessCheck{
{ID: "tools", Status: patrolReadinessWarning, Message: "warning message"},
{ID: "model", Status: patrolReadinessNotReady, Message: "not ready message"},
})
require.Equal(t, patrolReadinessNotReady, readiness.Status)
require.False(t, readiness.Ready)
require.Equal(t, "not ready message", readiness.Summary)
}
func requirePatrolReadinessCheck(t *testing.T, readiness PatrolReadinessResponse, id string) PatrolReadinessCheck {
t.Helper()
for _, check := range readiness.Checks {
if check.ID == id {
return check
}
}
t.Fatalf("readiness check %q not found in %+v", id, readiness.Checks)
return PatrolReadinessCheck{}
}
func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
t.Parallel()

View file

@ -12,6 +12,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
const (
@ -64,6 +65,15 @@ func TestHandleGetPatrolStatus_NoPatrolService(t *testing.T) {
if !resp.Healthy {
t.Error("expected Healthy to be true when patrol not initialized (no issues)")
}
if resp.Readiness == nil {
t.Fatal("expected readiness payload when patrol service is unavailable")
}
if resp.Readiness.Status != patrolReadinessNotReady {
t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessNotReady)
}
if check := requirePatrolReadinessCheck(t, *resp.Readiness, "service"); check.Message != "Pulse Patrol service is not available." {
t.Fatalf("service readiness message = %q", check.Message)
}
}
func TestHandleGetPatrolStatus_NoAIService(t *testing.T) {
@ -101,6 +111,59 @@ func TestHandleGetPatrolStatus_NoAIService(t *testing.T) {
if resp.LicenseStatus == "" {
t.Error("expected LicenseStatus to be set when AI service missing")
}
if resp.Readiness == nil {
t.Fatal("expected readiness payload when AI service is missing")
}
if resp.Readiness.Status != patrolReadinessNotReady {
t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessNotReady)
}
if check := requirePatrolReadinessCheck(t, *resp.Readiness, "service"); check.Message != "Pulse AI runtime service is not available." {
t.Fatalf("service readiness message = %q", check.Message)
}
}
func TestHandleGetPatrolStatus_NormalIncludesReadiness(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("save AI config: %v", err)
}
handler := newTestAISettingsHandler(cfg, persistence, nil)
handler.defaultAIService.SetReadState(unifiedresources.NewRegistry(nil))
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil)
rec := httptest.NewRecorder()
handler.HandleGetPatrolStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String())
}
var resp PatrolStatusResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Readiness == nil {
t.Fatal("expected readiness payload when patrol service is available")
}
if resp.Readiness.Status != patrolReadinessWarning {
t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessWarning)
}
if !resp.Readiness.Ready {
t.Fatal("warning readiness should still allow Patrol to run")
}
if resp.Readiness.Provider != "ollama" || resp.Readiness.Model != "ollama:llama3" {
t.Fatalf("unexpected readiness provider/model: %+v", resp.Readiness)
}
requirePatrolReadinessCheck(t, *resp.Readiness, "tools")
}
func TestHandleGetPatrolFindings_MethodNotAllowed(t *testing.T) {

View file

@ -9805,6 +9805,22 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) {
LicenseRequired: true,
LicenseStatus: "none",
UpgradeURL: "https://pulserelay.pro/upgrade?feature=ai_autofix",
Readiness: &PatrolReadinessResponse{
Status: patrolReadinessNotReady,
Ready: false,
Summary: "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.",
Provider: "ollama",
Model: "ollama:deepseek-r1:7b-llama-distill-q4_K_M",
Checks: []PatrolReadinessCheck{
{
ID: "tools",
Status: patrolReadinessNotReady,
Label: "Patrol tools",
Message: "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.",
Action: "open_provider_settings",
},
},
},
}
payload.Summary.Critical = 1
payload.Summary.Warning = 2
@ -9836,7 +9852,8 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) {
"license_required":true,
"license_status":"none",
"upgrade_url":"https://pulserelay.pro/upgrade?feature=ai_autofix",
"summary":{"critical":1,"warning":2,"watch":0,"info":4}
"summary":{"critical":1,"warning":2,"watch":0,"info":4},
"readiness":{"status":"not_ready","ready":false,"summary":"The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.","provider":"ollama","model":"ollama:deepseek-r1:7b-llama-distill-q4_K_M","checks":[{"id":"tools","status":"not_ready","label":"Patrol tools","message":"The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.","action":"open_provider_settings"}]}
}`
assertJSONSnapshot(t, got, want)

View file

@ -3627,7 +3627,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 183,
"line": 186,
"heading_line": 106,
}
],