Show Patrol approval handoff briefings in Assistant

This commit is contained in:
rcourtman
2026-05-07 01:53:40 +01:00
parent a886399e59
commit 3e2380d5b6
10 changed files with 146 additions and 11 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ───────────────────────────────────────────────────
@@ -314,6 +314,12 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (props) => {
<Show when={hasScopedApprovalHandoff() && controlLevel() === 'autonomous'}>
<div class="px-4 py-2 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950 flex items-center gap-2 text-[11px] text-blue-700 dark:text-blue-200">
<span>
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.
</span>
</div>
</Show>
@@ -1221,7 +1227,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
<Show when={contextBriefing()!.actionLabel || contextBriefing()!.commandSummary}>
<div class="mt-2 rounded border border-border bg-surface-alt px-2.5 py-2 text-[11px] text-muted">
<Show when={contextBriefing()!.actionLabel}>
<div class="font-medium text-base-content">{contextBriefing()!.actionLabel}</div>
<div class="font-medium text-base-content">
{contextBriefing()!.actionLabel}
</div>
</Show>
<Show when={contextBriefing()!.commandSummary}>
<div>{contextBriefing()!.commandSummary}</div>
@@ -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<ApprovalSectionProps> = (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<ApprovalSectionProps> = (props) => {
targetType: props.resourceType,
targetId: props.resourceId,
findingId: props.findingId,
briefing: approvalBriefing(approval),
autonomousMode: false,
});
};
@@ -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 () => {
@@ -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.',
});
});
});
@@ -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<PatrolAssistantApprovalBriefingInput>,
): 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;
}