mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
parent
0da15bc1bf
commit
371ce99694
14 changed files with 754 additions and 3 deletions
|
|
@ -2421,6 +2421,115 @@
|
|||
"kind": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "RA27",
|
||||
"summary": "Patrol status owns server-authored runtime readiness: provider, model, settings-persistence, and tool-calling prerequisites must travel as structured `/api/ai/patrol/status` checks, and known not-ready states must block manual Patrol runs before they become generic interrupted-analysis failures.",
|
||||
"kind": "invariant",
|
||||
"blocking_level": "repo-ready",
|
||||
"proof_type": "automated",
|
||||
"lane_ids": [
|
||||
"L6"
|
||||
],
|
||||
"subsystem_ids": [
|
||||
"ai-runtime",
|
||||
"api-contracts",
|
||||
"patrol-intelligence"
|
||||
],
|
||||
"release_gate_ids": [],
|
||||
"proof_commands": [
|
||||
{
|
||||
"id": "patrol-readiness-frontend-tests",
|
||||
"run": [
|
||||
"npm",
|
||||
"--prefix",
|
||||
"frontend-modern",
|
||||
"test",
|
||||
"--",
|
||||
"--run",
|
||||
"src/api/__tests__/patrol.test.ts",
|
||||
"src/pages/__tests__/AIIntelligence.test.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "patrol-readiness-status-contract-tests",
|
||||
"run": [
|
||||
"go",
|
||||
"test",
|
||||
"./internal/api",
|
||||
"-run",
|
||||
"PatrolReadiness|PatrolStatus|Contract_PatrolStatusResponseJSONSnapshot",
|
||||
"-count=1"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidence": [
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/status.json",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/ai-runtime.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/api-contracts.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/subsystems/patrol-intelligence.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/api/__tests__/patrol.test.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/api/patrol.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/ai_handlers.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/ai_handlers_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/ai_patrol_handlers_test.go",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "internal/api/contract_test.go",
|
||||
"kind": "file"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidence_reference_policy": {
|
||||
|
|
|
|||
|
|
@ -107,6 +107,10 @@ runtime cost control, and shared AI transport surfaces.
|
|||
Unavailable-provider blocked states must direct operators to Assistant &
|
||||
Patrol provider settings and tool-capable Patrol model selection, not
|
||||
legacy `Settings > Pulse Assistant` copy.
|
||||
Patrol status must also carry server-authored readiness for provider,
|
||||
model, settings-persistence, and tool-calling prerequisites so the UI can
|
||||
block known-bad manual Patrol runs before they become generic runtime
|
||||
failures.
|
||||
4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops
|
||||
5. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider
|
||||
External provider-bound unified-resource context must enforce the same
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@ product API routes free of maintainer commercial analytics.
|
|||
4. `frontend-modern/src/api/notifications.ts` shared with `notifications`: the notifications frontend client is both a notification delivery control surface and a canonical API payload contract boundary.
|
||||
5. `frontend-modern/src/api/orgs.ts` shared with `organization-settings`: the organization frontend client is both an organization settings control surface and a canonical API payload contract boundary.
|
||||
6. `frontend-modern/src/api/patrol.ts` shared with `ai-runtime`: the Patrol frontend client is both an AI runtime control surface and a canonical API payload contract boundary.
|
||||
The Patrol status payload owns Patrol readiness as structured API state:
|
||||
provider/model/settings/tool prerequisites must travel as bounded readiness
|
||||
checks instead of frontend-only heuristics or generic analysis-failed text.
|
||||
7. `frontend-modern/src/api/rbac.ts` shared with `organization-settings`: the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary.
|
||||
8. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary.
|
||||
9. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,10 @@ Patrol-specific presentation helpers.
|
|||
header badges, runtime banners, empty states, and settings handoffs must
|
||||
present provider setup as BYOK/local/self-managed rather than promising
|
||||
managed credits or account-backed AI access.
|
||||
Server-authored Patrol readiness from the status payload is part of the
|
||||
Patrol product surface: warnings must be visible before a run starts, and
|
||||
known not-ready states must disable manual Patrol runs instead of letting
|
||||
operators discover provider/model/tool incompatibility through a failed run.
|
||||
5. Keep customer-facing Patrol naming product-first: page titles, route chrome,
|
||||
summary copy, actions, and empty states should lead with `Patrol` or
|
||||
`Pulse Patrol` rather than generic `AI` branding. Reserve `AI` terminology
|
||||
|
|
|
|||
|
|
@ -64,12 +64,43 @@ describe('patrol api', () => {
|
|||
runtime_state: 'blocked',
|
||||
blocked_reason: 'Connect a provider to power Pulse Assistant and Patrol.',
|
||||
healthy: false,
|
||||
readiness: {
|
||||
status: 'not_ready',
|
||||
ready: false,
|
||||
summary:
|
||||
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
provider: 'ollama',
|
||||
model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M',
|
||||
checks: [
|
||||
{
|
||||
id: 'tools',
|
||||
status: 'not_ready',
|
||||
label: 'Patrol tools',
|
||||
message:
|
||||
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
action: 'open_provider_settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(getPatrolStatus()).resolves.toMatchObject({
|
||||
runtime_state: 'blocked',
|
||||
blocked_reason: 'Connect a provider to power Pulse Assistant and Patrol.',
|
||||
healthy: false,
|
||||
readiness: {
|
||||
status: 'not_ready',
|
||||
ready: false,
|
||||
provider: 'ollama',
|
||||
model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M',
|
||||
checks: [
|
||||
{
|
||||
id: 'tools',
|
||||
status: 'not_ready',
|
||||
action: 'open_provider_settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,24 @@ export interface FindingsSummary {
|
|||
|
||||
export type LicenseStatus = 'none' | 'active' | 'expired' | 'grace_period';
|
||||
export type PatrolRuntimeState = 'unavailable' | 'disabled' | 'running' | 'blocked' | 'active';
|
||||
export type PatrolReadinessStatus = 'ready' | 'warning' | 'not_ready';
|
||||
|
||||
export interface PatrolReadinessCheck {
|
||||
id: string;
|
||||
status: PatrolReadinessStatus;
|
||||
label: string;
|
||||
message: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface PatrolReadiness {
|
||||
status: PatrolReadinessStatus;
|
||||
ready: boolean;
|
||||
summary: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
checks: PatrolReadinessCheck[];
|
||||
}
|
||||
|
||||
export interface PatrolTriggerStatus {
|
||||
running: boolean;
|
||||
|
|
@ -172,6 +190,7 @@ export interface PatrolStatus {
|
|||
license_required?: boolean;
|
||||
license_status?: LicenseStatus;
|
||||
upgrade_url?: string;
|
||||
readiness?: PatrolReadiness;
|
||||
summary: FindingsSummary;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,69 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.showReadinessBanner() && !state.showBlockedBanner()}>
|
||||
<div
|
||||
class={`flex-shrink-0 border-b px-4 py-3 ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-900'
|
||||
: 'bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-800'
|
||||
}`}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class={`flex-shrink-0 p-1.5 rounded-md ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'bg-red-100 dark:bg-red-900'
|
||||
: 'bg-amber-100 dark:bg-amber-900'
|
||||
}`}
|
||||
>
|
||||
<ShieldAlertIcon
|
||||
class={`w-4 h-4 ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'text-red-600 dark:text-red-300'
|
||||
: 'text-amber-600 dark:text-amber-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class={`text-sm font-semibold ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'text-red-900 dark:text-red-100'
|
||||
: 'text-amber-900 dark:text-amber-100'
|
||||
}`}
|
||||
>
|
||||
{state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'Patrol readiness issue'
|
||||
: 'Patrol readiness warning'}
|
||||
</p>
|
||||
<p
|
||||
class={`text-xs ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'text-red-700 dark:text-red-200'
|
||||
: 'text-amber-700 dark:text-amber-300'
|
||||
}`}
|
||||
>
|
||||
{state.patrolReadiness()?.summary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={PATROL_PROVIDER_SETTINGS_ACTION.href}
|
||||
class={`inline-flex items-center justify-center gap-2 px-3 py-1.5 text-xs font-semibold rounded-md border transition-colors ${
|
||||
state.patrolReadiness()?.status === 'not_ready'
|
||||
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900 border-red-200 dark:border-red-700 hover:bg-red-200 dark:hover:bg-red-900'
|
||||
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900 border-amber-200 dark:border-amber-700 hover:bg-amber-200 dark:hover:bg-amber-900'
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon class="w-3.5 h-3.5" />
|
||||
{PATROL_PROVIDER_SETTINGS_ACTION.label}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={state.showBlockedBanner()}>
|
||||
<div class="flex-shrink-0 bg-amber-50 dark:bg-amber-900 border-b border-amber-200 dark:border-amber-800 px-4 py-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
|
|
|
|||
|
|
@ -336,13 +336,20 @@ export function usePatrolIntelligenceState() {
|
|||
normalizePatrolRuntimeBlockedReason(patrolStatus()?.blocked_reason),
|
||||
);
|
||||
const blockedAt = createMemo(() => patrolStatus()?.blocked_at);
|
||||
const patrolReadiness = createMemo(() => patrolStatus()?.readiness ?? null);
|
||||
const readinessBlocksPatrol = createMemo(() => patrolReadiness()?.status === 'not_ready');
|
||||
const showBlockedBanner = createMemo(() => runtimeState() === 'blocked');
|
||||
const canTriggerPatrol = createMemo(() => runtimeState() === 'active');
|
||||
const showReadinessBanner = createMemo(() => {
|
||||
const readiness = patrolReadiness();
|
||||
return runtimeState() === 'active' && readiness !== null && readiness.status !== 'ready';
|
||||
});
|
||||
const canTriggerPatrol = createMemo(() => runtimeState() === 'active' && !readinessBlocksPatrol());
|
||||
const triggerPatrolDisabledReason = createMemo(() => {
|
||||
if (runtimeState() === 'disabled') return 'Patrol is disabled';
|
||||
if (runtimeState() === 'blocked') return blockedReason() || 'Patrol is paused';
|
||||
if (runtimeState() === 'running') return 'Patrol is already running';
|
||||
if (runtimeState() === 'unavailable') return 'Patrol service is unavailable';
|
||||
if (readinessBlocksPatrol()) return patrolReadiness()?.summary || 'Patrol is not ready';
|
||||
return '';
|
||||
});
|
||||
|
||||
|
|
@ -683,6 +690,7 @@ export function usePatrolIntelligenceState() {
|
|||
patrolAnomalyTriggers,
|
||||
patrolInterval,
|
||||
patrolModel,
|
||||
patrolReadiness,
|
||||
patrolRunHistory,
|
||||
runtimeState,
|
||||
patrolStatus,
|
||||
|
|
@ -710,6 +718,7 @@ export function usePatrolIntelligenceState() {
|
|||
},
|
||||
showAdvancedSettings,
|
||||
showBlockedBanner,
|
||||
showReadinessBanner,
|
||||
showInvestigationContext,
|
||||
shouldSurfaceInvestigationContext,
|
||||
summaryStats,
|
||||
|
|
|
|||
|
|
@ -187,6 +187,9 @@ vi.mock('@/stores/aiIntelligence', () => {
|
|||
get circuitBreakerStatus() {
|
||||
return intelligenceState.circuitBreakerStatus;
|
||||
},
|
||||
get patrolPendingApprovals() {
|
||||
return [];
|
||||
},
|
||||
get correlations() {
|
||||
return correlationsState();
|
||||
},
|
||||
|
|
@ -408,6 +411,101 @@ describe('AIIntelligence entitlement gating', () => {
|
|||
expect(screen.getByTestId('pulse-patrol-logo')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
it('surfaces Patrol readiness issues before a manual run can start', async () => {
|
||||
getPatrolStatusMock.mockResolvedValue(
|
||||
defaultPatrolStatus({
|
||||
readiness: {
|
||||
status: 'not_ready',
|
||||
ready: false,
|
||||
summary:
|
||||
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
provider: 'ollama',
|
||||
model: 'ollama:deepseek-r1:7b-llama-distill-q4_K_M',
|
||||
checks: [
|
||||
{
|
||||
id: 'tools',
|
||||
status: 'not_ready',
|
||||
label: 'Patrol tools',
|
||||
message:
|
||||
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
action: 'open_provider_settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Patrol readiness issue')).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
for (const button of screen.getAllByRole('button', { name: /Run Patrol/i })) {
|
||||
expect(button).toBeDisabled();
|
||||
}
|
||||
expect(triggerPatrolRunMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces Patrol readiness warnings without blocking manual runs', async () => {
|
||||
getPatrolStatusMock.mockResolvedValue(
|
||||
defaultPatrolStatus({
|
||||
readiness: {
|
||||
status: 'warning',
|
||||
ready: true,
|
||||
summary:
|
||||
'Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification.',
|
||||
provider: 'ollama',
|
||||
model: 'ollama:llama3',
|
||||
checks: [
|
||||
{
|
||||
id: 'tools',
|
||||
status: 'warning',
|
||||
label: 'Patrol tools',
|
||||
message:
|
||||
'Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification.',
|
||||
action: 'open_provider_settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Patrol readiness warning')).toBeInTheDocument();
|
||||
});
|
||||
const runButtons = screen.getAllByRole('button', { name: /Run Patrol/i });
|
||||
for (const button of runButtons) {
|
||||
expect(button).not.toBeDisabled();
|
||||
}
|
||||
|
||||
fireEvent.click(runButtons[0]);
|
||||
await waitFor(() => {
|
||||
expect(triggerPatrolRunMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy Patrol status payloads without readiness compatible', async () => {
|
||||
getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ readiness: undefined }));
|
||||
|
||||
render(() => <AIIntelligence />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Patrol' })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Patrol readiness issue')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Patrol readiness warning')).not.toBeInTheDocument();
|
||||
for (const button of screen.getAllByRole('button', { name: /Run Patrol/i })) {
|
||||
expect(button).not.toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders canonical learned correlations in the summary page through the correlation card', async () => {
|
||||
hasFeatureMock.mockReturnValue(true);
|
||||
licenseStatusMock.mockReturnValue({ subscription_state: 'active' });
|
||||
|
|
|
|||
|
|
@ -4804,6 +4804,145 @@ type PatrolStatusResponse struct {
|
|||
Watch int `json:"watch"`
|
||||
Info int `json:"info"`
|
||||
} `json:"summary"`
|
||||
Readiness *PatrolReadinessResponse `json:"readiness,omitempty"`
|
||||
}
|
||||
|
||||
type PatrolReadinessResponse struct {
|
||||
Status string `json:"status"`
|
||||
Ready bool `json:"ready"`
|
||||
Summary string `json:"summary"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Checks []PatrolReadinessCheck `json:"checks"`
|
||||
}
|
||||
|
||||
type PatrolReadinessCheck struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Label string `json:"label"`
|
||||
Message string `json:"message"`
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
patrolReadinessReady = "ready"
|
||||
patrolReadinessWarning = "warning"
|
||||
patrolReadinessNotReady = "not_ready"
|
||||
)
|
||||
|
||||
func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService *ai.Service, patrolAvailable bool) PatrolReadinessResponse {
|
||||
checks := make([]PatrolReadinessCheck, 0, 4)
|
||||
addCheck := func(id, status, label, message, action string) {
|
||||
checks = append(checks, PatrolReadinessCheck{
|
||||
ID: id,
|
||||
Status: status,
|
||||
Label: label,
|
||||
Message: message,
|
||||
Action: action,
|
||||
})
|
||||
}
|
||||
|
||||
if aiService == nil {
|
||||
addCheck("service", patrolReadinessNotReady, "AI runtime service", "Pulse AI runtime service is not available.", "restart_service")
|
||||
return summarizePatrolReadiness("", "", checks)
|
||||
}
|
||||
if !patrolAvailable {
|
||||
addCheck("service", patrolReadinessNotReady, "Patrol service", "Pulse Patrol service is not available.", "restart_service")
|
||||
return summarizePatrolReadiness("", "", checks)
|
||||
}
|
||||
addCheck("service", patrolReadinessReady, "Patrol service", "Pulse Patrol service is available.", "")
|
||||
|
||||
cfg, err := h.loadAIConfig(ctx)
|
||||
if err != nil || cfg == nil {
|
||||
addCheck("settings", patrolReadinessNotReady, "Settings persistence", "Pulse Assistant settings could not be loaded from persistence.", "open_provider_settings")
|
||||
return summarizePatrolReadiness("", "", checks)
|
||||
}
|
||||
addCheck("settings", patrolReadinessReady, "Settings persistence", "Pulse Assistant and Patrol settings are readable.", "")
|
||||
|
||||
if !cfg.Enabled {
|
||||
addCheck("enabled", patrolReadinessNotReady, "Assistant enabled", "Pulse Assistant is disabled, so Patrol cannot run model-backed verification.", "open_provider_settings")
|
||||
} else {
|
||||
addCheck("enabled", patrolReadinessReady, "Assistant enabled", "Pulse Assistant is enabled for Patrol verification.", "")
|
||||
}
|
||||
|
||||
if !cfg.IsConfigured() {
|
||||
addCheck("provider", patrolReadinessNotReady, "Provider configured", "No AI provider is configured for Patrol.", "open_provider_settings")
|
||||
return summarizePatrolReadiness("", "", checks)
|
||||
}
|
||||
addCheck("provider", patrolReadinessReady, "Provider configured", "At least one AI provider is configured.", "")
|
||||
|
||||
model := strings.TrimSpace(cfg.GetPatrolModel())
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(cfg.GetChatModel())
|
||||
}
|
||||
provider, _ := config.ParseModelString(model)
|
||||
if model == "" || provider == "" || provider == config.AIProviderQuickstart {
|
||||
addCheck("model", patrolReadinessNotReady, "Patrol model", "No concrete Patrol model is selected.", "open_provider_settings")
|
||||
return summarizePatrolReadiness(provider, model, checks)
|
||||
}
|
||||
if !cfg.HasProvider(provider) {
|
||||
addCheck("model", patrolReadinessNotReady, "Patrol model", fmt.Sprintf("The selected Patrol model uses %s, but that provider is not configured.", provider), "open_provider_settings")
|
||||
return summarizePatrolReadiness(provider, model, checks)
|
||||
}
|
||||
addCheck("model", patrolReadinessReady, "Patrol model", "Patrol has a model selected from a configured provider.", "")
|
||||
|
||||
toolStatus, toolMessage := patrolToolReadinessForModel(provider, model)
|
||||
toolAction := ""
|
||||
if toolStatus != patrolReadinessReady {
|
||||
toolAction = "open_provider_settings"
|
||||
}
|
||||
addCheck("tools", toolStatus, "Patrol tools", toolMessage, toolAction)
|
||||
|
||||
return summarizePatrolReadiness(provider, model, checks)
|
||||
}
|
||||
|
||||
func summarizePatrolReadiness(provider, model string, checks []PatrolReadinessCheck) PatrolReadinessResponse {
|
||||
status := patrolReadinessReady
|
||||
summary := "Patrol is ready to run tool-backed verification."
|
||||
for _, check := range checks {
|
||||
if check.Status == patrolReadinessNotReady {
|
||||
status = patrolReadinessNotReady
|
||||
summary = check.Message
|
||||
break
|
||||
}
|
||||
if check.Status == patrolReadinessWarning && status == patrolReadinessReady {
|
||||
status = patrolReadinessWarning
|
||||
summary = check.Message
|
||||
}
|
||||
}
|
||||
|
||||
return PatrolReadinessResponse{
|
||||
Status: status,
|
||||
Ready: status != patrolReadinessNotReady,
|
||||
Summary: summary,
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
Checks: checks,
|
||||
}
|
||||
}
|
||||
|
||||
func ptrToPatrolReadiness(readiness PatrolReadinessResponse) *PatrolReadinessResponse {
|
||||
return &readiness
|
||||
}
|
||||
|
||||
func patrolToolReadinessForModel(provider, model string) (string, string) {
|
||||
normalizedModel := strings.ToLower(strings.TrimSpace(model))
|
||||
switch {
|
||||
case strings.Contains(normalizedModel, "deepseek-r1") ||
|
||||
strings.Contains(normalizedModel, "/r1") ||
|
||||
strings.Contains(normalizedModel, ":r1") ||
|
||||
strings.Contains(normalizedModel, "reasoner") ||
|
||||
strings.Contains(normalizedModel, "qwq"):
|
||||
return patrolReadinessNotReady, "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls. Patrol needs tool calling to inspect resources and create governed findings."
|
||||
case provider == config.AIProviderOpenRouter:
|
||||
return patrolReadinessWarning, "OpenRouter routes vary by model and endpoint. Patrol will fail closed if the routed model rejects tools or tool_choice."
|
||||
case provider == config.AIProviderOllama:
|
||||
return patrolReadinessWarning, "Ollama connectivity alone does not prove tool support. Use an Ollama model that returns tool_calls for Patrol verification."
|
||||
case provider == config.AIProviderDeepSeek:
|
||||
return patrolReadinessWarning, "DeepSeek model capability varies by model. Patrol requires a model that supports tool calling."
|
||||
default:
|
||||
return patrolReadinessReady, "The selected provider path supports Patrol's tool-backed analysis contract."
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AISettingsHandler) getPatrolService(ctx context.Context) *ai.PatrolService {
|
||||
|
|
@ -4858,6 +4997,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
|
|||
LicenseRequired: true,
|
||||
LicenseStatus: "none",
|
||||
UpgradeURL: upgradeURLForFeatureFromLicensing(featureAIAutoFixValue),
|
||||
Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), nil, false)),
|
||||
}
|
||||
if err := utils.WriteJSONResponse(w, response); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to write patrol status response (no AI service)")
|
||||
|
|
@ -4877,6 +5017,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
|
|||
Healthy: true,
|
||||
LicenseRequired: !hasAutoFixFeature,
|
||||
LicenseStatus: licenseStatus,
|
||||
Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), aiService, false)),
|
||||
}
|
||||
if !hasAutoFixFeature {
|
||||
response.UpgradeURL = upgradeURLForFeatureFromLicensing(featureAIAutoFixValue)
|
||||
|
|
@ -4921,6 +5062,7 @@ func (h *AISettingsHandler) HandleGetPatrolStatus(w http.ResponseWriter, r *http
|
|||
BlockedAt: status.BlockedAt,
|
||||
LicenseRequired: !hasAutoFixFeature,
|
||||
LicenseStatus: licenseStatus,
|
||||
Readiness: ptrToPatrolReadiness(h.buildPatrolReadiness(r.Context(), aiService, true)),
|
||||
}
|
||||
if !hasAutoFixFeature {
|
||||
response.UpgradeURL = upgradeURLForFeatureFromLicensing(featureAIAutoFixValue)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,195 @@ func TestAISettingsHandler_PatrolAutonomyMonitorOnlyAllowsMonitor(t *testing.T)
|
|||
require.Contains(t, premiumRec.Body.String(), "limited to Monitor")
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_PatrolReadinessFlagsReasoningOnlyModel(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.PatrolModel = "ollama:deepseek-r1:7b-llama-distill-q4_K_M"
|
||||
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
|
||||
require.NoError(t, persistence.SaveAIConfig(*aiCfg))
|
||||
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
readiness := handler.buildPatrolReadiness(context.Background(), handler.GetAIService(context.Background()), true)
|
||||
|
||||
require.Equal(t, patrolReadinessNotReady, readiness.Status)
|
||||
require.False(t, readiness.Ready)
|
||||
require.Equal(t, "ollama", readiness.Provider)
|
||||
require.Equal(t, "ollama:deepseek-r1:7b-llama-distill-q4_K_M", readiness.Model)
|
||||
require.Contains(t, readiness.Summary, "reasoning-only model family")
|
||||
|
||||
var toolCheck *PatrolReadinessCheck
|
||||
for i := range readiness.Checks {
|
||||
if readiness.Checks[i].ID == "tools" {
|
||||
toolCheck = &readiness.Checks[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, toolCheck)
|
||||
require.Equal(t, patrolReadinessNotReady, toolCheck.Status)
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_PatrolReadinessBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
patrolAvailable bool
|
||||
configure func(*config.AIConfig)
|
||||
wantStatus string
|
||||
wantReady bool
|
||||
wantCheckID string
|
||||
wantCheck string
|
||||
wantSummary string
|
||||
}{
|
||||
{
|
||||
name: "disabled assistant blocks patrol",
|
||||
patrolAvailable: true,
|
||||
wantStatus: patrolReadinessNotReady,
|
||||
wantReady: false,
|
||||
wantCheckID: "enabled",
|
||||
wantCheck: patrolReadinessNotReady,
|
||||
wantSummary: "disabled",
|
||||
configure: func(aiCfg *config.AIConfig) {
|
||||
aiCfg.Enabled = false
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provider configured without model blocks patrol",
|
||||
patrolAvailable: true,
|
||||
wantStatus: patrolReadinessNotReady,
|
||||
wantReady: false,
|
||||
wantCheckID: "model",
|
||||
wantCheck: patrolReadinessNotReady,
|
||||
wantSummary: "No concrete Patrol model",
|
||||
configure: func(aiCfg *config.AIConfig) {
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selected model provider must be configured",
|
||||
patrolAvailable: true,
|
||||
wantStatus: patrolReadinessNotReady,
|
||||
wantReady: false,
|
||||
wantCheckID: "model",
|
||||
wantCheck: patrolReadinessNotReady,
|
||||
wantSummary: "provider is not configured",
|
||||
configure: func(aiCfg *config.AIConfig) {
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "anthropic:claude-3-5-sonnet-latest"
|
||||
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ollama tool support is a warning",
|
||||
patrolAvailable: true,
|
||||
wantStatus: patrolReadinessWarning,
|
||||
wantReady: true,
|
||||
wantCheckID: "tools",
|
||||
wantCheck: patrolReadinessWarning,
|
||||
wantSummary: "Ollama connectivity alone does not prove tool support",
|
||||
configure: func(aiCfg *config.AIConfig) {
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "anthropic model is ready",
|
||||
patrolAvailable: true,
|
||||
wantStatus: patrolReadinessReady,
|
||||
wantReady: true,
|
||||
wantCheckID: "tools",
|
||||
wantCheck: patrolReadinessReady,
|
||||
wantSummary: "ready to run",
|
||||
configure: func(aiCfg *config.AIConfig) {
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "anthropic:claude-3-5-sonnet-latest"
|
||||
aiCfg.AnthropicAPIKey = "test-key"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deepseek aliases warn instead of silently ready",
|
||||
patrolAvailable: true,
|
||||
wantStatus: patrolReadinessWarning,
|
||||
wantReady: true,
|
||||
wantCheckID: "tools",
|
||||
wantCheck: patrolReadinessWarning,
|
||||
wantSummary: "DeepSeek model capability varies",
|
||||
configure: func(aiCfg *config.AIConfig) {
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "deepseek:deepseek-v4-flush7pro"
|
||||
aiCfg.DeepSeekAPIKey = "test-key"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing patrol service blocks before settings checks",
|
||||
patrolAvailable: false,
|
||||
wantStatus: patrolReadinessNotReady,
|
||||
wantReady: false,
|
||||
wantCheckID: "service",
|
||||
wantCheck: patrolReadinessNotReady,
|
||||
wantSummary: "Pulse Patrol service is not available",
|
||||
configure: func(aiCfg *config.AIConfig) {
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "anthropic:claude-3-5-sonnet-latest"
|
||||
aiCfg.AnthropicAPIKey = "test-key"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
if tt.configure != nil {
|
||||
tt.configure(aiCfg)
|
||||
}
|
||||
require.NoError(t, persistence.SaveAIConfig(*aiCfg))
|
||||
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
readiness := handler.buildPatrolReadiness(context.Background(), handler.GetAIService(context.Background()), tt.patrolAvailable)
|
||||
|
||||
require.Equal(t, tt.wantStatus, readiness.Status)
|
||||
require.Equal(t, tt.wantReady, readiness.Ready)
|
||||
require.Contains(t, readiness.Summary, tt.wantSummary)
|
||||
check := requirePatrolReadinessCheck(t, readiness, tt.wantCheckID)
|
||||
require.Equal(t, tt.wantCheck, check.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_PatrolReadinessNotReadyTakesPrecedenceOverWarnings(t *testing.T) {
|
||||
readiness := summarizePatrolReadiness("ollama", "ollama:llama3", []PatrolReadinessCheck{
|
||||
{ID: "tools", Status: patrolReadinessWarning, Message: "warning message"},
|
||||
{ID: "model", Status: patrolReadinessNotReady, Message: "not ready message"},
|
||||
})
|
||||
|
||||
require.Equal(t, patrolReadinessNotReady, readiness.Status)
|
||||
require.False(t, readiness.Ready)
|
||||
require.Equal(t, "not ready message", readiness.Summary)
|
||||
}
|
||||
|
||||
func requirePatrolReadinessCheck(t *testing.T, readiness PatrolReadinessResponse, id string) PatrolReadinessCheck {
|
||||
t.Helper()
|
||||
for _, check := range readiness.Checks {
|
||||
if check.ID == id {
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("readiness check %q not found in %+v", id, readiness.Checks)
|
||||
return PatrolReadinessCheck{}
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -64,6 +65,15 @@ func TestHandleGetPatrolStatus_NoPatrolService(t *testing.T) {
|
|||
if !resp.Healthy {
|
||||
t.Error("expected Healthy to be true when patrol not initialized (no issues)")
|
||||
}
|
||||
if resp.Readiness == nil {
|
||||
t.Fatal("expected readiness payload when patrol service is unavailable")
|
||||
}
|
||||
if resp.Readiness.Status != patrolReadinessNotReady {
|
||||
t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessNotReady)
|
||||
}
|
||||
if check := requirePatrolReadinessCheck(t, *resp.Readiness, "service"); check.Message != "Pulse Patrol service is not available." {
|
||||
t.Fatalf("service readiness message = %q", check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetPatrolStatus_NoAIService(t *testing.T) {
|
||||
|
|
@ -101,6 +111,59 @@ func TestHandleGetPatrolStatus_NoAIService(t *testing.T) {
|
|||
if resp.LicenseStatus == "" {
|
||||
t.Error("expected LicenseStatus to be set when AI service missing")
|
||||
}
|
||||
if resp.Readiness == nil {
|
||||
t.Fatal("expected readiness payload when AI service is missing")
|
||||
}
|
||||
if resp.Readiness.Status != patrolReadinessNotReady {
|
||||
t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessNotReady)
|
||||
}
|
||||
if check := requirePatrolReadinessCheck(t, *resp.Readiness, "service"); check.Message != "Pulse AI runtime service is not available." {
|
||||
t.Fatalf("service readiness message = %q", check.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetPatrolStatus_NormalIncludesReadiness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
aiCfg := config.NewDefaultAIConfig()
|
||||
aiCfg.Enabled = true
|
||||
aiCfg.Model = "ollama:llama3"
|
||||
aiCfg.OllamaBaseURL = "http://127.0.0.1:11434"
|
||||
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
|
||||
t.Fatalf("save AI config: %v", err)
|
||||
}
|
||||
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
handler.defaultAIService.SetReadState(unifiedresources.NewRegistry(nil))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleGetPatrolStatus(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp PatrolStatusResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Readiness == nil {
|
||||
t.Fatal("expected readiness payload when patrol service is available")
|
||||
}
|
||||
if resp.Readiness.Status != patrolReadinessWarning {
|
||||
t.Fatalf("readiness status = %q, want %q", resp.Readiness.Status, patrolReadinessWarning)
|
||||
}
|
||||
if !resp.Readiness.Ready {
|
||||
t.Fatal("warning readiness should still allow Patrol to run")
|
||||
}
|
||||
if resp.Readiness.Provider != "ollama" || resp.Readiness.Model != "ollama:llama3" {
|
||||
t.Fatalf("unexpected readiness provider/model: %+v", resp.Readiness)
|
||||
}
|
||||
requirePatrolReadinessCheck(t, *resp.Readiness, "tools")
|
||||
}
|
||||
|
||||
func TestHandleGetPatrolFindings_MethodNotAllowed(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -9805,6 +9805,22 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) {
|
|||
LicenseRequired: true,
|
||||
LicenseStatus: "none",
|
||||
UpgradeURL: "https://pulserelay.pro/upgrade?feature=ai_autofix",
|
||||
Readiness: &PatrolReadinessResponse{
|
||||
Status: patrolReadinessNotReady,
|
||||
Ready: false,
|
||||
Summary: "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.",
|
||||
Provider: "ollama",
|
||||
Model: "ollama:deepseek-r1:7b-llama-distill-q4_K_M",
|
||||
Checks: []PatrolReadinessCheck{
|
||||
{
|
||||
ID: "tools",
|
||||
Status: patrolReadinessNotReady,
|
||||
Label: "Patrol tools",
|
||||
Message: "The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.",
|
||||
Action: "open_provider_settings",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
payload.Summary.Critical = 1
|
||||
payload.Summary.Warning = 2
|
||||
|
|
@ -9836,7 +9852,8 @@ func TestContract_PatrolStatusResponseJSONSnapshot(t *testing.T) {
|
|||
"license_required":true,
|
||||
"license_status":"none",
|
||||
"upgrade_url":"https://pulserelay.pro/upgrade?feature=ai_autofix",
|
||||
"summary":{"critical":1,"warning":2,"watch":0,"info":4}
|
||||
"summary":{"critical":1,"warning":2,"watch":0,"info":4},
|
||||
"readiness":{"status":"not_ready","ready":false,"summary":"The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.","provider":"ollama","model":"ollama:deepseek-r1:7b-llama-distill-q4_K_M","checks":[{"id":"tools","status":"not_ready","label":"Patrol tools","message":"The selected Patrol model is a reasoning-only model family that commonly does not emit tool calls.","action":"open_provider_settings"}]}
|
||||
}`
|
||||
|
||||
assertJSONSnapshot(t, got, want)
|
||||
|
|
|
|||
|
|
@ -3627,7 +3627,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
|||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 183,
|
||||
"line": 186,
|
||||
"heading_line": 106,
|
||||
}
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue