diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index c94d877a4..448f6ec28 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7378,6 +7378,17 @@ The frontend Patrol history clients in `frontend-modern/src/api/patrol.ts` must mirror that normalization before sending the request: invalid and non-positive caller input collapses back to the client default of `30`, and oversized requests clamp to the backend maximum of `100`. +The outcome-first Patrol home consumes the existing typed attention contract; +it does not create a second server lifecycle. `GET /api/ai/patrol/attention` +with `filter=active` remains the source for current typed evidence and governed +action offers, while `filter=resolved` supplies receipt candidates. Frontend +presentation may call a resolved item handled only when the API-authored +`state` is `resolved` and `verificationState` is `succeeded`; it must preserve +pending, failed, unknown, and unavailable verification as non-receipts. The +frontend may use action eligibility and approval fields to decide whether an +item needs another operator decision under the effective autonomy level, but +that projection grants no action authority and cannot override server planning, +policy, approval, dispatch, or verification. Patrol run detail access for selected-history UX must now resolve a canonical single-run contract at `/api/ai/patrol/runs/{id}` instead of probing bounded history pages and hoping the target run is still inside a recent window; the diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a2a99c955..c022dbb46 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -771,6 +771,13 @@ Feature surfaces under `frontend-modern/src/features/` may own product-specific assessment semantics, but they must keep those semantics in their governed presentation helpers and render them inside the shared neutral Pulse surface language rather than introducing page-local verdict bands or nested cards. +The Patrol operator home composes the shared `PageHeader`, `Button`, `Toggle`, +`MetadataBadge`, native disclosure, dialog, and neutral bordered-surface +patterns. Patrol owns the meaning and order of background posture, objectives, +genuine decisions, verified receipts, and secondary operational records; the +shared primitive boundary owns keyboard focus, touch targets, responsive +stacking, and visual consistency. Plan-locked paid-mode discovery is not a +daily-page primitive and must not be added beside the effective mode. For Patrol, that includes the Open work description: it may use concise row-level guidance such as review evidence, approve a change, inspect automatic actions, or review verification results, but it must remain descriptive copy diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 794d40106..4fc4cd9af 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -15,9 +15,10 @@ ## Purpose -Own the Patrol intelligence route shell, feature surface, local state -orchestration, findings and approval presentation, run-history rendering, and -Patrol-specific presentation helpers. +Own the Patrol intelligence route shell, outcome-first feature surface, local +state orchestration, genuine-decision presentation, verified-work receipts, +findings and approval presentation, run-history rendering, and Patrol-specific +presentation helpers. The centralized Findings surface loads full history for its All and Resolved filters, keeps saved operator notes editable and clearable, and offers `Reopen finding` only on dismissed findings. Reopening removes the @@ -62,6 +63,8 @@ sources, and retains the note as operator context. 34. `tests/integration/tests/91-operational-trust-attention-workbench.spec.ts` 35. `frontend-modern/src/api/patrolAttention.ts` 36. `frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx` +37. `frontend-modern/src/features/patrol/patrolHomePresentation.ts` +38. `frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx` ## Shared Boundaries @@ -69,6 +72,40 @@ sources, and retains the note as operator context. ## Extension Points +### Quiet operator home + +The first-party Patrol page is an outcome and exception surface, not a mirror of +the internal lifecycle. Its primary order is the effective background posture, +the outcomes Patrol is looking after, work that genuinely needs the operator, +and concise recent receipts. Investigation internals, lifecycle filters, raw +findings, checks, and run history remain available as secondary operational +records without competing with those four questions. + +`patrolHomePresentation.ts` owns the deterministic projection from effective +autonomy and typed attention evidence into `needs user` versus `can continue` +work. Watch-only and ask-first modes keep current active issues operator-visible. +Safe-auto-fix and Autopilot may keep an issue quiet only when evidence is fresh +and complete and at least one eligible governed action can proceed without a +new approval. Unknown or stale evidence, failed or unknown verification, +ineligible or absent actions, policy denial, and outstanding approval remain +interruptions. This is presentation of existing authority, never a new grant of +authority or a bypass around planning, feasibility, approval, dispatch, and +verification. + +The `Recently handled` surface is proof-constrained. It may render a receipt +only for a resolved attention record whose verification state is `succeeded`, +and it describes the resolved record rather than inferring which offered action +ran. Pending, failed, unknown, or unavailable verification must never be styled +as handled work. Objective summary language similarly counts protection only +from active objectives whose server-authored coverage state is `covered`. + +The effective autonomy label and plain-language consequence stay visible, but +the four-level selector is a secondary disclosure. A plan-locked daily operator +surface shows the effective watch-only experience without advertising paid +modes or Plans & Billing; a wrong-runtime capability block may still expose its +direct runtime repair action. Mobile must reach protected outcomes and genuine +decisions without first traversing configuration or commercial chrome. + ### Model-authored operational objectives Patrol's long-term operator contract is a retained operations brief, not a diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 6a280e356..d22ee348f 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -6121,12 +6121,14 @@ "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts", "frontend-modern/src/features/patrol/patrolControlPresentation.ts", + "frontend-modern/src/features/patrol/patrolHomePresentation.ts", "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx", "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts", "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx", + "frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx", "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts", "frontend-modern/src/pages/AIIntelligence.tsx", "frontend-modern/src/stores/aiIntelligence.ts", @@ -6169,12 +6171,14 @@ "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts", "frontend-modern/src/features/patrol/patrolControlPresentation.ts", + "frontend-modern/src/features/patrol/patrolHomePresentation.ts", "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx", "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts", "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx", + "frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx", "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts", "frontend-modern/src/pages/AIIntelligence.tsx", "frontend-modern/src/stores/aiIntelligence.ts", @@ -6190,9 +6194,11 @@ "frontend-modern/src/components/Brand/__tests__/PulsePatrolLogo.test.tsx", "frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx", "frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts", + "frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts", "frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts", "frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts", "frontend-modern/src/features/patrol/__tests__/PatrolObjectivesPanel.test.tsx", + "frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx", "frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts", "frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts", "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx", diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 4ab4df24c..3240e6ef1 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,25 +1,29 @@ { "version": 1, - "base_sha": "6f09bc46768e809ec882f4c8c6782fae1e2ab710", - "verified_at": "2026-08-14T02:02:23Z", + "base_sha": "e5cad4c2c34529c0b15d8c32b8d1e53d0ddd84ad", + "verified_at": "2026-08-14T08:26:04Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/patrol.ts", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", + "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", - "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx" + "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx", + "frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx", + "frontend-modern/src/features/patrol/patrolHomePresentation.ts" ], "content_sha256": { - "frontend-modern/src/api/patrol.ts": "59e86aab6a4eb168de3563589cccb912d0ed6c275054d55544620598ab690496", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "8a0e42b07fa23cfd1a9c308986cff36bfe377dc2c1d2d84d0286af06709489b1", - "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "44856c28de9cc1bb81900517876791666cb5e86fdde3e3dd759617a691c99b44" + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "01f1010536c0b1551a09fa5634c9714cb771f10a37b274ae068aa1bbd3860b59", + "frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "41aa54fdf6d23ceb0019455e2a112b47d0164cf2e1dd36abc45f8faf47bc77e9", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "780a27fcab535413b8446c5ec89637f98c08af1d8b57f77157028e499ad6b4d4", + "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "8a68062a24352ac46384030bd267f1143572588ff36f061ec0cbed3c03555da4", + "frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx": "6d2cf91943e49db10cd94c6639390297ddaff170ebf136b6c48e991cd14c134d", + "frontend-modern/src/features/patrol/patrolHomePresentation.ts": "18799e39f1bd4cdeaf93e494f4b84fc898aef356b111b030687885cbfdbd478b" }, - "routes": [ - "/patrol" - ], + "routes": ["/patrol"], "viewports": [ { "width": 1440, - "height": 900 + "height": 1000 }, { "width": 390, @@ -27,23 +31,22 @@ } ], "states": [ - "initial loading and empty objective list", - "add-objective dialog open and closed", - "resource-scoped objective with disabled submit until an outcome is entered", - "persisted objective after reload with truthful uncovered coverage", - "paused and resumed objective states", - "edit-objective dialog kept open across background polling", - "clean empty state after objective deletion", - "desktop and narrow layouts without horizontal overflow, clipping, or console errors" + "watch-only background posture with effective autonomy consequence and no commercial upgrade action", + "empty objective list with honest zero-protection summary", + "ten genuine user decisions compacted to five initial rows", + "verified-receipt empty state without implying successful work", + "autonomy disclosure expanded", + "objective creation dialog open and dismissed", + "typed attention detail open and closed with evidence, protection, and timeline", + "desktop and narrow layouts without horizontal overflow" ], "interactions": [ - "opened the add-objective dialog, selected a real resource, entered an outcome, and saved it", - "reloaded the Patrol route and confirmed the objective persisted", - "paused and resumed the objective", - "opened the edit dialog and confirmed polling did not dismiss or overwrite it", - "dismissed dialogs with Escape and confirmed focus returned to the triggering control", - "confirmed body scrolling locked while the narrow dialog was open and unlocked on dismissal", - "deleted the temporary objective and confirmed the empty state returned", - "verified document scroll width matched the 390px viewport" + "expanded the secondary How Patrol operates disclosure and confirmed Watch only without Plans & Billing", + "opened the add-objective dialog, inspected the outcome-first form, cancelled it, and dismissed it with Escape", + "confirmed objective-dialog focus returned to Add objective after Escape", + "opened the deepest attention detail and inspected lifecycle, evidence, protection, timeline, and resource actions", + "closed attention detail and confirmed focus returned to the originating decision row", + "expanded all ten decisions, collapsed back to five, and confirmed the compact queue counts", + "verified document width matched both 1440px and 390px viewports" ] } diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index da6787450..dab382a0b 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -20,7 +20,6 @@ import ShieldOffIcon from 'lucide-solid/icons/shield-off'; import SparklesIcon from 'lucide-solid/icons/sparkles'; import XIcon from 'lucide-solid/icons/x'; import type { - AttentionFilter, AttentionActionOffer, AttentionItem, AttentionItemDetail, @@ -34,7 +33,6 @@ import { } from '@/api/patrolAttention'; import { ResourceActionsAPI } from '@/api/resourceActions'; import { Button, ButtonLink } from '@/components/shared/Button'; -import { LabeledFilterToggleGroup } from '@/components/shared/FilterToolbar'; import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import { MetadataBadge, type MetadataBadgeTone } from '@/components/shared/MetadataBadge'; import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog'; @@ -56,19 +54,23 @@ import type { EvidenceEnvelope } from '@/types/operationalTrust'; import type { ActionDetailResponse } from '@/types/actionAudit'; import { getAlertResourceIncidentAcknowledgedByLabel } from '@/utils/alertIncidentPresentation'; import { formatRelativeTime } from '@/utils/format'; +import type { PatrolAutonomyLevel } from '@/api/patrol'; +import { + partitionPatrolAttention, + PATROL_AUTONOMY_EXPERIENCE, + type PatrolAttentionDecision, +} from './patrolHomePresentation'; const PRIMARY_EVIDENCE_LIMIT = 3; +const PRIMARY_DECISION_LIMIT = 5; -const FILTERS: Array<{ id: AttentionFilter; label: string }> = [ - { id: 'active', label: 'Active' }, - { id: 'open', label: 'Open' }, - { id: 'acknowledged', label: 'Acknowledged' }, - { id: 'suppressed', label: 'Suppressed' }, - { id: 'stale_unknown', label: 'Stale or unknown' }, - { id: 'resolved', label: 'Recent resolved' }, -]; - -export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void } = {}) { +export function PatrolAttentionWorkbench( + props: { + autonomyLevel?: PatrolAutonomyLevel; + autonomyLocked?: boolean; + onOpenFindings?: () => void; + } = {}, +) { const location = useLocation(); const [selectedItemId, setSelectedItemId] = createSignal(''); const [actionDetail, setActionDetail] = createSignal(null); @@ -76,40 +78,28 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void } const [actionError, setActionError] = createSignal(''); const [lifecycleBusy, setLifecycleBusy] = createSignal(false); const [lifecycleError, setLifecycleError] = createSignal(''); + const [showAllDecisions, setShowAllDecisions] = createSignal(false); const itemButtons = new Map(); let detailPanel: HTMLDivElement | undefined; let actionTrigger: HTMLButtonElement | undefined; const selectedDetail = () => patrolAttentionStore.selectedDetail(); const summary = () => patrolAttentionStore.summary(); - const filterCount = (filter: AttentionFilter): number | undefined => { - const value = summary(); - if (!value) return undefined; - switch (filter) { - case 'active': - return value.activeCount; - case 'open': - return value.openCount; - case 'acknowledged': - return value.acknowledgedCount; - case 'suppressed': - return value.suppressedCount; - case 'stale_unknown': - return value.uncertainCount; - case 'resolved': - return value.resolvedCount; - default: - return undefined; - } - }; - const attentionFilterOptions = createMemo(() => - FILTERS.map((option) => { - const count = filterCount(option.id); - return { - value: option.id, - label: count === undefined ? option.label : `${option.label} ${count}`, - }; - }), + const effectiveAutonomyLevel = createMemo(() => + props.autonomyLocked ? 'monitor' : (props.autonomyLevel ?? 'monitor'), + ); + const autonomyExperience = createMemo(() => PATROL_AUTONOMY_EXPERIENCE[effectiveAutonomyLevel()]); + const attention = createMemo(() => + partitionPatrolAttention( + patrolAttentionStore.items(), + props.autonomyLevel ?? 'monitor', + props.autonomyLocked ?? false, + ), + ); + const visibleDecisions = createMemo(() => + showAllDecisions() + ? attention().needsUser + : attention().needsUser.slice(0, PRIMARY_DECISION_LIMIT), ); const loadCurrentFilter = () => patrolAttentionStore.load(patrolAttentionStore.filter()); @@ -133,10 +123,6 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void } void patrolAttentionStore.select(null); queueMicrotask(() => itemButtons.get(previous)?.focus()); }; - const changeFilter = (filter: AttentionFilter) => { - closeDetail(); - void patrolAttentionStore.load(filter); - }; const reviewAction = async ( item: AttentionItem, offer: AttentionActionOffer, @@ -205,6 +191,7 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void } const deepLinkedItem = parsePatrolAttentionItemId(location.search); const currentItem = untrack(selectedItemId); if (deepLinkedItem && deepLinkedItem !== currentItem) { + setShowAllDecisions(true); setSelectedItemId(deepLinkedItem); void patrolAttentionStore.select(deepLinkedItem); scrollDetailIntoView(); @@ -214,10 +201,9 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void } } }); - const activeCountLabel = createMemo(() => { - const count = summary()?.activeCount; - if (count === undefined) return 'Attention count unavailable'; - return `${count} active attention ${count === 1 ? 'item' : 'items'}`; + const decisionCountLabel = createMemo(() => { + const count = attention().needsUser.length; + return `${count} ${count === 1 ? 'decision needs' : 'decisions need'} you`; }); return ( @@ -230,22 +216,21 @@ export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void }

