mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-20 09:23:27 +00:00
Carry expired approval fix context into Assistant
This commit is contained in:
parent
1cbedbd192
commit
b95bcd7f6e
8 changed files with 282 additions and 20 deletions
|
|
@ -337,7 +337,11 @@ the canonical monitored-system blocked payload.
|
|||
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
|
||||
execution can proceed from missing command payloads. Expired-approval
|
||||
recovery handoffs may use a still-available structured proposed-fix payload
|
||||
only as safe metadata: description, target, risk, rationale, destructive
|
||||
posture, and command count may enter the briefing, while raw command text
|
||||
remains owned by governed remediation or approval surfaces. Direct
|
||||
alert-investigation API handoffs through `internal/api/ai_handlers.go` must
|
||||
enforce that same request-scoped boundary by setting
|
||||
`ai.ExecuteRequest.AutonomousMode` to
|
||||
|
|
|
|||
|
|
@ -827,7 +827,10 @@ frontend primitive boundary.
|
|||
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.
|
||||
shared drawer as context-free generic Assistant chat. If a feature-owned
|
||||
expired-approval recovery action still has structured proposed-fix metadata,
|
||||
the shared drawer may receive only safe summary fields and command counts;
|
||||
raw command text remains outside shared Assistant primitives.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -181,6 +181,12 @@ Patrol-specific presentation helpers.
|
|||
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 live approval is gone but the structured proposed-fix payload is still
|
||||
available, the recovery Assistant briefing must carry only safe proposed-fix
|
||||
metadata such as description, target, risk, rationale, destructive posture,
|
||||
and command count. Raw command text remains in the governed remediation or
|
||||
approval panel, while Assistant gets enough context to explain approval
|
||||
recovery and risk without becoming an execution surface.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ interface ApprovalSectionProps {
|
|||
resourceId?: string;
|
||||
}
|
||||
|
||||
interface AssistantBriefingFixSource {
|
||||
description?: string | null;
|
||||
commands?: string[] | null;
|
||||
target_host?: string | null;
|
||||
risk_level?: string | null;
|
||||
rationale?: string | null;
|
||||
destructive?: boolean | null;
|
||||
}
|
||||
|
||||
export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
||||
const [actionLoading, setActionLoading] = createSignal<string | null>(null);
|
||||
const [executionResult, setExecutionResult] = createSignal<ApprovalExecutionResult | null>(null);
|
||||
|
|
@ -39,7 +48,10 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
|||
|
||||
const canAutoFix = createMemo(() => hasFeature('ai_autofix'));
|
||||
|
||||
const approvalBriefing = (approval: ApprovalRequest | null) =>
|
||||
const approvalBriefing = (
|
||||
approval: ApprovalRequest | null,
|
||||
fix?: AssistantBriefingFixSource | null,
|
||||
) =>
|
||||
buildPatrolAssistantFindingBriefing({
|
||||
title: props.findingTitle || 'Patrol finding',
|
||||
subject: props.resourceName || 'affected resource',
|
||||
|
|
@ -56,17 +68,21 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
|||
targetName: approval.targetName,
|
||||
}
|
||||
: null,
|
||||
proposedFix: fix
|
||||
? {
|
||||
description: fix.description,
|
||||
riskLevel: fix.risk_level,
|
||||
targetHost: fix.target_host,
|
||||
rationale: fix.rationale,
|
||||
commandCount: fix.commands?.length ?? 0,
|
||||
destructive: fix.destructive,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
const handleFixWithAssistant = (
|
||||
approval: ApprovalRequest | null,
|
||||
fix: {
|
||||
description?: string;
|
||||
commands?: string[];
|
||||
target_host?: string;
|
||||
risk_level?: string;
|
||||
rationale?: string;
|
||||
} | null,
|
||||
fix: AssistantBriefingFixSource | null,
|
||||
e: Event,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -88,7 +104,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (props) => {
|
|||
targetType: props.resourceType,
|
||||
targetId: props.resourceId,
|
||||
findingId: props.findingId,
|
||||
briefing: approvalBriefing(approval),
|
||||
briefing: approvalBriefing(approval, fix),
|
||||
autonomousMode: false,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -207,6 +207,76 @@ describe('ApprovalSection', () => {
|
|||
expect(JSON.stringify(context.briefing)).not.toContain('systemctl restart nginx');
|
||||
});
|
||||
|
||||
it('opens Assistant from an expired approval with safe proposed-fix briefing metadata', async () => {
|
||||
getInvestigationMock.mockResolvedValue({
|
||||
id: 'session-1',
|
||||
finding_id: 'finding-1',
|
||||
session_id: 'session-1',
|
||||
status: 'completed',
|
||||
started_at: '2026-05-06T12:00:00Z',
|
||||
turn_count: 1,
|
||||
outcome: 'fix_queued',
|
||||
proposed_fix: {
|
||||
id: 'fix-1',
|
||||
description: 'Restart the workload service',
|
||||
commands: ['systemctl restart nginx'],
|
||||
risk_level: 'high',
|
||||
destructive: true,
|
||||
target_host: 'node-1',
|
||||
rationale: 'Service is wedged after IO pressure.',
|
||||
},
|
||||
});
|
||||
|
||||
render(() => (
|
||||
<ApprovalSection
|
||||
findingId="finding-1"
|
||||
investigationOutcome="fix_queued"
|
||||
findingTitle="CPU saturation"
|
||||
resourceName="node-1"
|
||||
resourceType="agent"
|
||||
resourceId="agent-1"
|
||||
/>
|
||||
));
|
||||
|
||||
expect(await screen.findByText('approval expired')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /fix with assistant/i }));
|
||||
|
||||
expect(openWithPromptMock).toHaveBeenCalledTimes(1);
|
||||
const [prompt, context] = openWithPromptMock.mock.calls[0];
|
||||
expect(prompt).toContain('queued a governed fix for review');
|
||||
expect(prompt).toContain('**Proposed fix:** Restart the workload service');
|
||||
expect(prompt).toContain('**Target:** node-1');
|
||||
expect(prompt).toContain('**Risk level:** high');
|
||||
expect(prompt).not.toContain('systemctl restart nginx');
|
||||
expect(context).toEqual({
|
||||
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: 'Fix Queued',
|
||||
detailLines: expect.arrayContaining([
|
||||
expect.stringContaining('fix queued for governed review'),
|
||||
expect.stringContaining('Proposed fix: Restart the workload service'),
|
||||
expect.stringContaining('Recover or regenerate the governed approval before execution'),
|
||||
]),
|
||||
actionLabel: 'Restart the workload service',
|
||||
commandSummary: '1 command recorded for approval context',
|
||||
safetyNote:
|
||||
'Command details stay in approval context; destructive actions require governed approval.',
|
||||
suggestedPrompts: [
|
||||
'Review approval risk and next step',
|
||||
'Explain current finding status',
|
||||
'Summarize remediation without command text',
|
||||
],
|
||||
}),
|
||||
autonomousMode: false,
|
||||
});
|
||||
expect(JSON.stringify(context.briefing)).not.toContain('systemctl restart nginx');
|
||||
});
|
||||
|
||||
it('recreates and executes a queued fix when autofix is available', async () => {
|
||||
state.hasAutoFix = true;
|
||||
getInvestigationMock.mockResolvedValue(null);
|
||||
|
|
|
|||
|
|
@ -614,4 +614,44 @@ describe('patrolInvestigationContextModel', () => {
|
|||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds queued-fix recovery briefing from safe proposed-fix metadata', () => {
|
||||
expect(
|
||||
buildPatrolAssistantFindingBriefing({
|
||||
title: 'CPU saturation',
|
||||
subject: 'node-1',
|
||||
findingStatus: 'active',
|
||||
investigationOutcome: 'fix_queued',
|
||||
loopState: 'fix_queued',
|
||||
proposedFix: {
|
||||
description: 'Restart workload service',
|
||||
riskLevel: 'high',
|
||||
targetHost: 'node-1',
|
||||
rationale: 'service is wedged',
|
||||
commandCount: 1,
|
||||
destructive: true,
|
||||
},
|
||||
}),
|
||||
).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',
|
||||
'Proposed fix: Restart workload service; target node-1; high risk; 1 command recorded for approval context; destructive proposed fix; rationale service is wedged',
|
||||
'Decision: Recover or regenerate the governed approval before execution; do not execute from chat context.',
|
||||
],
|
||||
evidence: [],
|
||||
actionLabel: 'Restart workload service',
|
||||
commandSummary: '1 command recorded for approval context',
|
||||
safetyNote:
|
||||
'Command details stay in approval context; destructive actions require governed approval.',
|
||||
suggestedPrompts: [
|
||||
'Review approval risk and next step',
|
||||
'Explain current finding status',
|
||||
'Summarize remediation without command text',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,6 +68,15 @@ export interface PatrolAssistantApprovalBriefingInput {
|
|||
targetName?: string | null;
|
||||
}
|
||||
|
||||
export interface PatrolAssistantProposedFixBriefingInput {
|
||||
description?: string | null;
|
||||
riskLevel?: string | null;
|
||||
targetHost?: string | null;
|
||||
rationale?: string | null;
|
||||
commandCount?: number | null;
|
||||
destructive?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PatrolAssistantFindingBriefingInput {
|
||||
title: string;
|
||||
subject: string;
|
||||
|
|
@ -80,6 +89,7 @@ export interface PatrolAssistantFindingBriefingInput {
|
|||
lastRegressionAt?: string | null;
|
||||
remediationId?: string | null;
|
||||
pendingApproval?: PatrolAssistantApprovalBriefingInput | null;
|
||||
proposedFix?: PatrolAssistantProposedFixBriefingInput | null;
|
||||
investigationRecord?: InvestigationRecord | null;
|
||||
}
|
||||
|
||||
|
|
@ -1021,6 +1031,7 @@ export function buildPatrolAssistantFindingBriefing(
|
|||
const title = normalizeText(input.title) || 'Patrol finding';
|
||||
const subject = normalizeText(input.subject) || 'affected resource';
|
||||
const pendingApproval = normalizeApprovalBriefing(input.pendingApproval);
|
||||
const proposedFix = record.proposedFix || normalizeProposedFixBriefing(input.proposedFix);
|
||||
const approvalStatusParts = !record.hasRecord
|
||||
? [
|
||||
pendingApproval.status ? `${formatIdentifierLabel(pendingApproval.status)} approval` : '',
|
||||
|
|
@ -1039,11 +1050,15 @@ export function buildPatrolAssistantFindingBriefing(
|
|||
if (!record.hasRecord && !attentionReason && !operatorDecision) {
|
||||
return undefined;
|
||||
}
|
||||
const proposedFixDetail = record.proposedFix
|
||||
? undefined
|
||||
: formatPatrolAssistantProposedFixDetail(proposedFix);
|
||||
|
||||
const detailLines = [
|
||||
attentionReason ? `Attention: ${attentionReason}` : undefined,
|
||||
record.conclusion,
|
||||
record.recommendedAction,
|
||||
proposedFixDetail,
|
||||
operatorDecision ? `Decision: ${operatorDecision}` : undefined,
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
|
|
@ -1058,11 +1073,16 @@ export function buildPatrolAssistantFindingBriefing(
|
|||
detailLines,
|
||||
evidence: [...record.evidenceSummaries, ...verificationLines].slice(0, 4),
|
||||
actionLabel:
|
||||
record.proposedFix?.description ||
|
||||
proposedFix?.description ||
|
||||
(pendingApproval.id ? `Approval ${pendingApproval.id}` : undefined),
|
||||
commandSummary: record.proposedFix?.commandSummary,
|
||||
safetyNote: buildPatrolAssistantSafetyNote(record, pendingApproval),
|
||||
suggestedPrompts: buildPatrolFindingSuggestedPrompts(input, record, pendingApproval),
|
||||
commandSummary: proposedFix?.commandSummary,
|
||||
safetyNote: buildPatrolAssistantSafetyNote(proposedFix, pendingApproval),
|
||||
suggestedPrompts: buildPatrolFindingSuggestedPrompts(
|
||||
input,
|
||||
record,
|
||||
pendingApproval,
|
||||
proposedFix,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1096,6 +1116,7 @@ function buildPatrolFindingSuggestedPrompts(
|
|||
input: PatrolAssistantFindingBriefingInput,
|
||||
record: PatrolInvestigationRecordPresentation,
|
||||
pendingApproval: Required<PatrolAssistantApprovalBriefingInput>,
|
||||
proposedFix?: PatrolInvestigationRecordPresentation['proposedFix'],
|
||||
): string[] {
|
||||
const prompts: string[] = [];
|
||||
const requiresApproval = patrolAssistantFindingHandoffRequiresApprovalMode({
|
||||
|
|
@ -1127,7 +1148,7 @@ function buildPatrolFindingSuggestedPrompts(
|
|||
prompts.push('List evidence to gather before action');
|
||||
}
|
||||
|
||||
if (record.proposedFix?.commandSummary) {
|
||||
if (proposedFix?.commandSummary) {
|
||||
prompts.push('Summarize remediation without command text');
|
||||
} else if (requiresApproval) {
|
||||
prompts.push('List approval prerequisites before action');
|
||||
|
|
@ -1360,11 +1381,11 @@ function buildPatrolAssistantOperatorDecision(
|
|||
}
|
||||
|
||||
function buildPatrolAssistantSafetyNote(
|
||||
record: PatrolInvestigationRecordPresentation,
|
||||
proposedFix?: PatrolInvestigationRecordPresentation['proposedFix'],
|
||||
pendingApproval?: Required<PatrolAssistantApprovalBriefingInput>,
|
||||
): string | undefined {
|
||||
const hasCommands = Boolean(record.proposedFix?.commandSummary);
|
||||
const isDestructive = Boolean(record.proposedFix?.destructive);
|
||||
const hasCommands = Boolean(proposedFix?.commandSummary);
|
||||
const isDestructive = Boolean(proposedFix?.destructive);
|
||||
if (hasCommands && isDestructive) {
|
||||
return 'Command details stay in approval context; destructive actions require governed approval.';
|
||||
}
|
||||
|
|
@ -1380,6 +1401,52 @@ function buildPatrolAssistantSafetyNote(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeProposedFixBriefing(
|
||||
proposedFix?: PatrolAssistantProposedFixBriefingInput | null,
|
||||
): PatrolInvestigationRecordPresentation['proposedFix'] | undefined {
|
||||
const commandSummary = formatCommandSummary(normalizeNonNegativeCount(proposedFix?.commandCount));
|
||||
const normalized = {
|
||||
description: normalizeText(proposedFix?.description),
|
||||
riskLabel: formatIdentifierLabel(proposedFix?.riskLevel),
|
||||
targetHost: normalizeText(proposedFix?.targetHost),
|
||||
rationale: normalizeText(proposedFix?.rationale),
|
||||
commandSummary,
|
||||
destructive: Boolean(proposedFix?.destructive),
|
||||
};
|
||||
|
||||
if (
|
||||
!normalized.description &&
|
||||
!normalized.riskLabel &&
|
||||
!normalized.targetHost &&
|
||||
!normalized.rationale &&
|
||||
!normalized.commandSummary &&
|
||||
!normalized.destructive
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatPatrolAssistantProposedFixDetail(
|
||||
proposedFix?: PatrolInvestigationRecordPresentation['proposedFix'],
|
||||
): string | undefined {
|
||||
if (!proposedFix) return undefined;
|
||||
const detail = formatBriefingStringList(
|
||||
[
|
||||
proposedFix.description,
|
||||
proposedFix.targetHost ? `target ${proposedFix.targetHost}` : undefined,
|
||||
proposedFix.riskLabel ? `${proposedFix.riskLabel.toLowerCase()} risk` : undefined,
|
||||
proposedFix.commandSummary,
|
||||
proposedFix.destructive ? 'destructive proposed fix' : undefined,
|
||||
proposedFix.rationale ? `rationale ${proposedFix.rationale}` : undefined,
|
||||
],
|
||||
6,
|
||||
'proposed-fix facts',
|
||||
);
|
||||
return detail ? `Proposed fix: ${detail}` : undefined;
|
||||
}
|
||||
|
||||
function normalizeApprovalBriefing(
|
||||
approval?: PatrolAssistantApprovalBriefingInput | null,
|
||||
): Required<PatrolAssistantApprovalBriefingInput> {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,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;
|
||||
let includeInvestigationProposedFix = false;
|
||||
|
||||
await page.route("**/api/security/status", async (route) => {
|
||||
await route.fulfill({
|
||||
|
|
@ -392,7 +393,29 @@ test.describe("Patrol Assistant operator briefing", () => {
|
|||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(null),
|
||||
body: JSON.stringify(
|
||||
includeInvestigationProposedFix
|
||||
? {
|
||||
id: "session-operator-briefing",
|
||||
finding_id: "finding-operator-briefing",
|
||||
session_id: "session-operator-briefing",
|
||||
status: "completed",
|
||||
started_at: "2026-05-06T12:00:00Z",
|
||||
turn_count: 1,
|
||||
outcome: "fix_queued",
|
||||
proposed_fix: {
|
||||
id: "fix-expired-1",
|
||||
description: "Restart the workload service",
|
||||
commands: ["systemctl restart workload.service"],
|
||||
risk_level: "high",
|
||||
destructive: true,
|
||||
target_host: "web-server",
|
||||
rationale:
|
||||
"Workload service stayed wedged after backup pressure.",
|
||||
},
|
||||
}
|
||||
: null,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -486,5 +509,38 @@ test.describe("Patrol Assistant operator briefing", () => {
|
|||
await expect(
|
||||
queuedAssistantContext.getByText("systemctl restart workload.service"),
|
||||
).toHaveCount(0);
|
||||
|
||||
includeInvestigationProposedFix = true;
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();
|
||||
|
||||
await page.getByText("High CPU usage").click();
|
||||
const expiredFinding = page.locator("#finding-finding-operator-briefing");
|
||||
await expect(expiredFinding.getByText("approval expired")).toBeVisible();
|
||||
await expiredFinding
|
||||
.getByRole("button", { name: "Fix with Assistant" })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
const expiredAssistantContext = page.getByLabel("Assistant context");
|
||||
await expect(expiredAssistantContext).toBeVisible();
|
||||
await expect(expiredAssistantContext).toContainText(
|
||||
"Operator briefing attached",
|
||||
);
|
||||
await expect(expiredAssistantContext).toContainText("Fix Queued");
|
||||
await expect(expiredAssistantContext).toContainText(
|
||||
"Proposed fix: Restart the workload service; target web-server; high risk; 1 command recorded for approval context; destructive proposed fix; rationale Workload service stayed wedged after backup pressure.",
|
||||
);
|
||||
await expect(expiredAssistantContext).toContainText(
|
||||
"Command details stay in approval context; destructive actions require governed approval.",
|
||||
);
|
||||
await expect(
|
||||
expiredAssistantContext.getByRole("button", {
|
||||
name: "Summarize remediation without command text",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
expiredAssistantContext.getByText("systemctl restart workload.service"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue