Remove test-only Patrol autonomy save helpers (#2740)

Stacked on #2695 (branch contains its head 32fad38aa4). Once #2695
merges, this PR's diff is the single commit 776c2774ba.

## What

`resolvePatrolAutonomyLevelForSave` and
`resolvePatrolAutonomySettingsForSave` in
`features/patrol/usePatrolIntelligenceState.ts` had no production
caller. Only their own unit tests imported them. Patrol mode saves go
through `handleAutonomyChange` and `acknowledgeAndActivateAutopilot`,
whose payload type `PatrolAutonomyUpdate` has no `full_mode_unlocked`
field, and both reconcile from `GET /api/ai/patrol/autonomy`. This PR
deletes both helpers and their tests.

Contract prose in patrol-intelligence, api-contracts,
frontend-primitives and ai-runtime described the helpers' model rather
than the live path: the browser clearing stale full-mode unlock state
before a monitor-only save, sending `full_mode_unlocked:false` when
locked, and sending `full_mode_unlocked:true` for Autopilot. The API
does that work. `HandleUpdatePatrolAutonomyMonitorOnly` and the paid
handler's `SaveAutonomySettings` adapter clear the unlock and Autopilot
activation on every save below Autopilot, and `GatePatrolAutonomyUpdate`
derives the compatibility flag from a recorded acknowledgement. The
prose now says what the header, the state owner and the API each do.
Three neighbouring api-contracts sentences about the same save were also
corrected:
- a `full` request without a current acknowledgement is refused by the
gate before any licence check;
- the "Patrol mode dialog" (removed in 27ba85ddc6) is now the header's
`Mode and automation` disclosure, with the same wording as #2691;
- the lock is checked "when a save starts", not "never submits", because
the Autopilot flow checks it only at entry.

The deleted helper tests are replaced by tests of the live locked path.
A page test loads stale paid state (Safe auto-fix plus the compatibility
unlock) under a runtime lock. It checks that Watch only is presented,
that the paid choices are disabled and save nothing, and that Watch only
sends exactly `autonomy_level`, `investigation_budget` and
`investigation_timeout_sec`. A source pin keeps the state owner's own
lock guards.

## Verification

- Production build: parent and this change produce byte-identical dists
(187 files), because Rollup had already dropped both helpers.
- Browser pass on pulse-dev, before and after, one DEMO_MODE stack at a
time, 1440x900 and 390x844. Covered paid saves (Ask first, Watch only,
Autopilot through the acknowledgement dialog) and the runtime lock with
and without the demo presentation override. 6 sessions and 80 recorded
fields, with 0 differences between before and after. No PUT ever carried
`full_mode_unlocked`. Receipt
`frontend-modern/browser-verification/994121d9684bd9f0.json`.
- `tsc --noEmit` and the Patrol hook, page, header, workspace,
commercial-boundary and resource-boundary suites pass (84 tests).
- Negative controls, each failing the intended test:
  - header showing the stale paid mode while locked;
  - payload carrying `full_mode_unlocked`;
  - hook lock guard removed;
  - paid choices enabled while locked.
This commit is contained in:
rcourtman
2026-10-08 03:41:42 +01:00
committed by GitHub
8 changed files with 148 additions and 136 deletions
@@ -4198,10 +4198,14 @@ resolve canonical/source IDs and unique aliases before collection, reject
human-facing labels in the settings payload must frame the operator boundary
as Patrol mode rather than Patrol configuration.
Monitor-only Patrol autonomy saves are part of the same runtime gate:
when the safe-remediation extension or entitlement is unavailable, both the
browser state owner and `internal/api/ai_handlers.go` must clear stale
full-mode unlock state while clamping autonomy back to `monitor`, so paid
remediation permission cannot survive through a free runtime save.
when the safe-remediation extension or entitlement is unavailable, the
API must clear stale full-mode unlock and Autopilot activation state while
clamping autonomy back to `monitor` (the monitor-only handler in
`internal/api/ai_handlers.go` without the extension, the
`SaveAutonomySettings` adapter in `internal/api/router_routes_ai_relay.go`
behind the paid handler), and the browser state owner must refuse a paid
level when a save starts, so paid remediation permission cannot survive
through a free runtime save.
Event-triggered Patrol runtime policy is also part of this gate: when local
development background automation or another runtime policy pauses automatic
alert/anomaly-triggered checks, `internal/ai` must expose an explicit
@@ -3348,8 +3348,8 @@ a new API state machine, queue contract, or verification-accounting field.
and the Patrol runtime-remediation destination shared with the AI settings endpoint, so run-history runtime-failure actions and runtime-finding actions may reuse the governed provider-settings route while still presenting that destination in Patrol as provider configuration instead of generic `AI Settings` copy
and the Patrol route-shell destination itself, so the thin page shell at `frontend-modern/src/pages/AIIntelligence.tsx` may continue to bridge the shared AI-runtime payload boundary while exposing `/patrol` as the canonical product route and keeping retired `/ai` browser entry points unregistered
and the Patrol route-shell accessibility boundary, so brand icons in `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx` stay decorative when the same heading already exposes visible Patrol text, preventing duplicate accessible names such as `Pulse Patrol Patrol`
and the Patrol mode selector boundary, so the default header and the
Patrol mode dialog compose the shared
and the Patrol mode selector boundary, so the header's `Mode and automation`
disclosure composes the shared
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` primitive for
the visible `Watch only` / `Ask first` / `Safe auto-fix` /
`Autopilot` presentation while the API contract remains the sole owner of
@@ -4036,14 +4036,20 @@ the authoritative analysis outcome.
self-hosted v6 GA UI
and the Patrol autonomy save contract, so Community/free runtime payloads
may persist only `monitor` autonomy settings through
`/api/ai/patrol/autonomy`, while `approval`, `assisted`, and `full` return
the canonical license-required response instead of a generic save failure,
and Patrol frontend state owners must clamp stale paid autonomy to `monitor`
and send `full_mode_unlocked:false` before submitting that endpoint when the
safe-remediation entitlement is not effective. The monitor-only backend path
must likewise clear stale full-mode unlock state in its response and
persistence layer instead of preserving paid remediation state through a free
configuration save
`/api/ai/patrol/autonomy`, while `approval`, `assisted`, and an
acknowledged `full` return the canonical license-required response instead
of a generic save failure (the shared acknowledgement gate refuses a `full`
request without a current Autopilot acknowledgement first, for example with
`409 acknowledgement_required` when it carries none), and the Patrol
frontend state owner must
refuse any level other than `monitor` when a save starts while the
safe-remediation entitlement is not effective; its payload carries only
`autonomy_level`, the investigation budget and timeout, and (for Autopilot)
`acknowledgement_id`, never `full_mode_unlocked`. The monitor-only backend
path owns the unlock clamp:
it must clear stale full-mode unlock and Autopilot activation state in its
response and persistence layer instead of preserving paid remediation state
through a free configuration save
and the Patrol settings-save readiness contract, so
`/api/settings/ai/update` may save a selected Patrol provider/model even
when that model is not ready for tool-backed Patrol execution, but it must
@@ -9803,19 +9809,22 @@ configuration from paid remediation autonomy. `GET /api/ai/patrol/autonomy`
continues to clamp effective Community autonomy to `monitor`, and
`PUT /api/ai/patrol/autonomy` in the open-source/free adapter must accept and
persist only `monitor` settings while returning the canonical license-required
payload for investigation or remediation autonomy levels. Frontend Patrol state
owners must not rely on that 402 as normal control flow for stale local state:
when the current entitlement locks safe remediation, they submit `monitor` even
if older persisted settings or a previous entitlement left `approval`,
`assisted`, or `full` in memory.
payload for investigation or remediation autonomy levels (a `full` request
reaches that adapter only with a current Autopilot acknowledgement; otherwise
the shared acknowledgement gate refuses it first, for example with
`409 acknowledgement_required` when it carries none). Frontend Patrol
state owners must not rely on that 402 as normal control flow for stale local
state: when the current entitlement locks safe remediation, the Patrol header
presents `monitor` as the effective mode and the state owner starts no save but
an explicit `monitor` choice, even if older persisted settings or a previous
entitlement left `approval`, `assisted`, or `full` in memory.
After a successful persisted update whose effective mode actually crosses from
`monitor` to `approval`, `assisted`, or eligible `full`, the shared API mutation
boundary invokes the bounded Patrol actionable-finding reconciliation pass.
The pass runs only after `ApplyPatrolAutonomyConfig` has published the new
runtime authority; failed writes, Community clamps, repeated paid-mode saves,
budget-only edits, and demotions must not start backlog work.
The Patrol header and configuration-dialog presentation for that API boundary
must compose the shared
The Patrol header presentation for that API boundary must compose the shared
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` selector: the
endpoint contract still owns the accepted autonomy values and license-required
response shape, while the frontend owns only the option mapping,
@@ -3207,9 +3207,13 @@ Agent`), with the plain-language source phrase available through accessible
configuration issue rather than a failed save. An Assistant handoff from it
must send a source-named, model-only briefing and close the popover so the
shared Assistant drawer is not visually hidden behind feature chrome. When
governed fixes are locked, the Patrol state owner must clear stale
full-mode unlock state before persisting the monitor-only autonomy payload,
so disabled paid controls cannot leak stale permission into a save. When a
governed fixes are locked, the Patrol header presents `Watch only` as the
effective mode with no enabled paid choice, and the Patrol state owner
refuses any other level when a save starts. Its autonomy payload
never carries `full_mode_unlocked`: `PUT /api/ai/patrol/autonomy` itself
clears stale full-mode unlock and Autopilot activation state on every save
below Autopilot, so disabled paid controls cannot leak stale permission
into a save. When a
restored Patrol assessment session is attached, the shared Assistant drawer
empty state must
stay aligned with that source-named briefing and must not render generic
@@ -1633,12 +1633,14 @@ after a page render: recoverable Patrol provider/model settings saves must
persist and echo structured readiness cause metadata, manual run requests must
return the structured readiness reason if a stale UI still submits, and
scheduled or scoped alert/anomaly runs must skip before calling the model while
preserving the blocked reason and cause in Patrol status. The Patrol
control state owner must also clamp stale investigation/remediation
autonomy back to findings-only `monitor` and clear stale full-mode unlock state
before persisting Patrol control when the safe-remediation entitlement is not
effective, so an expired or downgraded plan cannot turn a recoverable control
review into a Pro-only save failure. Patrol renders no inline error
preserving the blocked reason and cause in Patrol status. When the
safe-remediation entitlement is not effective, the Patrol header must present
findings-only `monitor` as the effective mode and the Patrol control state
owner must refuse any investigation/remediation level when a save starts;
the `monitor` save it does send is accepted without the entitlement, and the
API clears stale full-mode unlock and Autopilot activation state on it, so an
expired or downgraded plan cannot turn a recoverable control review into a
Pro-only save failure. Patrol renders no inline error
surface for these failures today: switching Patrol on or off while it is not
ready raises a fixed warning notification, a failed Patrol mode save raises an error
notification, and neither offers an Assistant handoff. A provider or model save
@@ -1653,8 +1655,10 @@ prominent layout so all four mode labels remain readable without inventing a
Patrol-local selector. Patrol owns the default visible four-level
policy presentation (`Watch only`, `Ask first`, `Safe auto-fix`,
`Autopilot`), entitlement locks, and the rule that choosing the highest
Autopilot level sends `full_mode_unlocked:true` while choosing any lower level clears that
acknowledgement. The shared primitive owns pressed-state semantics,
Autopilot level opens the acknowledgement dialog and saves `full` only with a
recorded `acknowledgement_id` (the API derives the compatibility
`full_mode_unlocked` value itself), while saving any lower level lets the API
clear the Autopilot activation. The shared primitive owns pressed-state semantics,
disabled-option behavior, and active/inactive selector styling.
That same Patrol-owned presentation rule also applies to the findings empty
state: `frontend-modern/src/components/AI/FindingsPanel.tsx` must not treat
@@ -0,0 +1,41 @@
{
"version": 1,
"base_sha": "32fad38aa486ac116f885cb77e27317ea351c957",
"verified_at": "2026-10-07T23:52:39Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
],
"content_sha256": {
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "789f7e4a6f28e07d6d1d28abc29843ab02a90eef4f787fa024f0ba383abc8563"
},
"routes": [
"/patrol (header 'Mode and automation' disclosure)",
"/patrol (Activate Autopilot acknowledgement dialog)"
],
"viewports": [
{
"width": 1440,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Removal of the test-only resolvePatrolAutonomyLevelForSave / resolvePatrolAutonomySettingsForSave exports; no production code called them.",
"Embedded production builds (vite build into internal/api/frontend-modern/dist, go build) of the parent 32fad38aa4 and of this change, run on pulse-dev because local memory pressure refused heavy work. The two dists are byte-identical (187 files, every sha256 equal): Rollup had already dropped both helpers.",
"Each build on its own private DEMO_MODE mock stack with a fresh data dir, one at a time; the same Playwright script ran against both at both widths. Results compared field by field: 6 sessions, 80 recorded fields, 0 differences.",
"Paid entitlement (ai_autofix added to /api/license/runtime-capabilities via page.route): all four Patrol mode choices enabled; Ask first, Watch only and Autopilot each saved through PUT /api/ai/patrol/autonomy (stubbed 200 plus a GET reconcile rewrite); Autopilot opened the acknowledgement dialog with no PUT until 'Record acknowledgement and activate' (one stubbed acknowledgement POST). Every PUT body carried only autonomy_level, investigation_budget, investigation_timeout_sec and, for Autopilot, acknowledgement_id; none carried full_mode_unlocked.",
"Runtime lock under demo presentation (ai_autofix blocked with paid_runtime_required): demo forces hideCommercial, so the header shows Watch only with no mode selector and no save path.",
"Runtime lock with the demo presentation flags cleared in the browser (GET /api/security/status rewritten): 'Pulse Pro runtime required' notice, Watch only selected, Ask first / Safe auto-fix / Autopilot disabled; forced clicks on them sent no PUT and opened no Autopilot dialog; Watch only sent exactly autonomy_level monitor plus the investigation budget and timeout.",
"Console/network noise identical on both builds and unrelated to the change: 403 POST /api/ai/workflow-prompts/activity (demo write guard on the starter record) in the paid sessions, 404 GET /api/license/commercial-posture in the cleared-presentation sessions."
],
"interactions": [
"Demo auto sign-in; open /patrol, expand 'Mode and automation', read the selector's pressed and disabled states.",
"Paid: click Ask first, then Watch only, then Autopilot (re-clicking only while the previous save was still reconciling), tick the acknowledgement checkbox and click 'Record acknowledgement and activate'.",
"Locked (selector visible): force-click Ask first, Safe auto-fix and Autopilot, then click Watch only.",
"Desktop 1440x900 and phone 390x844 contexts; screenshots of the initial, Autopilot dialog and final states inspected."
]
}
@@ -5,8 +5,6 @@ import {
getPatrolSavedReadinessWarning,
patrolStartFailureMessage,
recordPatrolControlStarterActivity,
resolvePatrolAutonomyLevelForSave,
resolvePatrolAutonomySettingsForSave,
resolvePatrolBlockedActionCause,
} from '../usePatrolIntelligenceState';
import patrolIntelligenceStateSource from '../usePatrolIntelligenceState.ts?raw';
@@ -80,6 +78,17 @@ describe('usePatrolIntelligenceState', () => {
expect(patrolIntelligenceStateSource).toContain('if (requestId === refreshRequestId) {');
});
it('refuses paid Patrol modes in the state owner while governed fixes are locked', () => {
// The header disables paid choices too; these guards keep the state owner
// from starting a paid save if a caller reaches it anyway.
expect(patrolIntelligenceStateSource).toContain(
"if (controlLocked && level !== 'monitor') return;",
);
expect(patrolIntelligenceStateSource).toContain(
'if (isUpdatingAutonomy() || autoFixLocked()) return;',
);
});
it('keeps browser/network start failures distinct from backend rejections', () => {
expect(patrolStartFailureMessage(new TypeError('Failed to fetch'))).toBe(
'Could not reach Pulse to start Patrol: Failed to fetch',
@@ -206,78 +215,6 @@ describe('usePatrolIntelligenceState', () => {
);
});
describe('resolvePatrolAutonomyLevelForSave', () => {
it('clamps stale paid autonomy to monitor when governed fixes are locked', () => {
expect(resolvePatrolAutonomyLevelForSave('full', true, true)).toBe('monitor');
expect(resolvePatrolAutonomyLevelForSave('assisted', false, true)).toBe('monitor');
expect(resolvePatrolAutonomyLevelForSave('approval', false, true)).toBe('monitor');
});
it('preserves paid autonomy choices when governed fixes are available', () => {
expect(resolvePatrolAutonomyLevelForSave('assisted', false, false)).toBe('assisted');
expect(resolvePatrolAutonomyLevelForSave('assisted', true, false)).toBe('assisted');
expect(resolvePatrolAutonomyLevelForSave('full', true, false)).toBe('full');
expect(resolvePatrolAutonomyLevelForSave('full', false, false)).toBe('assisted');
expect(resolvePatrolAutonomyLevelForSave('approval', false, false)).toBe('approval');
});
});
describe('resolvePatrolAutonomySettingsForSave', () => {
it('clears stale full-mode state when governed fixes are locked', () => {
expect(
resolvePatrolAutonomySettingsForSave({
level: 'full',
fullModeUnlocked: true,
autoFixLocked: true,
}),
).toEqual({ autonomyLevel: 'monitor', fullModeUnlocked: false });
});
it('does not carry full-mode state into non-remediation modes', () => {
expect(
resolvePatrolAutonomySettingsForSave({
level: 'monitor',
fullModeUnlocked: true,
autoFixLocked: false,
}),
).toEqual({ autonomyLevel: 'monitor', fullModeUnlocked: false });
expect(
resolvePatrolAutonomySettingsForSave({
level: 'approval',
fullModeUnlocked: true,
autoFixLocked: false,
}),
).toEqual({ autonomyLevel: 'approval', fullModeUnlocked: false });
});
it('keeps full control tied to the explicit full selection', () => {
expect(
resolvePatrolAutonomySettingsForSave({
level: 'assisted',
fullModeUnlocked: true,
autoFixLocked: false,
}),
).toEqual({ autonomyLevel: 'assisted', fullModeUnlocked: false });
expect(
resolvePatrolAutonomySettingsForSave({
level: 'full',
fullModeUnlocked: true,
autoFixLocked: false,
}),
).toEqual({ autonomyLevel: 'full', fullModeUnlocked: true });
expect(
resolvePatrolAutonomySettingsForSave({
level: 'full',
fullModeUnlocked: false,
autoFixLocked: false,
}),
).toEqual({ autonomyLevel: 'assisted', fullModeUnlocked: false });
});
});
describe('getPatrolSavedReadinessWarning', () => {
it('stays quiet when the saved settings do not block Patrol readiness', () => {
expect(getPatrolSavedReadinessWarning(null)).toBeNull();
@@ -86,36 +86,6 @@ export const patrolStartFailureMessage = (error: unknown): string =>
? `Could not reach Pulse to start Patrol: ${patrolErrorMessage(error, 'network request failed')}`
: patrolErrorMessage(error, 'Pulse rejected the Patrol run');
export function resolvePatrolAutonomyLevelForSave(
level: PatrolAutonomyLevel,
fullModeUnlocked: boolean,
autoFixLocked: boolean,
): PatrolAutonomyLevel {
if (autoFixLocked) return 'monitor';
if (level === 'full') {
return fullModeUnlocked ? 'full' : 'assisted';
}
return level;
}
export function resolvePatrolAutonomySettingsForSave({
level,
fullModeUnlocked,
autoFixLocked,
}: {
level: PatrolAutonomyLevel;
fullModeUnlocked: boolean;
autoFixLocked: boolean;
}): { autonomyLevel: PatrolAutonomyLevel; fullModeUnlocked: boolean } {
const canUseFullMode = !autoFixLocked && level === 'full' && fullModeUnlocked;
const autonomyLevel = resolvePatrolAutonomyLevelForSave(level, canUseFullMode, autoFixLocked);
return {
autonomyLevel,
fullModeUnlocked: autonomyLevel === 'full',
};
}
/**
* A Patrol on/off save whose response reports Patrol not ready was still
* saved, so it raises a warning rather than an error.
@@ -1242,6 +1242,49 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByText(/Install the Pulse Pro runtime/)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Open Pro downloads' })).not.toBeInTheDocument();
});
it('saves only Watch only, without an unlock field, while governed fixes are locked', async () => {
useLicensedCommunityRuntime();
// A previous entitlement left a paid mode and the compatibility unlock in memory.
getPatrolAutonomySettingsMock.mockResolvedValue(
defaultPatrolAutonomySettings({
autonomy_level: 'assisted',
requested_autonomy_level: 'assisted',
effective_autonomy_level: 'assisted',
full_mode_unlocked: true,
}),
);
render(() => <AIIntelligence />);
const modes = within(await screen.findByRole('group', { name: 'Patrol mode' }));
await waitFor(() => expect(getPatrolAutonomySettingsMock).toHaveBeenCalled());
await getPatrolAutonomySettingsMock.mock.results[0]?.value;
await new Promise((resolve) => setTimeout(resolve, 0));
expect(modes.getByRole('button', { name: 'Watch only' })).toHaveAttribute(
'aria-pressed',
'true',
);
for (const paidMode of ['Ask first', 'Safe auto-fix', 'Autopilot']) {
expect(modes.getByRole('button', { name: paidMode })).toBeDisabled();
fireEvent.click(modes.getByRole('button', { name: paidMode }));
}
expect(screen.queryByRole('dialog', { name: 'Activate Autopilot' })).toBeNull();
expect(updatePatrolAutonomySettingsMock).not.toHaveBeenCalled();
fireEvent.click(modes.getByRole('button', { name: 'Watch only' }));
// The browser sends no full_mode_unlocked field; the API clears stale
// unlock and Autopilot activation state on every non-Autopilot save.
await waitFor(() => {
expect(updatePatrolAutonomySettingsMock).toHaveBeenCalledTimes(1);
});
expect(updatePatrolAutonomySettingsMock).toHaveBeenCalledWith({
autonomy_level: 'monitor',
investigation_budget: 15,
investigation_timeout_sec: 300,
});
});
});
it('keeps the Patrol model catalog out of the operator page', async () => {