Remove test-only Patrol autonomy save helpers

resolvePatrolAutonomyLevelForSave and resolvePatrolAutonomySettingsForSave
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 afterwards. Rollup already dropped both
helpers, so the production build is byte-identical.

Several contract sentences 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.
The monitor-only handler and the paid SaveAutonomySettings adapter clear
the unlock and the Autopilot activation on every save below Autopilot,
and the autonomy gate derives the compatibility flag from a recorded
acknowledgement. Rewrite those sentences in patrol-intelligence,
api-contracts, frontend-primitives and ai-runtime to say what the header,
the state owner and the API each do. Two neighbouring api-contracts
sentences were also wrong about the same save: a `full` request without
a current acknowledgement gets 409 acknowledgement_required from the gate
before any licence check, and the Patrol mode dialog they name was
removed in 27ba85ddc6.

Replace the deleted helper tests with tests of the live locked path. The
page test loads stale paid state (Safe auto-fix plus the compatibility
unlock) under a runtime lock and checks that the header presents Watch
only, 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, which the disabled buttons otherwise hide from the page test.
This commit is contained in:
rcourtman
2026-10-08 01:10:22 +01:00
parent 32fad38aa4
commit 776c2774ba
8 changed files with 148 additions and 136 deletions
@@ -4195,10 +4195,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
@@ -3347,8 +3347,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
@@ -4028,14 +4028,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
@@ -9641,19 +9647,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,
@@ -3185,9 +3185,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
@@ -1631,12 +1631,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
@@ -1651,8 +1653,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.
@@ -1249,6 +1249,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 () => {