mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-22 03:02:35 +00:00
parent
c9198dd54b
commit
d2625c4dfb
32 changed files with 753 additions and 82 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]) : '';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
100
internal/ai/patrol_readiness_test.go
Normal file
100
internal/ai/patrol_readiness_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue