Frame Patrol Assistant handoff for operators

This commit is contained in:
rcourtman
2026-05-07 00:05:59 +01:00
parent 86244d8c13
commit cfa7483f09
11 changed files with 695 additions and 36 deletions
@@ -785,7 +785,10 @@ the canonical monitored-system blocked payload.
snoozed/dismissed/suppressed status, detection/last-seen/resolution
timestamps, recurrence/regression facts, and recent lifecycle events, so API
consumers do not reduce Assistant context to an outdated investigation
summary. The briefing must carry the primary finding's current attention
summary. The frontend store boundary must preserve those recurrence facts
from the shared payload, including `times_raised`, so Patrol presentation and
Assistant handoff helpers do not infer repeated findings from page-local
state. The briefing must carry the primary finding's current attention
reason, recency facts, bounded evidence snapshot, verification summary, and
explicit operator decision framing before investigation guidance and may
carry the latest lifecycle event as the current handoff state, while the
@@ -2944,8 +2947,10 @@ canonical operator-facing frame for Assistant: it carries the finding summary,
resource, priority, current attention reason, current recency facts, bounded
evidence and verification summaries, investigation confidence, recommended next
step, and operator-decision framing plus approval/proposed-fix posture without
raw command text, and the downstream chat service then hydrates live resource
state, timeline, and action audit context around that same handoff.
raw command text. Patrol's frontend Assistant drawer briefing must use that same
operator frame for visible handoffs from findings, while the downstream chat
service hydrates live resource state, timeline, and action audit context around
that same handoff.
Patrol run-history serialization and persistence must also preserve full field
parity across API responses and restart boundaries, including
`pmg_checked`, `rejected_findings`, `triage_flags`, `triage_skipped_llm`, and
@@ -811,8 +811,11 @@ frontend primitive boundary.
so shared drawer primitives stay shell-owned rather than becoming a
Patrol-specific prompt formatter. The drawer may render a generic
context-briefing band from `frontend-modern/src/stores/aiChat.ts`, but
feature-owned helpers must provide the source labels, evidence summaries,
action copy, and safety note.
feature-owned helpers must provide the source labels, attention reason,
evidence summaries, operator-decision copy, action copy, and safety note.
Patrol finding handoffs should still provide that briefing from current
finding facts when a durable Patrol investigation record is not attached
yet, rather than opening the shared drawer as empty generic chat.
11. Keep shared filter primitives coherent with route-owned option hydration.
Feature shells such as `frontend-modern/src/features/infrastructure/`
must keep a route-owned canonical option visible in shared selects like
@@ -2169,7 +2172,8 @@ or polling lifecycle. The Patrol feature is the current reference shape:
`frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` stays the
feature shell, `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`
owns the runtime state machine, `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts`
owns the pure investigation-context summary derivation,
owns the pure investigation-context summary and Patrol-to-Assistant operator
briefing derivation,
`frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` owns canonical AI
summary normalization at the shared store boundary, and the Patrol-owned
header/banner/summary/workspace section files under
@@ -46,6 +46,7 @@ Patrol-specific presentation helpers.
23. `frontend-modern/src/utils/patrolSummaryPresentation.ts`
24. `frontend-modern/src/utils/patrolRuntimePresentation.ts`
25. `frontend-modern/src/utils/textPresentation.ts`
26. `tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts`
## Shared Boundaries
@@ -134,7 +135,17 @@ Patrol-specific presentation helpers.
attention reason, recency, evidence snapshot, verification summary,
conclusion, latest lifecycle event, recommended next step, explicit operator
decision framing, and governed approval/proposed-fix posture instead of
behaving like a generic chat over a pasted incident dump. The assembled handoff must still pass
behaving like a generic chat over a pasted incident dump. The visible
Assistant drawer briefing opened from a Patrol finding must mirror that same
Patrol-owned operator frame, including current severity/status, recurrence or
regression, loop state, approval/proposed-fix posture, and the explicit
operator decision being requested. That visible briefing remains a summary
surface only: proposed-fix commands stay summarized by count and destructive
action copy must point back to governed approval/remediation context. When a
structured investigation record is not available yet, the same Patrol-owned
helper must still brief the operator from current finding facts such as
active status, severity, recurrence, and loop state instead of opening a
generic empty Assistant drawer. The assembled handoff must still pass
through the Assistant runtime's
resource-policy sanitizer before prompt injection, so Patrol-owned prose
cannot leak governed resource names, IDs, aliases, nodes, paths, or
@@ -150,7 +161,10 @@ Patrol-specific presentation helpers.
and explicitly separated from approval/execution authority. Patrol must keep
the visible finding and drawer briefing tied to the shared investigation
payload rather than forking a Patrol-local lifecycle, policy, topology, or
timeline summary.
timeline summary. The inline Patrol investigation surface must also treat a
structured `investigationRecord` as investigation data, even when the legacy
investigation-detail endpoint has no separate session payload, so it must not
render empty-state copy above a durable Patrol record.
## Current State
@@ -430,6 +430,13 @@ export const FindingsPanel: Component<FindingsPanelProps> = (props) => {
const briefing = buildPatrolAssistantFindingBriefing({
title,
subject,
severity: finding.severity,
findingStatus: finding.status,
loopState: finding.loopState,
timesRaised: finding.timesRaised,
regressionCount: finding.regressionCount,
lastRegressionAt: finding.lastRegressionAt,
remediationId: finding.remediationPlanId,
investigationRecord: finding.investigationRecord,
});
aiChatStore.openWithPrompt(prompt, {
@@ -57,6 +57,12 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
}
},
);
const hasInvestigationContext = createMemo(
() => Boolean(investigation()) || investigationRecord().hasRecord,
);
const investigationSectionState = createMemo(() =>
getInvestigationSectionState(investigation.loading, hasInvestigationContext()),
);
// Auto-poll while investigation is active
createEffect(() => {
@@ -149,23 +155,16 @@ export const InvestigationSection: Component<InvestigationSectionProps> = (props
</div>
{/* Loading */}
<Show
when={
!getInvestigationSectionState(investigation.loading, !!investigation()).empty &&
getInvestigationSectionState(investigation.loading, !!investigation()).text
}
>
<Show when={!investigationSectionState().empty && investigationSectionState().text}>
<div class="flex items-center gap-2 text-xs text-muted py-2">
<span class="h-3 w-3 border-2 border-current border-t-transparent rounded-full animate-spin" />
{getInvestigationSectionState(investigation.loading, !!investigation()).text}
{investigationSectionState().text}
</div>
</Show>
{/* No investigation data */}
<Show when={getInvestigationSectionState(investigation.loading, !!investigation()).empty}>
<p class="text-xs text-muted py-1">
{getInvestigationSectionState(investigation.loading, !!investigation()).text}
</p>
<Show when={investigationSectionState().empty}>
<p class="text-xs text-muted py-1">{investigationSectionState().text}</p>
</Show>
<Show when={investigationRecord().hasRecord}>
@@ -122,6 +122,7 @@ describe('InvestigationSection', () => {
expect(screen.getByText('Backup job saturated CPU.')).toBeInTheDocument();
expect(screen.getByText('CPU stayed above 95% for 10 minutes')).toBeInTheDocument();
expect(screen.getByText('1 command recorded for approval context')).toBeInTheDocument();
expect(screen.queryByText(/No investigation data available/)).not.toBeInTheDocument();
expect(screen.queryByText('systemctl restart workload.service')).not.toBeInTheDocument();
});
});
@@ -161,6 +161,13 @@ describe('patrolInvestigationContextModel', () => {
const briefing = buildPatrolAssistantFindingBriefing({
title: 'High CPU usage',
subject: 'web-server',
severity: 'critical',
findingStatus: 'active',
loopState: 'awaiting_approval',
timesRaised: 4,
regressionCount: 2,
lastRegressionAt: '2026-05-06T12:06:00Z',
remediationId: 'remediation-1',
investigationRecord: {
id: 'record-1',
finding_id: 'finding-1',
@@ -177,28 +184,58 @@ describe('patrolInvestigationContextModel', () => {
description: 'Restart the workload service',
commands: ['systemctl restart workload.service'],
risk_level: 'medium',
destructive: false,
destructive: true,
},
verification: ['CPU returned below 50%'],
tools_used: [],
started_at: '2026-05-06T12:00:00Z',
approval_id: 'approval-1',
},
});
expect(briefing).toEqual({
sourceLabel: 'Pulse Patrol',
title: 'Investigation record attached',
title: 'Operator briefing attached',
subject: 'High CPU usage on web-server',
statusLabel: 'Completed · Fix Queued · High confidence',
detailLines: [
'Attention: active critical finding; regressed 2 times; last regression 2026-05-06T12:06:00Z; loop awaiting approval; approval approval-1; destructive proposed fix; fix queued for governed review',
'Backup job saturated CPU.',
'Approve a controlled restart after the backup completes.',
'Decision: review governed approval approval-1 before execution; proposed fix fix-1; risk medium; destructive true',
],
evidence: ['CPU stayed above 95% for 10 minutes', 'Verified: CPU returned below 50%'],
actionLabel: 'Restart the workload service',
commandSummary: '1 command recorded for approval context',
safetyNote: 'Command details stay in approval context.',
safetyNote:
'Command details stay in approval context; destructive actions require governed approval.',
});
expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service');
});
it('builds an operator briefing from current finding facts before a Patrol record exists', () => {
expect(
buildPatrolAssistantFindingBriefing({
title: 'High CPU usage',
subject: 'web-server',
severity: 'warning',
findingStatus: 'active',
loopState: 'investigating',
timesRaised: 3,
}),
).toEqual({
sourceLabel: 'Pulse Patrol',
title: 'Operator briefing attached',
subject: 'High CPU usage on web-server',
statusLabel: undefined,
detailLines: [
'Attention: active warning finding; raised 3 times; loop investigating',
'Decision: Wait for Patrol to finish the investigation before approving remediation.',
],
evidence: [],
actionLabel: undefined,
commandSummary: undefined,
safetyNote: undefined,
});
});
});
@@ -50,6 +50,13 @@ export interface PatrolAssistantFindingPromptInput {
export interface PatrolAssistantFindingBriefingInput {
title: string;
subject: string;
severity?: string | null;
findingStatus?: string | null;
loopState?: string | null;
timesRaised?: number | null;
regressionCount?: number | null;
lastRegressionAt?: string | null;
remediationId?: string | null;
investigationRecord?: InvestigationRecord | null;
}
@@ -157,37 +164,217 @@ export function buildPatrolAssistantFindingBriefing(
input: PatrolAssistantFindingBriefingInput,
): AIChatContextBriefing | undefined {
const record = buildPatrolInvestigationRecordPresentation(input.investigationRecord);
if (!record.hasRecord) {
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 attentionReason = buildPatrolAssistantAttentionReason(input, record);
const operatorDecision = buildPatrolAssistantOperatorDecision(input);
if (!record.hasRecord && !attentionReason && !operatorDecision) {
return undefined;
}
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 detailLines = [record.conclusion, record.recommendedAction]
const detailLines = [
attentionReason ? `Attention: ${attentionReason}` : undefined,
record.conclusion,
record.recommendedAction,
operatorDecision ? `Decision: ${operatorDecision}` : undefined,
]
.filter(isNonEmptyString)
.slice(0, 2);
.slice(0, 4);
const verificationLines = record.verificationSummaries.map((summary) => `Verified: ${summary}`);
return {
sourceLabel: 'Pulse Patrol',
title: 'Investigation record attached',
title: 'Operator briefing attached',
subject: `${title} on ${subject}`,
statusLabel: statusParts.join(' · ') || undefined,
detailLines,
evidence: [...record.evidenceSummaries, ...verificationLines].slice(0, 4),
actionLabel: record.proposedFix?.description,
commandSummary: record.proposedFix?.commandSummary,
safetyNote: record.proposedFix?.commandSummary
? 'Command details stay in approval context.'
: undefined,
safetyNote: buildPatrolAssistantSafetyNote(record),
};
}
function buildPatrolAssistantAttentionReason(
input: PatrolAssistantFindingBriefingInput,
record: PatrolInvestigationRecordPresentation,
): string | undefined {
const parts: string[] = [];
const status = normalizeText(input.findingStatus).toLowerCase();
const severity = normalizeText(input.severity).toLowerCase();
switch (status) {
case 'active':
parts.push(severity ? `active ${severity} finding` : 'active finding');
break;
case 'resolved':
parts.push(
normalizeNonNegativeCount(input.regressionCount) > 0
? 'resolved after prior regression'
: 'resolved finding',
);
break;
case 'snoozed':
parts.push('snoozed finding');
break;
case 'dismissed':
parts.push('dismissed finding');
break;
}
const regressionCount = normalizeNonNegativeCount(input.regressionCount);
const timesRaised = normalizeNonNegativeCount(input.timesRaised);
if (regressionCount > 0) {
parts.push(`regressed ${regressionCount} time${regressionCount === 1 ? '' : 's'}`);
} else if (timesRaised > 1) {
parts.push(`raised ${timesRaised} times`);
}
const lastRegressionAt = normalizeText(input.lastRegressionAt);
if (lastRegressionAt) {
parts.push(`last regression ${lastRegressionAt}`);
}
const loopState = formatIdentifierLabel(input.loopState)?.toLowerCase();
if (loopState) {
parts.push(`loop ${loopState}`);
}
const rawRecord = input.investigationRecord;
const approvalId = normalizeText(rawRecord?.approval_id);
if (approvalId) {
parts.push(`approval ${approvalId}`);
}
if (record.proposedFix?.destructive) {
parts.push('destructive proposed fix');
}
switch (normalizeText(rawRecord?.outcome)) {
case 'fix_queued':
parts.push('fix queued for governed review');
break;
case 'fix_executed':
parts.push('fix executed awaiting verification');
break;
case 'fix_failed':
parts.push('fix failed');
break;
case 'fix_verification_failed':
parts.push('verification failed');
break;
case 'fix_verification_unknown':
parts.push('verification inconclusive');
break;
case 'needs_attention':
parts.push('needs operator attention');
break;
case 'cannot_fix':
parts.push('Patrol cannot safely fix');
break;
case 'timed_out':
parts.push('Patrol timed out');
break;
}
return formatBriefingStringList(parts, 8, 'attention facts');
}
function buildPatrolAssistantOperatorDecision(
input: PatrolAssistantFindingBriefingInput,
): string | undefined {
if (normalizeText(input.findingStatus).toLowerCase() === 'resolved') {
return 'Finding is resolved; explain the resolution and monitoring follow-up without proposing execution.';
}
const record = input.investigationRecord;
if (record) {
const approvalId = normalizeText(record.approval_id);
if (approvalId) {
const parts = [`review governed approval ${approvalId} before execution`];
if (record.proposed_fix) {
const fixId = normalizeText(record.proposed_fix.id);
if (fixId) {
parts.push(`proposed fix ${fixId}`);
} else if (normalizeText(record.proposed_fix.description)) {
parts.push('proposed fix recorded');
}
const risk = normalizeText(record.proposed_fix.risk_level);
if (risk) {
parts.push(`risk ${risk}`);
}
if (record.proposed_fix.destructive) {
parts.push('destructive true');
}
}
return parts.join('; ');
}
switch (normalizeText(record.outcome)) {
case 'fix_queued':
return 'Review the proposed fix in the governed approval or remediation flow before execution.';
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.';
}
switch (normalizeText(record.status)) {
case 'pending':
case 'running':
return 'Wait for Patrol to finish the investigation before approving remediation.';
case 'failed':
return 'Review the Patrol investigation failure and gather evidence before remediation.';
case 'needs_attention':
return 'Operator intervention is required; use the evidence to choose the next manual step.';
}
}
const remediationId = normalizeText(input.remediationId);
if (remediationId) {
return `Review governed remediation ${remediationId} before execution.`;
}
const loopState = normalizeText(input.loopState).toLowerCase();
if (loopState.includes('approval')) {
return 'Review the governed approval flow before execution.';
}
if (loopState.includes('investigat')) {
return 'Wait for Patrol to finish the investigation before approving remediation.';
}
if (normalizeText(input.findingStatus).toLowerCase() === 'active') {
return 'Continue investigation or monitoring; no governed action reference is ready.';
}
return undefined;
}
function buildPatrolAssistantSafetyNote(
record: PatrolInvestigationRecordPresentation,
): string | undefined {
const hasCommands = Boolean(record.proposedFix?.commandSummary);
const isDestructive = Boolean(record.proposedFix?.destructive);
if (hasCommands && isDestructive) {
return 'Command details stay in approval context; destructive actions require governed approval.';
}
if (hasCommands) {
return 'Command details stay in approval context.';
}
if (isDestructive) {
return 'Destructive actions require governed approval.';
}
return undefined;
}
function normalizeCorrelationCount(correlations?: CorrelationsResponse | null): number {
if (!correlations) return 0;
if (typeof correlations.count === 'number' && Number.isFinite(correlations.count)) {
@@ -213,6 +400,30 @@ function formatCommandSummary(count: number): string | undefined {
: `${count} commands recorded for approval context`;
}
function formatBriefingStringList(
values: Array<string | undefined>,
limit: number,
itemName: string,
): string | undefined {
if (limit <= 0 || values.length === 0) return undefined;
const parts: string[] = [];
let total = 0;
for (const value of values) {
const normalized = normalizeText(value);
if (!normalized) continue;
total += 1;
if (parts.length < limit) {
parts.push(normalized);
}
}
if (parts.length === 0) return undefined;
const remaining = total - parts.length;
if (remaining > 0) {
parts.push(`${remaining} more ${itemName || 'items'}`);
}
return parts.join('; ');
}
function formatIdentifierLabel(value?: string | null): string | undefined {
const normalized = normalizeText(value);
if (!normalized) return undefined;
@@ -60,6 +60,7 @@ describe('aiIntelligenceStore', () => {
description: 'CPU usage is high',
detected_at: '2026-03-01T00:00:00Z',
last_seen_at: '2026-03-05T00:00:00Z',
times_raised: 5,
alertIdentifier: 'instance:node:100::metric/cpu',
investigation_record: {
id: 'investigation-1',
@@ -92,6 +93,7 @@ describe('aiIntelligenceStore', () => {
expect(aiIntelligenceStore.findings[0]).toMatchObject({
alertIdentifier: 'instance:node:100::metric/cpu',
lastSeenAt: '2026-03-05T00:00:00Z',
timesRaised: 5,
investigationRecord: {
id: 'investigation-1',
finding_id: 'finding-1',
@@ -148,6 +148,7 @@ export interface UnifiedFinding {
to?: string;
metadata?: Record<string, string>;
}>;
timesRaised?: number;
regressionCount?: number;
lastRegressionAt?: string;
}
@@ -318,6 +319,7 @@ export const aiIntelligenceStore = {
investigationRecord: item.investigation_record,
loopState: item.loop_state || undefined,
lifecycle: item.lifecycle || [],
timesRaised: item.times_raised || 0,
regressionCount: item.regression_count || 0,
lastRegressionAt: item.last_regression_at || undefined,
};
@@ -0,0 +1,377 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test as base } from "@playwright/test";
import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
};
const SCREENSHOT_PATH = "/tmp/patrol-assistant-operator-briefing.png";
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [
async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
"..",
"..",
"tmp",
"playwright-auth",
`patrol-assistant-operator-briefing-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
},
{ scope: "worker" },
],
});
test.describe("Patrol Assistant operator briefing", () => {
test.setTimeout(180_000);
test("shows attention and operator-decision context in the Assistant drawer", async ({
page,
}) => {
await page.route("**/api/resources**", async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname !== "/api/resources") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [
{
id: "host:web-server",
type: "host",
name: "web-server",
displayName: "web-server",
status: "online",
lastSeen: "2026-05-06T12:10:00Z",
canonicalIdentity: {
displayName: "web-server",
hostname: "web-server",
},
},
],
meta: {
page: 1,
limit: 100,
total: 1,
totalPages: 1,
},
}),
});
});
await page.route("**/api/ai/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ running: true, engine: "test" }),
});
});
await page.route("**/api/ai/sessions", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/api/ai/models", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
models: [{ id: "openai:gpt-4o-mini", name: "GPT-4o mini" }],
}),
});
});
await page.route("**/api/settings/ai", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
model: "openai:gpt-4o-mini",
chat_model: "",
control_level: "read_only",
discovery_enabled: true,
patrol_enabled: true,
patrol_interval_minutes: 360,
patrol_model: "",
alert_triggered_analysis: true,
patrol_alert_triggers_enabled: true,
patrol_anomaly_triggers_enabled: true,
patrol_event_triggers_enabled: true,
patrol_auto_fix: false,
auto_fix_model: "",
}),
});
});
await page.route("**/api/ai/patrol/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
runtime_state: "active",
running: false,
enabled: true,
last_patrol_at: "2026-05-06T12:00:00Z",
last_activity_at: "2026-05-06T12:06:00Z",
next_patrol_at: "2026-05-06T18:00:00Z",
last_duration_ms: 180000,
resources_checked: 12,
findings_count: 1,
error_count: 0,
healthy: false,
interval_ms: 21600000,
fixed_count: 0,
blocked_reason: "",
blocked_at: "",
license_required: false,
license_status: "active",
summary: {
critical: 1,
warning: 0,
watch: 0,
info: 0,
},
}),
});
});
await page.route("**/api/ai/patrol/runs*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "run-operator-briefing",
started_at: "2026-05-06T12:00:00Z",
completed_at: "2026-05-06T12:03:00Z",
duration_ms: 180000,
type: "full",
trigger_reason: "scheduled",
resources_checked: 12,
findings_summary: "1 finding",
finding_ids: ["finding-operator-briefing"],
error_count: 0,
status: "warning",
triage_flags: 0,
tool_call_count: 0,
},
]),
});
});
await page.route("**/api/ai/patrol/autonomy", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
autonomy_level: "monitor",
full_mode_unlocked: false,
investigation_budget: 15,
investigation_timeout_sec: 300,
}),
});
});
await page.route("**/api/ai/unified/findings*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
findings: [
{
id: "finding-operator-briefing",
source: "ai-patrol",
severity: "critical",
category: "performance",
resource_id: "host:web-server",
resource_name: "web-server",
resource_type: "host",
title: "High CPU usage",
description: "CPU stayed above 95%.",
detected_at: "2026-05-06T12:00:00Z",
last_seen_at: "2026-05-06T12:06:00Z",
status: "active",
times_raised: 4,
regression_count: 2,
last_regression_at: "2026-05-06T12:06:00Z",
loop_state: "awaiting_approval",
remediation_id: "remediation-1",
investigation_status: "completed",
investigation_outcome: "fix_queued",
investigation_attempts: 1,
investigation_record: {
id: "record-1",
finding_id: "finding-operator-briefing",
subject: {
resource_id: "host:web-server",
resource_name: "web-server",
resource_type: "host",
},
trigger: {
detected_at: "2026-05-06T12:00:00Z",
title: "High CPU usage",
},
status: "completed",
outcome: "fix_queued",
confidence: "high",
conclusion: "Backup job saturated CPU.",
recommended_action:
"Approve a controlled restart after the backup completes.",
evidence: [
{
kind: "metrics",
summary: "CPU stayed above 95% for 10 minutes",
},
],
proposed_fix: {
id: "fix-1",
description: "Restart the workload service",
commands: ["systemctl restart workload.service"],
risk_level: "medium",
destructive: true,
},
verification: ["CPU returned below 50%"],
tools_used: [],
started_at: "2026-05-06T12:00:00Z",
approval_id: "approval-1",
},
},
],
count: 1,
active_count: 1,
}),
});
});
await page.route("**/api/ai/intelligence/correlations*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ correlations: [], count: 0 }),
});
});
await page.route("**/api/ai/intelligence", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
timestamp: "2026-05-06T12:06:00Z",
overall_health: {
score: 62,
grade: "D",
trend: "degrading",
factors: [],
prediction: "web-server needs operator attention.",
},
findings_count: {
critical: 1,
warning: 0,
watch: 0,
info: 0,
total: 1,
},
predictions_count: 0,
recent_changes_count: 0,
recent_changes: [],
learning: {
resources_with_knowledge: 0,
total_notes: 0,
resources_with_baselines: 0,
patterns_detected: 0,
correlations_learned: 0,
incidents_tracked: 0,
},
}),
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 42,
total_failures: 0,
}),
});
});
await page.route("**/api/ai/approvals", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ approvals: [] }),
});
});
await page.route("**/api/ai/remediation/plans", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ plans: [] }),
});
});
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("button", { name: "Findings" })).toBeVisible();
await page.getByText("High CPU usage").click();
const finding = page.locator("#finding-finding-operator-briefing");
await finding
.getByRole("button", { name: "Discuss with Assistant" })
.first()
.click();
const assistantContext = page.getByLabel("Assistant context");
await expect(assistantContext).toBeVisible();
await expect(assistantContext).toContainText("Operator briefing attached");
await expect(assistantContext).toContainText(
"Attention: active critical finding; regressed 2 times; last regression 2026-05-06T12:06:00Z; loop awaiting approval; approval approval-1; destructive proposed fix; fix queued for governed review",
);
await expect(assistantContext).toContainText(
"Decision: review governed approval approval-1 before execution; proposed fix fix-1; risk medium; destructive true",
);
await expect(assistantContext).toContainText(
"Command details stay in approval context; destructive actions require governed approval.",
);
await expect(
page.getByText("systemctl restart workload.service"),
).toHaveCount(0);
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true });
});
});