diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 4d455b9f8..01505359f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -7923,3 +7923,12 @@ longer reaches the start of the window. Unknown model pricing is reported as `monitor`. `frontend-modern/src/api/patrol.ts` mirrors the payload as `PatrolDigest`. Proofs: `internal/ai/patrol_digest_test.go` and `frontend-modern/src/api/__tests__/patrol.test.ts`. + +### Patrol digest client is the only consumer path + +`getPatrolDigest(days)` in `frontend-modern/src/api/patrol.ts` is the single +client for `GET /api/ai/patrol/digest`; it forwards the `days` window and +returns the typed `PatrolDigest` payload unchanged. Presentation code must not +recompute digest counts from run history, findings, or cost events, and must +not call the endpoint through any other client. Proof: +`frontend-modern/src/api/__tests__/patrol.test.ts`. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ae4a06d39..15f92ce6c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -10542,3 +10542,12 @@ an error so clients can render an honest "Patrol has not run" state, and `internal/api/security_regression_test.go`, `frontend-modern/src/api/__tests__/patrol.test.ts`, and `frontend-modern/src/utils/__tests__/docsLinks.test.ts`. + +### Patrol digest client mirrors the endpoint exactly + +The `PatrolDigest` types and `getPatrolDigest` client in +`frontend-modern/src/api/patrol.ts` mirror the `GET /api/ai/patrol/digest` +payload field for field (snake_case, `by_outcome` as an object, optional +`history_since` and `last_run_at`). New payload fields are additive and the +client must tolerate their absence. Proof: +`frontend-modern/src/api/__tests__/patrol.test.ts`. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 44ca69e15..e7894ab66 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7044,3 +7044,16 @@ the settings surface; `useAISettingsState` fetches `aiPatrolCostPresentation.ts` owns the copy. `AIModelPicker.test.tsx`, `AISettings.test.tsx`, and `settingsArchitecture.test.ts` pin those distinctions. + +### Patrol weekly digest card is a read-only summary + +The Patrol Activity tab gains `PatrolWeeklyDigestCard` ("This week") above +Verified outcomes. It renders the server-computed `GET /api/ai/patrol/digest` +rollup as definition-list stat tiles built from the shared `Button` and +`ButtonLink` primitives and the existing surface, border, and muted text +tokens; it introduces no new shared primitive, theme token, or layout helper. +The only navigation it offers is the existing `/actions` route, shown only when +Patrol-origin fixes are waiting for approval. Loading, failed-load, no-runs, +and truncated-history states carry distinct copy, and a failed load never +renders zero counts as if the week were quiet. Browser proof covers the desktop +and narrow Activity tab in `frontend-modern/browser-verification.json`. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index deaf9e516..819d3616c 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -65,6 +65,7 @@ sources, and retains the note as operator context. 36. `frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx` 37. `frontend-modern/src/features/patrol/patrolHomePresentation.ts` 38. `frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx` +39. `frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx` ## Shared Boundaries @@ -2558,3 +2559,22 @@ finding and carried onto the unified finding as `failureCause`, and a provider preflight success no longer clears a budget-exhausted runtime finding, so the setup card still routes to the budget after a restart clears the in-memory block state. + +### This week card answers what Patrol did for the customer + +The Activity tab leads with `PatrolWeeklyDigestCard` ("This week"), rendered +above Verified outcomes and never inside the Inbox decision surface. It shows +six tiles in plain customer language: Patrol runs, New issues, Issues resolved, +Investigated, Fixes run, and Estimated spend, with the effective Patrol mode +sentence underneath, and it links to `/actions` only when Patrol-origin fixes +are waiting for approval. The card reads `GET /api/ai/patrol/digest` and must +not recompute counts from findings or run history client-side. Forensic +vocabulary (evidence classes, verdicts, model names, tool traces) stays out of +the card and remains in run history and the Actions audit. Empty history +renders "Patrol has not run in the last N days"; a truncated history says +"Since (older runs are no longer kept)"; a failed load says the summary +is unavailable rather than showing zeros. Proofs: +`frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx`, +`frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx`, +`frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx` (card ordering), +and the browser receipt in `frontend-modern/browser-verification.json`. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 7dba434b2..4d29339e3 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,42 +1,42 @@ { "version": 1, - "base_sha": "23b3893ae88b86497f3f103c8972663508555766", - "verified_at": "2026-09-02T07:15:12Z", + "base_sha": "facee87bb4e7b84a0ce682d6e142d35b55be3ace", + "verified_at": "2026-09-02T08:55:07Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/CommandPaletteModal.tsx", - "frontend-modern/src/components/shared/SearchField.tsx", - "frontend-modern/src/components/shared/searchFieldModel.ts", - "frontend-modern/src/components/shared/useCommandPaletteState.ts" + "frontend-modern/src/api/patrol.ts", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", + "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx" ], "content_sha256": { - "frontend-modern/src/components/shared/CommandPaletteModal.tsx": "300030975b76634513d98b9459fe9ae4d07e8d7d03fedffa91f702746a7d3f06", - "frontend-modern/src/components/shared/SearchField.tsx": "0c2cc0d002763b59c2c065ceccff134318122aa975fe45ddc31271387cd623ad", - "frontend-modern/src/components/shared/searchFieldModel.ts": "3b9ca4ffc6aef0e5910e5094daebb9b03878b2b6ef4c9c7aed165bf7f46097db", - "frontend-modern/src/components/shared/useCommandPaletteState.ts": "df4baf2ccba420f0c8d7d67ee7ddeba5a55ddbee8af323d8d7924aa389ad9b3a" + "frontend-modern/src/api/patrol.ts": "c411c12d504b53e64b1435c5b336ba953f5051f6909f2bfef3e46067d5b88f34", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "0d46fd5afbbdf8e1b885fa558be070c07203cc6613dda4458e9790fa8f2e4099", + "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx": "9a9728f618c502a5379f56e93cad29ea939f5c26e73e3c7d22195308a8087b81" }, - "routes": ["/proxmox"], + "routes": [ + "/patrol" + ], "viewports": [ { "width": 1280, - "height": 720 + "height": 800 }, { - "width": 393, - "height": 851 + "width": 375, + "height": 812 } ], "states": [ - "Command palette open with twelve results and the first result selected at desktop and narrow widths", - "Command palette scrolled to the last keyboard-selected result at desktop and narrow widths", - "Command palette empty result state after a query with no matching commands at desktop and narrow widths", - "Command palette closed after Escape and after backdrop dismissal at desktop and narrow widths" + "Activity tab with the This week card above Verified outcomes, populated from an isolated mock-mode backend (11 runs, 3 new issues, watch-only mode)", + "card tiles in single column at 375px with no horizontal overflow", + "card refresh in flight and settled", + "Verified outcomes empty state and Review and history below the card", + "watch-only tile copy for Investigated and Fixes run" ], "interactions": [ - "opened the command palette with Control+K on the authenticated Proxmox route", - "verified the search retained DOM focus while pointer hover and Home and End keys updated aria-activedescendant and aria-selected", - "verified End scrolled the last selected option fully into the result viewport and Tab did not focus an option", - "entered a no-match query and verified the combobox collapsed, cleared its active descendant, and removed the listbox", - "dismissed the palette with Escape and reopened and dismissed it through the backdrop" + "clicked the Activity workspace tab", + "scrolled the card into view at desktop and narrow widths", + "clicked Refresh this week's summary and confirmed the tiles reloaded without an error state", + "checked console for card-originated errors (only unrelated dev websocket/update-check noise)" ] } diff --git a/frontend-modern/src/api/__tests__/patrol.test.ts b/frontend-modern/src/api/__tests__/patrol.test.ts index f9a63f401..741418d60 100644 --- a/frontend-modern/src/api/__tests__/patrol.test.ts +++ b/frontend-modern/src/api/__tests__/patrol.test.ts @@ -6,6 +6,7 @@ vi.mock('@/utils/apiClient', () => ({ import { getPatrolStatus, + getPatrolDigest, getPatrolRun, getPatrolFindings, getPatrolRunHistory, @@ -582,4 +583,18 @@ describe('triggerPatrolRun scope body', () => { await triggerPatrolRun({ resource_ids: [], resource_types: [] }); expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/patrol/run', { method: 'POST' }); }); + + it('reads the weekly digest for the requested window', async () => { + const digest = { window: { days: 7 }, runs: { total: 3 } }; + apiFetchJSONMock.mockResolvedValueOnce(digest as any); + await expect(getPatrolDigest()).resolves.toBe(digest); + expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/digest?days=7', { + signal: undefined, + }); + + await getPatrolDigest(30); + expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/digest?days=30', { + signal: undefined, + }); + }); }); diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index 590b3fa0b..e45416d2f 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -1051,3 +1051,93 @@ export async function runPatrolModelReadiness( signal, }); } + +// --- Weekly digest ("what Patrol did for you") --------------------------- +// Mirrors internal/ai/patrol_digest.go. Every number is a rollup over records +// Pulse already keeps; see docs/PATROL_WEEKLY_DIGEST.md for sources and limits. + +export type PatrolDigestMode = 'monitor' | 'approval' | 'assisted' | 'full'; + +export interface PatrolDigestWindow { + start: string; + end: string; + days: number; + history_complete: boolean; + history_since?: string; +} + +export interface PatrolDigestRuns { + total: number; + scheduled: number; + event_triggered: number; + manual: number; + failed: number; + checks: number; + resources_covered: number; + last_run_at?: string; +} + +export interface PatrolDigestSeverityCounts { + critical: number; + warning: number; + watch: number; + info: number; +} + +export interface PatrolDigestFindings { + new: number; + open_by_severity: PatrolDigestSeverityCounts; + resolved: number; + auto_resolved: number; + dismissed: number; + suppressed: number; +} + +export interface PatrolDigestInvestigations { + total: number; + by_outcome: Record; +} + +export interface PatrolDigestActions { + proposed: number; + approved: number; + rejected: number; + executed: number; + verified: number; + failed: number; + pending: number; +} + +export interface PatrolDigestAlerts { + reviewed: number; +} + +export interface PatrolDigestSpend { + estimated_usd: number; + pricing_known: boolean; + input_tokens: number; + output_tokens: number; + calls: number; +} + +export interface PatrolDigest { + generated_at: string; + window: PatrolDigestWindow; + mode: PatrolDigestMode; + runs: PatrolDigestRuns; + findings: PatrolDigestFindings; + investigations: PatrolDigestInvestigations; + actions: PatrolDigestActions; + alerts: PatrolDigestAlerts; + spend: PatrolDigestSpend; +} + +export const PATROL_DIGEST_DEFAULT_DAYS = 7; + +export async function getPatrolDigest( + days: number = PATROL_DIGEST_DEFAULT_DAYS, + signal?: AbortSignal, +): Promise { + const search = new URLSearchParams({ days: String(days) }); + return apiFetchJSON(`/api/ai/patrol/digest?${search.toString()}`, { signal }); +} diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx index b9587c060..92c5ee61b 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -13,6 +13,7 @@ import { PatrolIntelligenceWorkspace } from './PatrolIntelligenceWorkspace'; import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench'; import { PatrolObjectivesPanel } from './PatrolObjectivesPanel'; import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel'; +import { PatrolWeeklyDigestCard } from './PatrolWeeklyDigestCard'; import type { AttentionItem } from '@/api/patrolAttention'; type PatrolWorkspaceView = 'inbox' | 'protection' | 'activity'; @@ -131,6 +132,8 @@ export function PatrolIntelligenceSurface() { aria-labelledby="patrol-activity-tab" class="space-y-4 lg:space-y-5" > + +
= [ + { key: 'needs_attention', label: 'need you' }, + { key: 'fix_failed', label: 'fix failed' }, + { key: 'fix_verification_failed', label: 'fix not confirmed' }, + { key: 'cannot_fix', label: 'could not fix' }, + { key: 'timed_out', label: 'timed out' }, + { key: 'fix_queued', label: 'fix waiting for approval' }, + { key: 'fix_executed', label: 'fix run' }, + { key: 'fix_verification_unknown', label: 'fix run, result unknown' }, + { key: 'fix_verified', label: 'fixed and verified' }, + { key: 'resolved', label: 'resolved' }, + { key: 'fix_rejected', label: 'fix declined' }, +]; + +const plural = (count: number, singular: string, pluralForm = `${singular}s`): string => + `${count} ${count === 1 ? singular : pluralForm}`; + +const formatDigestError = (error: unknown): string => + error instanceof Error ? error.message : 'The weekly summary could not be loaded.'; + +const formatWindowDate = (iso: string): string => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +}; + +export function describeDigestInvestigationOutcomes( + byOutcome: Record, + limit = 2, +): string[] { + const lines: string[] = []; + for (const entry of INVESTIGATION_OUTCOME_COPY) { + const count = byOutcome[entry.key] ?? 0; + if (count > 0) lines.push(`${count} ${entry.label}`); + if (lines.length >= limit) break; + } + return lines; +} + +export function describeDigestOpenFindings(digest: PatrolDigest): string { + const open = digest.findings.open_by_severity; + const openTotal = open.critical + open.warning + open.watch + open.info; + if (digest.findings.new === 0) return 'Nothing new was raised.'; + if (openTotal === 0) return 'All of them have since cleared.'; + const parts: string[] = []; + if (open.critical > 0) parts.push(`${open.critical} critical`); + if (open.warning > 0) parts.push(`${open.warning} warning`); + const detail = parts.length > 0 ? ` (${parts.join(', ')})` : ''; + return `${openTotal} still open${detail}.`; +} + +interface DigestTile { + id: string; + value: string; + label: string; + details: string[]; + tone?: 'default' | 'positive' | 'attention'; +} + +export function buildDigestTiles(digest: PatrolDigest): DigestTile[] { + const { runs, findings, investigations, actions, alerts, spend } = digest; + + const runDetails = [ + `${plural(runs.checks, 'check')} across ${plural(runs.resources_covered, 'resource')}.`, + ]; + if (alerts.reviewed > 0) runDetails.push(`${plural(alerts.reviewed, 'alert')} looked into.`); + if (runs.failed > 0) runDetails.push(`${plural(runs.failed, 'run')} failed.`); + if (runs.last_run_at) runDetails.push(`Last run ${formatRelativeTime(runs.last_run_at)}.`); + + const resolvedDetails: string[] = []; + if (findings.auto_resolved > 0) { + resolvedDetails.push(`${findings.auto_resolved} cleared by Patrol on its own.`); + } + if (findings.dismissed > 0) resolvedDetails.push(`${findings.dismissed} dismissed by you.`); + if (findings.suppressed > 0) resolvedDetails.push(`${findings.suppressed} muted for good.`); + if (resolvedDetails.length === 0) { + resolvedDetails.push( + findings.resolved > 0 ? 'Resolved by you.' : 'No issues were resolved this period.', + ); + } + + const investigationDetails = describeDigestInvestigationOutcomes(investigations.by_outcome); + if (investigationDetails.length === 0) { + investigationDetails.push( + digest.mode === 'monitor' + ? 'Patrol is watch only, so it reports issues without investigating them.' + : 'No issues needed a closer look.', + ); + } else { + investigationDetails[investigationDetails.length - 1] += '.'; + if (investigationDetails.length > 1) investigationDetails[0] += ','; + } + + const actionDetails: string[] = []; + if (actions.executed > 0) { + actionDetails.push(`${actions.verified} of ${actions.executed} verified afterwards.`); + } + if (actions.failed > 0) actionDetails.push(`${plural(actions.failed, 'action')} failed.`); + if (actions.rejected > 0) actionDetails.push(`${actions.rejected} declined by you.`); + if (actionDetails.length === 0 && actions.pending === 0) { + actionDetails.push( + digest.mode === 'monitor' + ? 'Patrol is watch only, so no fixes were proposed.' + : 'No fixes were needed.', + ); + } + + const spendDetails = [`${plural(spend.calls, 'model call')}.`]; + if (spend.calls > 0 && !spend.pricing_known) { + spendDetails.push('Some calls used a model with no known price.'); + } + + return [ + { id: 'runs', value: String(runs.total), label: 'Patrol runs', details: runDetails }, + { + id: 'new', + value: String(findings.new), + label: 'New issues', + details: [describeDigestOpenFindings(digest)], + tone: + findings.open_by_severity.critical + findings.open_by_severity.warning > 0 + ? 'attention' + : 'default', + }, + { + id: 'resolved', + value: String(findings.resolved), + label: 'Issues resolved', + details: resolvedDetails, + tone: findings.resolved > 0 ? 'positive' : 'default', + }, + { + id: 'investigated', + value: String(investigations.total), + label: 'Investigated', + details: investigationDetails, + }, + { + id: 'actions', + value: String(actions.executed), + label: 'Fixes run', + details: actionDetails, + tone: actions.pending > 0 ? 'attention' : actions.executed > 0 ? 'positive' : 'default', + }, + { + id: 'spend', + value: spend.calls > 0 ? usdFormatter.format(spend.estimated_usd) : usdFormatter.format(0), + label: 'Estimated spend', + details: spendDetails, + }, + ]; +} + +export function PatrolWeeklyDigestCard() { + const [digest, setDigest] = createSignal(null); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(''); + + const load = async (quiet = false) => { + if (!quiet) setLoading(true); + try { + setDigest(await getPatrolDigest(PATROL_DIGEST_DEFAULT_DAYS)); + setError(''); + } catch (cause) { + setError(formatDigestError(cause)); + } finally { + if (!quiet) setLoading(false); + } + }; + + onMount(() => { + void load(); + const refresh = () => { + if (document.visibilityState === 'visible') void load(true); + }; + const timer = window.setInterval(refresh, REFRESH_INTERVAL_MS); + document.addEventListener('visibilitychange', refresh); + onCleanup(() => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', refresh); + }); + }); + + const windowLabel = createMemo(() => { + const current = digest(); + if (!current) return `Last ${PATROL_DIGEST_DEFAULT_DAYS} days`; + if (!current.window.history_complete && current.window.history_since) { + const since = formatWindowDate(current.window.history_since); + return since + ? `Since ${since} (older runs are no longer kept)` + : `Last ${current.window.days} days`; + } + return `Last ${current.window.days} days`; + }); + + const tiles = createMemo(() => { + const current = digest(); + return current ? buildDigestTiles(current) : []; + }); + + const pendingCount = createMemo(() => digest()?.actions.pending ?? 0); + const hasRuns = createMemo(() => (digest()?.runs.total ?? 0) > 0); + + return ( +
+
+
+

+ This week +

+

+ What Patrol did for you. {windowLabel()}. +

+
+ +
+ +
+ + {(message) => ( +
+

This week's summary is unavailable

+

{message()}

+
+ )} +
+ + Adding up this week…

} + > + + {(current) => ( + +
+ } + > +
+ + {(tile) => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ + {(line) => {line}} + + 0}> + + {plural(pendingCount(), 'fix')} waiting for your approval + + +
+
+ )} +
+
+ + )} + + + +
+ ); +} + +export default PatrolWeeklyDigestCard; diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx index 11cb6529a..07696612e 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx @@ -54,6 +54,10 @@ vi.mock('../PatrolRecentWorkPanel', () => ({ PatrolRecentWorkPanel: () =>
Recent work
, })); +vi.mock('../PatrolWeeklyDigestCard', () => ({ + PatrolWeeklyDigestCard: () =>
This week
, +})); + vi.mock('@/stores/actionInbox', () => ({ actionInboxStore: { pendingActionCount: 0 }, })); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx new file mode 100644 index 000000000..2f00c6ad5 --- /dev/null +++ b/frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx @@ -0,0 +1,205 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PatrolDigest } from '@/api/patrol'; + +const apiMocks = vi.hoisted(() => ({ getDigest: vi.fn() })); + +vi.mock('@/api/patrol', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getPatrolDigest: (...args: unknown[]) => apiMocks.getDigest(...args), + }; +}); + +vi.mock('@/components/shared/Button', () => ({ + Button: (props: { children?: unknown; onClick?: () => void; 'aria-label'?: string }) => ( + + ), + ButtonLink: (props: { href: string; children?: unknown }) => ( + {props.children as never} + ), +})); + +import { + PatrolWeeklyDigestCard, + buildDigestTiles, + describeDigestInvestigationOutcomes, + describeDigestOpenFindings, +} from '../PatrolWeeklyDigestCard'; + +const digest = (overrides: Partial = {}): PatrolDigest => ({ + generated_at: '2026-09-01T12:00:00Z', + window: { + start: '2026-08-25T12:00:00Z', + end: '2026-09-01T12:00:00Z', + days: 7, + history_complete: true, + }, + mode: 'approval', + runs: { + total: 38, + scheduled: 34, + event_triggered: 3, + manual: 1, + failed: 0, + checks: 1520, + resources_covered: 40, + last_run_at: '2026-09-01T11:00:00Z', + }, + findings: { + new: 12, + open_by_severity: { critical: 1, warning: 3, watch: 0, info: 0 }, + resolved: 9, + auto_resolved: 7, + dismissed: 2, + suppressed: 0, + }, + investigations: { total: 4, by_outcome: { fix_verified: 2, needs_attention: 1, resolved: 1 } }, + actions: { + proposed: 3, + approved: 2, + rejected: 0, + executed: 2, + verified: 1, + failed: 0, + pending: 1, + }, + alerts: { reviewed: 5 }, + spend: { + estimated_usd: 1.2345, + pricing_known: true, + input_tokens: 4_000_000, + output_tokens: 200_000, + calls: 40, + }, + ...overrides, +}); + +describe('PatrolWeeklyDigestCard', () => { + beforeEach(() => apiMocks.getDigest.mockReset()); + afterEach(cleanup); + + it('adds up the week in customer terms and links pending fixes to Actions', async () => { + apiMocks.getDigest.mockResolvedValue(digest()); + + render(() => ); + + expect(await screen.findByText('Patrol runs')).toBeInTheDocument(); + expect(apiMocks.getDigest).toHaveBeenCalledWith(7); + expect(screen.getByText('38')).toBeInTheDocument(); + expect(screen.getByText('1520 checks across 40 resources.')).toBeInTheDocument(); + expect(screen.getByText('5 alerts looked into.')).toBeInTheDocument(); + expect(screen.getByText('New issues')).toBeInTheDocument(); + expect(screen.getByText('4 still open (1 critical, 3 warning).')).toBeInTheDocument(); + expect(screen.getByText('7 cleared by Patrol on its own.')).toBeInTheDocument(); + expect(screen.getByText('2 dismissed by you.')).toBeInTheDocument(); + expect(screen.getByText('1 need you,')).toBeInTheDocument(); + expect(screen.getByText('2 fixed and verified.')).toBeInTheDocument(); + expect(screen.getByText('1 of 2 verified afterwards.')).toBeInTheDocument(); + const pendingLink = screen.getByText('1 fix waiting for your approval'); + expect(pendingLink.closest('a')).toHaveAttribute('href', '/actions'); + expect(screen.getByText('$1.23')).toBeInTheDocument(); + expect(screen.getByText('40 model calls.')).toBeInTheDocument(); + // The page header already states the mode sentence; the card must not repeat it. + expect(screen.queryByText(/every change waits for your approval/)).not.toBeInTheDocument(); + expect(screen.getByText(/Last 7 days/)).toBeInTheDocument(); + // Forensic vocabulary stays out of the customer summary. + expect(screen.queryByText(/evidence class/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/verdict/i)).not.toBeInTheDocument(); + }); + + it('says plainly when Patrol has not run and when history is cut short', async () => { + apiMocks.getDigest.mockResolvedValueOnce( + digest({ + runs: { + total: 0, + scheduled: 0, + event_triggered: 0, + manual: 0, + failed: 0, + checks: 0, + resources_covered: 0, + }, + }), + ); + render(() => ); + expect(await screen.findByText('Patrol has not run in the last 7 days')).toBeInTheDocument(); + expect(screen.queryByText('Patrol runs')).not.toBeInTheDocument(); + cleanup(); + + apiMocks.getDigest.mockResolvedValueOnce( + digest({ + window: { + start: '2026-08-25T12:00:00Z', + end: '2026-09-01T12:00:00Z', + days: 7, + history_complete: false, + history_since: '2026-08-29T08:00:00Z', + }, + }), + ); + render(() => ); + expect(await screen.findByText(/older runs are no longer kept/)).toBeInTheDocument(); + }); + + it('reports a failed load without pretending the week was empty', async () => { + apiMocks.getDigest.mockRejectedValueOnce(new Error('digest offline')); + render(() => ); + expect(await screen.findByText("This week's summary is unavailable")).toBeInTheDocument(); + expect(screen.getByText('digest offline')).toBeInTheDocument(); + expect(screen.queryByText(/Patrol has not run/)).not.toBeInTheDocument(); + }); + + it('keeps tile copy honest for watch-only installs and unknown pricing', () => { + const tiles = buildDigestTiles( + digest({ + mode: 'monitor', + investigations: { total: 0, by_outcome: {} }, + actions: { + proposed: 0, + approved: 0, + rejected: 0, + executed: 0, + verified: 0, + failed: 0, + pending: 0, + }, + spend: { + estimated_usd: 0.5, + pricing_known: false, + input_tokens: 1, + output_tokens: 1, + calls: 3, + }, + }), + ); + const byId = Object.fromEntries(tiles.map((tile) => [tile.id, tile])); + expect(byId.investigated.details).toEqual([ + 'Patrol is watch only, so it reports issues without investigating them.', + ]); + expect(byId.actions.details).toEqual(['Patrol is watch only, so no fixes were proposed.']); + expect(byId.spend.details).toEqual([ + '3 model calls.', + 'Some calls used a model with no known price.', + ]); + expect(describeDigestOpenFindings(digest({ findings: { ...digest().findings, new: 0 } }))).toBe( + 'Nothing new was raised.', + ); + expect( + describeDigestOpenFindings( + digest({ + findings: { + ...digest().findings, + open_by_severity: { critical: 0, warning: 0, watch: 0, info: 0 }, + }, + }), + ), + ).toBe('All of them have since cleared.'); + expect( + describeDigestInvestigationOutcomes({ resolved: 3, fix_failed: 1, cannot_fix: 2 }), + ).toEqual(['1 fix failed', '2 could not fix']); + }); +}); diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 0ae1abe3f..2e0add29d 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -154,6 +154,46 @@ vi.mock('@/api/patrol', () => ({ triggerPatrolRun: (...args: unknown[]) => triggerPatrolRunMock(...args), getPatrolRunHistory: (...args: unknown[]) => getPatrolRunHistoryMock(...args), getPatrolObjectives: vi.fn().mockResolvedValue([]), + PATROL_DIGEST_DEFAULT_DAYS: 7, + getPatrolDigest: vi.fn().mockResolvedValue({ + generated_at: '2026-09-01T12:00:00Z', + window: { + start: '2026-08-25T12:00:00Z', + end: '2026-09-01T12:00:00Z', + days: 7, + history_complete: true, + }, + mode: 'monitor', + runs: { + total: 0, + scheduled: 0, + event_triggered: 0, + manual: 0, + failed: 0, + checks: 0, + resources_covered: 0, + }, + findings: { + new: 0, + open_by_severity: { critical: 0, warning: 0, watch: 0, info: 0 }, + resolved: 0, + auto_resolved: 0, + dismissed: 0, + suppressed: 0, + }, + investigations: { total: 0, by_outcome: {} }, + actions: { + proposed: 0, + approved: 0, + rejected: 0, + executed: 0, + verified: 0, + failed: 0, + pending: 0, + }, + alerts: { reviewed: 0 }, + spend: { estimated_usd: 0, pricing_known: true, input_tokens: 0, output_tokens: 0, calls: 0 }, + }), createPatrolObjective: vi.fn(), updatePatrolObjective: vi.fn(), deletePatrolObjective: vi.fn(), @@ -564,12 +604,15 @@ describe('AIIntelligence entitlement gating', () => { const attentionIndex = patrolIntelligenceSurfaceSource.indexOf(' {