mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
Show Patrol approval handoff briefings in Assistant
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user