Make Patrol runtime failures actionable

Refs #1463
This commit is contained in:
rcourtman 2026-05-07 15:18:19 +01:00
parent 8e17f16605
commit 88475d1cde
16 changed files with 450 additions and 36 deletions

View file

@ -98,6 +98,12 @@ runtime cost control, and shared AI transport surfaces.
1. Update this contract when canonical AI runtime or transport entry points move
2. Keep AI runtime and shared API proof routing aligned in `registry.json`
3. Preserve explicit coverage for chat, Patrol, remediation, and cost-control behavior when AI runtime changes
Patrol runtime failures are part of that runtime contract: provider, model,
tool-calling, auth, quota, rate-limit, context-window, and connectivity
failures must be classified in `internal/ai/` before they reach operators,
surfaced as the synthetic Patrol runtime finding, and preserved on patrol
run records as structured error summary/detail instead of collapsing to
generic analysis-failed copy.
4. Keep discovery scheduling authoritative through `internal/config/ai.go`: `discovery_enabled` and `discovery_interval_hours` must govern both lightweight infrastructure discovery and deep service-discovery background loops
5. Preserve auditability for outbound model-bound context exports and keep the export record aligned with the prompt boundary that actually reaches the provider
External provider-bound unified-resource context must enforce the same

View file

@ -926,6 +926,11 @@ the canonical monitored-system blocked payload.
rather than inventing a second finding-context transport shape.
7. Keep Patrol summary payload consumers aligned on one assessment hierarchy: transport-driven Patrol summary surfaces may show supporting counts and outcomes, but the canonical assessment and verification states must remain singular and not be repeated as a second compact verdict strip
8. Keep Patrol verification and activity facts unified on one transport-backed secondary status area: when frontend consumers combine Patrol status payloads (`runtime_state`, `last_patrol_at`, `last_activity_at`, `trigger_status`) with run-history transport, the latest run result, activity mix, scoped-trigger state, and circuit-breaker context must read as one supporting explanation beneath the primary assessment instead of being re-expanded into a separate full-width status strip plus duplicate summary layers
and the Patrol runtime-failure run-history contract, so backend payloads,
persistence adapters, and `frontend-modern/src/api/patrol.ts` preserve
`error_summary` and `error_detail` whenever an erroring run has structured
provider, model, tool, context-window, quota, auth, rate-limit, or
connectivity failure context
and the main Patrol page composition boundary, so once that governed
secondary area exists inside the summary shell the same payloads must not
also drive a second page-level status strip elsewhere on the route
@ -3103,6 +3108,12 @@ That same frontend run-history path must also preserve and expose
`triage_flags` and `triage_skipped_llm` from canonical patrol run records so
deterministic triage-only runs do not collapse into generic "no analysis"
history entries.
Patrol run-history payloads must also preserve structured runtime failure
context. When a Patrol run records `error_count > 0`, the backend may include
`error_summary` and `error_detail`; persistence, API responses, and
`frontend-modern/src/api/patrol.ts` must preserve those fields so the Patrol UI
can explain provider/model/tool runtime failures without scraping finding
copy or inferring meaning from a generic error status.
Patrol status payloads no longer carry quickstart credit state as an ordinary
v6 GA API contract. `quickstart_credits_remaining`,
`quickstart_credits_total`, and `using_quickstart` are retired public fields;

View file

@ -430,6 +430,12 @@ the summary card and the findings list. The active finding row should surface
the same Patrol runtime classification with a runtime-qualified severity badge
such as `Runtime issue` or `Runtime critical`, rather than pairing a generic
infrastructure severity chip like `warning` with a second Patrol-runtime
label.
Patrol run history must follow the same operator-facing runtime failure
contract. Expanded erroring runs should show the backend-provided
`error_summary` and bounded `error_detail` near the run narrative, so a
provider/model/tool failure remains actionable even when the operator starts
from the Runs tab rather than the synthetic runtime finding.
classification badge.
That same shared finding-presentation helper should also own Patrol finding
subject labels, so Patrol-owned synthetic service findings render as