- Needs attention + Needs you

0 ? 'warning' : 'success'} + tone={attention().needsUser.length > 0 ? 'warning' : 'success'} size="sm" shape="rounded" - aria-label={activeCountLabel()} + aria-label={decisionCountLabel()} > - {summary()?.activeCount ?? 0} + {attention().needsUser.length}

- Current operational issues, ordered by urgency, affected resources, protection - concern, evidence quality, and age. + {autonomyExperience().needsYouDescription}

- -
- changeFilter(value as AttentionFilter)} - options={attentionFilterOptions()} - selectClass="min-w-[11rem]" - /> -
+ 0}> +

+ {attention().quiet.length} other current{' '} + {attention().quiet.length === 1 ? 'issue does' : 'issues do'} not require a decision.{' '} + {autonomyExperience().quietWorkDescription} +

+
void } class={`min-w-0 ${selectedItemId() ? 'border-b border-border lg:border-b-0 lg:border-r' : ''}`} > void }
+ PRIMARY_DECISION_LIMIT}> +
+ +
+
; onSelect: (itemId: string) => void; @@ -351,7 +347,7 @@ function AttentionList(props: { 0} + when={!patrolAttentionStore.loading() || props.decisions.length > 0} fallback={
@@ -359,52 +355,63 @@ function AttentionList(props: {
} > - 0} fallback={}> + 0} + fallback={ 0} />} + >
    - - {(item) => ( -
  • - -
  • - )} + + + ); + }}
