From 1cbedbd19280d89aad14538c83991e33cd5edda9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 09:39:43 +0100 Subject: [PATCH] Route queued fix recovery through Patrol briefing --- .../v6/internal/subsystems/api-contracts.md | 12 ++- .../subsystems/frontend-primitives.md | 5 +- .../subsystems/patrol-intelligence.md | 5 ++ .../src/components/AI/FindingsPanel.tsx | 1 + .../src/components/patrol/ApprovalSection.tsx | 10 ++- .../patrol/__tests__/ApprovalSection.test.tsx | 17 ++++- .../patrolInvestigationContextModel.test.ts | 30 ++++++++ .../patrol/patrolInvestigationContextModel.ts | 23 +++++- ...patrol-assistant-operator-briefing.spec.ts | 74 +++++++++++++++---- 9 files changed, 154 insertions(+), 23 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 5982f4d6a..0204328d6 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -332,9 +332,15 @@ the canonical monitored-system blocked payload. briefing payloads may include short suggested prompts, but those prompts must be derived from the same safe action posture, evidence, recurrence, and remediation-plan metadata as the briefing and must never carry raw approval, - command, or rollback command text. Direct alert-investigation API - handoffs through `internal/api/ai_handlers.go` must enforce that same - request-scoped boundary by setting `ai.ExecuteRequest.AutonomousMode` to + command, or rollback command text. Frontend queued-fix recovery handoffs + where the live approval or proposed-fix payload is unavailable must still + carry that Patrol-owned operator briefing, current `fix_queued` posture, + request-local approval-required mode, and safe suggested prompts; they must + not degrade into generic Assistant investigation chat or imply that + execution can proceed from missing command payloads. Direct + alert-investigation API handoffs through `internal/api/ai_handlers.go` must + enforce that same request-scoped boundary by setting + `ai.ExecuteRequest.AutonomousMode` to false and `ai.ExecuteRequest.RequireCommandApproval` to true; API proof must keep this guarded in both `internal/api/ai_handlers_test.go` and `internal/api/contract_test.go`. The operator diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 03d4c8750..70d93974c 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -824,7 +824,10 @@ frontend primitive boundary. suggested prompts must be derived from safe briefing metadata such as evidence, confidence, approval risk, prerequisites, recurrence, rollback, and verification posture; they must not become another primitive path for - raw approval, command, or rollback command payload text. + raw approval, command, or rollback command payload text. Missing-detail + queued-fix recovery actions must still provide the feature-owned Patrol + briefing and request-local approval-required posture rather than opening the + shared drawer as context-free generic Assistant chat. When those feature-owned helpers attach backend model-only context, the drawer store may carry only bounded handoff text and structured resource references for the shared chat transport; approval, lifecycle, and command diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index faae0a29d..b22655d41 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -176,6 +176,11 @@ Patrol-specific presentation helpers. injection, so Patrol-owned prose cannot leak governed resource names, IDs, aliases, nodes, paths, or addresses outside the canonical policy boundary. + If a `fix_queued` finding no longer has a live approval payload or detailed + proposed-fix payload available, the recovery action must still open Assistant + with the same Patrol-owned visible briefing and approval-required posture + from current finding facts; it must not fall back to generic investigation + chat or invite execution from missing command details. If the referenced finding is no longer current, Assistant must drop the stored handoff instead of continuing from stale Patrol context. Assistant handoff context must also carry the unified diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index 5f690afde..a6b82549b 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -449,6 +449,7 @@ export const FindingsPanel: Component = (props) => { subject, severity: finding.severity, findingStatus: finding.status, + investigationOutcome: finding.investigationOutcome, loopState: finding.loopState, timesRaised: finding.timesRaised, regressionCount: finding.regressionCount, diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index f80ef2b0c..57a8bdf82 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -44,6 +44,7 @@ export const ApprovalSection: Component = (props) => { title: props.findingTitle || 'Patrol finding', subject: props.resourceName || 'affected resource', findingStatus: 'active', + investigationOutcome: props.investigationOutcome, loopState: props.investigationOutcome || 'awaiting_approval', pendingApproval: approval ? { @@ -94,12 +95,19 @@ export const ApprovalSection: Component = (props) => { const handleDiscussQueuedFix = (e: Event) => { e.stopPropagation(); + const title = props.findingTitle || 'Patrol finding'; + const subject = props.resourceName || 'affected resource'; aiChatStore.openWithPrompt( - `Patrol queued a fix for a finding, but the original approval details are no longer available.\n\n**Finding:** ${props.findingTitle || 'Unknown finding'} on ${props.resourceName || 'unknown resource'}\n\nPlease help me investigate the issue again and propose a safe remediation approach.`, + [ + `Patrol queued a governed fix for ${title} on ${subject}, but the live approval payload is no longer available.`, + 'Use the attached Patrol briefing to explain the current action state, approval recovery path, and safest next step.', + 'Do not infer, repeat, or execute raw command text from this chat handoff.', + ].join('\n\n'), { targetType: props.resourceType, targetId: props.resourceId, findingId: props.findingId, + briefing: approvalBriefing(null), autonomousMode: false, }, ); diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index fc16f6c03..33374dd7f 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -123,11 +123,26 @@ describe('ApprovalSection', () => { fireEvent.click(screen.getByRole('button', { name: /discuss with assistant/i })); expect(openWithPromptMock).toHaveBeenCalledWith( - expect.stringContaining('Patrol queued a fix for a finding'), + expect.stringContaining('Patrol queued a governed fix for CPU saturation on node-1'), { targetType: 'host', targetId: 'host-1', findingId: 'finding-1', + briefing: expect.objectContaining({ + sourceLabel: 'Pulse Patrol', + title: 'Operator briefing attached', + subject: 'CPU saturation on node-1', + statusLabel: 'Fix Queued', + detailLines: expect.arrayContaining([ + expect.stringContaining('fix queued for governed review'), + expect.stringContaining('Recover or regenerate the governed approval before execution'), + ]), + suggestedPrompts: [ + 'Review approval risk and next step', + 'Explain current finding status', + 'List approval prerequisites before action', + ], + }), autonomousMode: false, }, ); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index b7b740d52..35d16c914 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -584,4 +584,34 @@ describe('patrolInvestigationContextModel', () => { ], }); }); + + it('builds a queued-fix recovery briefing when live approval details are unavailable', () => { + expect( + buildPatrolAssistantFindingBriefing({ + title: 'CPU saturation', + subject: 'node-1', + findingStatus: 'active', + investigationOutcome: 'fix_queued', + loopState: 'fix_queued', + }), + ).toEqual({ + sourceLabel: 'Pulse Patrol', + title: 'Operator briefing attached', + subject: 'CPU saturation on node-1', + statusLabel: 'Fix Queued', + detailLines: [ + 'Attention: active finding; loop fix queued; fix queued for governed review', + 'Decision: Recover or regenerate the governed approval before execution; do not execute from chat context.', + ], + evidence: [], + actionLabel: undefined, + commandSummary: undefined, + safetyNote: undefined, + suggestedPrompts: [ + 'Review approval risk and next step', + 'Explain current finding status', + 'List approval prerequisites before action', + ], + }); + }); }); diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 6c0fa0440..895d00652 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -73,6 +73,7 @@ export interface PatrolAssistantFindingBriefingInput { subject: string; severity?: string | null; findingStatus?: string | null; + investigationOutcome?: string | null; loopState?: string | null; timesRaised?: number | null; regressionCount?: number | null; @@ -1024,6 +1025,7 @@ export function buildPatrolAssistantFindingBriefing( ? [ pendingApproval.status ? `${formatIdentifierLabel(pendingApproval.status)} approval` : '', pendingApproval.riskLevel ? `${formatIdentifierLabel(pendingApproval.riskLevel)} risk` : '', + !pendingApproval.id ? formatIdentifierLabel(input.investigationOutcome) || '' : '', ] : []; const statusParts = [ @@ -1097,7 +1099,7 @@ function buildPatrolFindingSuggestedPrompts( ): string[] { const prompts: string[] = []; const requiresApproval = patrolAssistantFindingHandoffRequiresApprovalMode({ - investigationOutcome: input.investigationRecord?.outcome, + investigationOutcome: input.investigationOutcome || input.investigationRecord?.outcome, remediationId: input.remediationId, pendingApproval, investigationRecord: input.investigationRecord, @@ -1198,7 +1200,7 @@ function buildPatrolAssistantAttentionReason( parts.push('destructive proposed fix'); } - switch (normalizeText(rawRecord?.outcome)) { + switch (normalizeText(rawRecord?.outcome || input.investigationOutcome)) { case 'fix_queued': parts.push('fix queued for governed review'); break; @@ -1327,6 +1329,23 @@ function buildPatrolAssistantOperatorDecision( return `Review governed remediation ${remediationId} before execution.`; } + switch (normalizeText(input.investigationOutcome).toLowerCase()) { + case 'fix_queued': + return 'Recover or regenerate the governed approval before execution; do not execute from chat context.'; + case 'fix_executed': + return 'Verify the execution result before closing or resolving the finding.'; + case 'fix_failed': + case 'fix_verification_failed': + return 'Review failed remediation evidence before retrying or escalating.'; + case 'fix_verification_unknown': + return 'Gather verification evidence before closing or retrying remediation.'; + case 'needs_attention': + case 'cannot_fix': + return 'Operator intervention is required; use the evidence to choose the next manual step.'; + case 'timed_out': + return 'Patrol timed out; rerun investigation or gather more evidence before remediation.'; + } + const loopState = normalizeText(input.loopState).toLowerCase(); if (loopState.includes('approval')) { return 'Review the governed approval flow before execution.'; diff --git a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts index 60361280c..cccae09bd 100644 --- a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts +++ b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts @@ -47,6 +47,7 @@ test.describe("Patrol Assistant operator briefing", () => { }) => { const approvalRequestedAt = new Date(Date.now() - 60_000).toISOString(); const approvalExpiresAt = new Date(Date.now() + 10 * 60_000).toISOString(); + let includePendingApproval = true; await page.route("**/api/security/status", async (route) => { await route.fulfill({ @@ -366,25 +367,35 @@ test.describe("Patrol Assistant operator briefing", () => { status: 200, contentType: "application/json", body: JSON.stringify({ - approvals: [ - { - id: "approval-1", - toolId: "investigation_fix", - command: "systemctl restart workload.service", - targetType: "host", - targetId: "finding-operator-briefing", - targetName: "web-server", - context: "Restart the workload service", - riskLevel: "high", - status: "pending", - requestedAt: approvalRequestedAt, - expiresAt: approvalExpiresAt, - }, - ], + approvals: includePendingApproval + ? [ + { + id: "approval-1", + toolId: "investigation_fix", + command: "systemctl restart workload.service", + targetType: "host", + targetId: "finding-operator-briefing", + targetName: "web-server", + context: "Restart the workload service", + riskLevel: "high", + status: "pending", + requestedAt: approvalRequestedAt, + expiresAt: approvalExpiresAt, + }, + ] + : [], }), }); }); + await page.route("**/api/ai/findings/*/investigation", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(null), + }); + }); + await page.route("**/api/ai/remediation/plans", async (route) => { await route.fulfill({ status: 200, @@ -442,5 +453,38 @@ test.describe("Patrol Assistant operator briefing", () => { ).toHaveValue("Review approval risk and next step"); await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true }); + + includePendingApproval = false; + await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByRole("button", { name: "Findings" })).toBeVisible(); + + await page.getByText("High CPU usage").click(); + const queuedFinding = page.locator("#finding-finding-operator-briefing"); + await expect(queuedFinding.getByText("details unavailable")).toBeVisible(); + await queuedFinding + .getByRole("button", { name: "Discuss with Assistant" }) + .last() + .click(); + + const queuedAssistantContext = page.getByLabel("Assistant context"); + await expect(queuedAssistantContext).toBeVisible(); + await expect(queuedAssistantContext).toContainText( + "Operator briefing attached", + ); + await expect(queuedAssistantContext).toContainText("Fix Queued"); + await expect(queuedAssistantContext).toContainText( + "Attention: active finding; loop fix queued; fix queued for governed review", + ); + await expect(queuedAssistantContext).toContainText( + "Decision: Recover or regenerate the governed approval before execution; do not execute from chat context.", + ); + await expect( + queuedAssistantContext.getByRole("button", { + name: "List approval prerequisites before action", + }), + ).toBeVisible(); + await expect( + queuedAssistantContext.getByText("systemctl restart workload.service"), + ).toHaveCount(0); }); });