View file

@ -129,6 +129,8 @@ describe('patrol api', () => {
finding_ids: [],
error_count: 0,
status: 'healthy',
error_summary: 'Selected model does not support Patrol tools',
error_detail: 'provider rejected tool_choice',
triage_flags: 2,
triage_skipped_llm: true,
tool_call_count: 0,
@ -143,6 +145,8 @@ describe('patrol api', () => {
alertIdentifier: 'canonical-alert-1',
effective_scope_resource_ids: ['resource-1', 'resource-2'],
truenas_checked: 1,
error_summary: 'Selected model does not support Patrol tools',
error_detail: 'provider rejected tool_choice',
triage_flags: 2,
triage_skipped_llm: true,
});

View file

@ -429,6 +429,8 @@ export interface PatrolRunRecord {
finding_ids?: string[];
error_count: number;
status: PatrolRunStatus;
error_summary?: string;
error_detail?: string;
triage_flags: number;
triage_skipped_llm?: boolean;
ai_analysis?: string;

View file

@ -153,6 +153,9 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
const coverageSummary = getPatrolRunCoverageSummary(run);
const hasFindingsSnapshot = run.finding_ids !== undefined;
const runStatus = getPatrolRunStatusPresentation(run.status, run.error_count, hasFindingsSnapshot);
const runErrorSummary = String(run.error_summary || '').trim();
const runErrorDetail = String(run.error_detail || '').trim();
const hasRunErrorDetail = run.error_count > 0 && (runErrorSummary || runErrorDetail);
return (
<div
@ -268,6 +271,20 @@ export function RunHistoryEntry(props: RunHistoryEntryProps) {
</p>
</div>
<Show when={hasRunErrorDetail}>
<div class="mt-3 flex items-start gap-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200">
<AlertTriangleIcon class="mt-0.5 h-4 w-4 flex-shrink-0" />
<div class="min-w-0 space-y-1">
<p class="font-medium">{runErrorSummary || 'Patrol analysis did not complete'}</p>
<Show when={runErrorDetail && runErrorDetail !== runErrorSummary}>
<p class="break-words text-xs leading-relaxed text-red-700 dark:text-red-300">
{runErrorDetail}
</p>
</Show>
</div>
</div>
</Show>
{/* Section 2: Resources Scanned */}
<Show when={run.resources_checked > 0}>
<div class="mt-3">

View file

@ -195,6 +195,30 @@ describe('RunHistoryEntry', () => {
).toBeInTheDocument();
});
it('surfaces structured Patrol runtime error details on expanded runs', () => {
render(() => (
<RunHistoryEntry
run={{
...run,
id: 'run-runtime-error',
error_count: 1,
status: 'error',
error_summary: 'Selected model does not support Patrol tools',
error_detail:
"agentic patrol failed: API error (404): No endpoints found that support the provided 'tool_choice' value.",
}}
isLive={false}
patrolStream={patrolStream}
selected={true}
onSelect={vi.fn()}
/>
));
expect(screen.getByText('Selected model does not support Patrol tools')).toBeInTheDocument();
expect(screen.getByText(/tool_choice/)).toBeInTheDocument();
expect(screen.getByText('error')).toBeInTheDocument();
});
it('keeps zero-coverage scoped runs on the shared coverage narrative', () => {
render(() => (
<RunHistoryEntry

View file

@ -142,6 +142,8 @@ type PatrolRunRecord struct {
FindingIDs []string `json:"finding_ids"` // IDs of findings from this run
ErrorCount int `json:"error_count"`
Status string `json:"status"` // "healthy", "issues_found", "error"
ErrorSummary string `json:"error_summary,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
// Triage stats
TriageFlags int `json:"triage_flags"` // Number of deterministic flags found
TriageSkippedLLM bool `json:"triage_skipped_llm,omitempty"` // True if LLM was skipped (quiet infra)
@ -186,6 +188,8 @@ type patrolRunRecordJSON struct {
FindingIDs []string `json:"finding_ids"`
ErrorCount int `json:"error_count"`
Status string `json:"status"`
ErrorSummary string `json:"error_summary,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
TriageFlags int `json:"triage_flags"`
TriageSkippedLLM bool `json:"triage_skipped_llm,omitempty"`
AIAnalysis string `json:"ai_analysis,omitempty"`
@ -261,6 +265,8 @@ func (r PatrolRunRecord) MarshalJSON() ([]byte, error) {
FindingIDs: normalized.FindingIDs,
ErrorCount: normalized.ErrorCount,
Status: normalized.Status,
ErrorSummary: normalized.ErrorSummary,
ErrorDetail: normalized.ErrorDetail,
TriageFlags: normalized.TriageFlags,
TriageSkippedLLM: normalized.TriageSkippedLLM,
AIAnalysis: normalized.AIAnalysis,
@ -310,6 +316,8 @@ func (r *PatrolRunRecord) UnmarshalJSON(data []byte) error {
FindingIDs: payload.FindingIDs,
ErrorCount: payload.ErrorCount,
Status: payload.Status,
ErrorSummary: payload.ErrorSummary,
ErrorDetail: payload.ErrorDetail,
TriageFlags: payload.TriageFlags,
TriageSkippedLLM: payload.TriageSkippedLLM,
AIAnalysis: payload.AIAnalysis,

View file

@ -67,6 +67,8 @@ func (a *PatrolHistoryPersistenceAdapter) SavePatrolRunHistory(runs []PatrolRunR
FindingIDs: normalized.FindingIDs,
ErrorCount: normalized.ErrorCount,
Status: normalized.Status,
ErrorSummary: normalized.ErrorSummary,
ErrorDetail: normalized.ErrorDetail,
TriageFlags: normalized.TriageFlags,
TriageSkippedLLM: normalized.TriageSkippedLLM,
AIAnalysis: normalized.AIAnalysis,
@ -123,6 +125,8 @@ func (a *PatrolHistoryPersistenceAdapter) LoadPatrolRunHistory() ([]PatrolRunRec
FindingIDs: r.FindingIDs,
ErrorCount: r.ErrorCount,
Status: r.Status,
ErrorSummary: r.ErrorSummary,
ErrorDetail: r.ErrorDetail,
TriageFlags: r.TriageFlags,
TriageSkippedLLM: r.TriageSkippedLLM,
AIAnalysis: r.AIAnalysis,

View file

@ -613,6 +613,8 @@ func TestPatrolHistoryPersistenceAdapter_PreservesEmptySnapshotsAndParityFields(
FindingIDs: []string{},
ErrorCount: 0,
Status: "healthy",
ErrorSummary: "Selected model does not support Patrol tools",
ErrorDetail: "provider rejected tool_choice",
TriageFlags: 3,
TriageSkippedLLM: true,
},
@ -656,6 +658,12 @@ func TestPatrolHistoryPersistenceAdapter_PreservesEmptySnapshotsAndParityFields(
if !loaded[0].TriageSkippedLLM {
t.Fatal("expected TriageSkippedLLM to survive persistence")
}
if loaded[0].ErrorSummary != "Selected model does not support Patrol tools" {
t.Fatalf("expected ErrorSummary to survive persistence, got %q", loaded[0].ErrorSummary)
}
if loaded[0].ErrorDetail != "provider rejected tool_choice" {
t.Fatalf("expected ErrorDetail to survive persistence, got %q", loaded[0].ErrorDetail)
}
}
func TestPatrolHistoryPersistenceAdapter_LoadError(t *testing.T) {

View file

@ -289,7 +289,9 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge
triageSkippedLLM bool
findingIDs []string
errors int
lastAIError error // Preserve original error for circuit breaker categorization
lastAIError error // Preserve original error for circuit breaker categorization
errorSummary string
errorDetail string
aiAnalysis *AIAnalysisResult // Stores the AI's analysis for the run record
}
var newFindings []*Finding
@ -383,43 +385,12 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge
log.Warn().Err(aiErr).Msg("AI Patrol: LLM analysis failed")
runStats.errors++
runStats.lastAIError = aiErr
failure := patrolRuntimeFailureFromError(aiErr)
runStats.errorSummary = failure.Summary
runStats.errorDetail = failure.Detail
// Create a finding to surface this error to the user
errMsg := aiErr.Error()
var title, description, recommendation string
if strings.Contains(errMsg, "Insufficient Balance") || strings.Contains(errMsg, "402") {
title = "Pulse Patrol: Provider billing or quota issue"
description = "Pulse Patrol cannot analyze your infrastructure because the configured AI provider rejected the request for billing or quota reasons."
recommendation = "Resolve the billing or quota issue with your AI provider, or switch to a different provider or local model in Pulse Assistant settings."
} else if strings.Contains(errMsg, "401") || strings.Contains(errMsg, "Unauthorized") {
title = "Pulse Patrol: Invalid API key"
description = "Pulse Patrol cannot analyze your infrastructure because the API key is invalid or expired."
recommendation = "Check your API key in Pulse Assistant settings and verify it is correct."
} else if strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "429") {
title = "Pulse Patrol: Rate limited"
description = "Pulse Patrol is being rate limited by your provider. Analysis will be retried on the next patrol run."
recommendation = "Wait for the rate limit to reset, or consider upgrading your API plan for higher limits."
} else {
title = "Pulse Patrol: Analysis failed"
description = fmt.Sprintf("Pulse Patrol encountered an error while analyzing your infrastructure: %s", errMsg)
recommendation = "Check your Pulse Assistant settings and API key. If the problem persists, check the logs for more details."
}
errorFinding := &Finding{
ID: generateFindingID("ai-service", "reliability", "ai-patrol-error"),
Key: "ai-patrol-error",
Severity: "warning",
Category: "reliability",
ResourceID: "ai-service",
ResourceName: "Pulse Patrol Service",
ResourceType: "service",
Title: title,
Description: description,
Recommendation: recommendation,
Evidence: fmt.Sprintf("Error: %s", errMsg),
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
}
errorFinding := newPatrolRuntimeFailureFinding(failure, time.Now())
trackFinding(errorFinding)
} else if aiResult != nil {
runStats.aiAnalysis = aiResult
@ -559,6 +530,8 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge
FindingIDs: runStats.findingIDs,
ErrorCount: runStats.errors,
Status: status,
ErrorSummary: runStats.errorSummary,
ErrorDetail: runStats.errorDetail,
}
if scope != nil {
@ -699,6 +672,8 @@ func (p *PatrolService) runScopedPatrol(ctx context.Context, scope PatrolScope)
triageSkippedLLM bool
findingIDs []string
errors int
errorSummary string
errorDetail string
aiAnalysis *AIAnalysisResult
}
@ -814,6 +789,16 @@ func (p *PatrolService) runScopedPatrol(ctx context.Context, scope PatrolScope)
if aiErr != nil {
log.Warn().Err(aiErr).Msg("AI Patrol (scoped): LLM analysis failed")
runStats.errors++
failure := patrolRuntimeFailureFromError(aiErr)
runStats.errorSummary = failure.Summary
runStats.errorDetail = failure.Detail
errorFinding := newPatrolRuntimeFailureFinding(failure, time.Now())
if p.recordFinding(errorFinding) {
runStats.newFindings++
} else {
runStats.existingFindings++
}
runStats.findingIDs = append(runStats.findingIDs, errorFinding.ID)
} else if aiResult != nil {
runStats.aiAnalysis = aiResult
runStats.rejectedFindings = aiResult.RejectedFindings
@ -899,6 +884,8 @@ func (p *PatrolService) runScopedPatrol(ctx context.Context, scope PatrolScope)
FindingIDs: runStats.findingIDs,
ErrorCount: runStats.errors,
Status: status,
ErrorSummary: runStats.errorSummary,
ErrorDetail: runStats.errorDetail,
}
if runStats.aiAnalysis != nil {

View file

@ -0,0 +1,126 @@
package ai
import (
"fmt"
"strings"
"time"
)
const patrolRuntimeFailureDetailLimit = 2000
type patrolRuntimeFailure struct {
Title string
Summary string
Description string
Recommendation string
Detail string
Evidence string
}
func patrolRuntimeFailureFromError(err error) patrolRuntimeFailure {
raw := ""
if err != nil {
raw = strings.TrimSpace(err.Error())
}
detail := truncateString(raw, patrolRuntimeFailureDetailLimit)
lower := strings.ToLower(raw)
failure := patrolRuntimeFailure{
Title: "Pulse Patrol: Provider analysis error",
Summary: "Provider analysis error",
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,
}
switch {
case strings.Contains(lower, "tool_choice") ||
strings.Contains(lower, "tool calling") ||
strings.Contains(lower, "tools are not supported") ||
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.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") ||
strings.Contains(lower, "not found") ||
strings.Contains(lower, "does not exist") ||
strings.Contains(lower, "no such model")):
failure.Title = "Pulse Patrol: Selected model unavailable"
failure.Summary = "Selected model unavailable"
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.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") ||
strings.Contains(lower, "402") ||
strings.Contains(lower, "payment required") ||
strings.Contains(lower, "quota") ||
strings.Contains(lower, "credit"):
failure.Title = "Pulse Patrol: Provider billing or quota issue"
failure.Summary = "Provider billing or quota issue"
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") ||
strings.Contains(lower, "429") ||
strings.Contains(lower, "too many requests"):
failure.Title = "Pulse Patrol: Provider rate limited"
failure.Summary = "Provider rate limited"
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") ||
strings.Contains(lower, "403") ||
strings.Contains(lower, "unauthorized") ||
strings.Contains(lower, "forbidden") ||
strings.Contains(lower, "api key"):
failure.Title = "Pulse Patrol: Provider authentication issue"
failure.Summary = "Provider authentication issue"
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") ||
strings.Contains(lower, "chat service not available") ||
strings.Contains(lower, "provider not available") ||
strings.Contains(lower, "failed to create provider"):
failure.Title = "Pulse Patrol: Provider not ready"
failure.Summary = "Provider not ready"
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") ||
strings.Contains(lower, "connection refused") ||
strings.Contains(lower, "no such host") ||
strings.Contains(lower, "i/o timeout") ||
strings.Contains(lower, "context deadline exceeded") ||
strings.Contains(lower, "timeout"):
failure.Title = "Pulse Patrol: Provider connection issue"
failure.Summary = "Provider connection issue"
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."
}
if failure.Detail != "" {
failure.Evidence = fmt.Sprintf("Provider error: %s", failure.Detail)
}
return failure
}
func newPatrolRuntimeFailureFinding(failure patrolRuntimeFailure, now time.Time) *Finding {
return &Finding{
ID: generateFindingID(patrolRuntimeResourceID, "reliability", patrolRuntimeFindingKey),
Key: patrolRuntimeFindingKey,
Severity: FindingSeverityWarning,
Category: FindingCategoryReliability,
ResourceID: patrolRuntimeResourceID,
ResourceName: "Pulse Patrol Service",
ResourceType: "service",
Title: failure.Title,
Description: failure.Description,
Recommendation: failure.Recommendation,
Evidence: failure.Evidence,
DetectedAt: now,
LastSeenAt: now,
}
}

View file

@ -0,0 +1,177 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
func TestPatrolRuntimeFailureFromError_ClassifiesToolCallingUnsupported(t *testing.T) {
err := errors.New(`agentic patrol failed: API error (404): {"error":{"message":"No endpoints found that support the provided 'tool_choice' value."}}`)
failure := patrolRuntimeFailureFromError(err)
if failure.Title != "Pulse Patrol: Selected model does not support Patrol tools" {
t.Fatalf("unexpected title %q", failure.Title)
}
if failure.Summary != "Selected model does not support Patrol tools" {
t.Fatalf("unexpected summary %q", failure.Summary)
}
if !strings.Contains(failure.Recommendation, "supports tool calling") {
t.Fatalf("expected recommendation to mention tool calling, got %q", failure.Recommendation)
}
if !strings.Contains(failure.Evidence, "tool_choice") {
t.Fatalf("expected evidence to keep provider detail, got %q", failure.Evidence)
}
}
func TestPatrolRuntimeFailureFromError_ClassifiesUnavailableModel(t *testing.T) {
err := errors.New(`connected to Ollama but model "qwen3.5:2b" is not available; found: qwen3.5:4b`)
failure := patrolRuntimeFailureFromError(err)
if failure.Title != "Pulse Patrol: Selected model unavailable" {
t.Fatalf("unexpected title %q", failure.Title)
}
if failure.Summary != "Selected model unavailable" {
t.Fatalf("unexpected summary %q", failure.Summary)
}
if !strings.Contains(failure.Recommendation, "models currently returned by the provider") {
t.Fatalf("unexpected recommendation %q", failure.Recommendation)
}
}
func TestPatrolRuntimeFailureFromError_DefaultIsActionable(t *testing.T) {
failure := patrolRuntimeFailureFromError(errors.New("upstream returned unexpected eof"))
if strings.Contains(strings.ToLower(failure.Title), "analysis failed") {
t.Fatalf("default title should not collapse to analysis failed: %q", failure.Title)
}
if failure.Summary != "Provider analysis error" {
t.Fatalf("unexpected summary %q", failure.Summary)
}
}
func TestNewPatrolRuntimeFailureFindingUsesCanonicalRuntimeIdentity(t *testing.T) {
failure := patrolRuntimeFailureFromError(errors.New("rate limit exceeded"))
finding := newPatrolRuntimeFailureFinding(failure, time.Unix(123, 0))
if finding.Key != patrolRuntimeFindingKey {
t.Fatalf("unexpected key %q", finding.Key)
}
if finding.ResourceID != patrolRuntimeResourceID {
t.Fatalf("unexpected resource ID %q", finding.ResourceID)
}
if finding.Title != "Pulse Patrol: Provider rate limited" {
t.Fatalf("unexpected title %q", finding.Title)
}
if finding.LastSeenAt.Unix() != 123 {
t.Fatalf("unexpected last seen %v", finding.LastSeenAt)
}
}
func TestRunPatrolRecordsStructuredRuntimeFailure(t *testing.T) {
svc := NewService(config.NewConfigPersistence(t.TempDir()), nil)
svc.cfg = &config.AIConfig{Enabled: true, PatrolModel: "openrouter/free-model"}
svc.provider = &mockProvider{}
svc.SetChatService(&mockChatService{
executor: tools.NewPulseToolExecutor(tools.ExecutorConfig{}),
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
return nil, errors.New(`API error (404): {"error":{"message":"No endpoints found that support the provided 'tool_choice' value."}}`)
},
})
ps := NewPatrolService(svc, &mockStateProvider{
state: models.StateSnapshot{
Nodes: []models.Node{{ID: "node-1", Name: "pve-1", Status: "online", CPU: 95}},
},
})
ps.SetConfig(PatrolConfig{
Enabled: true,
Interval: time.Hour,
AnalyzeNodes: true,
})
ps.runPatrol(context.Background())
runs := ps.runHistoryStore.GetRecent(1)
if len(runs) != 1 {
t.Fatalf("expected one patrol run, got %d", len(runs))
}
if runs[0].ErrorSummary != "Selected model does not support Patrol tools" {
t.Fatalf("expected structured run error summary, got %q", runs[0].ErrorSummary)
}
if !strings.Contains(runs[0].ErrorDetail, "tool_choice") {
t.Fatalf("expected run error detail to preserve provider message, got %q", runs[0].ErrorDetail)
}
finding := ps.findings.Get(generateFindingID(patrolRuntimeResourceID, "reliability", patrolRuntimeFindingKey))
if finding == nil {
t.Fatal("expected Patrol runtime finding")
}
if finding.Title != "Pulse Patrol: Selected model does not support Patrol tools" {
t.Fatalf("unexpected runtime finding title %q", finding.Title)
}
}
func TestRunScopedPatrolRecordsStructuredRuntimeFailure(t *testing.T) {
svc := NewService(config.NewConfigPersistence(t.TempDir()), nil)
svc.cfg = &config.AIConfig{Enabled: true, PatrolModel: "ollama:qwen3.5:2b"}
svc.provider = &mockProvider{}
svc.SetChatService(&mockChatService{
executor: tools.NewPulseToolExecutor(tools.ExecutorConfig{}),
executePatrolStreamFunc: func(ctx context.Context, req PatrolExecuteRequest, callback ChatStreamCallback) (*PatrolStreamResponse, error) {
return nil, errors.New(`connected to Ollama but model "qwen3.5:2b" is not available; found: qwen3.5:4b`)
},
})
ps := NewPatrolService(svc, &mockStateProvider{
state: models.StateSnapshot{
Nodes: []models.Node{{ID: "node-1", Name: "pve-1", Status: "online", CPU: 95}},
},
})
ps.SetConfig(PatrolConfig{
Enabled: true,
Interval: time.Hour,
AnalyzeNodes: true,
})
ps.findings.Add(&Finding{
ID: "existing-node-finding",
Severity: FindingSeverityWarning,
Category: FindingCategoryPerformance,
ResourceID: "node-1",
ResourceName: "pve-1",
ResourceType: "node",
Title: "Existing node finding",
DetectedAt: time.Now(),
LastSeenAt: time.Now(),
})
ps.runScopedPatrol(context.Background(), PatrolScope{
ResourceIDs: []string{"node-1"},
Reason: TriggerReasonManual,
NoStream: true,
})
runs := ps.runHistoryStore.GetRecent(1)
if len(runs) != 1 {
t.Fatalf("expected one scoped patrol run, got %d", len(runs))
}
if runs[0].ErrorSummary != "Selected model unavailable" {
t.Fatalf("expected structured run error summary, got %q", runs[0].ErrorSummary)
}
finding := ps.findings.Get(generateFindingID(patrolRuntimeResourceID, "reliability", patrolRuntimeFindingKey))
if finding == nil {
t.Fatal("expected Patrol runtime finding")
}
if finding.Title != "Pulse Patrol: Selected model unavailable" {
t.Fatalf("unexpected runtime finding title %q", finding.Title)
}
}

View file

@ -2505,6 +2505,8 @@ type PatrolRunRecord struct {
FindingIDs []string `json:"finding_ids"`
ErrorCount int `json:"error_count"`
Status string `json:"status"` // "healthy", "issues_found", "critical", "error"
ErrorSummary string `json:"error_summary,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
// Triage stats
TriageFlags int `json:"triage_flags"`
TriageSkippedLLM bool `json:"triage_skipped_llm,omitempty"`
@ -2549,6 +2551,8 @@ type patrolRunRecordJSON struct {
FindingIDs []string `json:"finding_ids"`
ErrorCount int `json:"error_count"`
Status string `json:"status"`
ErrorSummary string `json:"error_summary,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
TriageFlags int `json:"triage_flags"`
TriageSkippedLLM bool `json:"triage_skipped_llm,omitempty"`
AIAnalysis string `json:"ai_analysis,omitempty"`
@ -2626,6 +2630,8 @@ func (r PatrolRunRecord) MarshalJSON() ([]byte, error) {
FindingIDs: normalized.FindingIDs,
ErrorCount: normalized.ErrorCount,
Status: normalized.Status,
ErrorSummary: normalized.ErrorSummary,
ErrorDetail: normalized.ErrorDetail,
TriageFlags: normalized.TriageFlags,
TriageSkippedLLM: normalized.TriageSkippedLLM,
AIAnalysis: normalized.AIAnalysis,
@ -2675,6 +2681,8 @@ func (r *PatrolRunRecord) UnmarshalJSON(data []byte) error {
FindingIDs: payload.FindingIDs,
ErrorCount: payload.ErrorCount,
Status: payload.Status,
ErrorSummary: payload.ErrorSummary,
ErrorDetail: payload.ErrorDetail,
TriageFlags: payload.TriageFlags,
TriageSkippedLLM: payload.TriageSkippedLLM,
AIAnalysis: payload.AIAnalysis,

View file

@ -137,6 +137,8 @@ func TestConfigPersistence_AIChatSessionsMigratesPlaintextFile(t *testing.T) {
func TestPatrolRunRecordJSONCanonicalOutput(t *testing.T) {
record := config.PatrolRunRecord{
ID: "run-1",
ErrorSummary: "Selected model does not support Patrol tools",
ErrorDetail: "provider rejected tool_choice",
AlertIdentifier: "instance:node:100::metric/cpu",
}
@ -158,10 +160,18 @@ func TestPatrolRunRecordJSONCanonicalOutput(t *testing.T) {
if _, ok := payload["alert_id"]; ok {
t.Fatalf("did not expect alert_id in canonical payload, got %#v", payload["alert_id"])
}
if payload["error_summary"] != "Selected model does not support Patrol tools" {
t.Fatalf("expected error_summary to persist, got %#v", payload["error_summary"])
}
if payload["error_detail"] != "provider rejected tool_choice" {
t.Fatalf("expected error_detail to persist, got %#v", payload["error_detail"])
}
var decoded config.PatrolRunRecord
if err := json.Unmarshal([]byte(`{
"id":"run-1",
"error_summary":"Selected model does not support Patrol tools",
"error_detail":"provider rejected tool_choice",
"alert_identifier":"instance:node:100::metric/cpu"
}`), &decoded); err != nil {
t.Fatalf("unmarshal canonical patrol run: %v", err)
@ -169,6 +179,12 @@ func TestPatrolRunRecordJSONCanonicalOutput(t *testing.T) {
if decoded.AlertIdentifier != "instance:node:100::metric/cpu" {
t.Fatalf("expected canonical alert_identifier to load, got %q", decoded.AlertIdentifier)
}
if decoded.ErrorSummary != "Selected model does not support Patrol tools" {
t.Fatalf("expected error_summary to load, got %q", decoded.ErrorSummary)
}
if decoded.ErrorDetail != "provider rejected tool_choice" {
t.Fatalf("expected error_detail to load, got %q", decoded.ErrorDetail)
}
}
func TestConfigPersistence_UpdateEnvFile(t *testing.T) {

View file

@ -126,6 +126,9 @@ function buildScopedTriggerRunHistory() {
finding_ids: ["finding-triggered"],
error_count: 1,
status: "healthy",
error_summary: "Selected model does not support Patrol tools",
error_detail:
"agentic patrol failed: API error (404): No endpoints found that support the provided 'tool_choice' value.",
triage_flags: 0,
tool_call_count: 0,
},
@ -672,5 +675,12 @@ test.describe("Patrol runtime-state browser contract", () => {
),
).toBeVisible();
await expect(page.getByText("Last full patrol")).toBeVisible();
await page.getByRole("button", { name: "Runs" }).click();
await page.getByRole("button", { name: /Alert fired/i }).click();
await expect(
page.getByText("Selected model does not support Patrol tools"),
).toBeVisible();
await expect(page.getByText(/tool_choice/)).toBeVisible();
});
});