@@ -413,7 +420,7 @@ function AttentionList(props: { ); } -function AttentionEmptyState() { +function AttentionEmptyState(props: { hasQuietWork: boolean }) { const summary = () => patrolAttentionStore.summary(); const activeFilter = () => patrolAttentionStore.filter() === 'active'; const trustworthyCalm = () => @@ -425,7 +432,7 @@ function AttentionEmptyState() { return (
@@ -326,91 +290,98 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState } class="relative z-[200] mb-3" - actions={ - - } /> -
-
- - {runtimePresentation().label} +
+
+
+
+ +
+
+
+

+ {state.patrolEnabledLocal() + ? 'Patrol is looking after your infrastructure' + : 'Patrol is off'} +

+ + {selectedAutonomyExperience().label} + +
+

+ {state.patrolEnabledLocal() + ? selectedAutonomyExperience().summary + : 'Turn Patrol on when you want it to watch your estate in the background.'} +

+ +

{triggerStatusSummary()}

+
+ +

+ {recency().label}:{' '} + {formatRelativeTime(recency().timestamp, { + compact: true, + emptyText: 'Never', + })} + + {' '} + — {recency().resourcesCheckedLabel} + + + {' '} + ·{' '} + + +

+
+
+
+ + +
+ {renderRunControl( + 'flex min-h-11 items-center gap-2 rounded-md border border-border bg-surface px-3 py-1.5 text-sm font-medium text-base-content shadow-sm transition-colors hover:bg-surface-alt disabled:text-muted sm:min-h-0', + )} + + + + Settings + + +
+
- - {triggerStatusSummary()} - - - -
- {renderRunControl( - 'flex min-h-11 items-center gap-2 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:bg-surface-alt disabled:text-muted sm:hidden', - )} - - - - - Settings - - +
+ + How Patrol operates + +
+
- -
- -
-
+ +
); } diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx index aa548150c..a162629a4 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -7,6 +7,7 @@ import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners'; import { PatrolIntelligenceWorkspace } from './PatrolIntelligenceWorkspace'; import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench'; import { PatrolObjectivesPanel } from './PatrolObjectivesPanel'; +import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel'; export function PatrolIntelligenceSurface() { const state = usePatrolIntelligenceState(); @@ -26,7 +27,12 @@ export function PatrolIntelligenceSurface() { - + +
- Patrol checks, investigations, and run history + Operational records and run history 0}> { const resourceById = createMemo( () => new Map(resources().map((resource) => [resource.id, resource])), ); + const protectionSummary = createMemo(() => getPatrolObjectiveProtectionSummary(objectives())); const loadObjectives = async (quiet = false) => { if (!quiet) setLoading(true); @@ -195,7 +197,7 @@ export const PatrolObjectivesPanel: Component = () => {

- What Patrol should keep true + What Patrol is looking after

Describe the outcome. Patrol chooses a cheap local signal, wakes the model only when @@ -224,6 +226,28 @@ export const PatrolObjectivesPanel: Component = () => {

} > +
+
+

+ {protectionSummary().headline} +

+ 0}> + + {protectionSummary().paused} paused + + +
+

{protectionSummary().detail}

+
0} fallback={ diff --git a/frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx b/frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx new file mode 100644 index 000000000..d0312a3df --- /dev/null +++ b/frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx @@ -0,0 +1,141 @@ +import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; +import CheckCircleIcon from 'lucide-solid/icons/circle-check'; +import RefreshIcon from 'lucide-solid/icons/refresh-cw'; +import { getPatrolAttention, type AttentionItem } from '@/api/patrolAttention'; +import { Button } from '@/components/shared/Button'; +import { formatRelativeTime } from '@/utils/format'; +import { getVerifiedPatrolReceiptSummary, isVerifiedPatrolReceipt } from './patrolHomePresentation'; + +const RECEIPT_LIMIT = 6; + +const formatRecentWorkError = (error: unknown): string => + error instanceof Error ? error.message : 'Verified work could not be loaded.'; + +export function PatrolRecentWorkPanel() { + const [items, setItems] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(''); + const receipts = createMemo(() => + items().filter(isVerifiedPatrolReceipt).slice(0, RECEIPT_LIMIT), + ); + + const load = async (quiet = false) => { + if (!quiet) setLoading(true); + try { + const response = await getPatrolAttention('resolved', 1, 50); + setItems(response.data); + setError(''); + } catch (cause) { + setError(formatRecentWorkError(cause)); + } finally { + if (!quiet) setLoading(false); + } + }; + + onMount(() => { + void load(); + const refresh = () => { + if (document.visibilityState === 'visible') void load(true); + }; + const timer = window.setInterval(refresh, 30_000); + document.addEventListener('visibilitychange', refresh); + onCleanup(() => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', refresh); + }); + }); + + return ( +
+
+
+

+ Recently handled +

+

+ Concise receipts appear only after Patrol has verified the outcome. +

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

Verified work is unavailable

+

{message()}

+
+ )} +
+ + 0} + fallback={

Loading verified work…

} + > + 0} + fallback={ +
+
+ } + > +
    + + {(item) => ( +
  1. +
  2. + )} +
    +
+
+
+
+
+ ); +} + +export default PatrolRecentWorkPanel; diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx index 67dd22bc3..b781444a8 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx @@ -204,14 +204,16 @@ describe('PatrolAttentionWorkbench', () => { renderWorkbench(); expect( - await screen.findByRole('heading', { name: 'Nothing needs your attention' }), + await screen.findByRole('heading', { name: 'Nothing needs you right now' }), + ).toBeInTheDocument(); + expect( + screen.getByText(/current operational evaluation has no active items/i), ).toBeInTheDocument(); - expect(screen.getByText(/current operational lifecycle evaluation/i)).toBeInTheDocument(); expect(screen.queryByText(/trust score/i)).not.toBeInTheDocument(); expect(screen.queryByText(/auto-resolved/i)).not.toBeInTheDocument(); }); - it('keeps the fixed attention states available through one responsive shared control', async () => { + it('keeps lifecycle filters out of the primary decision surface', async () => { const attentionSummary = summary({ activeCount: 1, openCount: 1, @@ -224,26 +226,75 @@ describe('PatrolAttentionWorkbench', () => { apiMocks.getList.mockResolvedValue(listResponse([item()], attentionSummary)); renderWorkbench(); - const activeButton = await screen.findByRole('button', { name: 'Active 1' }); - const stateGroup = screen.getByRole('group', { name: 'Attention state' }); - expect(stateGroup).toHaveClass('hidden', 'xl:inline-flex'); - expect(activeButton).toHaveAttribute('aria-pressed', 'true'); + expect(await screen.findByRole('heading', { name: 'Needs you' })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Open Disk pressure on Database VM' }), + ).toBeInTheDocument(); + expect(screen.queryByRole('group', { name: 'Attention state' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: 'Attention state' })).not.toBeInTheDocument(); + expect(apiMocks.getList).toHaveBeenCalledWith('active'); + }); - const stateSelect = screen.getByRole('combobox', { name: 'Attention state' }); - expect(stateSelect.parentElement).toHaveClass('xl:hidden'); - expect(stateSelect).toHaveValue('active'); - expect(within(stateSelect).getByRole('option', { name: 'Recent resolved 5' })).toHaveValue( - 'resolved', + it('does not interrupt assisted mode for safe eligible work', async () => { + const safeWork = item({ + availableActions: [ + { + targetResourceId: 'pve:vm:101', + capability: 'cleanup_disk', + kind: 'cleanup', + label: 'Clean safe temporary files', + mode: 'execute', + risk: 'low', + approval: 'not-required', + eligibility: 'eligible', + reasons: [], + evidenceIds: ['evidence-1'], + expectedPostcondition: 'Disk use returns below the warning threshold.', + verificationPolicy: 'disk-pressure', + requiresApproval: false, + }, + ], + verificationState: 'pending', + }); + apiMocks.getList.mockResolvedValue( + listResponse([safeWork], summary({ activeCount: 1, openCount: 1, calm: false })), ); - fireEvent.change(stateSelect, { target: { value: 'resolved' } }); + render(() => ( + + ( + + )} + /> + + )); - await waitFor(() => expect(apiMocks.getList).toHaveBeenLastCalledWith('resolved')); - expect(stateSelect).toHaveValue('resolved'); - expect(screen.getByRole('button', { name: 'Recent resolved 5' })).toHaveAttribute( - 'aria-pressed', - 'true', + expect( + await screen.findByRole('heading', { name: 'Nothing needs you right now' }), + ).toBeInTheDocument(); + expect( + screen.getByText(/1 other current issue does not require a decision/i), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Open Disk pressure/ })).not.toBeInTheDocument(); + }); + + it('keeps a long decision queue compact until the operator asks for all of it', async () => { + const decisions = Array.from({ length: 7 }, (_, index) => + item({ id: `record-${index + 1}`, title: `Decision ${index + 1}` }), ); + apiMocks.getList.mockResolvedValue( + listResponse(decisions, summary({ activeCount: 7, openCount: 7, calm: false })), + ); + renderWorkbench(); + + const showAll = await screen.findByRole('button', { name: 'Show all 7 decisions' }); + expect(screen.getAllByRole('button', { name: /Open Decision/ })).toHaveLength(5); + + fireEvent.click(showAll); + expect(screen.getAllByRole('button', { name: /Open Decision/ })).toHaveLength(7); + expect(screen.getByRole('button', { name: 'Show fewer decisions' })).toBeInTheDocument(); }); it('opens deepest typed evidence, protection, and timeline detail from one queue item', async () => { @@ -371,7 +422,7 @@ describe('PatrolAttentionWorkbench', () => { }); expect(screen.getByText(/protection context is incomplete/i)).toBeInTheDocument(); expect( - screen.queryByRole('heading', { name: 'Nothing needs your attention' }), + screen.queryByRole('heading', { name: 'Nothing needs you right now' }), ).not.toBeInTheDocument(); }); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts index 3e00f0249..3cee6c38b 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts @@ -69,7 +69,7 @@ describe('PatrolIntelligenceHeader', () => { expect(headerSource).toContain('Open Patrol settings'); expect(headerSource).toContain("settingsTabPath('system-ai-patrol')"); expect(headerSource).not.toContain('Patrol schedule and model settings'); - expect(headerSource).toContain('Run Patrol'); + expect(headerSource).toContain('Check now'); }); it('turns provider-blocked manual run controls into setup actions', () => { @@ -92,7 +92,9 @@ describe('PatrolIntelligenceHeader', () => { }); it('keeps primary Patrol actions touch-sized on phones without inflating desktop chrome', () => { - expect(headerSource).toContain('flex min-h-11 items-center gap-2 rounded-md bg-blue-600'); + expect(headerSource).toContain( + 'flex min-h-11 items-center gap-2 rounded-md border border-border bg-surface', + ); expect(headerSource).toContain( 'flex min-h-11 items-center gap-2 rounded-md border border-border', ); @@ -228,10 +230,10 @@ describe('PatrolIntelligenceHeader', () => { external: false, }, }); - expect(headerSource).toContain('showAutonomyPlanBillingAction'); - expect(headerSource).toContain("autonomyAvailability().kind === 'plan_locked'"); - expect(headerSource).toContain('CreditCardIcon'); - expect(headerSource).toContain('{autonomyAvailability().actionLabel}'); + expect(headerSource).toContain('commercialSurfacesHidden: true'); + expect(headerSource).not.toContain('showAutonomyPlanBillingAction'); + expect(headerSource).not.toContain('CreditCardIcon'); + expect(headerSource).not.toContain('Plans & Billing'); const hiddenUpgradePresentation = getPatrolAutonomyAvailabilityPresentation({ autoFixLocked: true, diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx new file mode 100644 index 000000000..53334c6a7 --- /dev/null +++ b/frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx @@ -0,0 +1,113 @@ +import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AttentionItem, AttentionListResponse } from '@/api/patrolAttention'; + +const apiMocks = vi.hoisted(() => ({ getList: vi.fn() })); + +vi.mock('@/api/patrolAttention', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args), + }; +}); + +import { PatrolRecentWorkPanel } from '../PatrolRecentWorkPanel'; + +const receipt = (overrides: Partial = {}): AttentionItem => ({ + id: 'receipt-1', + operationalRecordId: 'record-1', + subjectResourceId: 'docker:host/service/jellyfin', + subjectResourceName: 'Jellyfin', + kind: 'availability', + title: 'Jellyfin playback restored', + plainLanguageSummary: 'Playback recovered.', + severity: 'warning', + state: 'resolved', + firstObservedAt: '2026-08-14T07:00:00Z', + lastObservedAt: '2026-08-14T07:05:00Z', + evidenceFreshness: 'fresh', + evidenceCompleteness: 'complete', + relatedResources: [], + availableActions: [ + { + targetResourceId: 'docker:host/service/jellyfin', + capability: 'restart_service', + kind: 'restart', + label: 'Restart Jellyfin', + mode: 'execute', + risk: 'low', + approval: 'not-required', + eligibility: 'eligible', + reasons: [], + evidenceIds: ['evidence-1'], + expectedPostcondition: 'Playback health checks return to normal.', + verificationPolicy: 'availability', + requiresApproval: false, + }, + ], + verificationState: 'succeeded', + ...overrides, +}); + +const response = (items: AttentionItem[]): AttentionListResponse => ({ + data: items, + summary: { + activeCount: 0, + openCount: 0, + acknowledgedCount: 0, + suppressedCount: 0, + uncertainCount: 0, + resolvedCount: items.length, + calm: true, + coverageState: 'current', + evaluatedAt: '2026-08-14T07:05:00Z', + }, + meta: { page: 1, limit: 50, total: items.length, totalPages: items.length ? 1 : 0 }, +}); + +describe('PatrolRecentWorkPanel', () => { + beforeEach(() => apiMocks.getList.mockReset()); + afterEach(cleanup); + + it('shows only successfully verified resolved work as receipts', async () => { + apiMocks.getList.mockResolvedValue( + response([ + receipt(), + receipt({ id: 'failed', verificationState: 'failed' }), + receipt({ id: 'open', state: 'open' }), + ]), + ); + + render(() => ); + + expect(await screen.findByText('Playback recovered.')).toBeInTheDocument(); + expect(screen.getAllByText('Verified')).toHaveLength(1); + expect(apiMocks.getList).toHaveBeenCalledWith('resolved', 1, 50); + }); + + it('does not imply successful work when there is no verified receipt', async () => { + apiMocks.getList.mockResolvedValue(response([receipt({ verificationState: 'unknown' })])); + + render(() => ); + + expect( + await screen.findByRole('heading', { name: 'No verified work yet' }), + ).toBeInTheDocument(); + expect(screen.queryByText('Verified')).not.toBeInTheDocument(); + }); + + it('keeps the last truthful receipts visible if a background refresh fails', async () => { + apiMocks.getList.mockResolvedValueOnce(response([receipt()])); + apiMocks.getList.mockRejectedValueOnce(new Error('relay unavailable')); + + render(() => ); + await screen.findByText('Playback recovered.'); + document.dispatchEvent(new Event('visibilitychange')); + + await waitFor(() => + expect(screen.getByText('Verified work is unavailable')).toBeInTheDocument(), + ); + expect(screen.getByText('Playback recovered.')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts index 5954158c4..4cca3bc5c 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolCommercialBoundary.test.ts @@ -11,17 +11,13 @@ describe('patrol commercial boundary', () => { expect(patrolIntelligenceBannersSource).toContain('!state.showBlockedBanner()'); expect(patrolIntelligenceBannersSource).toContain('!state.shouldShowPatrolSetupOnly()'); expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesUpgradePrompts'); - expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesCommercialSurfaces'); expect(patrolIntelligenceHeaderSource).toContain('!presentationPolicyHidesUpgradePrompts()'); - expect(patrolIntelligenceHeaderSource).toContain('!commercialSurfacesHidden()'); expect(patrolIntelligenceHeaderSource).toContain('state.autoFixLocked()'); expect(patrolIntelligenceHeaderSource).toContain( "autonomyAvailability().kind === 'runtime_locked'", ); - expect(patrolIntelligenceHeaderSource).toContain('showAutonomyPlanBillingAction'); - expect(patrolIntelligenceHeaderSource).toContain( - "autonomyAvailability().kind === 'plan_locked'", - ); + expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true'); + expect(patrolIntelligenceHeaderSource).not.toContain('showAutonomyPlanBillingAction'); expect(patrolAutonomyAvailabilitySource).toContain('Plans & Billing'); expect(patrolAutonomyAvailabilitySource).toContain('input.upgradePromptsHidden'); expect(patrolIntelligenceHeaderSource).toContain('getPatrolAutonomyAvailabilityPresentation'); @@ -36,7 +32,8 @@ describe('patrol commercial boundary', () => { expect(patrolIntelligenceHeaderSource).toContain( '', ); - expect(patrolIntelligenceHeaderSource).toContain('showProBadge'); + expect(patrolIntelligenceHeaderSource).not.toContain('showProBadge'); + expect(patrolIntelligenceHeaderSource).not.toContain('Plans & Billing'); expect(patrolIntelligenceHeaderSource).not.toContain('const isProLocked = () =>'); expect(patrolIntelligenceHeaderSource).not.toContain('requires Pulse Pro'); }); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts new file mode 100644 index 000000000..1684e83ee --- /dev/null +++ b/frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import type { PatrolObjective } from '@/api/patrol'; +import type { AttentionItem } from '@/api/patrolAttention'; +import { + getPatrolAttentionDecisionReason, + getPatrolObjectiveProtectionSummary, + getVerifiedPatrolReceiptSummary, + isVerifiedPatrolReceipt, + partitionPatrolAttention, +} from '../patrolHomePresentation'; + +const attentionItem = (overrides: Partial = {}): AttentionItem => ({ + id: 'attention-1', + operationalRecordId: 'record-1', + subjectResourceId: 'docker:host/service/jellyfin', + subjectResourceName: 'Jellyfin', + kind: 'availability', + title: 'Jellyfin playback unavailable', + plainLanguageSummary: 'Playback health checks are failing.', + severity: 'warning', + state: 'open', + firstObservedAt: '2026-08-14T07:00:00Z', + lastObservedAt: '2026-08-14T07:05:00Z', + evidenceFreshness: 'fresh', + evidenceCompleteness: 'complete', + relatedResources: [], + availableActions: [ + { + targetResourceId: 'docker:host/service/jellyfin', + capability: 'restart_service', + kind: 'restart', + label: 'Restart Jellyfin', + mode: 'execute', + risk: 'low', + approval: 'not-required', + eligibility: 'eligible', + reasons: [], + evidenceIds: ['evidence-1'], + expectedPostcondition: 'Playback health checks return to normal.', + verificationPolicy: 'availability', + requiresApproval: false, + }, + ], + verificationState: 'pending', + ...overrides, +}); + +const objective = (overrides: Partial = {}): PatrolObjective => ({ + id: 'objective-1', + brief: 'Keep Jellyfin playback healthy', + scope: { resource_ids: [] }, + status: 'active', + coverage: { + state: 'covered', + reason_code: 'observer_current', + summary: 'Watching playback health locally.', + }, + revision: 1, + created_at: '2026-08-14T07:00:00Z', + updated_at: '2026-08-14T07:00:00Z', + ...overrides, +}); + +describe('patrol home presentation', () => { + it('keeps every active issue visible as a decision in watch-only and ask-first modes', () => { + const item = attentionItem(); + + expect(getPatrolAttentionDecisionReason(item, 'monitor')).toMatch(/will not make changes/i); + expect(getPatrolAttentionDecisionReason(item, 'approval')).toMatch(/requires your approval/i); + expect(getPatrolAttentionDecisionReason(item, 'full', true)).toMatch(/will not make changes/i); + }); + + it('keeps safe eligible work quiet in assisted and full modes', () => { + const item = attentionItem(); + + expect(getPatrolAttentionDecisionReason(item, 'assisted')).toBeNull(); + expect(getPatrolAttentionDecisionReason(item, 'full')).toBeNull(); + expect(partitionPatrolAttention([item], 'full')).toEqual({ needsUser: [], quiet: [item] }); + }); + + it('interrupts for approval, trust gaps, failed verification, and missing actions', () => { + const approvalRequired = attentionItem({ + availableActions: [ + { + ...attentionItem().availableActions[0], + approval: 'required', + requiresApproval: true, + }, + ], + }); + const staleEvidence = attentionItem({ evidenceFreshness: 'stale' }); + const failedVerification = attentionItem({ verificationState: 'failed' }); + const noAction = attentionItem({ availableActions: [] }); + + for (const item of [approvalRequired, staleEvidence, failedVerification, noAction]) { + expect(getPatrolAttentionDecisionReason(item, 'full')).not.toBeNull(); + } + }); + + it('does not interrupt when at least one eligible governed path can proceed', () => { + const safeAction = attentionItem().availableActions[0]; + const item = attentionItem({ + availableActions: [ + { ...safeAction, capability: 'safe_cleanup' }, + { + ...safeAction, + capability: 'expand_disk', + approval: 'required', + requiresApproval: true, + }, + ], + }); + + expect(getPatrolAttentionDecisionReason(item, 'assisted')).toBeNull(); + }); + + it('summarizes protected, uncovered, and paused objectives without overstating health', () => { + expect(getPatrolObjectiveProtectionSummary([])).toMatchObject({ + headline: 'No outcomes protected yet', + tone: 'neutral', + }); + expect(getPatrolObjectiveProtectionSummary([objective()])).toMatchObject({ + headline: 'Protecting 1 outcome', + covered: 1, + needsCoverage: 0, + tone: 'success', + }); + expect( + getPatrolObjectiveProtectionSummary([ + objective(), + objective({ + id: 'objective-2', + coverage: { + state: 'uncovered', + reason_code: 'observer_missing', + summary: 'Monitoring is being prepared.', + }, + }), + objective({ id: 'objective-3', status: 'paused' }), + ]), + ).toMatchObject({ + headline: '1 of 2 outcomes protected', + covered: 1, + needsCoverage: 1, + paused: 1, + tone: 'warning', + }); + }); + + it('creates receipts only from resolved records with successful verification', () => { + const resolved = attentionItem({ + state: 'resolved', + verificationState: 'succeeded', + plainLanguageSummary: 'Playback health checks returned to normal.', + }); + + expect(isVerifiedPatrolReceipt(resolved)).toBe(true); + expect(isVerifiedPatrolReceipt(attentionItem({ state: 'resolved' }))).toBe(false); + expect(getVerifiedPatrolReceiptSummary(resolved)).toBe( + 'Playback health checks returned to normal.', + ); + }); +}); diff --git a/frontend-modern/src/features/patrol/patrolHomePresentation.ts b/frontend-modern/src/features/patrol/patrolHomePresentation.ts new file mode 100644 index 000000000..40b373435 --- /dev/null +++ b/frontend-modern/src/features/patrol/patrolHomePresentation.ts @@ -0,0 +1,186 @@ +import type { PatrolAutonomyLevel, PatrolObjective } from '@/api/patrol'; +import type { AttentionItem } from '@/api/patrolAttention'; + +export interface PatrolAutonomyExperience { + label: string; + summary: string; + needsYouDescription: string; + quietWorkDescription: string; +} + +export const PATROL_AUTONOMY_EXPERIENCE: Record = { + monitor: { + label: 'Watch only', + summary: 'Patrol watches your infrastructure. You decide what happens next.', + needsYouDescription: + 'Patrol reports current issues here because Watch only never makes infrastructure changes.', + quietWorkDescription: 'Patrol continues watching these issues without making changes.', + }, + approval: { + label: 'Ask first', + summary: 'Patrol investigates and prepares fixes, then waits for your approval.', + needsYouDescription: + 'These issues need your decision before Patrol can make a change or close the loop.', + quietWorkDescription: 'Patrol continues investigating while it waits for the decisions above.', + }, + assisted: { + label: 'Safe auto-fix', + summary: 'Patrol handles policy-allowed safe work and asks when risk or evidence needs you.', + needsYouDescription: + 'Patrol only interrupts when policy, risk, missing evidence, or verification requires your decision.', + quietWorkDescription: + 'Other current issues can proceed within the safe auto-fix policy without a decision from you.', + }, + full: { + label: 'Autopilot', + summary: + 'Patrol handles allowed work in the background and interrupts only when it cannot proceed safely.', + needsYouDescription: + 'Patrol only interrupts when it is blocked, lacks trustworthy evidence, or still requires approval.', + quietWorkDescription: + 'Other current issues do not require a decision under Autopilot and remain visible in Alerts.', + }, +}; + +export interface PatrolAttentionDecision { + item: AttentionItem; + reason: string; +} + +const itemHasTrustGap = (item: AttentionItem): boolean => + item.state === 'unknown' || + item.evidenceFreshness !== 'fresh' || + item.evidenceCompleteness !== 'complete' || + item.verificationState === 'failed' || + item.verificationState === 'unknown'; + +export function getPatrolAttentionDecisionReason( + item: AttentionItem, + autonomyLevel: PatrolAutonomyLevel, + autonomyLocked = false, +): string | null { + const effectiveLevel = autonomyLocked ? 'monitor' : autonomyLevel; + + if (effectiveLevel === 'monitor') { + return 'Watch only will not make changes. Review this issue and decide what should happen.'; + } + + if (effectiveLevel === 'approval') { + return item.availableActions.length > 0 + ? 'Patrol has an action to review, but Ask first requires your approval.' + : 'Patrol cannot complete this issue without your decision.'; + } + + if (itemHasTrustGap(item)) { + return item.verificationState === 'failed' || item.verificationState === 'unknown' + ? 'Patrol could not verify the outcome and needs you to review the evidence.' + : 'Patrol does not have trustworthy enough evidence to proceed automatically.'; + } + + const eligibleActions = item.availableActions.filter( + (action) => action.eligibility === 'eligible', + ); + if (eligibleActions.length === 0) { + return item.availableActions.length > 0 + ? 'Available actions are blocked or ineligible, so Patrol needs your decision.' + : 'Patrol has no governed action for this issue and needs you to decide what happens next.'; + } + + const canProceedWithoutAnotherDecision = eligibleActions.some( + (action) => + action.approval === 'not-required' || + action.approval === 'granted' || + (!action.requiresApproval && action.approval !== 'denied'), + ); + if (!canProceedWithoutAnotherDecision) { + return 'A governed action still requires your approval or a policy decision.'; + } + + return null; +} + +export function partitionPatrolAttention( + items: AttentionItem[], + autonomyLevel: PatrolAutonomyLevel, + autonomyLocked = false, +): { needsUser: PatrolAttentionDecision[]; quiet: AttentionItem[] } { + const needsUser: PatrolAttentionDecision[] = []; + const quiet: AttentionItem[] = []; + + for (const item of items) { + const reason = getPatrolAttentionDecisionReason(item, autonomyLevel, autonomyLocked); + if (reason) { + needsUser.push({ item, reason }); + } else { + quiet.push(item); + } + } + + return { needsUser, quiet }; +} + +export interface PatrolObjectiveProtectionSummary { + active: number; + covered: number; + needsCoverage: number; + paused: number; + headline: string; + detail: string; + tone: 'success' | 'warning' | 'neutral'; +} + +export function getPatrolObjectiveProtectionSummary( + objectives: PatrolObjective[], +): PatrolObjectiveProtectionSummary { + const activeObjectives = objectives.filter((objective) => objective.status === 'active'); + const covered = activeObjectives.filter( + (objective) => objective.coverage.state === 'covered', + ).length; + const needsCoverage = activeObjectives.length - covered; + const paused = objectives.filter((objective) => objective.status === 'paused').length; + + if (activeObjectives.length === 0) { + return { + active: 0, + covered: 0, + needsCoverage: 0, + paused, + headline: 'No outcomes protected yet', + detail: + paused > 0 + ? `${paused} ${paused === 1 ? 'objective is' : 'objectives are'} paused.` + : 'Add the outcomes you want Patrol to keep true.', + tone: 'neutral', + }; + } + + if (needsCoverage === 0) { + return { + active: activeObjectives.length, + covered, + needsCoverage: 0, + paused, + headline: `Protecting ${covered} ${covered === 1 ? 'outcome' : 'outcomes'}`, + detail: 'Every active objective has current background coverage.', + tone: 'success', + }; + } + + return { + active: activeObjectives.length, + covered, + needsCoverage, + paused, + headline: `${covered} of ${activeObjectives.length} ${activeObjectives.length === 1 ? 'outcome' : 'outcomes'} protected`, + detail: `${needsCoverage} ${needsCoverage === 1 ? 'objective needs' : 'objectives need'} monitoring coverage before Patrol can rely on them.`, + tone: 'warning', + }; +} + +export function isVerifiedPatrolReceipt(item: AttentionItem): boolean { + return item.state === 'resolved' && item.verificationState === 'succeeded'; +} + +export function getVerifiedPatrolReceiptSummary(item: AttentionItem): string { + return item.plainLanguageSummary; +} diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index e06909a2e..24e4e33c8 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -693,12 +693,9 @@ describe('AIIntelligence entitlement gating', () => { expect(patrolControl.queryByRole('button', { name: 'Ask first Pro' })).toBeNull(); expect(patrolControl.queryByRole('button', { name: 'Safe auto-fix Pro' })).toBeNull(); expect(patrolControl.queryByRole('button', { name: 'Autopilot Pro' })).toBeNull(); - expect(screen.getByRole('link', { name: 'Plans & Billing' })).toHaveAttribute( - 'href', - SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF, - ); + expect(screen.queryByRole('link', { name: 'Plans & Billing' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument(); - expect(operationsLoopAnchor?.parentElement).toBe(patrolControlAnchor); + expect(patrolControlAnchor).toContainElement(operationsLoopAnchor); expect(screen.queryByTestId('patrol-current-work')).not.toBeInTheDocument(); expect(screen.getByText('Current Patrol issues appear here.')).toBeInTheDocument(); expect( @@ -711,7 +708,7 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.queryByText('Duty trail')).not.toBeInTheDocument(); expect(screen.queryByText('External agents')).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Pulse MCP' })).not.toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'Run Patrol' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('button', { name: 'Check now' }).length).toBeGreaterThan(0); }); it('consumes Patrol control route handoffs without loading legacy loop status', async () => { @@ -809,7 +806,7 @@ describe('AIIntelligence entitlement gating', () => { }); expect(providerSettingsLinks.length).toBeGreaterThan(0); expect(providerSettingsLinks[0]).toHaveAttribute('href', '/settings/pulse-intelligence/patrol'); - expect(screen.queryByRole('button', { name: /Run Patrol/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Check now/i })).not.toBeInTheDocument(); expect(triggerPatrolRunMock).not.toHaveBeenCalled(); }); @@ -843,7 +840,7 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.getByText('Patrol model warning')).toBeInTheDocument(); }); expect(screen.queryByText('Patrol readiness warning')).not.toBeInTheDocument(); - const runButtons = screen.getAllByRole('button', { name: /Run Patrol/i }); + const runButtons = screen.getAllByRole('button', { name: /Check now/i }); for (const button of runButtons) { expect(button).not.toBeDisabled(); } @@ -863,7 +860,7 @@ describe('AIIntelligence entitlement gating', () => { getCorrelationsMock.mockImplementation(() => new Promise(() => {})); - fireEvent.click(screen.getAllByRole('button', { name: /Run Patrol/i })[0]); + fireEvent.click(screen.getAllByRole('button', { name: /Check now/i })[0]); await waitFor(() => { expect(triggerPatrolRunMock).toHaveBeenCalled(); @@ -888,7 +885,7 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.getByRole('heading', { name: 'Open work' })).toBeInTheDocument(); }); - fireEvent.click(screen.getAllByRole('button', { name: /Run Patrol/i })[0]); + fireEvent.click(screen.getAllByRole('button', { name: /Check now/i })[0]); await waitFor(() => { expect(notificationErrorMock).toHaveBeenCalledWith( @@ -909,7 +906,7 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.queryByText('Patrol setup warning')).not.toBeInTheDocument(); expect(screen.queryByText('Patrol readiness issue')).not.toBeInTheDocument(); expect(screen.queryByText('Patrol readiness warning')).not.toBeInTheDocument(); - for (const button of screen.getAllByRole('button', { name: /Run Patrol/i })) { + for (const button of screen.getAllByRole('button', { name: /Check now/i })) { expect(button).not.toBeDisabled(); } }); @@ -1073,10 +1070,7 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.queryByRole('button', { name: 'Ask first Pro' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Safe auto-fix Pro' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Autopilot Pro' })).toBeNull(); - expect(screen.getByRole('link', { name: 'Plans & Billing' })).toHaveAttribute( - 'href', - SELF_HOSTED_PRO_BILLING_PLAN_SELECTION_HREF, - ); + expect(screen.queryByRole('link', { name: 'Plans & Billing' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'View plans' })).not.toBeInTheDocument(); expect(screen.queryByText('Unlock Patrol mode')).not.toBeInTheDocument(); expect(screen.queryByText('More Patrol modes')).not.toBeInTheDocument(); @@ -1540,7 +1534,9 @@ describe('AIIntelligence entitlement gating', () => { await waitFor(() => { expect(getPatrolStatusMock).toHaveBeenCalled(); - expect(screen.getByText('Patrol enabled')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Patrol is looking after your infrastructure' }), + ).toBeInTheDocument(); }); expect(screen.queryByText('Patrol quickstart exhausted')).not.toBeInTheDocument(); @@ -1614,7 +1610,9 @@ describe('AIIntelligence entitlement gating', () => { await waitFor(() => { expect(getPatrolStatusMock).toHaveBeenCalled(); - expect(screen.getByText('Patrol enabled')).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Patrol is looking after your infrastructure' }), + ).toBeInTheDocument(); }); expect(screen.queryByText(/Patrol quickstart/i)).not.toBeInTheDocument(); @@ -1899,7 +1897,7 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.queryByText('Runtime issue')).not.toBeInTheDocument(); expect(screen.queryByText(/regressed \d+×/)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Runtime issue/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Run Patrol' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Check now' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Open Patrol settings' })).not.toBeInTheDocument(); expect(screen.queryByText(/Automation:/)).not.toBeInTheDocument(); const patrolControlAnchor = document.getElementById(PATROL_CONTROL_ANCHOR); @@ -1929,7 +1927,8 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.queryByTestId('patrol-status-bar')).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: 'Open work' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Active' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Needs you' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Active' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Resolved' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Details' })).not.toBeInTheDocument(); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 1ed24596a..5db0fc442 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -3567,9 +3567,9 @@ describe('frontend resource type boundaries', () => { expect(patrolIntelligenceBannersSource).toContain('!presentationPolicyHidesUpgradePrompts()'); expect(patrolIntelligenceBannersSource).toContain('state.licenseRequired()'); expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesUpgradePrompts'); - expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesCommercialSurfaces'); expect(patrolIntelligenceHeaderSource).toContain('!presentationPolicyHidesUpgradePrompts()'); - expect(patrolIntelligenceHeaderSource).toContain('!commercialSurfacesHidden()'); + expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true'); + expect(patrolIntelligenceHeaderSource).not.toContain('Plans & Billing'); expect(patrolIntelligenceHeaderSource).toContain( "from '@/components/shared/FilterButtonGroup'", ); diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index d17d7fe06..9fd812be9 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -22,7 +22,9 @@ PATROL_PAGE_AND_STATE_EXACT_FILES = [ "frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx", "frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts", "frontend-modern/src/features/patrol/__tests__/PatrolObjectivesPanel.test.tsx", + "frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx", "frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts", + "frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts", "frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts", "frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts", "frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts",