Drop Patrol state members no surface reads (#2695)

`usePatrolIntelligenceState()` returned 18 members that no file outside
the hook reads. A TypeScript checker scan of src and tests found no
readers and no spread, computed or `Object.*` escapes of the state
object. Several were also dead inside the hook.

## Change

- **Return object.** Drops activePatrolFindings, activityRefreshTrigger,
acceptedManualRunId, assistantHandoffFindingId, circuitBreakerStatus,
fullModeUnlocked, initialSurfaceReady, isRefreshing, loadAllData,
openAssistantOperationsLoopForFinding, the four decision/outcome counts,
both work-evidence counts, recordPatrolWorkflowStarterActivity and
setFullModeUnlocked, plus handleAssistantFindingHandoff (see below).
- **Dead locals.** Removes the write-only signals (initialSurfaceReady,
isRefreshing, fullModeUnlocked, assistantHandoffFindingId), the 15 s
background refresh timeout that only reset isRefreshing, the unread
memos, and the never-called `openAssistantOperationsLoopForFinding`.
That last one was a second copy of the handoff `FindingsPanel` already
performs. Locals still used inside the hook (activePatrolFindings,
loadAllData, activityRefreshTrigger, acceptedManualRunId) stay.
- **Test-only exports.** `openPatrolAssistantWorkflowHandoff` and
`recordPatrolWorkflowStarterActivity` were used only by that dead path
and by their own tests, so they go.
- **Workspace.** `handleAssistantFindingHandoff` only wrote the unread
handoff signal, so `PatrolIntelligenceWorkspace` no longer passes
`FindingsPanel` an `onAssistantHandoff` callback. The prop is optional.
- **Tests.** Raw-source pins on removed code are dropped. The
manual-sync timeout test now pins the Retry path's own generation guard;
a mutation of its timeout fails it. The non-blocking starter test now
exercises `recordPatrolControlStarterActivity`.
- **Contracts.**
- patrol-intelligence said a finding-to-Assistant handoff *must* record
a `pulse_patrol` workflow starter. Only the dead path did that; the live
`FindingsPanel` handoff never has, so `pulse_patrol` has no browser
producer. The clause now says that and keeps the content-free rules as a
condition.
- The "local Patrol state must expose work evidence" sentence no longer
requires counts nothing reads.
- Three refresh clauses that described a header refresh action now name
the stale-data Retry, the only operator refresh left.
- api-contracts and frontend-primitives record that `FindingsPanel` owns
the handoff.

## Not in this PR

The store still fetches `GET /api/ai/circuit/status` for nothing. That
removal is a follow-up after #2677, because it edits the same lines.

## Evidence

- Production builds before and after compared with chunk hashes, SRI
strings and short identifiers normalised. Only the removed hook code
differs, the unused `pulse_patrol` constant drops out of `aiChat.js`,
and `index.js` reorders the same preload list. `check-bundle-size.mjs`
passes.
- Private demo-mode stacks for both builds, driven by the same
Playwright script at 1440x900 and 390x844: Patrol load, Activity >
Finding options and history, Open in Assistant (drawer opens with the
finding), tab hide/show, and the forced stale-data banner plus Retry.
Each phase hit the same `/api` endpoints on both builds, and neither
build had console errors.
- `tsc --noEmit`, eslint, and vitest for `src/features/patrol`,
`AIIntelligence.test.tsx`, `frontendResourceTypeBoundaries.test.ts` and
`FindingsPanel.test.ts` pass, as does the full pre-commit battery.

Stacked on #2687 (which carries #2682 and #2686). Until those merge,
this PR shows their commits too. `git merge-tree` is clean against main,
#2690, #2677 and #2691.
This commit is contained in:
rcourtman
2026-10-08 03:01:08 +01:00
committed by GitHub
10 changed files with 153 additions and 349 deletions
@@ -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,
@@ -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
@@ -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
@@ -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."
]
}
@@ -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."
]
}
@@ -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(),
@@ -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');
@@ -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<void> {
return recordPatrolWorkflowStarterActivityForSurface(
PULSE_PATROL_CONTROL_WORKFLOW_PROMPT_SURFACE,
@@ -111,27 +78,6 @@ export function recordPatrolControlStarterActivity(): Promise<void> {
);
}
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<PatrolTab>('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<PatrolAutonomyLevel>('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<PatrolRunRecord | null>(null);
const [assistantHandoffFindingId, setAssistantHandoffFindingId] = createSignal('');
const [patrolLoadError, setPatrolLoadError] = createSignal('');
let cancelAcceptanceReconciliation: (() => void) | undefined;
let findingScrollTimerRef: ReturnType<typeof setTimeout> | undefined;
let refreshTimeoutRef: ReturnType<typeof setTimeout> | undefined;
let manualRefreshTimeoutRef: ReturnType<typeof setTimeout> | 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<PatrolRunRecord | null>(() => {
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<typeof allPatrolFindings>[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<boolean> {
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<typeof setTimeout> | undefined) => {
@@ -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');
@@ -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');