mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
8e17f16605
commit
88475d1cde
16 changed files with 450 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
126
internal/ai/patrol_runtime_failure.go
Normal file
126
internal/ai/patrol_runtime_failure.go
Normal 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,
|
||||
}
|
||||
}
|
||||
177
internal/ai/patrol_runtime_failure_test.go
Normal file
177
internal/ai/patrol_runtime_failure_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue