From d5d61891edb4354d520504aeb3920b49d59de54d Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 25 Mar 2026 17:24:20 +0000 Subject: [PATCH] Separate Patrol runtime issues from infrastructure issues Patrol assessment now classifies synthetic Patrol runtime findings separately from infrastructure findings so the top summary can truthfully describe Patrol-owned service failures. --- .../subsystems/patrol-intelligence.md | 6 + .../patrol/PatrolIntelligenceSummary.tsx | 1 + .../patrol/usePatrolIntelligenceState.ts | 10 ++ .../pages/__tests__/AIIntelligence.test.tsx | 10 +- .../patrolSummaryPresentation.test.ts | 39 ++++++ .../src/utils/patrolSummaryPresentation.ts | 115 +++++++++++++++++- 6 files changed, 174 insertions(+), 7 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index a96611ab0..9f2edfe6d 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -142,6 +142,12 @@ must not pair an `Issues detected` headline with a raw coverage-only `overall_health.prediction` sentence from a separate source; when active findings and incomplete verification are both true, the Patrol summary should describe both in one canonical assessment message. +That same assessment contract must also distinguish Patrol-owned runtime +findings from infrastructure findings. When the only active Patrol findings are +synthetic Patrol service/runtime conditions such as the `ai-service` +provider-credit failure, the top assessment should read as a Patrol runtime +issue rather than implying infrastructure issues were detected across the +estate. The summary recency chip must follow the same governed scope distinction. When the latest completed activity was only a scoped run, the summary should label that timestamp as `Last activity` instead of `Last patrol`; `Last full patrol` diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx index 688445a2f..0f3ee8995 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx @@ -46,6 +46,7 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat blockedReason: state.blockedReason(), criticalFindings: summaryStats().criticalFindings, warningFindings: summaryStats().warningFindings, + activeFindings: state.activePatrolFindings(), }), ); const assessmentTonePresentation = createMemo(() => diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 33fe0125b..054ba7426 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -605,6 +605,15 @@ export function usePatrolIntelligenceState() { }; }; + const activePatrolFindings = () => + aiIntelligenceStore.findings.filter( + (finding) => + finding.status === 'active' && + finding.source !== 'threshold' && + !finding.isThreshold && + !hasTriggeringAlert(finding), + ); + onMount(async () => { await Promise.all([ loadLicenseStatus(), @@ -644,6 +653,7 @@ export function usePatrolIntelligenceState() { return { activeTab, + activePatrolFindings, activityRefreshTrigger, alertAnalysisUpgradeUrl, alertAnalysisLocked, diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index c5b1f0472..dee512545 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -889,12 +889,14 @@ describe('AIIntelligence entitlement gating', () => { intelligenceState.findings = [ { id: 'finding-1', - source: 'patrol', + source: 'ai-patrol', isThreshold: false, status: 'active', severity: 'warning', - resourceId: 'svc-patrol', + resourceId: 'ai-service', + resourceName: 'Pulse Patrol Service', resourceType: 'service', + title: 'Pulse Patrol: Insufficient API credits', detectedAt: '2026-03-12T09:57:00Z', }, ]; @@ -938,10 +940,10 @@ describe('AIIntelligence entitlement gating', () => { render(() => ); await waitFor(() => { - expect(screen.getByText('Issues detected')).toBeInTheDocument(); + expect(screen.getByText('Patrol runtime issue')).toBeInTheDocument(); expect( screen.getByText( - 'Patrol surfaced 1 active warning finding. Recent coverage is also incomplete, so the rest of your infrastructure is not fully verified.', + 'Patrol surfaced 1 active warning finding about its own runtime. Recent coverage is also incomplete, so the rest of your infrastructure is not fully verified.', ), ).toBeInTheDocument(); }); diff --git a/frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts b/frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts index f345b22ff..20ab857b7 100644 --- a/frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/patrolSummaryPresentation.test.ts @@ -107,6 +107,45 @@ describe('getPatrolSummaryPresentation', () => { }); }); + it('classifies patrol-owned service failures as runtime issues instead of infrastructure issues', () => { + expect( + getPatrolAssessmentPresentation({ + overallHealth: { + score: 60, + grade: 'C', + trend: 'stable', + factors: [ + { + name: 'Patrol coverage incomplete', + impact: -0.35, + description: 'Patrol coverage is incomplete.', + category: 'coverage', + }, + ], + prediction: + 'Patrol coverage is incomplete: recent activity was limited to scoped runs and ended with errors, so overall health is not fully verified.', + }, + warningFindings: 1, + activeFindings: [ + { + status: 'active', + severity: 'warning', + resourceId: 'ai-service', + resourceName: 'Pulse Patrol Service', + title: 'Pulse Patrol: Insufficient API credits', + }, + ] as never, + }), + ).toEqual({ + title: 'Patrol runtime issue', + description: + 'Patrol surfaced 1 active warning finding about its own runtime. Recent coverage is also incomplete, so the rest of your infrastructure is not fully verified.', + eyebrow: 'Patrol assessment', + compactLabel: 'Patrol runtime issue', + tone: 'warning', + }); + }); + it('keeps no-issues copy only for fully healthy patrol states', () => { expect( getPatrolNoIssuesPresentation({ diff --git a/frontend-modern/src/utils/patrolSummaryPresentation.ts b/frontend-modern/src/utils/patrolSummaryPresentation.ts index 4a10c76c5..683c89e70 100644 --- a/frontend-modern/src/utils/patrolSummaryPresentation.ts +++ b/frontend-modern/src/utils/patrolSummaryPresentation.ts @@ -1,4 +1,5 @@ import type { PatrolRunRecord, PatrolRuntimeState } from '@/api/patrol'; +import type { UnifiedFinding } from '@/stores/aiIntelligence'; import type { IntelligenceHealthScore } from '@/types/aiIntelligence'; import type { SemanticTone } from '@/utils/semanticTonePresentation'; import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation'; @@ -37,6 +38,11 @@ export interface PatrolRecencyPresentation { timestamp?: string; } +type PatrolAssessmentFinding = Pick< + UnifiedFinding, + 'resourceId' | 'resourceName' | 'title' | 'severity' | 'status' +>; + export const PATROL_NO_ISSUES_LABEL = 'No issues found'; const QUIET_ICON_CONTAINER = 'bg-surface border-border'; @@ -90,21 +96,101 @@ function formatFindingCount(count: number, severity: 'critical' | 'warning'): st return `${count} active ${severity} finding${count === 1 ? '' : 's'}`; } +function isPatrolRuntimeFinding(finding: PatrolAssessmentFinding): boolean { + const resourceId = String(finding.resourceId || '').trim().toLowerCase(); + const resourceName = String(finding.resourceName || '').trim().toLowerCase(); + const title = String(finding.title || '').trim().toLowerCase(); + + return ( + resourceId === 'ai-service' || + resourceName === 'pulse patrol service' || + title.startsWith('pulse patrol:') + ); +} + +function classifyActiveFindings(activeFindings: PatrolAssessmentFinding[] | undefined) { + const runtimeCritical = (activeFindings ?? []).filter( + (finding) => + finding.status === 'active' && + finding.severity === 'critical' && + isPatrolRuntimeFinding(finding), + ).length; + const runtimeWarning = (activeFindings ?? []).filter( + (finding) => + finding.status === 'active' && + finding.severity === 'warning' && + isPatrolRuntimeFinding(finding), + ).length; + const infrastructureCritical = (activeFindings ?? []).filter( + (finding) => + finding.status === 'active' && + finding.severity === 'critical' && + !isPatrolRuntimeFinding(finding), + ).length; + const infrastructureWarning = (activeFindings ?? []).filter( + (finding) => + finding.status === 'active' && + finding.severity === 'warning' && + !isPatrolRuntimeFinding(finding), + ).length; + + return { + runtimeCritical, + runtimeWarning, + infrastructureCritical, + infrastructureWarning, + runtimeTotal: runtimeCritical + runtimeWarning, + infrastructureTotal: infrastructureCritical + infrastructureWarning, + }; +} + +function joinAssessmentParts(parts: string[]): string { + if (parts.length <= 1) return parts[0] ?? ''; + if (parts.length === 2) return `${parts[0]} and ${parts[1]}`; + return `${parts.slice(0, -1).join(', ')}, and ${parts.at(-1)}`; +} + function getFindingAssessmentDescription(args: { criticalFindings?: number; warningFindings?: number; overallHealth?: IntelligenceHealthScore; + activeFindings?: PatrolAssessmentFinding[]; }): string { const criticalFindings = args.criticalFindings ?? 0; const warningFindings = args.warningFindings ?? 0; const hasCoverageGap = Boolean( args.overallHealth?.factors.some((factor) => factor.category === 'coverage'), ); + const classified = classifyActiveFindings(args.activeFindings); + const findingSummaryParts: string[] = []; + + if (classified.infrastructureCritical > 0) { + findingSummaryParts.push( + `${formatFindingCount(classified.infrastructureCritical, 'critical')} in your infrastructure`, + ); + } + if (classified.infrastructureWarning > 0) { + findingSummaryParts.push( + `${formatFindingCount(classified.infrastructureWarning, 'warning')} in your infrastructure`, + ); + } + if (classified.runtimeCritical > 0) { + findingSummaryParts.push( + `${formatFindingCount(classified.runtimeCritical, 'critical')} about its own runtime`, + ); + } + if (classified.runtimeWarning > 0) { + findingSummaryParts.push( + `${formatFindingCount(classified.runtimeWarning, 'warning')} about its own runtime`, + ); + } const findingSummary = - criticalFindings > 0 - ? `Patrol surfaced ${formatFindingCount(criticalFindings, 'critical')}.` - : `Patrol surfaced ${formatFindingCount(warningFindings, 'warning')}.`; + findingSummaryParts.length > 0 + ? `Patrol surfaced ${joinAssessmentParts(findingSummaryParts)}.` + : criticalFindings > 0 + ? `Patrol surfaced ${formatFindingCount(criticalFindings, 'critical')}.` + : `Patrol surfaced ${formatFindingCount(warningFindings, 'warning')}.`; if (hasCoverageGap) { return `${findingSummary} Recent coverage is also incomplete, so the rest of your infrastructure is not fully verified.`; @@ -141,7 +227,10 @@ export function getPatrolAssessmentPresentation(args: { blockedReason?: string; criticalFindings?: number; warningFindings?: number; + activeFindings?: PatrolAssessmentFinding[]; }): PatrolAssessmentPresentation { + const classified = classifyActiveFindings(args.activeFindings); + if ( args.runtimeState === 'blocked' || args.runtimeState === 'disabled' || @@ -158,6 +247,16 @@ export function getPatrolAssessmentPresentation(args: { } if ((args.criticalFindings ?? 0) > 0) { + if (classified.infrastructureTotal === 0 && classified.runtimeTotal > 0) { + return { + title: 'Critical Patrol runtime issue', + description: getFindingAssessmentDescription(args), + eyebrow: 'Patrol assessment', + compactLabel: 'Patrol runtime issue', + tone: 'error', + }; + } + return { title: 'Critical issues detected', description: getFindingAssessmentDescription(args), @@ -168,6 +267,16 @@ export function getPatrolAssessmentPresentation(args: { } if ((args.warningFindings ?? 0) > 0) { + if (classified.infrastructureTotal === 0 && classified.runtimeTotal > 0) { + return { + title: 'Patrol runtime issue', + description: getFindingAssessmentDescription(args), + eyebrow: 'Patrol assessment', + compactLabel: 'Patrol runtime issue', + tone: 'warning', + }; + } + return { title: 'Issues detected', description: getFindingAssessmentDescription(args),