From cfa7483f096aeea37965bcff8f0666bd5cfbb1c8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 7 May 2026 00:05:59 +0100 Subject: [PATCH] Frame Patrol Assistant handoff for operators --- .../v6/internal/subsystems/api-contracts.md | 11 +- .../subsystems/frontend-primitives.md | 10 +- .../subsystems/patrol-intelligence.md | 18 +- .../src/components/AI/FindingsPanel.tsx | 7 + .../patrol/InvestigationSection.tsx | 21 +- .../__tests__/InvestigationSection.test.tsx | 1 + .../patrolInvestigationContextModel.test.ts | 43 +- .../patrol/patrolInvestigationContextModel.ts | 239 ++++++++++- .../stores/__tests__/aiIntelligence.test.ts | 2 + frontend-modern/src/stores/aiIntelligence.ts | 2 + ...patrol-assistant-operator-briefing.spec.ts | 377 ++++++++++++++++++ 11 files changed, 695 insertions(+), 36 deletions(-) create mode 100644 tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a7559bafd..34f5c42bc 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9b8b1a0a2..29abe8738 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 230313c09..e9e5c91ed 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/frontend-modern/src/components/AI/FindingsPanel.tsx b/frontend-modern/src/components/AI/FindingsPanel.tsx index 469329a17..7f6f8ff88 100644 --- a/frontend-modern/src/components/AI/FindingsPanel.tsx +++ b/frontend-modern/src/components/AI/FindingsPanel.tsx @@ -430,6 +430,13 @@ export const FindingsPanel: Component = (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, { diff --git a/frontend-modern/src/components/patrol/InvestigationSection.tsx b/frontend-modern/src/components/patrol/InvestigationSection.tsx index 017665cc2..51c2e857a 100644 --- a/frontend-modern/src/components/patrol/InvestigationSection.tsx +++ b/frontend-modern/src/components/patrol/InvestigationSection.tsx @@ -57,6 +57,12 @@ export const InvestigationSection: Component = (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 = (props {/* Loading */} - +
- {getInvestigationSectionState(investigation.loading, !!investigation()).text} + {investigationSectionState().text}
{/* No investigation data */} - -

- {getInvestigationSectionState(investigation.loading, !!investigation()).text} -

+ +

{investigationSectionState().text}

diff --git a/frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx index 85e088109..a71337365 100644 --- a/frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/InvestigationSection.test.tsx @@ -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(); }); }); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 3b4d1a1f8..98bc2fb4e 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -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, + }); + }); }); diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index e5af82784..886641e9e 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -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, + 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; diff --git a/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts b/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts index 0f5397ae8..85d13998c 100644 --- a/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts +++ b/frontend-modern/src/stores/__tests__/aiIntelligence.test.ts @@ -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', diff --git a/frontend-modern/src/stores/aiIntelligence.ts b/frontend-modern/src/stores/aiIntelligence.ts index 2c702675c..7f7fcc46b 100644 --- a/frontend-modern/src/stores/aiIntelligence.ts +++ b/frontend-modern/src/stores/aiIntelligence.ts @@ -148,6 +148,7 @@ export interface UnifiedFinding { to?: string; metadata?: Record; }>; + 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, }; diff --git a/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts new file mode 100644 index 000000000..8a5032d32 --- /dev/null +++ b/tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts @@ -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 }); + }); +});