diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 1c8ea791fb..475fabad79 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3326,6 +3326,7 @@ a new API state machine, queue contract, or verification-accounting field. and any frontend presentation of the AI summary's `policy_posture` counts, which must render that canonical snapshot through one shared component instead of page-local posture loops; no frontend surface renders those counts today, and the resource drawer stays on per-resource policy lines and the dedicated `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` owner, so if Patrol recent-change, learned-correlation, or policy-coverage summary text returns, it is derived from the canonical AI payload in that one place instead of as hook-local count and pluralization logic; Patrol renders no such summary today and the model carries no such derivation and the Watch-only forward-path handoffs owned by `frontend-modern/src/features/patrol/patrolControlPresentation.ts` together with the Actions inbox empty-state read of `GET /api/ai/patrol/autonomy`, so finding-level and inbox-level mode guidance consumes the canonical Patrol autonomy read/save contract through the Patrol state hook (`handleAutonomyChange`) instead of introducing a second mode mutation path or a page-local autonomy dialect + and the Patrol finding-to-Assistant handoff owned by `frontend-modern/src/components/AI/FindingsPanel.tsx`, so the pending-approval, investigation, and proposed-fix reads behind that handoff happen once in the panel instead of in a second, unwired copy inside the Patrol state hook and that same Patrol investigation-context owner, so visible Assistant drawer handoffs may include live pending-approval metadata only as safe operator context: approval ID, status, risk, requested/expiry timestamps, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5864eb539b..f726c2be1d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -3767,11 +3767,11 @@ production table, router and styles; it does not qualify full-app scrolling. assessment readout, but shared drawer/chrome primitives stay free of the `FindingsTrustSummary` shape so adding new trust signals goes through the contract first rather than per-shell branching. - Patrol header refresh controls stay on that same feature-owned shell + Patrol refresh controls stay on that same feature-owned shell boundary: `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` must make the refresh affordance generation-aware and timeout-bounded, so - a slow supporting intelligence read cannot permanently disable the shared - Patrol header control while Patrol findings and status remain visible. + a slow supporting intelligence read cannot permanently disable the + stale-data Retry control while Patrol findings and status remain visible. That feature-owned presentation helper is the single emitter for investigation-record `impact` and `rollback` fields: when an investigation record exists but those fields @@ -3915,6 +3915,10 @@ production table, router and styles; it does not qualify full-app scrolling. finding-presentation destinations such as the Patrol provider-settings route, while `patrol_autonomy` and legacy Pro activation URLs remain parser aliases only and verified review links use the plain Patrol history anchor. + The Patrol finding-to-Assistant handoff has one owner, + `frontend-modern/src/components/AI/FindingsPanel.tsx`; the Patrol state + hook keeps no second copy of it, and the Patrol workspace passes no + handoff callback because nothing on the page reacts to a handoff. UI surfaces must not duplicate the `patrolControlStarter` query string or write Patrol control or legacy entry-point starter telemetry from local click handlers. @@ -6856,7 +6860,7 @@ That same Patrol shell ownership includes refresh affordance state: `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` must keep operator refresh controls generation-aware, timeout-bounded, and separate from background polling state, so a slow supporting intelligence read cannot make the -shared Patrol header Refresh Patrol action spin indefinitely or stay disabled +Patrol stale-data Retry action spin indefinitely or stay disabled while Patrol findings and status remain visible. Neither the Patrol load and poll path (the store's `loadDashboardData` bundle) nor the Retry path's background supporting reads fetch the global diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 819447e11a..4ccd8fc25c 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -329,13 +329,14 @@ or historical proof/counting for resolved-only work. shared parent node, or explicit correlated finding IDs. Grouping must not infer causality from title or description prose, and the queue's affected resource count must continue to count canonical resources rather than - visual groups. Because a - contextual Assistant handoff from that workflow is still a first-party Patrol - starter for the same governed journey, it must record content-free workflow - prompt activity through the shared marker route with the `pulse_patrol` - surface before the drawer opens; it must not include finding IDs, prompt - text, resource context, model output, or direct-action payloads in that - marker. + visual groups. A contextual Assistant handoff from that workflow opens + through `frontend-modern/src/components/AI/FindingsPanel.tsx` and records no + workflow starter today, so the first-party `pulse_patrol` marker has no + browser producer. If that handoff starts recording one, it must write + content-free workflow prompt activity through the shared marker route with + the `pulse_patrol` surface before the drawer opens, with no finding IDs, + prompt text, resource context, model output, or direct-action payloads in + that marker. A compact Patrol work-group row may appear above the list only when it adds cross-source current-work grouping that the row title alone cannot express: pending approvals, failed governed actions, a failed/latest Patrol check, @@ -471,8 +472,11 @@ or historical proof/counting for resolved-only work. compact progress label must describe current work state (`ready`, `needs attention`, `approval needed`, `outcome verified`, `no active work`) instead of repeating the selected mode; the Patrol mode selector/header - owns current-mode copy, and local Patrol state must expose work evidence as + owns current-mode copy, and any local Patrol work evidence must be Patrol-owned issue/work counts rather than legacy proof counts. + No Patrol surface reads issue-evidence, governed-action decision, or + verified-outcome counts today, so `usePatrolIntelligenceState.ts` computes + none of them. When no active finding or pending approval remains, terminal verified or rejected outcomes must read as history behind a `no active work` current state; current-state copy must not ask operators to reconcile old approved @@ -771,12 +775,12 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead only block the selected run view while the run references finding ids that still need the direct Patrol findings payload. Patrol page refresh state must also separate background data loads from the - operator-clicked Update status action: slow or stalled supporting - intelligence reads may continue in the background, but they must not make the - shared header action spin or stay disabled once Patrol findings and status - remain visible. The header action is a status/history sync affordance; it - must not read like another Patrol run or imply that it changes - infrastructure. + operator-clicked Retry on the stale-data banner: background polls hold no + busy state, and slow or stalled supporting intelligence reads may continue + in the background, but they must not make Retry spin or stay disabled once + Patrol findings and status remain visible. Retry is a status/history sync + affordance; it must not read like another Patrol run or imply that it + changes infrastructure. Patrol trigger-status copy in the default header and activity strip must stay actionable. Runtime policy pauses for alert/anomaly-triggered background checks, such as the local development safety guard, may explain a blocked diff --git a/frontend-modern/browser-verification/5c56f082a2aed083.json b/frontend-modern/browser-verification/5c56f082a2aed083.json new file mode 100644 index 0000000000..e6480c0176 --- /dev/null +++ b/frontend-modern/browser-verification/5c56f082a2aed083.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "base_sha": "1a48d27aa0ddfcbc4df0354a08c2312cc1849603", + "verified_at": "2026-10-07T23:28:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts" + ], + "content_sha256": { + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "9bdafb9fa51b259ddada914607c80265f43cb4055e1c56128648c6ed0135c1a4" + }, + "routes": [ + "/patrol", + "/patrol (Activity > Finding options and history)", + "/patrol (Pulse Assistant drawer)" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Follow-up receipt for the merge of origin/main (7f45148d15) into this branch: the hook's final content differs from both parents only by main's own removals of summaryStats (#2690) and the loadCorrelations supporting read (#2677).", + "Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of origin/main 7f45148d15 and of this merged tree (1a48d27aa0), run on pulse-dev because local memory pressure refused heavy work; each on a private DEMO_MODE mock stack with a fresh data dir; the same Playwright script ran against both at both widths.", + "Patrol load with one served active finding (page.route on /api/ai/patrol/findings and /api/ai/unified/findings), Activity > Finding options and history, Resolve or dismiss > Open in Assistant: the Pulse Assistant drawer opens with the finding attached on both builds; no console errors on either.", + "Tab hidden then shown (visibilitychange) and the stale-data banner forced by failing GET /api/settings/ai on reload: 'Patrol could not refresh' shows on both builds and Retry clears it on both, at both widths (state-based waits; a first run with fixed 5 s sleeps missed the banner on the loaded host for timing reasons).", + "Across the whole run both builds hit the same set of /api endpoints; per-endpoint counts differ by one to three only for timer-driven attention, pending-action and active-alert polls, which this change does not touch.", + "Dist proof on pulse-dev: 187 files each side; after normalising chunk hashes, SRI strings and short identifiers, only the removed hook code differs in AIIntelligence.js, the unused pulse_patrol constant drops out of aiChat.js, and index.js reorders the same preload list." + ], + "interactions": [ + "Demo auto sign-in; on /patrol open the Activity tab and 'Finding options and history', expand the served finding, open 'Resolve or dismiss', click 'Open in Assistant', close the drawer.", + "Dispatch visibilitychange hidden then visible; reload with GET /api/settings/ai failing, wait for the banner, restore the route and click Retry, wait for the banner to clear.", + "Desktop 1440x900 and phone 390x844 contexts; screenshots of the Patrol page, Finding options and history, the Assistant drawer and the stale-data banner inspected." + ] +} diff --git a/frontend-modern/browser-verification/6b2e5775c9c3e3d3.json b/frontend-modern/browser-verification/6b2e5775c9c3e3d3.json new file mode 100644 index 0000000000..72622150c7 --- /dev/null +++ b/frontend-modern/browser-verification/6b2e5775c9c3e3d3.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "base_sha": "2cf213a77d2a50dcd47c0b0ab07c49f97bdd0534", + "verified_at": "2026-10-07T15:52:00Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx", + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts" + ], + "content_sha256": { + "frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx": "04ef5ff520eb84b7350d203799a406c22252a35aa6947de6aa7c922e5f4050b4", + "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "c217fd2affd44a102ef39ba440519b5ca931db54b0f46555a23924d968bc7beb" + }, + "routes": [ + "/patrol", + "/patrol (Activity > Finding options and history)", + "/patrol (Pulse Assistant drawer)" + ], + "viewports": [ + { + "width": 1440, + "height": 900 + }, + { + "width": 390, + "height": 844 + } + ], + "states": [ + "Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent tree (92abdcd9ad; rebased onto 2cf213a77d, which changes only patrol-intelligence.md) and this tree, each on a private DEMO_MODE mock stack with its own data dir and port; the same Playwright script ran against both at both widths.", + "Patrol load with one served active finding (page.route on /api/ai/patrol/findings and /api/ai/unified/findings): Inbox renders, Activity > Finding options and history lists the finding, no console errors, same /api endpoint set as the parent build.", + "Finding expanded, Resolve or dismiss menu open, Open in Assistant clicked: the Pulse Assistant drawer opens with the finding attached ('High CPU usage on web-server (host)'), same requests as the parent build and no workflow-starter POST on either build.", + "Tab hidden then shown (visibilitychange): polling pauses and one reload runs; same endpoint set as the parent build.", + "Stale-data banner forced by failing GET /api/settings/ai on reload: 'Patrol could not refresh' with Retry shows on both builds; after unrouting, Retry clears the banner on both builds.", + "Dist proof: 187 files each side; after normalising chunk hashes, SRI strings and short identifiers, only the removed hook code differs in AIIntelligence.js, the now-unused pulse_patrol constant drops out of aiChat.js, and index.js reorders the same AIIntelligence preload list; check-bundle-size.mjs passes." + ], + "interactions": [ + "Demo auto sign-in; on /patrol open the Activity tab and 'Finding options and history', expand the served finding, open 'Resolve or dismiss', click 'Open in Assistant', close the drawer.", + "Dispatch visibilitychange hidden then visible; reload with GET /api/settings/ai failing, then click Retry with the route restored.", + "Desktop 1440x900 and phone 390x844 contexts; screenshots of the Patrol page, the Assistant drawer and the stale-data banner inspected at both widths." + ] +} diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx index b3c70a6068..47fc806a53 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx @@ -282,7 +282,6 @@ export function PatrolIntelligenceWorkspace(props: { findingsSource="patrol" runSnapshot={state.selectedRun() ?? undefined} showControls={!state.selectedRun()} - onAssistantHandoff={(finding) => state.handleAssistantFindingHandoff(finding.id)} patrolProHandoff={(finding) => getPatrolProInvestigationHandoff({ autoFixLocked: state.autoFixLocked(), diff --git a/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts b/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts index a7e54782cb..4e771ea3a9 100644 --- a/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts @@ -2,18 +2,14 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { PATROL_MANUAL_SYNC_TIMEOUT_MS, - PATROL_REFRESH_TIMEOUT_MS, getPatrolSavedReadinessWarning, - openPatrolAssistantWorkflowHandoff, patrolStartFailureMessage, recordPatrolControlStarterActivity, - recordPatrolWorkflowStarterActivity, resolvePatrolAutonomyLevelForSave, resolvePatrolAutonomySettingsForSave, resolvePatrolBlockedActionCause, } from '../usePatrolIntelligenceState'; import patrolIntelligenceStateSource from '../usePatrolIntelligenceState.ts?raw'; -import type { AIChatContext } from '@/stores/aiChat'; import type { AISettings, PatrolReadiness } from '@/types/ai'; const recordWorkflowPromptActivityMock = vi.hoisted(() => vi.fn()); @@ -57,19 +53,31 @@ describe('usePatrolIntelligenceState', () => { vi.clearAllMocks(); }); - it('bounds refresh UI state with a generation-aware timeout', () => { - expect(PATROL_REFRESH_TIMEOUT_MS).toBe(15000); + it('bounds manual sync UI state with a generation-aware timeout', () => { + const manualRefreshStart = patrolIntelligenceStateSource.indexOf( + 'async function handleRefreshPatrol()', + ); + const manualRefreshBody = patrolIntelligenceStateSource.slice( + manualRefreshStart, + patrolIntelligenceStateSource.indexOf( + 'async function consumeRoutePatrolControlStarterHandoff()', + manualRefreshStart, + ), + ); + expect(PATROL_MANUAL_SYNC_TIMEOUT_MS).toBe(5000); - expect(patrolIntelligenceStateSource).toContain('let refreshRequestId = 0;'); expect(patrolIntelligenceStateSource).toContain('let manualRefreshRequestId = 0;'); + expect(manualRefreshBody).toContain('const requestId = ++manualRefreshRequestId;'); + expect(manualRefreshBody).toContain('}, PATROL_MANUAL_SYNC_TIMEOUT_MS);'); + expect(manualRefreshBody).toContain('if (requestId === manualRefreshRequestId) {'); + expect(manualRefreshBody).toContain('setIsManualRefreshRunning(false);'); + expect(manualRefreshBody).toContain('clearManualRefreshTimeout();'); + }); + + it('ignores load results that a newer background load superseded', () => { + expect(patrolIntelligenceStateSource).toContain('let refreshRequestId = 0;'); expect(patrolIntelligenceStateSource).toContain('const requestId = ++refreshRequestId;'); expect(patrolIntelligenceStateSource).toContain('if (requestId === refreshRequestId) {'); - expect(patrolIntelligenceStateSource).toContain('setIsRefreshing(false);'); - expect(patrolIntelligenceStateSource).toContain('setIsManualRefreshRunning(false);'); - expect(patrolIntelligenceStateSource).toContain('handleRefreshPatrol'); - expect(patrolIntelligenceStateSource).toContain('clearRefreshTimeout();'); - expect(patrolIntelligenceStateSource).toContain('clearManualRefreshTimeout();'); - expect(patrolIntelligenceStateSource).toContain('PATROL_MANUAL_SYNC_TIMEOUT_MS'); }); it('keeps browser/network start failures distinct from backend rejections', () => { @@ -90,7 +98,6 @@ describe('usePatrolIntelligenceState', () => { expect(patrolIntelligenceStateSource).toContain('const [patrolLoadError, setPatrolLoadError]'); expect(patrolIntelligenceStateSource).toContain('rememberPatrolLoadError'); expect(patrolIntelligenceStateSource).toContain('Promise.allSettled(['); - expect(patrolIntelligenceStateSource).toContain('setInitialSurfaceReady(true);'); expect(patrolIntelligenceStateSource).toContain( "logger.debug('[Patrol] Failed to refresh Patrol data'", ); @@ -128,30 +135,13 @@ describe('usePatrolIntelligenceState', () => { expect(patrolIntelligenceStateSource).toContain( 'function loadSupportingPatrolDataInBackground()', ); - expect(loadAllDataBody).toContain('PATROL_REFRESH_TIMEOUT_MS'); expect(loadAllDataBody).not.toContain('PATROL_MANUAL_SYNC_TIMEOUT_MS'); expect(manualRefreshBody).toContain('await loadVisiblePatrolData();'); expect(manualRefreshBody).toContain('loadSupportingPatrolDataInBackground();'); expect(manualRefreshBody).toContain('PATROL_MANUAL_SYNC_TIMEOUT_MS'); - expect(manualRefreshBody).not.toContain('PATROL_REFRESH_TIMEOUT_MS'); expect(manualRefreshBody).not.toContain('await loadAllData();'); }); - it('counts rejected Patrol fixes as governed action decisions', () => { - expect(patrolIntelligenceStateSource).toContain('const PATROL_GOVERNED_ACTION_OUTCOMES'); - expect(patrolIntelligenceStateSource).toContain("'fix_rejected'"); - expect(patrolIntelligenceStateSource).toContain('patrolGovernedActionCount'); - }); - - it('separates generic Patrol runs from issue-backed Patrol work evidence', () => { - expect(patrolIntelligenceStateSource).toContain('const patrolWorkIssueEvidenceCount'); - expect(patrolIntelligenceStateSource).toContain("finding.status === 'active'"); - expect(patrolIntelligenceStateSource).toContain("finding.status === 'resolved'"); - expect(patrolIntelligenceStateSource).toContain('status?.findings_count'); - expect(patrolIntelligenceStateSource).toContain('status?.trust?.fix_verified'); - expect(patrolIntelligenceStateSource).toContain('patrolWorkIssueEvidenceCount,'); - }); - it('keeps MCP readiness out of first-party Patrol workflow state', () => { expect(patrolIntelligenceStateSource).not.toContain('agentCapabilitiesManifest'); expect(patrolIntelligenceStateSource).not.toContain('fetchAgentCapabilitiesManifest'); @@ -182,18 +172,6 @@ describe('usePatrolIntelligenceState', () => { ); }); - it('records direct Patrol workflow starters with the shared content-free activity route', () => { - recordWorkflowPromptActivityMock.mockResolvedValueOnce(undefined); - - recordPatrolWorkflowStarterActivity(); - - expect(recordWorkflowPromptActivityMock).toHaveBeenCalledWith({ - name: 'pulse_operations_loop', - surface: 'pulse_patrol', - }); - expect(patrolIntelligenceStateSource).toContain('recordPatrolWorkflowStarterActivity,'); - }); - it('records route-backed Patrol mode starters with the shared content-free activity route', async () => { recordWorkflowPromptActivityMock.mockResolvedValueOnce(undefined); @@ -216,56 +194,18 @@ describe('usePatrolIntelligenceState', () => { expect(patrolIntelligenceStateSource).not.toContain('await loadPatrolWorkStatus();'); }); - it('keeps direct Patrol starter recording non-blocking when the marker route fails', async () => { + it('keeps Patrol mode starter recording non-blocking when the marker route fails', async () => { const error = new Error('offline'); recordWorkflowPromptActivityMock.mockRejectedValueOnce(error); - recordPatrolWorkflowStarterActivity(); - await Promise.resolve(); + await expect(recordPatrolControlStarterActivity()).resolves.toBeUndefined(); expect(loggerDebugMock).toHaveBeenCalledWith( - '[Patrol] Failed to record Patrol workflow starter', + '[Patrol mode handoff] Failed to record Patrol workflow starter', error, ); }); - it('records a Patrol starter before opening the Assistant workflow handoff', () => { - const callOrder: string[] = []; - const recordStarterActivity = vi.fn(() => callOrder.push('record')); - const openAssistant = vi.fn((context: AIChatContext) => { - callOrder.push('open'); - expect(context).toMatchObject({ - targetType: 'storage', - targetId: 'storage-1', - findingId: 'finding-1', - autonomousMode: false, - preferredWorkflowPromptName: 'pulse_operations_loop', - }); - expect(context.handoffContext).toBe('Scoped Patrol context'); - }); - - openPatrolAssistantWorkflowHandoff( - { - context: { - targetType: 'storage', - targetId: 'storage-1', - findingId: 'finding-1', - autonomousMode: false, - handoffContext: 'Scoped Patrol context', - preferredWorkflowPromptName: 'legacy_prompt', - }, - }, - { - recordStarterActivity, - openAssistant, - }, - ); - - expect(recordStarterActivity).toHaveBeenCalledWith(); - expect(openAssistant).toHaveBeenCalledTimes(1); - expect(callOrder).toEqual(['record', 'open']); - }); - describe('resolvePatrolAutonomyLevelForSave', () => { it('clamps stale paid autonomy to monitor when governed fixes are locked', () => { expect(resolvePatrolAutonomyLevelForSave('full', true, true)).toBe('monitor'); diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index 3b6f509208..5c33d076a8 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -19,7 +19,6 @@ import { type AssistantWorkflowPromptActivitySurface, PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME, PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE, - PULSE_PATROL_WORKFLOW_PROMPT_SURFACE, } from '@/api/aiChat'; import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { @@ -28,7 +27,6 @@ import { loadAIRuntimeSettings, syncAIRuntimeSettings, } from '@/stores/aiRuntimeState'; -import { aiChatStore, type AIChatContext } from '@/stores/aiChat'; import { notificationStore } from '@/stores/notifications'; import { usePatrolStream } from '@/hooks/usePatrolStream'; import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; @@ -41,10 +39,7 @@ import { import { PATROL_AUTONOMY_FEATURE_KEY } from './patrolAutonomyAvailability'; import type { AISettings } from '@/types/ai'; import { apiErrorStatus } from '@/api/responseUtils'; -import { - hasFindingInvestigationHandoffPointer, - isPatrolRuntimeFinding, -} from '@/utils/aiFindingPresentation'; +import { isPatrolRuntimeFinding } from '@/utils/aiFindingPresentation'; import { getCanonicalScopeResourceIds } from '@/utils/patrolFormat'; import { normalizePatrolRuntimeBlockedReason } from '@/utils/patrolRuntimePresentation'; import { logger } from '@/utils/logger'; @@ -54,30 +49,10 @@ import { PATROL_CONTROL_STARTER_QUERY_PARAM, PATROL_OPERATIONS_LOOP_STARTER_QUERY_PARAM, } from '@/routing/resourceLinks'; -import { - buildPatrolAssistantApprovalBriefingInput, - buildPatrolAssistantFindingHandoffFromUnifiedFinding, - buildPatrolAssistantProposedFixBriefingInput, - buildPatrolAssistantProposedFixBriefingInputFromApproval, - patrolAssistantFindingHandoffRequiresApprovalMode, - type PatrolAssistantApprovalBriefingInput, - type PatrolAssistantFindingHandoff, -} from './patrolInvestigationContextModel'; import { schedulePatrolRunAcceptanceReconciliation } from './patrolRunAcceptance'; type PatrolTab = 'findings' | 'history'; -const PATROL_GOVERNED_ACTION_OUTCOMES = new Set([ - 'fix_executed', - 'fix_failed', - 'fix_rejected', - 'fix_verified', - 'fix_verification_failed', - 'fix_verification_unknown', -]); -const PATROL_VERIFIED_OUTCOMES = new Set(['fix_verified']); -const PATROL_OPERATIONS_LOOP_WORKFLOW_PROMPT = PULSE_OPERATIONS_LOOP_WORKFLOW_PROMPT_NAME; - const recordPatrolWorkflowStarterActivityForSurface = async ( surface: AssistantWorkflowPromptActivitySurface, logContext: string, @@ -92,18 +67,10 @@ const recordPatrolWorkflowStarterActivityForSurface = async ( } }; -export const PATROL_REFRESH_TIMEOUT_MS = 15000; export const PATROL_MANUAL_SYNC_TIMEOUT_MS = 5000; export const PATROL_ACCEPTANCE_RECONCILE_MS = 15000; export const PATROL_ACCEPTANCE_REFRESH_TIMEOUT_MS = 5000; -export function recordPatrolWorkflowStarterActivity(): void { - void recordPatrolWorkflowStarterActivityForSurface( - PULSE_PATROL_WORKFLOW_PROMPT_SURFACE, - 'Patrol', - ); -} - export function recordPatrolControlStarterActivity(): Promise { return recordPatrolWorkflowStarterActivityForSurface( PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE, @@ -111,27 +78,6 @@ export function recordPatrolControlStarterActivity(): Promise { ); } -interface PatrolAssistantWorkflowHandoffOptions { - recordStarterActivity?: () => void; - openAssistant?: (context: AIChatContext) => void; -} - -export function openPatrolAssistantWorkflowHandoff( - handoff: PatrolAssistantFindingHandoff, - options: PatrolAssistantWorkflowHandoffOptions = {}, -): void { - const { - recordStarterActivity = recordPatrolWorkflowStarterActivity, - openAssistant = (context) => aiChatStore.open(context), - } = options; - - recordStarterActivity(); - openAssistant({ - ...handoff.context, - preferredWorkflowPromptName: PATROL_OPERATIONS_LOOP_WORKFLOW_PROMPT, - }); -} - const patrolErrorMessage = (error: unknown, fallback: string) => error instanceof Error && error.message.trim() ? error.message : fallback; @@ -199,12 +145,10 @@ export function resolvePatrolBlockedActionCause( } export function usePatrolIntelligenceState() { - const [initialSurfaceReady, setInitialSurfaceReady] = createSignal(false); const [activeTab, setActiveTab] = createSignal('findings'); const [findingsFilterOverride, setFindingsFilterOverride] = createSignal< 'all' | 'active' | 'resolved' | 'approvals' | 'attention' | undefined >(undefined); - const [isRefreshing, setIsRefreshing] = createSignal(false); const [isManualRefreshRunning, setIsManualRefreshRunning] = createSignal(false); const [autonomyLevel, setAutonomyLevel] = createSignal('monitor'); const [requestedAutonomyLevel, setRequestedAutonomyLevel] = @@ -219,16 +163,13 @@ export function usePatrolIntelligenceState() { const [liveRunStartedAt, setLiveRunStartedAt] = createSignal(''); const [investigationBudget, setInvestigationBudget] = createSignal(15); const [investigationTimeout, setInvestigationTimeout] = createSignal(300); - const [fullModeUnlocked, setFullModeUnlocked] = createSignal(false); const [isTogglingPatrol, setIsTogglingPatrol] = createSignal(false); const [isTriggeringPatrol, setIsTriggeringPatrol] = createSignal(false); const [selectedRun, setSelectedRun] = createSignal(null); - const [assistantHandoffFindingId, setAssistantHandoffFindingId] = createSignal(''); const [patrolLoadError, setPatrolLoadError] = createSignal(''); let cancelAcceptanceReconciliation: (() => void) | undefined; let findingScrollTimerRef: ReturnType | undefined; - let refreshTimeoutRef: ReturnType | undefined; let manualRefreshTimeoutRef: ReturnType | undefined; let refreshRequestId = 0; let manualRefreshRequestId = 0; @@ -249,13 +190,6 @@ export function usePatrolIntelligenceState() { setManualRunRequested(false); }; - const clearRefreshTimeout = () => { - if (refreshTimeoutRef !== undefined) { - clearTimeout(refreshTimeoutRef); - refreshTimeoutRef = undefined; - } - }; - const clearManualRefreshTimeout = () => { if (manualRefreshTimeoutRef !== undefined) { clearTimeout(manualRefreshTimeoutRef); @@ -263,14 +197,6 @@ export function usePatrolIntelligenceState() { } }; - const finishRefresh = (requestId: number) => { - if (requestId !== refreshRequestId) { - return; - } - clearRefreshTimeout(); - setIsRefreshing(false); - }; - const rememberPatrolLoadError = (error: unknown, fallback: string) => { const message = patrolErrorMessage(error, fallback); setPatrolLoadError(message); @@ -526,7 +452,6 @@ export function usePatrolIntelligenceState() { }); const intelligenceSummary = createMemo(() => aiIntelligenceStore.intelligenceSummary); - const circuitBreakerStatus = createMemo(() => aiIntelligenceStore.circuitBreakerStatus); const liveRunRecord = createMemo(() => { if (!shouldShowLiveRun()) return null; return { @@ -573,7 +498,6 @@ export function usePatrolIntelligenceState() { setRequestedAutonomyLevel(settings.requested_autonomy_level); setAutonomyLevel(settings.effective_autonomy_level); setAutopilotStatus(settings.autopilot_acknowledgement); - setFullModeUnlocked(settings.autopilot_acknowledgement.active); setInvestigationBudget(settings.investigation_budget); setInvestigationTimeout(settings.investigation_timeout_sec); } catch (err) { @@ -663,72 +587,6 @@ export function usePatrolIntelligenceState() { } } - function handleAssistantFindingHandoff(findingId: string) { - setAssistantHandoffFindingId(findingId.trim()); - } - - const loadLatestInvestigationProposedFixBriefing = async ( - finding: ReturnType[number], - pendingApprovalBriefing: PatrolAssistantApprovalBriefingInput | undefined, - ) => { - if (finding.investigationRecord?.proposed_fix) { - return undefined; - } - const hasInvestigationPointer = - hasFindingInvestigationHandoffPointer(finding) || Boolean(pendingApprovalBriefing?.id); - if (!hasInvestigationPointer) { - return undefined; - } - if ( - !patrolAssistantFindingHandoffRequiresApprovalMode({ - investigationOutcome: finding.investigationOutcome, - remediationId: finding.remediationPlanId, - pendingApproval: pendingApprovalBriefing, - investigationRecord: finding.investigationRecord, - }) - ) { - return undefined; - } - - try { - const investigation = await AIAPI.getInvestigation(finding.id); - return buildPatrolAssistantProposedFixBriefingInput(investigation?.proposed_fix); - } catch { - return undefined; - } - }; - - async function openAssistantOperationsLoopForFinding(findingId: string): Promise { - const normalizedFindingId = findingId.trim(); - if (!normalizedFindingId) return false; - - const finding = allPatrolFindings().find((candidate) => candidate.id === normalizedFindingId); - if (!finding) { - return false; - } - - await aiIntelligenceStore.loadPendingApprovals(); - const pendingApproval = aiIntelligenceStore.patrolPendingApprovals.find( - (approval) => approval.toolId === 'investigation_fix' && approval.targetId === finding.id, - ); - const pendingApprovalBriefing = buildPatrolAssistantApprovalBriefingInput(pendingApproval); - const latestInvestigationProposedFix = await loadLatestInvestigationProposedFixBriefing( - finding, - pendingApprovalBriefing, - ); - const proposedFix = - latestInvestigationProposedFix || - buildPatrolAssistantProposedFixBriefingInputFromApproval(pendingApproval); - const handoff = buildPatrolAssistantFindingHandoffFromUnifiedFinding(finding, { - pendingApproval: pendingApprovalBriefing, - proposedFix, - }); - - openPatrolAssistantWorkflowHandoff(handoff); - setAssistantHandoffFindingId(finding.id); - return true; - } - function startPolling() { clearInterval(refreshInterval); clearInterval(approvalPollInterval); @@ -743,15 +601,6 @@ export function usePatrolIntelligenceState() { async function loadAllData() { const requestId = ++refreshRequestId; - clearRefreshTimeout(); - setIsRefreshing(true); - refreshTimeoutRef = setTimeout(() => { - if (requestId === refreshRequestId) { - refreshTimeoutRef = undefined; - setIsRefreshing(false); - } - }, PATROL_REFRESH_TIMEOUT_MS); - try { await Promise.all([ // The Patrol surface needs dismissed findings too: the attention @@ -773,8 +622,6 @@ export function usePatrolIntelligenceState() { if (requestId === refreshRequestId) { rememberPatrolLoadError(error, 'Patrol could not refresh.'); } - } finally { - finishRefresh(requestId); } } @@ -854,54 +701,6 @@ export function usePatrolIntelligenceState() { } return activeFindings.length > 0 && activeFindings.every(isPatrolRuntimeFinding); }); - const patrolWorkIssueEvidenceCount = createMemo(() => { - const issueFindingCount = allPatrolFindings().filter( - (finding) => - finding.status === 'active' || - finding.status === 'resolved' || - Boolean(finding.investigationSessionId) || - Boolean(finding.investigationStatus) || - Boolean(finding.investigationOutcome) || - Boolean(finding.remediationPlanId), - ).length; - const status = patrolStatus(); - const statusFindingCount = - (status?.findings_count ?? 0) + - (status?.fixed_count ?? 0) + - (status?.trust?.resolved ?? 0) + - (status?.trust?.fix_verified ?? 0); - return issueFindingCount + patrolPendingApprovalCount() + statusFindingCount; - }); - const patrolWorkEvidenceCount = createMemo( - () => - (patrolRunHistory.value()?.length ?? 0) + - allPatrolFindings().length + - (patrolStatus()?.last_patrol_at || patrolStatus()?.last_activity_at ? 1 : 0), - ); - const patrolGovernedActionCount = createMemo( - () => - allPatrolFindings().filter( - (finding) => - finding.investigationOutcome && - PATROL_GOVERNED_ACTION_OUTCOMES.has(finding.investigationOutcome), - ).length, - ); - const patrolRejectedDecisionCount = createMemo( - () => - allPatrolFindings().filter((finding) => finding.investigationOutcome === 'fix_rejected') - .length, - ); - const patrolApprovedDecisionCount = createMemo(() => - Math.max(0, patrolGovernedActionCount() - patrolRejectedDecisionCount()), - ); - const patrolVerifiedOutcomeCount = createMemo( - () => - allPatrolFindings().filter( - (finding) => - finding.investigationOutcome && - PATROL_VERIFIED_OUTCOMES.has(finding.investigationOutcome), - ).length, - ); const findingsTabBadgeFindings = createMemo(() => { const snapshotFindings = selectedRunPatrolFindings(); if (snapshotFindings === null) { @@ -921,23 +720,19 @@ export function usePatrolIntelligenceState() { }); onMount(async () => { - try { - await consumeRoutePatrolControlStarterHandoff(); - const initialLoads = await Promise.allSettled([ - loadRuntimeCapabilities(), - loadAllData(), - loadAutonomySettings(), - loadAIRuntimeModels(), - loadAIRuntimeSettings(), - ]); - const failedLoad = initialLoads.find( - (result): result is PromiseRejectedResult => result.status === 'rejected', - ); - if (failedLoad) { - rememberPatrolLoadError(failedLoad.reason, 'Patrol could not refresh.'); - } - } finally { - setInitialSurfaceReady(true); + await consumeRoutePatrolControlStarterHandoff(); + const initialLoads = await Promise.allSettled([ + loadRuntimeCapabilities(), + loadAllData(), + loadAutonomySettings(), + loadAIRuntimeModels(), + loadAIRuntimeSettings(), + ]); + const failedLoad = initialLoads.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (failedLoad) { + rememberPatrolLoadError(failedLoad.reason, 'Patrol could not refresh.'); } }); @@ -956,7 +751,6 @@ export function usePatrolIntelligenceState() { document.addEventListener('visibilitychange', handleVisibility); onCleanup(() => { document.removeEventListener('visibilitychange', handleVisibility); - clearRefreshTimeout(); clearManualRefreshTimeout(); }); }); @@ -974,10 +768,6 @@ export function usePatrolIntelligenceState() { return { activeTab, - activePatrolFindings, - activityRefreshTrigger, - acceptedManualRunId, - assistantHandoffFindingId, autonomyLevel, requestedAutonomyLevel, autopilotStatus, @@ -989,54 +779,39 @@ export function usePatrolIntelligenceState() { blockedCause, blockedReason, canTriggerPatrol, - circuitBreakerStatus, displayRunHistory, findingsTabBadgeCount, findingsTabBadgeFindings, findingsFilterOverride, - fullModeUnlocked, handleAutonomyChange, - handleAssistantFindingHandoff, handleRefreshPatrol, handleRunPatrol, handleTogglePatrol, historicalRegressionCount, - initialSurfaceReady, intelligenceSummary, isManualRefreshRunning, - isRefreshing, isTogglingPatrol, isTriggeringPatrol, isUpdatingAutonomy, revokeAutopilot, licenseRequired, - loadAllData, licenseRuntimeIdentity, manualRunRequested, - openAssistantOperationsLoopForFinding, patrolEnabledLocal, - patrolApprovedDecisionCount, - patrolGovernedActionCount, patrolPreflight, patrolLoadError, patrolPendingApprovalCount, patrolReadiness, - patrolRejectedDecisionCount, patrolRunHistory, - patrolVerifiedOutcomeCount, - patrolWorkEvidenceCount, - patrolWorkIssueEvidenceCount, runtimeState, patrolStatus, patrolStream, - recordPatrolWorkflowStarterActivity, selectedRun, selectedRunFindingIds, selectedRunHasFindingsSnapshot, selectedRunScopeResourceIds, setActiveTab, setFindingsFilterOverride, - setFullModeUnlocked, setAutopilotDialogOpen, setSelectedRun, setFindingScrollTimer: (timer: ReturnType | undefined) => { diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 6ae0100ff0..f945d40037 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -568,8 +568,8 @@ async function openPatrolActivityMode() { describe('AIIntelligence entitlement gating', () => { it('keeps Patrol page data sync bounded without making it a primary action', () => { - expect(patrolIntelligenceStateSource).toContain('PATROL_REFRESH_TIMEOUT_MS'); - expect(patrolIntelligenceStateSource).toContain('finishRefresh(requestId)'); + expect(patrolIntelligenceStateSource).toContain('PATROL_MANUAL_SYNC_TIMEOUT_MS'); + expect(patrolIntelligenceStateSource).toContain('requestId === manualRefreshRequestId'); expect(patrolIntelligenceStateSource).toContain('requestId === refreshRequestId'); expect(patrolIntelligenceStateSource).toContain('isManualRefreshRunning'); expect(patrolIntelligenceStateSource).toContain('handleRefreshPatrol'); diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 484ecd88c9..6690fbc986 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -3603,11 +3603,10 @@ describe('frontend resource type boundaries', () => { expect(aiIntelligenceSummaryModelSource).toContain( 'export function normalizeIntelligenceSummary', ); - // Verify the symbol is imported from the model without prescribing a - // specific single- vs multi-line import shape; usePatrolIntelligenceState - // groups several patrolInvestigationContextModel imports together. - expect(patrolIntelligenceStateSource).toMatch( - /import\s*\{[^}]*\bbuildPatrolAssistantFindingHandoffFromUnifiedFinding\b[^}]*\}\s*from '\.\/patrolInvestigationContextModel';/, + // FindingsPanel owns the Patrol finding to Assistant handoff; the page + // state hook must not carry a second, unwired copy of it. + expect(patrolIntelligenceStateSource).not.toContain( + 'buildPatrolAssistantFindingHandoffFromUnifiedFinding', ); expect(patrolIntelligenceStateSource).toContain('getPatrolSavedReadinessWarning'); expect(patrolIntelligenceStateSource).not.toContain('recent_changes?.length');