Persist Patrol settings with readiness handoff

Refs #1463
This commit is contained in:
rcourtman 2026-05-07 19:26:00 +01:00
parent c9198dd54b
commit d2625c4dfb
32 changed files with 753 additions and 82 deletions

View file

@ -2424,7 +2424,7 @@
},
{
"id": "RA27",
"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.",
"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. Settings saves must persist recoverable provider/model changes while returning structured readiness, and known not-ready states must block manual, scheduled, and scoped Patrol runs before they become generic interrupted-analysis failures.",
"kind": "invariant",
"blocking_level": "repo-ready",
"proof_type": "automated",
@ -2484,7 +2484,7 @@
"test",
"./internal/api",
"-run",
"UpdateSettingsRejectsNotReadyPatrolModel|UpdateSettingsDoesNotLockUnrelatedSavesBehindExistingPatrolReadiness|HandleForcePatrol_BlocksNotReadyPatrolModel",
"UpdateSettingsPersistsNotReadyPatrolModelWithReadiness|UpdateSettingsDoesNotLockUnrelatedSavesBehindExistingPatrolReadiness|HandleForcePatrol_BlocksNotReadyPatrolModel",
"-count=1"
]
},
@ -2531,11 +2531,26 @@
"path": "frontend-modern/src/api/patrol.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
"kind": "file"
},
{
"repo": "pulse",
"path": "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
@ -2561,11 +2576,26 @@
"path": "internal/ai/patrol_readiness.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_readiness_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_run.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_runtime_failure.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/patrol_runtime_failure_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/service.go",

View file

@ -218,6 +218,10 @@ profile and assignment columns, but embedded table framing must route through
`internal/api/` route wiring: monitor-mode configuration and remediation
entitlement payloads remain AI runtime/API-contract owned and must not create
agent lifecycle authority, install-token scope, or fleet command semantics.
Patrol readiness and settings-save payload changes on those shared handlers
are also adjacent only: structured provider/model/tool causes may be exposed
to Patrol and Assistant, but they do not grant agent install, enrollment,
or fleet command authority.
Hosted handoff subjects consumed through the shared API auth boundary must
already be stable, non-email principals; lifecycle-adjacent routes must not
recover authority from a blank handoff subject by falling back to contact

View file

@ -111,10 +111,11 @@ runtime cost control, and shared AI transport surfaces.
model, settings-persistence, and tool-calling prerequisites so the UI can
block known-bad manual Patrol runs before they become generic runtime
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.
runtime admission directly: settings saves that are needed to recover a bad
provider/model selection must persist and return structured readiness cause
metadata, while 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,10 +114,11 @@ 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.
`/api/settings/ai/update` must persist recoverable provider/model changes
and return the same structured readiness cause in its settings payload, while
`/api/ai/patrol/run` must use the `patrol_readiness_not_ready` error taxonomy
when it rejects a known-bad Patrol runtime configuration. Bounded `status`,
`cause`, `provider`, and `model` details are the canonical transport shape.
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.
@ -838,6 +839,11 @@ the canonical monitored-system blocked payload.
may persist only `monitor` autonomy settings through
`/api/ai/patrol/autonomy`, while `approval`, `assisted`, and `full` return
the canonical license-required response instead of a generic save failure
and the Patrol settings-save readiness contract, so
`/api/settings/ai/update` may save a selected Patrol provider/model even
when that model is not ready for tool-backed Patrol execution, but it must
echo `patrol_readiness` with stable `cause` metadata and execution routes
must continue to fail closed before model calls
and the structured investigation-record contract, so unified findings may
expose `investigation_record` only through the shared
`aicontracts.InvestigationRecord` payload shape, with frontend API types

View file

@ -709,7 +709,10 @@ frontend primitive boundary.
names remain hidden from operators. The Patrol configuration popover is part
of that shared feature-presentation boundary: it must stay viewport-bounded,
expose an accessible dialog label, and pass backend save rejection reasons
through to the toast surface instead of replacing them with generic copy.
through as inline dialog state instead of replacing them with generic toast
copy. If that inline state opens Assistant, the Patrol feature must hand off
a source-named, model-only briefing and close the popover so the shared
Assistant drawer is not visually hidden behind feature chrome.
19. Keep the shared `system-ai` settings shell product-first.
`frontend-modern/src/components/Settings/AISettings.tsx`,
`frontend-modern/src/components/Settings/settingsHeaderMeta.ts`,

View file

@ -84,9 +84,10 @@ 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 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.
known not-ready states must keep recoverable provider/model settings saves
visible and actionable while blocking 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,13 +351,17 @@ That same browser proof now covers the Patrol configuration save contract.
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.
a generic `Failed to save advanced settings` toast. That inline failure may
handoff to Assistant only as model-only explanation context: raw command,
script, credential, and provider-detail payloads stay redacted, Assistant opens
with `autonomousMode:false`, and the configuration panel closes so the operator
is not left behind an overlapping popover.
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.
after a page render: recoverable Patrol provider/model settings saves must
persist and echo structured readiness cause metadata, 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 and cause 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

@ -221,6 +221,10 @@ bypass the API fail-closed execution gate.
those notes. Recovery-adjacent diagnostics consumers must preserve the
source-specific Docker / Podman wording and recovery destinations governed
by the shared diagnostics API contract.
When shared `internal/api/` handlers expose structured Patrol readiness or
provider/model/tool causes, storage and recovery surfaces may treat them only
as adjacent operator context and must not convert them into storage health,
recovery execution, or backup remediation authority.
Shared Patrol autonomy routes may also touch broad `internal/api/` wiring,
but monitor-mode AI configuration and remediation entitlement responses stay
AI runtime/API-contract owned and must not become recovery-local policy,

View file

@ -63,10 +63,12 @@ describe('patrol api', () => {
apiFetchJSONMock.mockResolvedValueOnce({
runtime_state: 'blocked',
blocked_reason: 'Connect a provider to power Pulse Assistant and Patrol.',
blocked_cause: 'provider_not_configured',
healthy: false,
readiness: {
status: 'not_ready',
ready: false,
cause: 'model_unsupported_tools',
summary:
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
provider: 'ollama',
@ -75,6 +77,7 @@ describe('patrol api', () => {
{
id: 'tools',
status: 'not_ready',
cause: 'model_unsupported_tools',
label: 'Patrol tools',
message:
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
@ -87,16 +90,19 @@ describe('patrol api', () => {
await expect(getPatrolStatus()).resolves.toMatchObject({
runtime_state: 'blocked',
blocked_reason: 'Connect a provider to power Pulse Assistant and Patrol.',
blocked_cause: 'provider_not_configured',
healthy: false,
readiness: {
status: 'not_ready',
ready: false,
cause: 'model_unsupported_tools',
provider: 'ollama',
model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M',
checks: [
{
id: 'tools',
status: 'not_ready',
cause: 'model_unsupported_tools',
action: 'open_provider_settings',
},
],

View file

@ -146,6 +146,7 @@ export type PatrolReadinessStatus = 'ready' | 'warning' | 'not_ready';
export interface PatrolReadinessCheck {
id: string;
status: PatrolReadinessStatus;
cause?: string;
label: string;
message: string;
action?: string;
@ -154,6 +155,7 @@ export interface PatrolReadinessCheck {
export interface PatrolReadiness {
status: PatrolReadinessStatus;
ready: boolean;
cause?: string;
summary: string;
provider?: string;
model?: string;
@ -186,6 +188,7 @@ export interface PatrolStatus {
interval_ms: number; // Patrol interval in milliseconds
fixed_count: number; // Number of issues remediated by Patrol
blocked_reason?: string; // Canonical server-authored Patrol block reason.
blocked_cause?: string;
blocked_at?: string;
license_required?: boolean;
license_status?: LicenseStatus;

View file

@ -2,6 +2,7 @@ import { createMemo, For, Show } from 'solid-js';
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
import PlayIcon from 'lucide-solid/icons/play';
import CircleHelpIcon from 'lucide-solid/icons/circle-help';
import MessageSquareIcon from 'lucide-solid/icons/message-square';
import XIcon from 'lucide-solid/icons/x';
import SettingsIcon from 'lucide-solid/icons/settings';
import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo';
@ -404,6 +405,35 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
</div>
<div class="pt-4 border-t border-border-subtle">
<Show when={state.advancedSettingsError()}>
{(failure) => (
<div
role="alert"
data-testid="patrol-configuration-error"
class="mb-3 rounded-md border border-red-300 bg-red-50 px-3 py-2.5 text-red-950 dark:border-red-800 dark:bg-red-950/30 dark:text-red-100"
>
<p class="text-xs font-semibold">Patrol configuration was not saved</p>
<p class="mt-1 text-xs leading-relaxed">{failure().message}</p>
<Show when={failure().code || failure().readiness?.cause}>
<p class="mt-1 text-[11px] leading-relaxed opacity-80">
{[failure().code, failure().readiness?.cause]
.filter(Boolean)
.join(' · ')}
</p>
</Show>
<button
type="button"
data-testid="patrol-configuration-error-assistant-button"
onClick={state.openAdvancedSettingsErrorInAssistant}
class="mt-2 inline-flex items-center gap-1.5 rounded-md border border-red-300 bg-white/80 px-2 py-1 text-xs font-medium text-red-950 transition-colors hover:bg-white dark:border-red-700 dark:bg-red-950/40 dark:text-red-100 dark:hover:bg-red-900/50"
>
<MessageSquareIcon class="h-3.5 w-3.5" />
Discuss with Assistant
</button>
</div>
)}
</Show>
<button
onClick={state.saveAdvancedSettings}
disabled={state.isSavingAdvanced()}

View file

@ -9,6 +9,7 @@ import {
buildPatrolAssistantFindingHandoffActions,
buildPatrolAssistantFindingPrompt,
buildPatrolAssistantProposedFixBriefingInput,
buildPatrolConfigurationFailureHandoff,
buildPatrolInvestigationContextSummary,
buildPatrolRunAssistantHandoff,
buildPatrolInvestigationRecordPresentation,
@ -450,6 +451,57 @@ describe('patrolInvestigationContextModel', () => {
expect(JSON.stringify(handoff)).not.toContain('provider trace');
});
it('builds a model-only Assistant handoff for a Patrol configuration failure', () => {
const handoff = buildPatrolConfigurationFailureHandoff({
message: 'The selected Patrol model is a reasoning-only model family.',
code: 'patrol_readiness_not_ready',
status: 400,
details: {
cause: 'model_unsupported_tools',
provider: 'ollama',
command: 'systemctl restart pulse.service',
},
autonomyLevel: 'approval',
fullModeUnlocked: false,
investigationBudget: 10,
investigationTimeoutSec: 120,
readiness: {
status: 'not_ready',
cause: 'model_unsupported_tools',
summary: 'The selected Patrol model is a reasoning-only model family.',
provider: 'ollama',
model: 'ollama:deepseek-r1:7b',
},
runtimeState: 'active',
});
expect(handoff.prompt).toContain('Discuss this Pulse Patrol configuration failure');
expect(handoff.prompt).toContain('Do not infer, repeat, or execute raw command text');
expect(handoff.context.autonomousMode).toBe(false);
expect(handoff.context.handoffMetadata).toMatchObject({
kind: 'patrol_configuration_failure',
runtimeFailure: true,
});
expect(handoff.context.handoffContext).toContain('[Patrol Configuration Failure Context]');
expect(handoff.context.handoffContext).toContain('Server Code: patrol_readiness_not_ready');
expect(handoff.context.handoffContext).toContain('Provider: ollama');
expect(handoff.context.handoffContext).toContain('Model: ollama:deepseek-r1:7b');
expect(handoff.context.handoffContext).toContain(
'Command: sensitive or command detail withheld',
);
expect(handoff.context.briefing).toMatchObject({
sourceLabel: 'Pulse Patrol',
title: 'Patrol configuration failure attached',
actionLabel: 'Review Patrol configuration failure',
suggestedPrompts: [
'Explain why Patrol configuration failed',
'List provider or model checks',
'What should I change before retrying?',
],
});
expect(JSON.stringify(handoff)).not.toContain('systemctl restart pulse.service');
});
it('builds operator-facing Patrol record presentation without exposing raw commands', () => {
const presentation = buildPatrolInvestigationRecordPresentation({
id: 'record-1',

View file

@ -253,6 +253,32 @@ export interface PatrolRunAssistantHandoff {
context: Omit<AIChatContext, 'initialPrompt'>;
}
export interface PatrolConfigurationFailureInput {
message: string;
code?: string;
status?: number;
details?: Record<string, string>;
autonomyLevel?: string;
fullModeUnlocked?: boolean;
investigationBudget?: number;
investigationTimeoutSec?: number;
readiness?: {
status?: string;
cause?: string;
summary?: string;
provider?: string;
model?: string;
} | null;
runtimeState?: string;
blockedReason?: string;
blockedCause?: string;
}
export interface PatrolConfigurationFailureHandoff {
prompt: string;
context: Omit<AIChatContext, 'initialPrompt'>;
}
const MAX_ASSESSMENT_FINDINGS = 5;
const MAX_ASSESSMENT_RECENT_CHANGES = 3;
const MAX_ASSESSMENT_CORRELATIONS = 3;
@ -608,6 +634,67 @@ export function buildPatrolRunAssistantHandoff(run: PatrolRunRecord): PatrolRunA
};
}
export function buildPatrolConfigurationFailureHandoff(
input: PatrolConfigurationFailureInput,
): PatrolConfigurationFailureHandoff {
const message = normalizeText(input.message) || 'Patrol configuration could not be saved.';
const code = normalizeText(input.code);
const readinessSummary = normalizeText(input.readiness?.summary);
const cause = normalizeText(input.readiness?.cause) || normalizeText(input.blockedCause);
const provider = normalizeText(input.readiness?.provider);
const model = normalizeText(input.readiness?.model);
const detailLines = [
readinessSummary ? `Readiness: ${readinessSummary}` : undefined,
cause ? `Cause: ${formatIdentifierLabel(cause) || cause}` : undefined,
provider ? `Provider: ${provider}` : undefined,
model ? `Model: ${model}` : undefined,
formatConfigurationFailureSettings(input),
].filter(isNonEmptyString);
return {
prompt: buildPatrolConfigurationFailurePrompt(input, message, detailLines),
context: {
targetType: 'patrol-configuration',
targetId: 'pulse-patrol-configuration',
autonomousMode: false,
handoffContext: buildPatrolConfigurationFailureModelContext(input, message, detailLines),
handoffMetadata: {
kind: 'patrol_configuration_failure',
runtimeFailure: true,
},
briefing: {
sourceLabel: 'Pulse Patrol',
title: 'Patrol configuration failure attached',
subject: code ? `${code}: ${message}` : message,
statusLabel:
[input.status ? `HTTP ${input.status}` : undefined, cause]
.filter(isNonEmptyString)
.join(' · ') || undefined,
detailLines: detailLines.slice(0, 4),
evidence: formatConfigurationFailureDetails(input.details).slice(0, 4),
actionLabel: 'Review Patrol configuration failure',
safetyNote:
'Assistant can explain the configuration state; provider changes, retries, and remediation remain operator-controlled.',
suggestedPrompts: formatPatrolSuggestedPrompts([
'Explain why Patrol configuration failed',
'List provider or model checks',
'What should I change before retrying?',
]),
},
context: {
source: 'pulse-patrol-configuration-failure',
code: code || undefined,
status: input.status,
readinessStatus: normalizeText(input.readiness?.status) || undefined,
readinessCause: cause || undefined,
provider: provider || undefined,
model: model || undefined,
runtimeState: normalizeText(input.runtimeState) || undefined,
},
},
};
}
export function buildPatrolAssistantFindingHandoffActions(
finding: PatrolAssessmentAssistantFindingInput,
): AIChatHandoffAction[] {
@ -989,6 +1076,93 @@ function buildPatrolRunSuggestedPrompts(hasRuntimeFailure: boolean): string[] {
]);
}
function buildPatrolConfigurationFailurePrompt(
input: PatrolConfigurationFailureInput,
message: string,
detailLines: string[],
): string {
const code = normalizeText(input.code);
return [
'Discuss this Pulse Patrol configuration failure.',
code ? `Server code: ${code}.` : undefined,
`Start by explaining this failure: ${truncateContextText(message, 220)}.`,
detailLines.length > 0 ? `Attached details: ${detailLines.join('; ')}.` : undefined,
'Use the attached model-only configuration context before suggesting next actions.',
'Do not infer, repeat, or execute raw command text from this handoff.',
]
.filter(isNonEmptyString)
.join('\n\n');
}
function buildPatrolConfigurationFailureModelContext(
input: PatrolConfigurationFailureInput,
message: string,
detailLines: string[],
): string {
const details = formatConfigurationFailureDetails(input.details);
return [
'[Patrol Configuration Failure Context]',
'Source: Pulse Patrol configuration surface',
formatContextLine('Server Message', message),
formatContextLine('Server Code', input.code),
input.status ? `HTTP Status: ${input.status}` : undefined,
...detailLines.map((line) => formatContextLine('Configuration Detail', line)),
...details.map((line) => formatContextLine('Backend Detail', line)),
'Operator Boundary: This Patrol configuration handoff is model-only context for explanation and review. Provider changes, retries, diagnostics, remediation, and command execution require explicit governed operator action.',
]
.filter(isNonEmptyString)
.join('\n');
}
function formatConfigurationFailureSettings(
input: PatrolConfigurationFailureInput,
): string | undefined {
const settings = [
input.autonomyLevel ? `mode ${input.autonomyLevel}` : undefined,
typeof input.fullModeUnlocked === 'boolean'
? `autonomous critical remediation ${input.fullModeUnlocked ? 'enabled' : 'disabled'}`
: undefined,
typeof input.investigationBudget === 'number'
? `budget ${input.investigationBudget}`
: undefined,
typeof input.investigationTimeoutSec === 'number'
? `timeout ${input.investigationTimeoutSec}s`
: undefined,
].filter(isNonEmptyString);
return settings.length > 0 ? `Requested settings: ${settings.join(', ')}` : undefined;
}
function formatConfigurationFailureDetails(details?: Record<string, string>): string[] {
if (!details) return [];
return Object.entries(details)
.map(([key, value]) => formatSafeConfigurationFailureDetail(key, value))
.filter(isNonEmptyString)
.slice(0, 6);
}
function formatSafeConfigurationFailureDetail(key: string, value: string): string | undefined {
const normalizedKey = normalizeText(key);
const normalizedValue = normalizeText(value);
if (!normalizedKey || !normalizedValue) return undefined;
const label = formatIdentifierLabel(normalizedKey) || normalizedKey;
if (configurationFailureDetailShouldBeWithheld(normalizedKey, normalizedValue)) {
return `${label}: sensitive or command detail withheld`;
}
return `${label}: ${truncateContextText(normalizedValue, 180)}`;
}
function configurationFailureDetailShouldBeWithheld(key: string, value: string): boolean {
const normalizedKey = key.toLowerCase();
const normalizedValue = value.toLowerCase();
return (
/(password|secret|token|api[_-]?key|credential|command|script|shell)/.test(normalizedKey) ||
/\b(systemctl|sudo|bash|sh\s+-c|curl|wget|kubectl|docker|ssh)\b/.test(normalizedValue)
);
}
function buildPatrolRunHandoffResources(run: PatrolRunRecord): AIChatHandoffResource[] {
const type =
run.scope_resource_types?.length === 1 ? normalizeText(run.scope_resource_types[0]) : '';

View file

@ -19,6 +19,7 @@ import {
loadAIRuntimeSettings,
syncAIRuntimeSettings,
} from '@/stores/aiRuntimeState';
import { aiChatStore } from '@/stores/aiChat';
import { notificationStore } from '@/stores/notifications';
import { hasTriggeringAlert } from '@/utils/findingAlertIdentity';
import { usePatrolStream } from '@/hooks/usePatrolStream';
@ -27,10 +28,20 @@ import { hasFeature, loadRuntimeCapabilities } from '@/stores/license';
import type { AISettings } from '@/types/ai';
import { getCanonicalScopeResourceIds } from '@/utils/patrolFormat';
import { normalizePatrolRuntimeBlockedReason } from '@/utils/patrolRuntimePresentation';
import { buildPatrolInvestigationContextSummary } from './patrolInvestigationContextModel';
import {
buildPatrolConfigurationFailureHandoff,
buildPatrolInvestigationContextSummary,
type PatrolConfigurationFailureInput,
} from './patrolInvestigationContextModel';
type PatrolTab = 'findings' | 'history';
type PatrolAPIError = Error & {
code?: string;
details?: Record<string, string>;
status?: number;
};
const patrolErrorMessage = (error: unknown, fallback: string) =>
error instanceof Error && error.message.trim() ? error.message : fallback;
@ -52,6 +63,8 @@ export function usePatrolIntelligenceState() {
const [investigationTimeout, setInvestigationTimeout] = createSignal(300);
const [showAdvancedSettings, setShowAdvancedSettings] = createSignal(false);
const [isSavingAdvanced, setIsSavingAdvanced] = createSignal(false);
const [advancedSettingsError, setAdvancedSettingsError] =
createSignal<PatrolConfigurationFailureInput | null>(null);
const [fullModeUnlocked, setFullModeUnlocked] = createSignal(false);
const availableModels = aiRuntimeModels;
const [patrolModel, setPatrolModel] = createSignal<string>('');
@ -128,6 +141,7 @@ export function usePatrolIntelligenceState() {
const handleClickOutside = (event: MouseEvent) => {
if (advancedSettingsRef && !advancedSettingsRef.contains(event.target as Node)) {
if (advancedSettingsError()) return;
setShowAdvancedSettings(false);
}
};
@ -137,6 +151,7 @@ export function usePatrolIntelligenceState() {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
setAdvancedSettingsError(null);
}
});
@ -240,6 +255,7 @@ export function usePatrolIntelligenceState() {
const updated = await AIAPI.updateSettings({ patrol_model: modelId });
syncAIRuntimeSettings(updated);
setPatrolModel(updated.patrol_model || modelId);
await refetchPatrolStatus();
} catch (err) {
console.error('Failed to update patrol model:', err);
notificationStore.error(patrolErrorMessage(err, 'Failed to update patrol model'));
@ -350,7 +366,9 @@ export function usePatrolIntelligenceState() {
const readiness = patrolReadiness();
return runtimeState() === 'active' && readiness !== null && readiness.status !== 'ready';
});
const canTriggerPatrol = createMemo(() => runtimeState() === 'active' && !readinessBlocksPatrol());
const canTriggerPatrol = createMemo(
() => runtimeState() === 'active' && !readinessBlocksPatrol(),
);
const triggerPatrolDisabledReason = createMemo(() => {
if (runtimeState() === 'disabled') return 'Patrol is disabled';
if (runtimeState() === 'blocked') return blockedReason() || 'Patrol is paused';
@ -501,8 +519,37 @@ export function usePatrolIntelligenceState() {
}
}
const buildAdvancedSettingsFailure = (err: unknown): PatrolConfigurationFailureInput => {
const apiError = err as PatrolAPIError;
const message = patrolErrorMessage(err, 'Failed to save Patrol configuration');
const readiness = patrolReadiness();
return {
message,
code: apiError.code,
status: apiError.status,
details: apiError.details,
autonomyLevel: autonomyLevel(),
fullModeUnlocked: fullModeUnlocked(),
investigationBudget: investigationBudget(),
investigationTimeoutSec: investigationTimeout(),
readiness: readiness
? {
status: readiness.status,
cause: readiness.cause,
summary: readiness.summary,
provider: readiness.provider,
model: readiness.model,
}
: null,
runtimeState: runtimeState(),
blockedReason: blockedReason(),
blockedCause: patrolStatus()?.blocked_cause,
};
};
async function saveAdvancedSettings() {
setIsSavingAdvanced(true);
setAdvancedSettingsError(null);
try {
let effectiveLevel = autonomyLevel();
const inAutoFix = effectiveLevel === 'assisted' || effectiveLevel === 'full';
@ -520,15 +567,26 @@ export function usePatrolIntelligenceState() {
setAutonomyLevel(result.settings.autonomy_level);
setFullModeUnlocked(result.settings.full_mode_unlocked);
}
setAdvancedSettingsError(null);
setShowAdvancedSettings(false);
} catch (err) {
console.error('Failed to save advanced settings:', err);
notificationStore.error((err as Error).message || 'Failed to save advanced settings');
const failure = buildAdvancedSettingsFailure(err);
setAdvancedSettingsError(failure);
await refetchPatrolStatus();
} finally {
setIsSavingAdvanced(false);
}
}
function openAdvancedSettingsErrorInAssistant() {
const failure = advancedSettingsError();
if (!failure) return;
const handoff = buildPatrolConfigurationFailureHandoff(failure);
aiChatStore.openWithPrompt(handoff.prompt, handoff.context);
setShowAdvancedSettings(false);
}
function startPolling() {
clearInterval(refreshInterval);
clearInterval(approvalPollInterval);
@ -654,6 +712,7 @@ export function usePatrolIntelligenceState() {
activeTab,
activePatrolFindings,
activityRefreshTrigger,
advancedSettingsError,
alertAnalysisLocked,
alertTriggeredAnalysis,
autonomyLevel,
@ -692,6 +751,7 @@ export function usePatrolIntelligenceState() {
licenseRequired,
loadAllData,
manualRunRequested,
openAdvancedSettingsErrorInAssistant,
patrolEnabledLocal,
patrolAlertTriggers,
patrolAnomalyTriggers,

View file

@ -2,6 +2,26 @@
export type AIProvider = 'anthropic' | 'openai' | 'openrouter' | 'ollama' | 'deepseek' | 'gemini';
export type AuthMethod = 'api_key' | 'oauth';
export type PatrolReadinessStatus = 'ready' | 'warning' | 'not_ready';
export interface PatrolReadinessCheck {
id: string;
status: PatrolReadinessStatus;
cause?: string;
label: string;
message: string;
action?: string;
}
export interface PatrolReadiness {
status: PatrolReadinessStatus;
ready: boolean;
cause?: string;
summary: string;
provider?: string;
model?: string;
checks: PatrolReadinessCheck[];
}
export interface ModelInfo {
id: string;
@ -54,6 +74,9 @@ export interface AISettings {
// AI Discovery settings
discovery_enabled?: boolean;
discovery_interval_hours?: number;
// Current Pulse Patrol runtime readiness for this settings snapshot
patrol_readiness?: PatrolReadiness;
}
export interface AISettingsUpdateRequest {

View file

@ -117,6 +117,7 @@ type Finding struct {
Recommendation string `json:"recommendation,omitempty"`
Evidence string `json:"evidence,omitempty"` // data/commands that led to this finding
Source string `json:"source,omitempty"` // "ai-analysis" for LLM findings, empty for rule-based
FailureCause string `json:"failure_cause,omitempty"`
DetectedAt time.Time `json:"detected_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
@ -161,6 +162,7 @@ type findingJSON struct {
Recommendation string `json:"recommendation,omitempty"`
Evidence string `json:"evidence,omitempty"`
Source string `json:"source,omitempty"`
FailureCause string `json:"failure_cause,omitempty"`
DetectedAt time.Time `json:"detected_at"`
LastSeenAt time.Time `json:"last_seen_at"`
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
@ -201,6 +203,7 @@ func (f Finding) MarshalJSON() ([]byte, error) {
Recommendation: f.Recommendation,
Evidence: f.Evidence,
Source: f.Source,
FailureCause: f.FailureCause,
DetectedAt: f.DetectedAt,
LastSeenAt: f.LastSeenAt,
ResolvedAt: f.ResolvedAt,
@ -246,6 +249,7 @@ func (f *Finding) UnmarshalJSON(data []byte) error {
Recommendation: payload.Recommendation,
Evidence: payload.Evidence,
Source: payload.Source,
FailureCause: payload.FailureCause,
DetectedAt: payload.DetectedAt,
LastSeenAt: payload.LastSeenAt,
ResolvedAt: payload.ResolvedAt,

View file

@ -34,6 +34,7 @@ func BuildFindingInvestigationRecord(f *Finding, session *InvestigationSession)
Title: strings.TrimSpace(f.Title),
DetectedAt: f.DetectedAt,
Description: strings.TrimSpace(f.Description),
Cause: strings.TrimSpace(f.FailureCause),
},
Status: aicontracts.InvestigationStatus(strings.TrimSpace(f.InvestigationStatus)),
Outcome: aicontracts.InvestigationOutcome(strings.TrimSpace(f.InvestigationOutcome)),

View file

@ -24,6 +24,7 @@ func TestBuildFindingInvestigationRecord_FromSession(t *testing.T) {
Recommendation: "Reduce CPU pressure",
Evidence: "cpu=96%",
Source: "ai-analysis",
FailureCause: string(PatrolFailureCauseModelUnsupportedTools),
DetectedAt: detectedAt,
InvestigationSessionID: "chat-1",
InvestigationStatus: string(InvestigationStatusCompleted),
@ -65,6 +66,9 @@ func TestBuildFindingInvestigationRecord_FromSession(t *testing.T) {
if record.Trigger.FindingKey != "cpu-high" || record.Trigger.Title != "High CPU" {
t.Fatalf("unexpected trigger: %#v", record.Trigger)
}
if record.Trigger.Cause != string(PatrolFailureCauseModelUnsupportedTools) {
t.Fatalf("trigger cause = %q", record.Trigger.Cause)
}
if record.Conclusion != "Postgres was consuming CPU." {
t.Fatalf("conclusion = %q", record.Conclusion)
}

View file

@ -103,6 +103,7 @@ type PatrolStatus struct {
Healthy bool `json:"healthy"`
IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds
BlockedReason string `json:"blocked_reason,omitempty"`
BlockedCause PatrolFailureCause `json:"blocked_cause,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
}
@ -457,6 +458,7 @@ type PatrolService struct {
resourcesChecked int
errorCount int
lastBlockedReason string
lastBlockedCause PatrolFailureCause
lastBlockedAt time.Time
nextScheduledAt time.Time // Tracks actual next patrol time (accounts for ticker resets)

View file

@ -107,11 +107,16 @@ func (p *PatrolService) RejectManualActionForRuntimeFinding(findingID string, ac
}
func (p *PatrolService) setBlockedReason(reason string) {
p.setBlockedReasonWithCause(reason, "")
}
func (p *PatrolService) setBlockedReasonWithCause(reason string, cause PatrolFailureCause) {
if reason == "" {
return
}
p.mu.Lock()
p.lastBlockedReason = reason
p.lastBlockedCause = cause
p.lastBlockedAt = time.Now()
p.mu.Unlock()
}
@ -119,6 +124,7 @@ func (p *PatrolService) setBlockedReason(reason string) {
func (p *PatrolService) clearBlockedReason() {
p.mu.Lock()
p.lastBlockedReason = ""
p.lastBlockedCause = ""
p.lastBlockedAt = time.Time{}
p.mu.Unlock()
}

View file

@ -144,6 +144,8 @@ type PatrolConfig struct {
// 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"`
// RuntimeBlockedCause is the stable machine-readable cause for the runtime block.
RuntimeBlockedCause PatrolFailureCause `json:"runtime_blocked_cause,omitempty"`
}
// GetInterval returns the effective patrol interval, handling migration from old config
@ -296,6 +298,7 @@ func (p *PatrolService) SetConfig(cfg PatrolConfig) {
p.mu.Lock()
oldInterval := p.config.GetInterval()
oldBlockedReason := strings.TrimSpace(p.config.RuntimeBlockedReason)
oldBlockedCause := p.config.RuntimeBlockedCause
p.config = cfg
newInterval := cfg.GetInterval()
configCh := p.configChanged
@ -303,9 +306,11 @@ func (p *PatrolService) SetConfig(cfg PatrolConfig) {
switch {
case newBlockedReason != "":
p.lastBlockedReason = newBlockedReason
p.lastBlockedCause = cfg.RuntimeBlockedCause
p.lastBlockedAt = time.Now()
case oldBlockedReason != "" && p.lastBlockedReason == oldBlockedReason:
case oldBlockedReason != "" && p.lastBlockedReason == oldBlockedReason && p.lastBlockedCause == oldBlockedCause:
p.lastBlockedReason = ""
p.lastBlockedCause = ""
p.lastBlockedAt = time.Time{}
}
p.mu.Unlock()

View file

@ -13,9 +13,31 @@ const (
PatrolReadinessNotReady = "not_ready"
)
type PatrolFailureCause string
const (
PatrolFailureCauseNone PatrolFailureCause = "none"
PatrolFailureCauseSettingsPersistence PatrolFailureCause = "settings_persistence"
PatrolFailureCauseServiceUnavailable PatrolFailureCause = "service_unavailable"
PatrolFailureCauseAssistantDisabled PatrolFailureCause = "assistant_disabled"
PatrolFailureCauseProviderNotConfigured PatrolFailureCause = "provider_not_configured"
PatrolFailureCauseModelNotSelected PatrolFailureCause = "model_not_selected"
PatrolFailureCauseModelProviderUnconfigured PatrolFailureCause = "model_provider_unconfigured"
PatrolFailureCauseModelUnsupportedTools PatrolFailureCause = "model_unsupported_tools"
PatrolFailureCauseModelToolSupportUnverified PatrolFailureCause = "model_tool_support_unverified"
PatrolFailureCauseModelUnavailable PatrolFailureCause = "model_unavailable"
PatrolFailureCauseContextWindowTooSmall PatrolFailureCause = "context_window_too_small"
PatrolFailureCauseProviderBilling PatrolFailureCause = "provider_billing"
PatrolFailureCauseProviderRateLimited PatrolFailureCause = "provider_rate_limited"
PatrolFailureCauseProviderAuth PatrolFailureCause = "provider_auth"
PatrolFailureCauseProviderConnection PatrolFailureCause = "provider_connection"
PatrolFailureCauseCircuitOpen PatrolFailureCause = "circuit_open"
)
type PatrolConfigReadiness struct {
Status string
Ready bool
Cause PatrolFailureCause
Summary string
Provider string
Model string
@ -23,13 +45,13 @@ type PatrolConfigReadiness struct {
func EvaluatePatrolConfigReadiness(cfg *config.AIConfig) PatrolConfigReadiness {
if cfg == nil {
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "Pulse Assistant settings could not be loaded from persistence.")
return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseSettingsPersistence, "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.")
return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseAssistantDisabled, "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.")
}
if !cfg.IsConfigured() {
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "No AI provider is configured for Patrol.")
return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseProviderNotConfigured, "No AI provider is configured for Patrol.")
}
model := strings.TrimSpace(cfg.GetPatrolModel())
@ -38,20 +60,21 @@ func EvaluatePatrolConfigReadiness(cfg *config.AIConfig) PatrolConfigReadiness {
}
provider, _ := config.ParseModelString(model)
if model == "" || provider == "" || provider == config.AIProviderQuickstart {
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, "No concrete Patrol model is selected.")
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, PatrolFailureCauseModelNotSelected, "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))
return patrolConfigReadiness(provider, model, PatrolReadinessNotReady, PatrolFailureCauseModelProviderUnconfigured, fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", provider))
}
status, message := PatrolToolReadinessForModel(provider, model)
status, cause, message := PatrolToolReadinessForModel(provider, model)
if status == PatrolReadinessReady {
cause = PatrolFailureCauseNone
message = "Patrol is ready to run tool-backed verification."
}
return patrolConfigReadiness(provider, model, status, message)
return patrolConfigReadiness(provider, model, status, cause, message)
}
func PatrolToolReadinessForModel(provider, model string) (string, string) {
func PatrolToolReadinessForModel(provider, model string) (string, PatrolFailureCause, string) {
normalizedModel := strings.ToLower(strings.TrimSpace(model))
switch {
case strings.Contains(normalizedModel, "deepseek-r1") ||
@ -59,22 +82,26 @@ func PatrolToolReadinessForModel(provider, model string) (string, string) {
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."
return PatrolReadinessNotReady, PatrolFailureCauseModelUnsupportedTools, "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."
return PatrolReadinessWarning, PatrolFailureCauseModelToolSupportUnverified, "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."
return PatrolReadinessWarning, PatrolFailureCauseModelToolSupportUnverified, "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."
return PatrolReadinessWarning, PatrolFailureCauseModelToolSupportUnverified, "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."
return PatrolReadinessReady, PatrolFailureCauseNone, "The selected provider path supports Patrol's tool-backed analysis contract."
}
}
func patrolConfigReadiness(provider, model, status, summary string) PatrolConfigReadiness {
func patrolConfigReadiness(provider, model, status string, cause PatrolFailureCause, summary string) PatrolConfigReadiness {
if cause == "" {
cause = PatrolFailureCauseNone
}
return PatrolConfigReadiness{
Status: status,
Ready: status != PatrolReadinessNotReady,
Cause: cause,
Summary: summary,
Provider: provider,
Model: model,
@ -83,7 +110,7 @@ func patrolConfigReadiness(provider, model, status, summary string) PatrolConfig
func (s *Service) PatrolRuntimeReadiness() PatrolConfigReadiness {
if s == nil {
return patrolConfigReadiness("", "", PatrolReadinessNotReady, "Pulse AI runtime service is not available.")
return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseServiceUnavailable, "Pulse AI runtime service is not available.")
}
s.mu.RLock()
cfg := s.cfg

View file

@ -0,0 +1,100 @@
package ai
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func TestEvaluatePatrolConfigReadiness_AssignsStableCause(t *testing.T) {
tests := []struct {
name string
configure func(*config.AIConfig)
wantCause PatrolFailureCause
wantReady bool
}{
{
name: "assistant disabled",
wantCause: PatrolFailureCauseAssistantDisabled,
wantReady: false,
configure: func(cfg *config.AIConfig) {
cfg.Enabled = false
cfg.Model = "ollama:llama3.2"
cfg.OllamaBaseURL = "http://127.0.0.1:11434"
},
},
{
name: "provider not configured",
wantCause: PatrolFailureCauseProviderNotConfigured,
wantReady: false,
configure: func(cfg *config.AIConfig) {
cfg.Enabled = true
cfg.Model = ""
},
},
{
name: "model not selected",
wantCause: PatrolFailureCauseModelNotSelected,
wantReady: false,
configure: func(cfg *config.AIConfig) {
cfg.Enabled = true
cfg.OllamaBaseURL = "http://127.0.0.1:11434"
},
},
{
name: "model provider unconfigured",
wantCause: PatrolFailureCauseModelProviderUnconfigured,
wantReady: false,
configure: func(cfg *config.AIConfig) {
cfg.Enabled = true
cfg.OpenRouterAPIKey = "sk-or"
cfg.PatrolModel = "ollama:llama3.2"
},
},
{
name: "model unsupported tools",
wantCause: PatrolFailureCauseModelUnsupportedTools,
wantReady: false,
configure: func(cfg *config.AIConfig) {
cfg.Enabled = true
cfg.OllamaBaseURL = "http://127.0.0.1:11434"
cfg.PatrolModel = "ollama:deepseek-r1:7b"
},
},
{
name: "tool support unverified warning",
wantCause: PatrolFailureCauseModelToolSupportUnverified,
wantReady: true,
configure: func(cfg *config.AIConfig) {
cfg.Enabled = true
cfg.OllamaBaseURL = "http://127.0.0.1:11434"
cfg.PatrolModel = "ollama:llama3.2"
},
},
{
name: "ready",
wantCause: PatrolFailureCauseNone,
wantReady: true,
configure: func(cfg *config.AIConfig) {
cfg.Enabled = true
cfg.AnthropicAPIKey = "sk-ant"
cfg.PatrolModel = "anthropic:claude-3-5-sonnet-latest"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.NewDefaultAIConfig()
tt.configure(cfg)
readiness := EvaluatePatrolConfigReadiness(cfg)
if readiness.Cause != tt.wantCause {
t.Fatalf("cause = %q, want %q", readiness.Cause, tt.wantCause)
}
if readiness.Ready != tt.wantReady {
t.Fatalf("ready = %t, want %t", readiness.Ready, tt.wantReady)
}
})
}
}

View file

@ -251,8 +251,8 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge
return
}
if reason := strings.TrimSpace(cfg.RuntimeBlockedReason); reason != "" {
p.setBlockedReason(reason)
log.Info().Str("reason", reason).Msg("AI Patrol: Skipping run - runtime readiness blocked")
p.setBlockedReasonWithCause(reason, cfg.RuntimeBlockedCause)
log.Info().Str("reason", reason).Str("cause", string(cfg.RuntimeBlockedCause)).Msg("AI Patrol: Skipping run - runtime readiness blocked")
return
}
@ -371,12 +371,14 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge
// Check if we can run LLM analysis (AI-only patrol)
if !canRunLLM {
reason := patrolProviderNotConfiguredReason
cause := PatrolFailureCauseProviderNotConfigured
if aiServiceEnabled && !llmAllowed {
reason = "circuit breaker is open"
cause = PatrolFailureCauseCircuitOpen
GetPatrolMetrics().RecordCircuitBlock()
}
p.setBlockedReason(reason)
log.Info().Str("reason", reason).Msg("AI Patrol: Skipping run - AI unavailable")
p.setBlockedReasonWithCause(reason, cause)
log.Info().Str("reason", reason).Str("cause", string(cause)).Msg("AI Patrol: Skipping run - AI unavailable")
return
}
@ -621,8 +623,8 @@ func (p *PatrolService) runScopedPatrol(ctx context.Context, scope PatrolScope)
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")
p.setBlockedReasonWithCause(reason, cfg.RuntimeBlockedCause)
log.Info().Str("reason", reason).Str("cause", string(cfg.RuntimeBlockedCause)).Msg("AI Patrol: Skipping scoped run - runtime readiness blocked")
return
}
@ -779,12 +781,14 @@ func (p *PatrolService) runScopedPatrol(ctx context.Context, scope PatrolScope)
if !canRunLLM {
reason := patrolProviderNotConfiguredReason
cause := PatrolFailureCauseProviderNotConfigured
if aiServiceEnabled && !llmAllowed {
reason = "circuit breaker is open"
cause = PatrolFailureCauseCircuitOpen
GetPatrolMetrics().RecordCircuitBlock()
}
p.setBlockedReason(reason)
log.Info().Str("reason", reason).Msg("AI Patrol: Skipping scoped run - AI unavailable")
p.setBlockedReasonWithCause(reason, cause)
log.Info().Str("reason", reason).Str("cause", string(cause)).Msg("AI Patrol: Skipping scoped run - AI unavailable")
return
}
@ -1498,6 +1502,7 @@ func (p *PatrolService) GetStatus() PatrolStatus {
ErrorCount: p.errorCount,
IntervalMs: intervalMs,
BlockedReason: p.lastBlockedReason,
BlockedCause: p.lastBlockedCause,
}
if p.triggerManager != nil {
triggerStatus := p.triggerManager.GetStatus()

View file

@ -12,6 +12,7 @@ const patrolProviderNotConfiguredReason = "Patrol provider not configured - open
type patrolRuntimeFailure struct {
Title string
Summary string
Cause PatrolFailureCause
Description string
Recommendation string
Detail string
@ -29,6 +30,7 @@ func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
failure := patrolRuntimeFailure{
Title: "Pulse Patrol: Provider analysis error",
Summary: "Provider analysis error",
Cause: PatrolFailureCauseProviderConnection,
Description: "Pulse Patrol reached the configured provider, but the provider did not complete the Patrol analysis request.",
Recommendation: "Review the Patrol provider settings, selected model, and provider logs, then rerun Patrol after the provider path is healthy.",
Detail: detail,
@ -41,6 +43,7 @@ func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
strings.Contains(lower, "no endpoints found") && strings.Contains(lower, "tool"):
failure.Title = "Pulse Patrol: Selected model does not support Patrol tools"
failure.Summary = "Selected model does not support Patrol tools"
failure.Cause = PatrolFailureCauseModelUnsupportedTools
failure.Description = "Pulse Patrol reached the provider, but the selected model or routed endpoint rejected tool-calling. Patrol needs tool support to inspect resources and report governed findings."
failure.Recommendation = "Choose a Patrol model or provider route that supports tool calling. For OpenRouter, select an endpoint that supports tools/tool_choice, or switch to a local or BYOK model with tool support."
case strings.Contains(lower, "model") && (strings.Contains(lower, "not available") ||
@ -49,11 +52,13 @@ func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
strings.Contains(lower, "no such model")):
failure.Title = "Pulse Patrol: Selected model unavailable"
failure.Summary = "Selected model unavailable"
failure.Cause = PatrolFailureCauseModelUnavailable
failure.Description = "Pulse Patrol reached the provider, but the configured Patrol model is not available from that provider path."
failure.Recommendation = "Open Patrol provider settings and choose one of the models currently returned by the provider, then rerun Patrol."
case isPatrolContextWindowError(err):
failure.Title = "Pulse Patrol: Selected model context window too small"
failure.Summary = "Selected model context window too small"
failure.Cause = PatrolFailureCauseContextWindowTooSmall
failure.Description = "The provider rejected Patrol analysis because the selected model could not fit the Patrol context after retrying with smaller context budgets."
failure.Recommendation = "Choose a model with a larger context window or run a narrower scoped Patrol check."
case strings.Contains(lower, "insufficient balance") ||
@ -63,6 +68,7 @@ func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
strings.Contains(lower, "credit"):
failure.Title = "Pulse Patrol: Provider billing or quota issue"
failure.Summary = "Provider billing or quota issue"
failure.Cause = PatrolFailureCauseProviderBilling
failure.Description = "Pulse Patrol cannot analyze your infrastructure because the configured provider rejected the request for billing or quota reasons."
failure.Recommendation = "Resolve the billing or quota issue with your provider, or switch Patrol to a different provider or local model."
case strings.Contains(lower, "rate limit") ||
@ -70,6 +76,7 @@ func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
strings.Contains(lower, "too many requests"):
failure.Title = "Pulse Patrol: Provider rate limited"
failure.Summary = "Provider rate limited"
failure.Cause = PatrolFailureCauseProviderRateLimited
failure.Description = "Pulse Patrol is being rate limited by the configured provider, so this analysis run could not complete."
failure.Recommendation = "Wait for the provider rate limit to reset, increase provider limits, or switch Patrol to another capable model."
case strings.Contains(lower, "401") ||
@ -79,6 +86,7 @@ func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
strings.Contains(lower, "api key"):
failure.Title = "Pulse Patrol: Provider authentication issue"
failure.Summary = "Provider authentication issue"
failure.Cause = PatrolFailureCauseProviderAuth
failure.Description = "Pulse Patrol cannot analyze your infrastructure because the provider rejected the configured credentials or account access."
failure.Recommendation = "Check the API key or provider authentication in Patrol provider settings, then rerun Patrol."
case strings.Contains(lower, "not configured") ||
@ -87,6 +95,7 @@ func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
strings.Contains(lower, "failed to create provider"):
failure.Title = "Pulse Patrol: Provider not ready"
failure.Summary = "Provider not ready"
failure.Cause = PatrolFailureCauseProviderNotConfigured
failure.Description = "Pulse Patrol could not start analysis because the Patrol provider runtime is not ready."
failure.Recommendation = "Open Patrol provider settings, complete provider configuration, verify the selected model, and rerun Patrol."
case strings.Contains(lower, "failed to connect") ||
@ -97,6 +106,7 @@ func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
strings.Contains(lower, "timeout"):
failure.Title = "Pulse Patrol: Provider connection issue"
failure.Summary = "Provider connection issue"
failure.Cause = PatrolFailureCauseProviderConnection
failure.Description = "Pulse Patrol could not maintain a healthy connection to the configured provider during analysis."
failure.Recommendation = "Check provider reachability, base URL, firewall or proxy rules, and provider availability, then rerun Patrol."
}
@ -121,6 +131,7 @@ func newPatrolRuntimeFailureFinding(failure patrolRuntimeFailure, now time.Time)
Description: failure.Description,
Recommendation: failure.Recommendation,
Evidence: failure.Evidence,
FailureCause: string(failure.Cause),
DetectedAt: now,
LastSeenAt: now,
}

View file

@ -23,6 +23,9 @@ func TestPatrolRuntimeFailureFromError_ClassifiesToolCallingUnsupported(t *testi
if failure.Summary != "Selected model does not support Patrol tools" {
t.Fatalf("unexpected summary %q", failure.Summary)
}
if failure.Cause != PatrolFailureCauseModelUnsupportedTools {
t.Fatalf("unexpected cause %q", failure.Cause)
}
if !strings.Contains(failure.Recommendation, "supports tool calling") {
t.Fatalf("expected recommendation to mention tool calling, got %q", failure.Recommendation)
}
@ -42,6 +45,9 @@ func TestPatrolRuntimeFailureFromError_ClassifiesUnavailableModel(t *testing.T)
if failure.Summary != "Selected model unavailable" {
t.Fatalf("unexpected summary %q", failure.Summary)
}
if failure.Cause != PatrolFailureCauseModelUnavailable {
t.Fatalf("unexpected cause %q", failure.Cause)
}
if !strings.Contains(failure.Recommendation, "models currently returned by the provider") {
t.Fatalf("unexpected recommendation %q", failure.Recommendation)
}
@ -56,6 +62,9 @@ func TestPatrolRuntimeFailureFromError_DefaultIsActionable(t *testing.T) {
if failure.Summary != "Provider analysis error" {
t.Fatalf("unexpected summary %q", failure.Summary)
}
if failure.Cause != PatrolFailureCauseProviderConnection {
t.Fatalf("unexpected cause %q", failure.Cause)
}
}
func TestNewPatrolRuntimeFailureFindingUsesCanonicalRuntimeIdentity(t *testing.T) {
@ -71,6 +80,9 @@ func TestNewPatrolRuntimeFailureFindingUsesCanonicalRuntimeIdentity(t *testing.T
if finding.Title != "Pulse Patrol: Provider rate limited" {
t.Fatalf("unexpected title %q", finding.Title)
}
if finding.FailureCause != string(PatrolFailureCauseProviderRateLimited) {
t.Fatalf("unexpected failure cause %q", finding.FailureCause)
}
if finding.LastSeenAt.Unix() != 123 {
t.Fatalf("unexpected last seen %v", finding.LastSeenAt)
}
@ -118,6 +130,9 @@ func TestRunPatrolRecordsStructuredRuntimeFailure(t *testing.T) {
if finding.Title != "Pulse Patrol: Selected model does not support Patrol tools" {
t.Fatalf("unexpected runtime finding title %q", finding.Title)
}
if finding.FailureCause != string(PatrolFailureCauseModelUnsupportedTools) {
t.Fatalf("unexpected runtime finding cause %q", finding.FailureCause)
}
}
func TestRunScopedPatrolRecordsStructuredRuntimeFailure(t *testing.T) {

View file

@ -960,6 +960,7 @@ func (s *Service) patrolConfigFromAIConfig(cfg *config.AIConfig) PatrolConfig {
if cfg == nil {
patrolCfg.Enabled = false
patrolCfg.RuntimeBlockedReason = "Pulse Assistant settings could not be loaded from persistence."
patrolCfg.RuntimeBlockedCause = PatrolFailureCauseSettingsPersistence
return patrolCfg
}
@ -972,6 +973,7 @@ func (s *Service) patrolConfigFromAIConfig(cfg *config.AIConfig) PatrolConfig {
if patrolCfg.Enabled {
if readiness := EvaluatePatrolConfigReadiness(cfg); !readiness.Ready {
patrolCfg.RuntimeBlockedReason = readiness.Summary
patrolCfg.RuntimeBlockedCause = readiness.Cause
}
}
return patrolCfg

View file

@ -2240,6 +2240,8 @@ type AISettingsResponse struct {
// Discovery settings
DiscoveryEnabled bool `json:"discovery_enabled"` // true if discovery is enabled
DiscoveryIntervalHours int `json:"discovery_interval_hours,omitempty"` // Hours between auto-scans (0 = manual only)
// Current Patrol runtime readiness after this settings snapshot is applied.
PatrolReadiness *PatrolReadinessResponse `json:"patrol_readiness,omitempty"`
}
func EmptyAISettingsResponse() AISettingsResponse {
@ -2578,10 +2580,10 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
if aiSettingsRequireModelResolution(settings) {
resolvedModel, resolveErr := ai.ResolveConfiguredModel(r.Context(), settings)
if resolveErr != nil {
http.Error(w, fmt.Sprintf("Failed to resolve provider model: %v", resolveErr), http.StatusBadGateway)
return
log.Warn().Err(resolveErr).Msg("Provider model resolution failed during enable; saving settings and reporting Patrol readiness instead")
} else {
settings.Model = resolvedModel
}
settings.Model = resolvedModel
}
}
}
@ -2716,24 +2718,27 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
if aiSettingsRequireModelResolution(settings) {
resolvedModel, resolveErr := ai.ResolveConfiguredModel(r.Context(), settings)
if resolveErr != nil {
http.Error(w, fmt.Sprintf("Failed to resolve provider model: %v", resolveErr), http.StatusBadGateway)
return
log.Warn().Err(resolveErr).Msg("Provider model resolution failed during settings update; saving settings and reporting Patrol readiness instead")
} else {
settings.Model = resolvedModel
}
settings.Model = resolvedModel
}
settings.NormalizeQuickstartModelAliases()
if settings.IsPatrolEnabled() && aiSettingsUpdateTouchesPatrolReadiness(req) {
readiness := ai.EvaluatePatrolConfigReadiness(settings)
if !readiness.Ready {
writePatrolReadinessNotReadyResponse(w, http.StatusBadRequest, readiness)
return
log.Info().
Str("cause", string(readiness.Cause)).
Str("provider", readiness.Provider).
Str("model", config.NormalizeQuickstartModelString(readiness.Model)).
Msg("AI settings saved with Patrol runtime readiness blocker")
}
}
// Save settings
if err := h.getPersistence(r.Context()).SaveAIConfig(*settings); err != nil {
log.Error().Err(err).Msg("Failed to save AI settings")
http.Error(w, "Failed to save settings", http.StatusInternalServerError)
writeErrorResponse(w, http.StatusInternalServerError, "ai_settings_save_failed", "Failed to save Assistant & Patrol settings", nil)
return
}
@ -2785,6 +2790,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
aiService := h.GetAIService(r.Context())
hasAutoFixFeature := aiService.HasLicenseFeature(ai.FeatureAIAutoFix)
hasAlertAnalysisFeature := aiService.HasLicenseFeature(ai.FeatureAIAlerts)
patrolReadiness := patrolReadinessResponseFromConfigReadiness(ai.EvaluatePatrolConfigReadiness(settings))
// Return updated settings
response := AISettingsResponse{
@ -2823,6 +2829,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
ProtectedGuests: settings.GetProtectedGuests(),
DiscoveryEnabled: settings.DiscoveryEnabled,
DiscoveryIntervalHours: settings.DiscoveryIntervalHours,
PatrolReadiness: ptrToPatrolReadiness(patrolReadiness),
}.NormalizeCollections()
if err := utils.WriteJSONResponse(w, response); err != nil {
@ -4827,6 +4834,7 @@ type PatrolStatusResponse struct {
IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds
FixedCount int `json:"fixed_count"` // Number of issues remediated by Patrol
BlockedReason string `json:"blocked_reason,omitempty"`
BlockedCause string `json:"blocked_cause,omitempty"`
BlockedAt *time.Time `json:"blocked_at,omitempty"`
// License status for Pro feature gating
LicenseRequired bool `json:"license_required"` // True if Pro license needed for full features
@ -4844,6 +4852,7 @@ type PatrolStatusResponse struct {
type PatrolReadinessResponse struct {
Status string `json:"status"`
Ready bool `json:"ready"`
Cause string `json:"cause,omitempty"`
Summary string `json:"summary"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
@ -4853,6 +4862,7 @@ type PatrolReadinessResponse struct {
type PatrolReadinessCheck struct {
ID string `json:"id"`
Status string `json:"status"`
Cause string `json:"cause,omitempty"`
Label string `json:"label"`
Message string `json:"message"`
Action string `json:"action,omitempty"`
@ -4866,10 +4876,11 @@ const (
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) {
addCheck := func(id, status string, cause ai.PatrolFailureCause, label, message, action string) {
checks = append(checks, PatrolReadinessCheck{
ID: id,
Status: status,
Cause: patrolFailureCauseResponse(cause),
Label: label,
Message: message,
Action: action,
@ -4877,33 +4888,33 @@ func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService
}
if aiService == nil {
addCheck("service", patrolReadinessNotReady, "AI runtime service", "Pulse AI runtime service is not available.", "restart_service")
addCheck("service", patrolReadinessNotReady, ai.PatrolFailureCauseServiceUnavailable, "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")
addCheck("service", patrolReadinessNotReady, ai.PatrolFailureCauseServiceUnavailable, "Patrol service", "Pulse Patrol service is not available.", "restart_service")
return summarizePatrolReadiness("", "", checks)
}
addCheck("service", patrolReadinessReady, "Patrol service", "Pulse Patrol service is available.", "")
addCheck("service", patrolReadinessReady, ai.PatrolFailureCauseNone, "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")
addCheck("settings", patrolReadinessNotReady, ai.PatrolFailureCauseSettingsPersistence, "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.", "")
addCheck("settings", patrolReadinessReady, ai.PatrolFailureCauseNone, "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")
addCheck("enabled", patrolReadinessNotReady, ai.PatrolFailureCauseAssistantDisabled, "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.", "")
addCheck("enabled", patrolReadinessReady, ai.PatrolFailureCauseNone, "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")
addCheck("provider", patrolReadinessNotReady, ai.PatrolFailureCauseProviderNotConfigured, "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.", "")
addCheck("provider", patrolReadinessReady, ai.PatrolFailureCauseNone, "Provider configured", "At least one AI provider is configured.", "")
model := strings.TrimSpace(cfg.GetPatrolModel())
if model == "" {
@ -4911,36 +4922,39 @@ func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService
}
provider, _ := config.ParseModelString(model)
if model == "" || provider == "" || provider == config.AIProviderQuickstart {
addCheck("model", patrolReadinessNotReady, "Patrol model", "No concrete Patrol model is selected.", "open_provider_settings")
addCheck("model", patrolReadinessNotReady, ai.PatrolFailureCauseModelNotSelected, "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")
addCheck("model", patrolReadinessNotReady, ai.PatrolFailureCauseModelProviderUnconfigured, "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.", "")
addCheck("model", patrolReadinessReady, ai.PatrolFailureCauseNone, "Patrol model", "Patrol has a model selected from a configured provider.", "")
toolStatus, toolMessage := ai.PatrolToolReadinessForModel(provider, model)
toolStatus, toolCause, toolMessage := ai.PatrolToolReadinessForModel(provider, model)
toolAction := ""
if toolStatus != patrolReadinessReady {
toolAction = "open_provider_settings"
}
addCheck("tools", toolStatus, "Patrol tools", toolMessage, toolAction)
addCheck("tools", toolStatus, toolCause, "Patrol tools", toolMessage, toolAction)
return summarizePatrolReadiness(provider, model, checks)
}
func summarizePatrolReadiness(provider, model string, checks []PatrolReadinessCheck) PatrolReadinessResponse {
status := patrolReadinessReady
cause := ""
summary := "Patrol is ready to run tool-backed verification."
for _, check := range checks {
if check.Status == patrolReadinessNotReady {
status = patrolReadinessNotReady
cause = check.Cause
summary = check.Message
break
}
if check.Status == patrolReadinessWarning && status == patrolReadinessReady {
status = patrolReadinessWarning
cause = check.Cause
summary = check.Message
}
}
@ -4948,6 +4962,7 @@ func summarizePatrolReadiness(provider, model string, checks []PatrolReadinessCh
return PatrolReadinessResponse{
Status: status,
Ready: status != patrolReadinessNotReady,
Cause: cause,
Summary: summary,
Provider: provider,
Model: model,
@ -4955,6 +4970,39 @@ func summarizePatrolReadiness(provider, model string, checks []PatrolReadinessCh
}
}
func patrolFailureCauseResponse(cause ai.PatrolFailureCause) string {
if cause == "" || cause == ai.PatrolFailureCauseNone {
return ""
}
return string(cause)
}
func patrolReadinessResponseFromConfigReadiness(readiness ai.PatrolConfigReadiness) PatrolReadinessResponse {
action := ""
if !readiness.Ready {
action = "open_provider_settings"
}
cause := patrolFailureCauseResponse(readiness.Cause)
return PatrolReadinessResponse{
Status: readiness.Status,
Ready: readiness.Ready,
Cause: cause,
Summary: readiness.Summary,
Provider: readiness.Provider,
Model: readiness.Model,
Checks: []PatrolReadinessCheck{
{
ID: "configuration",
Status: readiness.Status,
Cause: cause,
Label: "Patrol configuration",
Message: readiness.Summary,
Action: action,
},
},
}
}
func ptrToPatrolReadiness(readiness PatrolReadinessResponse) *PatrolReadinessResponse {
return &readiness
}
@ -4973,6 +5021,9 @@ func writePatrolServiceUnavailableResponse(w http.ResponseWriter) {
func writePatrolReadinessNotReadyResponse(w http.ResponseWriter, statusCode int, readiness ai.PatrolConfigReadiness) {
details := map[string]string{"status": readiness.Status}
if cause := patrolFailureCauseResponse(readiness.Cause); cause != "" {
details["cause"] = cause
}
if readiness.Provider != "" {
details["provider"] = readiness.Provider
}
@ -5084,6 +5135,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
IntervalMs: status.IntervalMs,
FixedCount: fixedCount,
BlockedReason: status.BlockedReason,
BlockedCause: patrolFailureCauseResponse(status.BlockedCause),
BlockedAt: status.BlockedAt,
LicenseRequired: !hasAutoFixFeature,
LicenseStatus: licenseStatus,

View file

@ -133,7 +133,7 @@ func TestAISettingsHandler_PatrolReadinessFlagsReasoningOnlyModel(t *testing.T)
require.Equal(t, patrolReadinessNotReady, toolCheck.Status)
}
func TestAISettingsHandler_UpdateSettingsRejectsNotReadyPatrolModel(t *testing.T) {
func TestAISettingsHandler_UpdateSettingsPersistsNotReadyPatrolModelWithReadiness(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
@ -152,18 +152,21 @@ func TestAISettingsHandler_UpdateSettingsRejectsNotReadyPatrolModel(t *testing.T
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
var payload APIError
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var payload AISettingsResponse
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"])
require.True(t, payload.Enabled)
require.NotNil(t, payload.PatrolReadiness)
require.Equal(t, patrolReadinessNotReady, payload.PatrolReadiness.Status)
require.Equal(t, string(ai.PatrolFailureCauseModelUnsupportedTools), payload.PatrolReadiness.Cause)
require.Contains(t, payload.PatrolReadiness.Summary, "reasoning-only model family")
require.Equal(t, "ollama", payload.PatrolReadiness.Provider)
require.Equal(t, model, payload.PatrolReadiness.Model)
persisted, err := persistence.LoadAIConfig()
require.NoError(t, err)
require.False(t, persisted.Enabled)
require.True(t, persisted.Enabled)
require.Equal(t, model, persisted.PatrolModel)
}
func TestAISettingsHandler_UpdateSettingsDoesNotLockUnrelatedSavesBehindExistingPatrolReadiness(t *testing.T) {

View file

@ -9801,6 +9801,7 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) {
IntervalMs: 21600000,
FixedCount: 2,
BlockedReason: "Awaiting AI provider configuration",
BlockedCause: string(ai.PatrolFailureCauseProviderNotConfigured),
BlockedAt: &blockedAt,
LicenseRequired: true,
LicenseStatus: "none",
@ -9808,6 +9809,7 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) {
Readiness: &PatrolReadinessResponse{
Status: patrolReadinessNotReady,
Ready: false,
Cause: string(ai.PatrolFailureCauseModelUnsupportedTools),
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",
@ -9815,6 +9817,7 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) {
{
ID: "tools",
Status: patrolReadinessNotReady,
Cause: string(ai.PatrolFailureCauseModelUnsupportedTools),
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",
@ -9848,12 +9851,13 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) {
"interval_ms":21600000,
"fixed_count":2,
"blocked_reason":"Awaiting AI provider configuration",
"blocked_cause":"provider_not_configured",
"blocked_at":"2026-03-12T09:45:00Z",
"license_required":true,
"license_status":"none",
"upgrade_url":"https://pulserelay.pro/upgrade?feature=ai_autofix",
"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"}]}
"readiness":{"status":"not_ready","ready":false,"cause":"model_unsupported_tools","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","cause":"model_unsupported_tools","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

@ -179,6 +179,7 @@ type InvestigationRecordTrigger struct {
Title string `json:"title,omitempty"`
DetectedAt time.Time `json:"detected_at"`
Description string `json:"description,omitempty"`
Cause string `json:"cause,omitempty"`
}
// InvestigationRecordEvidence points to evidence Patrol used or generated

View file

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

View file

@ -605,9 +605,14 @@ test.describe("Patrol runtime-state browser contract", () => {
contentType: "application/json",
body: JSON.stringify({
error: "license_required",
code: "patrol_autonomy_pro_required",
message: PATROL_AUTONOMY_PRO_REQUIRED,
feature: "ai_autofix",
upgrade_url: "https://www.pulseproxmox.com/pricing",
details: {
cause: "license_required",
command: "systemctl restart pulse.service",
},
}),
});
},
@ -635,6 +640,29 @@ test.describe("Patrol runtime-state browser contract", () => {
await applyButton.click();
await expect(page.getByText(PATROL_AUTONOMY_PRO_REQUIRED)).toBeVisible();
await expect(configPanel).toBeVisible();
const inlineError = configPanel.getByTestId("patrol-configuration-error");
await expect(inlineError).toBeVisible();
await expect(inlineError).toContainText(PATROL_AUTONOMY_PRO_REQUIRED);
await expect(inlineError).toContainText("patrol_autonomy_pro_required");
await inlineError
.getByTestId("patrol-configuration-error-assistant-button")
.click();
await expect(configPanel).toBeHidden();
const assistantContext = page.getByLabel("Assistant context");
await expect(assistantContext).toBeVisible();
await expect(assistantContext).toContainText(
"Patrol configuration failure attached",
);
await expect(assistantContext).toContainText(
"patrol_autonomy_pro_required",
);
await expect(assistantContext).toContainText(
"Command: sensitive or command detail withheld",
);
await expect(
assistantContext.getByText("systemctl restart pulse.service"),
).toHaveCount(0);
await expect(
page.getByText("Failed to save advanced settings"),
).toHaveCount(0);