mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Route queued fix recovery through Patrol briefing
This commit is contained in:
parent
1d70402f71
commit
1cbedbd192
9 changed files with 154 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -449,6 +449,7 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
|
|||
subject,
|
||||
severity: finding.severity,
|
||||
findingStatus: finding.status,
|
||||
investigationOutcome: finding.investigationOutcome,
|
||||
loopState: finding.loopState,
|
||||
timesRaised: finding.timesRaised,
|
||||
regressionCount: finding.regressionCount,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export const ApprovalSection: Component<ApprovalSectionProps> = (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<ApprovalSectionProps> = (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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue