From 3e2380d5b652f5baf13ac8e8e7c9dd5df771eadd Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 01:53:40 +0100 Subject: [PATCH] Show Patrol approval handoff briefings in Assistant --- .../v6/internal/subsystems/ai-runtime.md | 4 +++ .../v6/internal/subsystems/api-contracts.md | 6 +++- .../subsystems/frontend-primitives.md | 5 ++- .../subsystems/patrol-intelligence.md | 3 +- .../AI/Chat/__tests__/AIChat.test.tsx | 28 ++++++++++++++++ .../src/components/AI/Chat/index.tsx | 14 ++++++-- .../src/components/patrol/ApprovalSection.tsx | 20 ++++++++++++ .../patrol/__tests__/ApprovalSection.test.tsx | 13 ++++++++ .../patrolInvestigationContextModel.test.ts | 32 +++++++++++++++++++ .../patrol/patrolInvestigationContextModel.ts | 32 ++++++++++++++++--- 10 files changed, 146 insertions(+), 11 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a12e40dc4..a9579e052 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -282,6 +282,10 @@ runtime cost control, and shared AI transport surfaces. The Assistant drawer may also render an attached context briefing for that handoff, but the briefing is runtime context visibility only: it must not mutate chat control settings, execute tools, or reveal raw command payloads. + When the drawer renders a request-local approval-required banner for a + scoped handoff, the banner must derive its subject from the attached + briefing or structured finding context, so Patrol approval/finding handoffs + are named as Patrol handoffs rather than generic dashboard briefs. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 073e646d0..e7f63860e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -853,7 +853,11 @@ the canonical monitored-system blocked payload. for the finding when the durable record has no current approval ID, but those references must omit raw proposed-fix commands, remain model-only review context, and leave approval/execution authority with the governed approval - and remediation APIs. Chat execution may refresh approval status snapshots for + and remediation APIs. Frontend-visible pending-approval drawer briefings must + be a presentation of that same safe handoff context: approval ID, status, + risk, target, requested/expiry timestamps, action label, and approval-flow + safety posture may be shown, while raw commands stay outside chat prompt and + context payloads. Chat execution may refresh approval status snapshots for those references from the canonical approval store, but that snapshot is read-only, org-scoped, and must not expose or infer the raw command. When the API handoff builder recovers a live approval, the model-only operator diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 2d7fe68b5..fa1b84514 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -819,7 +819,10 @@ frontend primitive boundary. When the feature helper adds live approval state to the generic drawer briefing, it may pass only safe approval metadata into `AIChatContextBriefing`; raw approval commands remain owned by the governed - approval/remediation panels. Patrol approval-row Assistant prompts must + approval/remediation panels. The shared approval-required drawer banner must + derive its subject from that briefing or structured finding context, so + Patrol handoffs render as Patrol handoffs or Patrol findings rather than + generic dashboard briefs. Patrol approval-row Assistant prompts must follow that same drawer primitive contract: safe approval metadata may enter the prompt and context, but raw command text stays out and the scoped request must pass `autonomousMode:false` instead of changing the user's diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 8581dff66..8326f773b 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -159,7 +159,8 @@ Patrol-specific presentation helpers. recovered approval reference when framing the operator decision and action posture. Inline Patrol approval actions that open Assistant must follow the same rule: pass approval ID/status/risk/target as review context, force the - request-local approval-required mode, and never paste the approval command or + request-local approval-required mode, attach the Patrol-owned visible drawer + briefing for the pending approval, and never paste the approval command or proposed-fix command text into the chat prompt. Remediation-plan Assistant handoffs follow the same boundary: step labels, plan status, risk, and command counts are allowed, while command and rollback command text stays in the diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 2b385fb55..5a37b2170 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -1024,6 +1024,34 @@ describe('AIChat', () => { { autonomousMode: false }, ); }); + + it('names scoped Patrol handoffs in the approval banner', async () => { + mockAIAPI.getSettings.mockResolvedValue({ + model: 'gpt-4', + chat_model: '', + control_level: 'autonomous', + autonomous_mode: true, + discovery_enabled: true, + }); + mockAiChatStore.context = { + initialPrompt: undefined, + findingId: 'finding-1', + autonomousMode: false, + briefing: { + sourceLabel: 'Pulse Patrol', + title: 'Operator briefing attached', + }, + }; + + renderChat(); + + await waitFor(() => { + expect(screen.getByText(/Approval required for this Patrol handoff/)).toBeInTheDocument(); + }); + expect( + screen.queryByText(/Approval required for this dashboard brief/), + ).not.toBeInTheDocument(); + }); }); // ── Discovery hint ─────────────────────────────────────────────────── diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 599237fc0..dbb9239ef 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -314,6 +314,12 @@ export const AIChat: Component = (props) => { const contextBriefing = createMemo(() => aiChatStore.context.briefing); const contextBriefingEvidence = createMemo(() => contextBriefing()?.evidence ?? []); const contextBriefingDetails = createMemo(() => contextBriefing()?.detailLines ?? []); + const scopedApprovalHandoffLabel = createMemo(() => { + const source = contextBriefing()?.sourceLabel?.toLowerCase() || ''; + if (source.includes('patrol')) return 'this Patrol handoff'; + if (aiChatStore.context.findingId) return 'this Patrol finding'; + return 'this dashboard brief'; + }); // Compute current status for display const currentStatus = createMemo(() => { @@ -1103,8 +1109,8 @@ export const AIChat: Component = (props) => {
- Approval required for this dashboard brief. Commands will ask before running; your - default Assistant mode is unchanged. + Approval required for {scopedApprovalHandoffLabel()}. Commands will ask before + running; your default Assistant mode is unchanged.
@@ -1221,7 +1227,9 @@ export const AIChat: Component = (props) => {
-
{contextBriefing()!.actionLabel}
+
+ {contextBriefing()!.actionLabel} +
{contextBriefing()!.commandSummary}
diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index 7473bd406..f80ef2b0c 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -12,6 +12,7 @@ import { aiChatStore } from '@/stores/aiChat'; import { hasFeature } from '@/stores/license'; import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai'; import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation'; +import { buildPatrolAssistantFindingBriefing } from '@/features/patrol/patrolInvestigationContextModel'; import { RemediationStatus } from './RemediationStatus'; interface ApprovalSectionProps { @@ -38,6 +39,24 @@ export const ApprovalSection: Component = (props) => { const canAutoFix = createMemo(() => hasFeature('ai_autofix')); + const approvalBriefing = (approval: ApprovalRequest | null) => + buildPatrolAssistantFindingBriefing({ + title: props.findingTitle || 'Patrol finding', + subject: props.resourceName || 'affected resource', + findingStatus: 'active', + loopState: props.investigationOutcome || 'awaiting_approval', + pendingApproval: approval + ? { + id: approval.id, + status: approval.status, + riskLevel: approval.riskLevel, + requestedAt: approval.requestedAt, + expiresAt: approval.expiresAt, + targetName: approval.targetName, + } + : null, + }); + const handleFixWithAssistant = ( approval: ApprovalRequest | null, fix: { @@ -68,6 +87,7 @@ export const ApprovalSection: Component = (props) => { targetType: props.resourceType, targetId: props.resourceId, findingId: props.findingId, + briefing: approvalBriefing(approval), 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 6941f5b9e..fc16f6c03 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -175,8 +175,21 @@ describe('ApprovalSection', () => { targetType: 'agent', targetId: 'agent-1', findingId: 'finding-1', + briefing: expect.objectContaining({ + sourceLabel: 'Pulse Patrol', + title: 'Operator briefing attached', + subject: 'CPU saturation on node-1', + statusLabel: 'Pending approval · High risk', + detailLines: expect.arrayContaining([ + expect.stringContaining('live approval pending'), + expect.stringContaining('Review live governed approval approval-1 before execution'), + ]), + actionLabel: 'Approval approval-1', + safetyNote: 'Execution requires the governed approval flow.', + }), autonomousMode: false, }); + expect(JSON.stringify(context.briefing)).not.toContain('systemctl restart nginx'); }); it('recreates and executes a queued fix when autofix is available', async () => { diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 4e10ac7d4..7d61df7c2 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -350,4 +350,36 @@ describe('patrolInvestigationContextModel', () => { safetyNote: undefined, }); }); + + it('builds a pending approval briefing before full investigation record hydration', () => { + const briefing = buildPatrolAssistantFindingBriefing({ + title: 'CPU saturation', + subject: 'node-1', + findingStatus: 'active', + loopState: 'fix_queued', + pendingApproval: { + id: 'approval-1', + status: 'pending', + riskLevel: 'high', + requestedAt: '2026-05-06T12:00:00Z', + expiresAt: '2026-05-06T12:10:00Z', + targetName: 'node-1', + }, + }); + + expect(briefing).toEqual({ + sourceLabel: 'Pulse Patrol', + title: 'Operator briefing attached', + subject: 'CPU saturation on node-1', + statusLabel: 'Pending approval · High risk', + detailLines: [ + 'Attention: active finding; loop fix queued; live approval pending', + 'Decision: Review live governed approval approval-1 before execution. Status: pending. Target: node-1. Risk: high. Expires: 2026-05-06T12:10:00Z. Requested: 2026-05-06T12:00:00Z.', + ], + evidence: [], + actionLabel: 'Approval approval-1', + commandSummary: undefined, + safetyNote: 'Execution requires the governed approval flow.', + }); + }); }); diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 20386d231..94f8a9b9e 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -293,9 +293,19 @@ export function buildPatrolAssistantFindingBriefing( const record = buildPatrolInvestigationRecordPresentation(input.investigationRecord); const title = normalizeText(input.title) || 'Patrol finding'; const subject = normalizeText(input.subject) || 'affected resource'; - const statusParts = [record.statusLabel, record.outcomeLabel, record.confidenceLabel].filter( - isNonEmptyString, - ); + const pendingApproval = normalizeApprovalBriefing(input.pendingApproval); + const approvalStatusParts = !record.hasRecord + ? [ + pendingApproval.status ? `${formatIdentifierLabel(pendingApproval.status)} approval` : '', + pendingApproval.riskLevel ? `${formatIdentifierLabel(pendingApproval.riskLevel)} risk` : '', + ] + : []; + const statusParts = [ + record.statusLabel, + record.outcomeLabel, + record.confidenceLabel, + ...approvalStatusParts, + ].filter(isNonEmptyString); const attentionReason = buildPatrolAssistantAttentionReason(input, record); const operatorDecision = buildPatrolAssistantOperatorDecision(input); if (!record.hasRecord && !attentionReason && !operatorDecision) { @@ -319,9 +329,11 @@ export function buildPatrolAssistantFindingBriefing( statusLabel: statusParts.join(' · ') || undefined, detailLines, evidence: [...record.evidenceSummaries, ...verificationLines].slice(0, 4), - actionLabel: record.proposedFix?.description, + actionLabel: + record.proposedFix?.description || + (pendingApproval.id ? `Approval ${pendingApproval.id}` : undefined), commandSummary: record.proposedFix?.commandSummary, - safetyNote: buildPatrolAssistantSafetyNote(record), + safetyNote: buildPatrolAssistantSafetyNote(record, pendingApproval), }; } @@ -495,9 +507,15 @@ function buildPatrolAssistantOperatorDecision( if (pendingApproval.targetName) { parts.push(`Target: ${pendingApproval.targetName}.`); } + if (pendingApproval.riskLevel) { + parts.push(`Risk: ${pendingApproval.riskLevel}.`); + } if (pendingApproval.expiresAt) { parts.push(`Expires: ${pendingApproval.expiresAt}.`); } + if (pendingApproval.requestedAt) { + parts.push(`Requested: ${pendingApproval.requestedAt}.`); + } return parts.join(' '); } @@ -521,6 +539,7 @@ function buildPatrolAssistantOperatorDecision( function buildPatrolAssistantSafetyNote( record: PatrolInvestigationRecordPresentation, + pendingApproval?: Required, ): string | undefined { const hasCommands = Boolean(record.proposedFix?.commandSummary); const isDestructive = Boolean(record.proposedFix?.destructive); @@ -533,6 +552,9 @@ function buildPatrolAssistantSafetyNote( if (isDestructive) { return 'Destructive actions require governed approval.'; } + if (pendingApproval?.id) { + return 'Execution requires the governed approval flow.'; + } return undefined; }