From 238c806d1f809a7d382ea096934e3de7da94f0d9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 30 Jun 2026 15:20:42 +0100 Subject: [PATCH] Add Patrol row issue scaffolding --- ...tention-queue-strong-version-2026-06-25.md | 5 + .../v6/internal/subsystems/api-contracts.md | 6 +- .../subsystems/frontend-primitives.md | 6 +- .../subsystems/patrol-intelligence.md | 7 +- .../src/components/AI/FindingsPanel.tsx | 22 +++ .../AI/__tests__/FindingsPanel.test.ts | 14 ++ .../utils/__tests__/aiFindingWorkType.test.ts | 106 +++++++++++++ .../src/utils/aiFindingPresentation.ts | 143 ++++++++++++++++++ .../78-monitor-first-patrol-workbench.spec.ts | 37 +++++ 9 files changed, 343 insertions(+), 3 deletions(-) diff --git a/docs/release-control/v6/internal/records/protection-posture-attention-queue-strong-version-2026-06-25.md b/docs/release-control/v6/internal/records/protection-posture-attention-queue-strong-version-2026-06-25.md index 610b99a86..4eb3b556f 100644 --- a/docs/release-control/v6/internal/records/protection-posture-attention-queue-strong-version-2026-06-25.md +++ b/docs/release-control/v6/internal/records/protection-posture-attention-queue-strong-version-2026-06-25.md @@ -128,6 +128,11 @@ loop, and routes the operator from real findings/evidence to safe action. current findings, approvals, failed checks, stale protection, running checks, and setup failures have been ruled out, and browser proof keeps those facts out of the monitor-first launch page. +- 2026-06-30: Added active Patrol row scaffolding so each infrastructure + finding can expose its problem, affected resource, consequence, checked + evidence, next step, and verification state from existing finding, approval, + and workflow fields. The scaffold stays attached to the issue row and does + not create a separate proof, trust, or status strip. ## Evidence diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 034f00990..34bb46f68 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1920,7 +1920,11 @@ a new API state machine, queue contract, or verification-accounting field. rows use API-owned severity, subject, recency, recurrence, and actionable workflow state as semantic input while suppressing raw `loopState`, investigation-status, investigation-outcome, confidence, and generic - review/detected process badges from the default row; those process details + review/detected process badges from the default row. Row-owned Patrol issue + scaffolding may also use API-owned finding description, impact, evidence, + recommendation, approval state, and workflow fields to explain problem, + affected resource, checked evidence, next step, and verification state + without adding a new API payload or page-level proof surface; those process details remain available in expanded context, selected run records, Assistant handoff context, or API diagnostics when they are actually needed and the Patrol verification summary derived from run history, so the page also states whether recent Patrol evidence came from a successful full patrol or only from scoped/erroring runs instead of leaving verification scope implicit diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8225e5ba0..597eab2c3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1382,7 +1382,11 @@ settings`, and candidate review when discovered sources are waiting. It must empty and descriptive text must explain what Patrol-found problems will appear there, what the selected control level allows, and the next useful operator action; it must not fall back to activation-loop, proof, queue, or - verification-accounting language. Calm-day protection posture may use the + verification-accounting language. Active Patrol issue rows may use shared + definition-list and muted text primitives to show problem, affected + resource, checked evidence, next step, and verification state inside the + row, but that scaffold must not become a nested card, status strip, trust + strip, or page-level proof block. Calm-day protection posture may use the shared compact list and badge primitives inside that empty Open work context only after current findings, approvals, failed checks, running checks, setup failures, and overdue scheduled protection are ruled out; it diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index c62d1efe2..3ce1b46b7 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -74,7 +74,12 @@ or historical proof/counting for resolved-only work. second proof strip that restates the same finding, approval, or setup problem above the list. When active Patrol findings are the next operator step, the finding row owns the canonical primary action, contextual - Assistant handoffs, approvals, verification, and manual controls. Because a + Assistant handoffs, approvals, verification, and manual controls. A + compact row-owned issue scaffold may translate the same finding, approval, + and workflow fields into problem, affected resource, consequence, checked + evidence, next step, and verification state, but it must stay attached to + the active issue row and must not become a separate proof, trust, or status + strip. Because a contextual Assistant handoff from that workflow is still a first-party Patrol starter for the same governed journey, it must record content-free workflow prompt activity through the shared marker route with the `pulse_patrol` diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index 0c6dcd555..a7e118a5f 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -82,6 +82,7 @@ import { getInvestigationOutcomeSortOrder, getInvestigationStatusBadgeTone, getPatrolFindingActionableState, + getPatrolFindingRowScaffold, } from '@/utils/aiFindingPresentation'; import { copyToClipboard } from '@/utils/clipboard'; @@ -901,6 +902,10 @@ export const FindingsPanel: Component = (props) => { const patrolWorkflow = () => getFindingPatrolWorkflowPresentation(finding, aiIntelligenceStore.patrolPendingApprovals); const patrolActionableState = () => getPatrolFindingActionableState(finding); + const patrolRowScaffold = () => + isPatrolFindingsSource() + ? getPatrolFindingRowScaffold(finding, aiIntelligenceStore.patrolPendingApprovals) + : undefined; const collapsedApprovalAction = () => { const workflow = patrolWorkflow(); return workflow?.stage === 'approval' ? workflow : undefined; @@ -1179,6 +1184,23 @@ export const FindingsPanel: Component = (props) => { + + {(scaffold) => ( +
+ + {(item) => ( +
+
{item.label}
+
{item.value}
+
+ )} +
+
+ )} +
{/* Actions */}
diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index 033391d70..694185a5d 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -988,6 +988,20 @@ describe('aiFindingPresentation', () => { expect(findingsPanelSource).toContain('isPatrolFindingsSource() && patrolActionableState()'); }); + it('renders row-owned Patrol issue scaffolding without adding a trust strip', () => { + expect(findingsPanelSource).toContain('getPatrolFindingRowScaffold'); + expect(findingsPanelSource).toContain('patrolRowScaffold'); + expect(findingsPanelSource).toContain('aria-label="Patrol issue summary"'); + expect(aiFindingPresentationSource).toContain("label: 'Problem'"); + expect(aiFindingPresentationSource).toContain("label: 'Affected'"); + expect(aiFindingPresentationSource).toContain("label: 'Why it matters'"); + expect(aiFindingPresentationSource).toContain("label: 'What Pulse checked'"); + expect(aiFindingPresentationSource).toContain("label: 'Recommended next step'"); + expect(aiFindingPresentationSource).toContain("label: 'Verification'"); + expect(findingsPanelSource).not.toContain('Patrol trust summary'); + expect(findingsPanelSource).not.toContain('Patrol proof strip'); + }); + it('does not stack a default detected loop-state badge on active findings', () => { expect(findingsPanelSource).toContain('const shouldShowLoopStateBadge = () =>'); expect(findingsPanelSource).toContain('!isPatrolFindingsSource()'); diff --git a/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts b/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts index 6d97a7667..3354bff6a 100644 --- a/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts +++ b/frontend-modern/src/utils/__tests__/aiFindingWorkType.test.ts @@ -5,9 +5,12 @@ import { getPatrolWorkTypeComposition, getPatrolWorkTypeCompositionClause, getPatrolFindingActionableState, + getPatrolFindingRowScaffold, } from '@/utils/aiFindingPresentation'; type ClassifyInput = Parameters[0]; +type RowScaffoldInput = Parameters[0]; +type RowScaffoldApprovalInput = NonNullable[1]>; function makeFinding(overrides: Partial = {}): ClassifyInput { return { @@ -20,6 +23,26 @@ function makeFinding(overrides: Partial = {}): ClassifyInput { }; } +function makeRowScaffoldFinding(overrides: Partial = {}): RowScaffoldInput { + return { + id: 'finding-active-work', + source: 'ai-patrol', + status: 'active', + resourceId: 'node:pve-main', + resourceName: 'pve-main', + resourceType: 'agent', + title: 'High CPU pressure on pve-main', + description: 'Patrol detected sustained CPU pressure on the monitored Proxmox host.', + impact: 'Sustained CPU pressure can slow hosted workloads on this Proxmox host.', + evidence: 'CPU stayed above the configured warning threshold during the scheduled Patrol check.', + recommendation: 'Review the host load and move or stop noisy workloads before approving a fix.', + investigationStatus: undefined, + investigationOutcome: undefined, + loopState: undefined, + ...overrides, + }; +} + describe('classifyPatrolFindingWorkType', () => { it('classifies a plain active finding as new', () => { expect(classifyPatrolFindingWorkType(makeFinding())).toBe('new'); @@ -93,6 +116,89 @@ describe('classifyPatrolFindingWorkType', () => { }); }); +describe('getPatrolFindingRowScaffold', () => { + it('summarizes a plain active Patrol finding as row-owned issue text', () => { + expect(getPatrolFindingRowScaffold(makeRowScaffoldFinding())?.items).toEqual([ + { id: 'problem', label: 'Problem', value: 'High CPU pressure on pve-main' }, + { id: 'affected', label: 'Affected', value: 'pve-main (agent)' }, + { + id: 'why', + label: 'Why it matters', + value: 'Sustained CPU pressure can slow hosted workloads on this Proxmox host.', + }, + { + id: 'checked', + label: 'What Pulse checked', + value: 'CPU stayed above the configured warning threshold during the scheduled Patrol check.', + }, + { + id: 'next-step', + label: 'Recommended next step', + value: 'Review the host load and move or stop noisy workloads before approving a fix.', + }, + { id: 'verification', label: 'Verification', value: 'No fix has run yet.' }, + ]); + }); + + it('uses live approval workflow and verification state for queued fixes', () => { + const approvals: RowScaffoldApprovalInput = [ + { + status: 'pending', + toolId: 'investigation_fix', + targetId: 'finding-active-work', + expiresAt: '2099-06-30T08:11:00Z', + }, + ]; + + expect( + getPatrolFindingRowScaffold( + makeRowScaffoldFinding({ + investigationStatus: 'needs_attention', + investigationOutcome: 'fix_queued', + }), + approvals, + Date.parse('2026-06-30T08:07:00Z'), + )?.items, + ).toContainEqual({ + id: 'next-step', + label: 'Recommended next step', + value: 'A governed fix is waiting for an approve or reject decision.', + }); + expect( + getPatrolFindingRowScaffold( + makeRowScaffoldFinding({ + investigationStatus: 'needs_attention', + investigationOutcome: 'fix_queued', + }), + approvals, + Date.parse('2026-06-30T08:07:00Z'), + )?.items, + ).toContainEqual({ + id: 'verification', + label: 'Verification', + value: 'Waiting for approval before any fix runs.', + }); + }); + + it('does not scaffold non-Patrol, runtime setup, or resolved findings', () => { + expect( + getPatrolFindingRowScaffold(makeRowScaffoldFinding({ source: 'threshold' })), + ).toBeUndefined(); + expect( + getPatrolFindingRowScaffold( + makeRowScaffoldFinding({ + resourceId: 'ai-service', + resourceName: 'Pulse Patrol service', + title: 'Pulse Patrol: insufficient API credits', + }), + ), + ).toBeUndefined(); + expect( + getPatrolFindingRowScaffold(makeRowScaffoldFinding({ status: 'resolved' })), + ).toBeUndefined(); + }); +}); + describe('getPatrolWorkTypeComposition', () => { it('returns zero counts for an empty list', () => { expect(getPatrolWorkTypeComposition([])).toEqual({ diff --git a/frontend-modern/src/utils/aiFindingPresentation.ts b/frontend-modern/src/utils/aiFindingPresentation.ts index 3569054fb..666a7df1e 100644 --- a/frontend-modern/src/utils/aiFindingPresentation.ts +++ b/frontend-modern/src/utils/aiFindingPresentation.ts @@ -677,6 +677,24 @@ export interface PatrolActionableStatePresentation { tone: MetadataBadgeTone; } +export type PatrolFindingRowScaffoldItemId = + | 'affected' + | 'checked' + | 'next-step' + | 'problem' + | 'verification' + | 'why'; + +export interface PatrolFindingRowScaffoldItem { + id: PatrolFindingRowScaffoldItemId; + label: string; + value: string; +} + +export interface PatrolFindingRowScaffold { + items: PatrolFindingRowScaffoldItem[]; +} + export function getPatrolFindingActionableState( finding: Pick, ): PatrolActionableStatePresentation | undefined { @@ -701,6 +719,131 @@ export function getPatrolFindingActionableState( return undefined; } +function getPatrolFindingVerificationSummary( + finding: Pick, +): string { + switch (finding.investigationOutcome) { + case 'fix_queued': + return 'Waiting for approval before any fix runs.'; + case 'fix_executed': + return 'Fix ran; verification is in progress.'; + case 'fix_verified': + case 'resolved': + return 'Verified outcome recorded.'; + case 'fix_verification_failed': + return 'Verification failed and needs review.'; + case 'fix_verification_unknown': + return 'Verification was inconclusive.'; + case 'fix_failed': + case 'cannot_fix': + case 'timed_out': + case 'needs_attention': + return 'No verified fix; action needs review.'; + case 'fix_rejected': + return 'No change ran because the fix was rejected.'; + default: + break; + } + + if (finding.investigationStatus === 'running' || finding.investigationStatus === 'pending') { + return 'Patrol is investigating; no fix has run yet.'; + } + + return 'No fix has run yet.'; +} + +const getNonEmptyPresentationText = ( + value: string | undefined, + fallback: string, +): string => { + const normalized = String(value || '').trim(); + return normalized || fallback; +}; + +export function getPatrolFindingRowScaffold( + finding: Pick< + UnifiedFinding, + | 'description' + | 'evidence' + | 'id' + | 'impact' + | 'investigationOutcome' + | 'investigationStatus' + | 'loopState' + | 'recommendation' + | 'resourceId' + | 'resourceName' + | 'resourceType' + | 'source' + | 'status' + | 'title' + >, + approvals: Pick[] = [], + now = Date.now(), +): PatrolFindingRowScaffold | undefined { + if ( + finding.source !== 'ai-patrol' || + finding.status !== 'active' || + isPatrolRuntimeFinding(finding) + ) { + return undefined; + } + + const title = getNonEmptyPresentationText( + getFindingTitlePresentation(finding).label, + 'Current Patrol issue', + ); + const subject = getNonEmptyPresentationText( + getFindingSubjectPresentation(finding).label, + 'Affected resource not specified', + ); + const workflow = getFindingPatrolWorkflowPresentation(finding, approvals, now); + + return { + items: [ + { + id: 'problem', + label: 'Problem', + value: title, + }, + { + id: 'affected', + label: 'Affected', + value: subject, + }, + { + id: 'why', + label: 'Why it matters', + value: getNonEmptyPresentationText( + finding.impact || finding.description, + 'Review this Patrol finding before making infrastructure changes.', + ), + }, + { + id: 'checked', + label: 'What Pulse checked', + value: getNonEmptyPresentationText( + finding.evidence, + 'Patrol recorded this from the current check.', + ), + }, + { + id: 'next-step', + label: 'Recommended next step', + value: getNonEmptyPresentationText( + workflow?.detail || finding.recommendation, + 'Open details to review evidence and decide the next action.', + ), + }, + { + id: 'verification', + label: 'Verification', + value: getPatrolFindingVerificationSummary(finding), + }, + ], + }; +} + export const getPatrolFindingIssueCountLabel = (count: number): string => { const normalized = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0; if (normalized === 1) { diff --git a/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts b/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts index 5f6da06d2..ea858bb64 100644 --- a/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts +++ b/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts @@ -225,6 +225,12 @@ const buildPatrolFindingsResponse = ( title: "High CPU pressure on pve-main", description: "Patrol detected sustained CPU pressure on the monitored Proxmox host.", + impact: + "Sustained CPU pressure can slow hosted workloads on this Proxmox host.", + evidence: + "CPU stayed above the configured warning threshold during the scheduled Patrol check.", + recommendation: + "Review the host load and move or stop noisy workloads before approving a fix.", detected_at: "2026-06-30T08:05:00Z", last_seen_at: "2026-06-30T08:05:42Z", auto_resolved: false, @@ -574,6 +580,7 @@ test.describe("Monitor-first Patrol workbench browser contract", () => { await expect(page.getByText("Latest check needs review")).toHaveCount(0); await expect(page.getByText("1 approval waiting")).toHaveCount(0); await expect(page.getByText("Protection current")).toHaveCount(0); + await expect(page.getByText("What Pulse checked")).toHaveCount(0); await page.getByRole("tab", { name: /Patrol/ }).click(); await expect(page).toHaveURL(/\/patrol$/); @@ -594,6 +601,36 @@ test.describe("Monitor-first Patrol workbench browser contract", () => { await expect( page.getByRole("list", { name: "Patrol protection posture" }), ).toHaveCount(0); + const issueSummary = page.locator('dl[aria-label="Patrol issue summary"]'); + await expect(issueSummary).toBeVisible(); + await expect(issueSummary.getByText("Problem")).toBeVisible(); + await expect(issueSummary.getByText("Affected")).toBeVisible(); + await expect(issueSummary.getByText("Why it matters")).toBeVisible(); + await expect(issueSummary.getByText("What Pulse checked")).toBeVisible(); + await expect(issueSummary.getByText("Recommended next step")).toBeVisible(); + await expect(issueSummary.getByText("Verification")).toBeVisible(); + await expect( + issueSummary.getByText("High CPU pressure on pve-main"), + ).toBeVisible(); + await expect(issueSummary.getByText("pve-main (agent)")).toBeVisible(); + await expect( + issueSummary.getByText( + "Sustained CPU pressure can slow hosted workloads on this Proxmox host.", + ), + ).toBeVisible(); + await expect( + issueSummary.getByText( + "CPU stayed above the configured warning threshold during the scheduled Patrol check.", + ), + ).toBeVisible(); + await expect( + issueSummary.getByText( + "A governed fix is waiting for an approve or reject decision.", + ), + ).toBeVisible(); + await expect( + issueSummary.getByText("Waiting for approval before any fix runs."), + ).toBeVisible(); await expect( page.getByRole("heading", { name: /^Pulse Assistant$/ }), ).toHaveCount(0);