Drop the unused Patrol readiness-failure object behind the toggle toast (#2687)

Stacked on #2686 (which stacks on #2682 and #2674); this branch carries
their commits until they land. Only the top two commits are new.

## What

`buildPatrolSettingsReadinessFailure` built a full
`PatrolConfigurationFailureInput` for the Patrol page's inline "needs
attention" state. That state lost its render in 27ba85ddc6 (2026-06-23),
when the Configure Patrol popover was removed. Since then its only
production caller, the Patrol on/off toggle, has read just `.message`,
and that was always the fixed copy "Patrol setting was saved, but Patrol
is not ready to run."

- `getPatrolSavedReadinessWarning` returns that message when the saved
response reports `patrol_readiness` `not_ready`, and the toggle shows it
as a warning toast, as before.
- `buildReadinessDetails` and the `PatrolConfigurationFailureInput` type
are removed. Its last other consumer,
`getPatrolConfigurationFailureInlineDetails`, goes in #2686.
- The Assistant drawer still restores saved
`patrol_configuration_failure` sessions. Nothing on that path changed.

## Runtime delta

- Production builds of the parent and this branch differ in one chunk,
`AIIntelligence.js` (the Patrol page), which is 935 bytes smaller. After
normalising chunk hashes, SRI strings and minified identifiers, the only
change is the removed builder and details helper, replaced by one status
check. The toast string is byte-identical.
- The minifier had already folded `message || readiness.summary ||
default` to the caller's constant, so the readiness-summary fallback was
unreachable.
- The toggle no longer reads seven Patrol signals to build the object it
discarded.
- `check-bundle-size.mjs` passes.

## Verification

The same Playwright script ran against embedded builds of the parent and
this branch, at 1440 and 390 px, on a demo mock stack. Responses were
stubbed with `page.route`. All 31 observed fields were identical between
the two builds. In every case only a toast appears, with no inline
alert, no "needs attention" text and no Assistant action:

- Patrol on/off, switched off and back on, with a not-ready response:
the warning toast fires in both directions.
- A rejected Patrol mode save shows an error toast with the server
reason, and the mode returns to Watch only. An accepted save switches to
Ask first with no toast.
- Settings > Pulse Intelligence > Patrol and Provider & Models saves
with a not-ready response show a warning toast naming the summary,
provider and model.
- When Patrol status reports not ready, the page's own readiness banner
names the summary, provider and model, and the header shows Fix setup.

Tests: `src/features`, `src/utils` and `src/__tests__` pass, except the
known Mac-only `agentInstallCommand.test.ts`. `tsc --noEmit` passes.
contract, registry and status audits pass.

## Tests and pins

- The hook tests now cover the warning helper.
- The #1789 test pinned `blockedCause: patrolStatus()?.blocked_cause`.
That matched only the removed dead field, not the `blockedCause` memo
the test was written for, so it now pins the memo.
- `frontendResourceTypeBoundaries` pins the new helper name.
- A negative control (mutating both pinned lines) fails all four
affected tests.

## Contracts

`patrol-intelligence`, `frontend-primitives` and `api-contracts` still
described the retired Patrol schedule and model popover, its inline
dialog failure state, and a saved-configuration-issue surface with cause
metadata. They now say:

- where Patrol settings live (Settings > Pulse Intelligence > Patrol,
and Settings > Provider & Models);
- that every save outcome is a toast;
- that the Patrol page's readiness banner carries the not-ready
diagnosis.

The popover rules apply only if a popover returns. The popover-era
alert-trigger clauses are handled separately in
docs/patrol-settings-contract-location.

A second, docs-only commit fixes the patrol-intelligence clause that
still placed the Patrol mode selector in a "configuration dialog". The
selector now renders only in the header's `Mode and automation`
disclosure, and that clause sits directly under #2682's hunk, so it
lands here rather than in docs/patrol-settings-contract-location to
avoid a conflict.
This commit is contained in:
rcourtman
2026-10-07 23:27:33 +01:00
committed by GitHub
8 changed files with 126 additions and 199 deletions
@@ -4039,12 +4039,16 @@ the authoritative analysis outcome.
`/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
echo `patrol_readiness` with stable `cause` metadata and execution routes
must continue to fail closed before model calls. Frontend Patrol settings
must continue to fail closed before model calls. Frontend settings
consumers must surface that saved-but-not-ready response as a saved
configuration issue with the echoed provider, model, cause, and summary
instead of reporting the successful save as a failed save or hiding the
readiness blocker behind a generic notification; and the structured
investigation-record contract, so unified findings may
configuration issue rather than a failed save, and never as inline failure
state: every Pulse Intelligence settings page (Provider & Models, Patrol,
Assistant, Service context) raises a warning notification naming the echoed
summary, provider, and model, while the Patrol page's on/off save raises a
fixed not-ready warning and leaves the diagnosis to the page's own readiness
surfaces, such as the readiness banner that names the summary, provider,
and model while the header offers `Fix setup` when Patrol is active; and
the structured investigation-record contract, so unified findings may
expose `investigation_record` only through the shared
`aicontracts.InvestigationRecord` payload shape, with frontend API types
and backend contract tests updated in the same slice as any field change.
@@ -3164,33 +3164,30 @@ Agent`), with the plain-language source phrase available through accessible
operator action or selected Patrol context, uses distinct monitor labels,
and does not become a nested card, generic dashboard strip, trust summary,
or duplicate Patrol empty-work list. The
Patrol schedule and model drawer is part of that shared
feature-presentation boundary: it must stay viewport-bounded, expose an
accessible dialog label, keep the four-level control policy on the default
Patrol header, and keep provider model, schedule, trigger tuning, and
readiness validation inside the secondary disclosure. Backend save rejection reasons must pass
through as inline dialog state instead of being replaced with generic toast
copy, and that advanced disclosure must open when the inline state exists.
When the failure includes
Patrol readiness context, the inline state must expose the provider, model,
and readiness summary next to a direct provider-settings action instead of
hiding that diagnosis behind Assistant alone. The provider-model selector in
that popover must stay bound to the shared runtime settings/model catalog
even when the popover mounts after async catalog loading, but the full
catalog must stay behind an explicit change action so the default advanced
drawer leads with the current effective model summary rather than a raw
provider route list. A saved direct-provider Patrol model still renders as
that model instead of visually falling back to the default selection.
Successful provider-model saves that return a not-ready Patrol
readiness snapshot must use that same inline surface with `needs attention`
wording, and any Assistant handoff from that state describes a saved
configuration issue rather than a failed save. When governed fixes are locked, the same 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. If that inline state opens Assistant, the Patrol
feature must hand off
a source-named, model-only briefing and close the popover so the shared
Assistant drawer is not visually hidden behind feature chrome. When a
Patrol page keeps the four-level control policy on its header and has no
schedule and model drawer: schedule, trigger tuning, and the Patrol model
override live on Settings > Pulse Intelligence > Patrol, and providers and
the shared default model on Settings > Provider & Models. Those saves
report through the shared notification toasts, not inline state: a
rejected save raises an error toast carrying the backend's concrete reason
rather than generic fallback copy, and a saved response whose Patrol
readiness is not ready or degraded raises a warning toast naming the
readiness summary, provider, and model instead of a success toast. On the
Patrol page, an on/off save whose response reports Patrol not ready raises
a fixed warning toast, and a failed Patrol mode save raises an error toast
and restores the previous mode; neither renders inline save-failure state
or offers an Assistant handoff. If a Patrol settings popover or inline
save-failure state returns, it must stay viewport-bounded, expose an
accessible dialog label, bind any model selector to the shared runtime
settings and model catalog, show readiness context (provider, model,
summary) next to a direct provider-settings action rather than behind
Assistant alone, and describe a saved but not ready response as a saved
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
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
@@ -1581,17 +1581,18 @@ suppress stale healthy summary headlines such as `Health A · 100/100` even if
the last summary payload still looks healthy. Legacy `/ai` entry points must
redirect into that same Patrol-owned shell rather than preserving a second
canonical route.
That same browser proof now covers the Patrol control and advanced-settings
split. The advanced Patrol settings drawer must stay within the desktop
viewport, avoid duplicating the inline Patrol control policy, expose
provider/model, schedule, trigger, and user-level model checks directly, and surface
the backend's concrete license/validation reason when a settings change is
rejected instead of replacing it with a generic `Failed to save advanced
settings` toast. That inline failure no longer
opens Assistant. If it does again, the handoff is model-only explanation
context: raw command, script, credential, and provider-detail payloads stay
redacted, Assistant opens with `autonomousMode:false`, and the Patrol control
panel closes so the operator is not left behind an overlapping popover.
That same browser proof now covers where Patrol settings are saved. The
Patrol page has no settings drawer or popover: Patrol schedule, triggers, and
the Patrol model override live on Settings > Pulse Intelligence > Patrol
(`Save Patrol settings`), and providers and the shared default model on
Settings > Provider & Models. A rejected save there raises an error
notification carrying the backend's concrete license/validation reason
instead of generic fallback copy, and it offers no Assistant handoff. If a
Patrol settings popover or an Assistant handoff from a settings failure
returns, the handoff is model-only explanation context: raw command, script,
credential, and provider-detail payloads stay redacted, Assistant opens with
`autonomousMode:false`, and the popover closes so the operator is not left
behind it.
Patrol-control save-failure sessions saved by earlier builds keep the
compatible `handoff_metadata.kind=patrol_configuration_failure` plus only the
runtime-failure boolean needed for drawer/session presentation, so they restore
@@ -1637,12 +1638,13 @@ ready raises a fixed warning notification, a failed Patrol mode save raises an e
notification, and neither offers an Assistant handoff. A provider or model save
in Settings > Provider & Models reports the readiness summary, provider and
model in its own save message.
The Patrol mode selector in that header and configuration dialog must
compose the shared `frontend-modern/src/components/shared/FilterButtonGroup.tsx`
instead of rebuilding a local active-button group. The wide default Patrol
header uses the segmented layout; the constrained configuration dialog may use
the shared prominent layout so all four mode labels remain readable without
inventing a Patrol-local selector. Patrol owns the default visible four-level
The Patrol mode selector in that header must compose the shared
`frontend-modern/src/components/shared/FilterButtonGroup.tsx` instead of
rebuilding a local active-button group. When the header shows Patrol mode
options, it renders the selector in the segmented layout inside its `Mode and
automation` disclosure; a constrained surface that hosts it may use the shared
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
@@ -0,0 +1,44 @@
{
"version": 1,
"base_sha": "421d1e5423e5b83b7923ea8c3b133ad520cd5f60",
"verified_at": "2026-10-07T15:20:38Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts"
],
"content_sha256": {
"frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts": "f1e249c36f0f0f9810330a37cc9a80cb43baa1cd123db1defd3f27720d39912a",
"frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts": "cb0a6962ed929238a974a3bbe5f69f4c89961ff30c88c2c94398073b9ef99a1c"
},
"routes": [
"/patrol",
"/patrol (Mode and automation disclosure)",
"/settings/pulse-intelligence/patrol",
"/settings/pulse-intelligence"
],
"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 (333e51d2de; rebased onto 421d1e5423, which changes only contract docs) and this tree, each on a private DEMO_MODE mock stack with a fresh data dir; the same Playwright script ran against both and produced identical results on all 31 observed fields at both widths.",
"Patrol on/off save whose PUT /api/settings/ai/update response (page.route) reports patrol_readiness not_ready: switching off and back on each raise only the warning toast 'Patrol setting was saved, but Patrol is not ready to run.' (role=alert inside the Notifications region, close button only); no role=alert outside the toast region, no 'needs attention' text, no readiness summary rendered inline, no Assistant action.",
"Failed Patrol mode save (Pro mode selector unlocked by adding ai_autofix to runtime-capabilities via page.route; PUT /api/ai/patrol/autonomy answered 500 {error}): only the error toast with the server reason; mode badge returns to Watch only; no inline error or 'needs attention' state.",
"Successful Patrol mode save (PUT 200, reconcile GET returns approval): Ask first becomes pressed and the badge reads Ask first; no new toast and no inline alert.",
"Settings > Pulse Intelligence > Patrol save (Schedule -> Every hour) and Settings > Provider & Models save with a not_ready response: only the warning toast '<saved label>, but Patrol is not ready: The selected model cannot run Patrol tools. \u00b7 Provider: OpenRouter \u00b7 Model: openrouter:deepseek/deepseek-r1'; no inline alert.",
"Patrol status with readiness not_ready (page.route on /api/ai/patrol/status): the page's own 'Patrol model issue' banner names summary, provider and model, and the header shows Fix setup, at both widths.",
"Dist proof: 187 files each side; after normalising chunk hashes and SRI strings only assets/AIIntelligence.js differs (935 bytes smaller): the removed failure builder, details helper and surface callback replaced by one status check, toast string byte-identical; check-bundle-size.mjs passes."
],
"interactions": [
"Demo sign-in; on /patrol click Toggle Patrol twice (off, then on) with a not-ready settings response; open 'Mode and automation' and click Ask first with a rejected save, then again with an accepted save; desktop mouse and phone touch (isMobile) contexts.",
"On /settings/pulse-intelligence/patrol select Schedule 'Every hour' and click Save Patrol settings; on /settings/pulse-intelligence click Save provider settings; both with a not-ready response.",
"Screenshots of each toast state captured and inspected at both widths."
]
}
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
PATROL_MANUAL_SYNC_TIMEOUT_MS,
PATROL_REFRESH_TIMEOUT_MS,
buildPatrolSettingsReadinessFailure,
getPatrolSavedReadinessWarning,
openPatrolAssistantWorkflowHandoff,
patrolStartFailureMessage,
recordPatrolControlStarterActivity,
@@ -338,24 +338,25 @@ describe('usePatrolIntelligenceState', () => {
});
});
describe('buildPatrolSettingsReadinessFailure', () => {
it('ignores settings snapshots that do not block Patrol readiness', () => {
describe('getPatrolSavedReadinessWarning', () => {
it('stays quiet when the saved settings do not block Patrol readiness', () => {
expect(getPatrolSavedReadinessWarning(null)).toBeNull();
expect(
buildPatrolSettingsReadinessFailure({
settings: settingsWithReadiness({
getPatrolSavedReadinessWarning(
settingsWithReadiness({
status: 'warning',
ready: true,
summary: 'Patrol can run with reduced confidence.',
checks: [],
}),
}),
),
).toBeNull();
});
it('builds a saved configuration issue from a not-ready settings response', () => {
it('warns that a not-ready Patrol setting was still saved', () => {
expect(
buildPatrolSettingsReadinessFailure({
settings: settingsWithReadiness({
getPatrolSavedReadinessWarning(
settingsWithReadiness({
status: 'not_ready',
ready: false,
cause: 'model_unsupported_tools',
@@ -364,40 +365,8 @@ describe('usePatrolIntelligenceState', () => {
model: 'ollama:deepseek-r1:7b',
checks: [],
}),
autonomyLevel: 'monitor',
fullModeUnlocked: false,
investigationBudget: 15,
investigationTimeoutSec: 300,
runtimeState: 'blocked',
blockedReason: 'Connect a tool-capable Patrol model.',
}),
).toEqual({
message: 'The selected model cannot run Patrol tools.',
code: 'patrol_readiness_not_ready',
status: 409,
saved: true,
details: {
status: 'not_ready',
cause: 'model_unsupported_tools',
summary: 'The selected model cannot run Patrol tools.',
provider: 'ollama',
model: 'ollama:deepseek-r1:7b',
},
autonomyLevel: 'monitor',
fullModeUnlocked: false,
investigationBudget: 15,
investigationTimeoutSec: 300,
readiness: {
status: 'not_ready',
cause: 'model_unsupported_tools',
summary: 'The selected model cannot run Patrol tools.',
provider: 'ollama',
model: 'ollama:deepseek-r1:7b',
},
runtimeState: 'blocked',
blockedReason: 'Connect a tool-capable Patrol model.',
blockedCause: undefined,
});
),
).toBe('Patrol setting was saved, but Patrol is not ready to run.');
});
});
@@ -410,6 +379,8 @@ describe('usePatrolIntelligenceState', () => {
'provider_not_configured',
);
expect(resolvePatrolBlockedActionCause('', '')).toBeUndefined();
expect(patrolIntelligenceStateSource).toContain('blockedCause: patrolStatus()?.blocked_cause');
expect(patrolIntelligenceStateSource).toContain(
'const blockedCause = createMemo(() => patrolStatus()?.blocked_cause);',
);
});
});
@@ -182,27 +182,6 @@ export interface PatrolRunAssistantHandoff {
context: AIChatContext;
}
export interface PatrolConfigurationFailureInput {
message: string;
code?: string;
status?: number;
saved?: boolean;
details?: Record<string, string>;
autonomyLevel?: string;
fullModeUnlocked?: boolean;
investigationBudget?: number;
investigationTimeoutSec?: number;
readiness?: {
status?: string;
cause?: string;
summary?: string;
provider?: string;
model?: string;
} | null;
runtimeState?: string;
blockedReason?: string;
blockedCause?: string;
}
const MAX_PATROL_RUN_HANDOFF_RESOURCES = 8;
export function buildPatrolInvestigationRecordPresentation(
@@ -62,7 +62,6 @@ import {
patrolAssistantFindingHandoffRequiresApprovalMode,
type PatrolAssistantApprovalBriefingInput,
type PatrolAssistantFindingHandoff,
type PatrolConfigurationFailureInput,
} from './patrolInvestigationContextModel';
import { schedulePatrolRunAcceptanceReconciliation } from './patrolRunAcceptance';
@@ -141,19 +140,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');
const buildReadinessDetails = (
readiness: NonNullable<AISettings['patrol_readiness']>,
): Record<string, string> => {
const details: Record<string, string> = {
status: readiness.status,
};
if (readiness.cause?.trim()) details.cause = readiness.cause.trim();
if (readiness.summary?.trim()) details.summary = readiness.summary.trim();
if (readiness.provider?.trim()) details.provider = readiness.provider.trim();
if (readiness.model?.trim()) details.model = readiness.model.trim();
return details;
};
export function resolvePatrolAutonomyLevelForSave(
level: PatrolAutonomyLevel,
fullModeUnlocked: boolean,
@@ -184,52 +170,16 @@ export function resolvePatrolAutonomySettingsForSave({
};
}
export function buildPatrolSettingsReadinessFailure({
settings,
message,
autonomyLevel,
fullModeUnlocked,
investigationBudget,
investigationTimeoutSec,
runtimeState,
blockedReason,
blockedCause,
}: {
settings: AISettings | null | undefined;
message?: string;
autonomyLevel?: string;
fullModeUnlocked?: boolean;
investigationBudget?: number;
investigationTimeoutSec?: number;
runtimeState?: string;
blockedReason?: string;
blockedCause?: string;
}): PatrolConfigurationFailureInput | null {
const readiness = settings?.patrol_readiness;
if (!readiness || readiness.status !== 'not_ready') return null;
return {
message:
message || readiness.summary || 'Patrol settings were saved, but Patrol is not ready to run.',
code: 'patrol_readiness_not_ready',
status: 409,
saved: true,
details: buildReadinessDetails(readiness),
autonomyLevel,
fullModeUnlocked,
investigationBudget,
investigationTimeoutSec,
readiness: {
status: readiness.status,
cause: readiness.cause,
summary: readiness.summary,
provider: readiness.provider,
model: readiness.model,
},
runtimeState,
blockedReason,
blockedCause,
};
/**
* A Patrol on/off save whose response reports Patrol not ready was still
* saved, so it raises a warning rather than an error.
*/
export function getPatrolSavedReadinessWarning(
settings: AISettings | null | undefined,
): string | null {
return settings?.patrol_readiness?.status === 'not_ready'
? 'Patrol setting was saved, but Patrol is not ready to run.'
: null;
}
/**
@@ -380,26 +330,6 @@ export function usePatrolIntelligenceState() {
applyPatrolAISettings(aiRuntimeSettings());
});
const surfaceSavedPatrolReadinessIssue = (
settings: AISettings | null | undefined,
message?: string,
) => {
const failure = buildPatrolSettingsReadinessFailure({
settings,
message,
autonomyLevel: autonomyLevel(),
fullModeUnlocked: fullModeUnlocked(),
investigationBudget: investigationBudget(),
investigationTimeoutSec: investigationTimeout(),
runtimeState: runtimeState(),
blockedReason: blockedReason(),
blockedCause: patrolStatus()?.blocked_cause,
});
if (failure) {
notificationStore.warning(failure.message);
}
};
async function handleTogglePatrol() {
if (isTogglingPatrol()) return;
setIsTogglingPatrol(true);
@@ -419,10 +349,10 @@ export function usePatrolIntelligenceState() {
} else {
setPatrolEnabledLocal(newValue);
}
surfaceSavedPatrolReadinessIssue(
data,
'Patrol setting was saved, but Patrol is not ready to run.',
);
const readinessWarning = getPatrolSavedReadinessWarning(data);
if (readinessWarning) {
notificationStore.warning(readinessWarning);
}
if (refetchPatrolStatus) {
refetchPatrolStatus();
}
@@ -3609,7 +3609,7 @@ describe('frontend resource type boundaries', () => {
expect(patrolIntelligenceStateSource).toMatch(
/import\s*\{[^}]*\bbuildPatrolAssistantFindingHandoffFromUnifiedFinding\b[^}]*\}\s*from '\.\/patrolInvestigationContextModel';/,
);
expect(patrolIntelligenceStateSource).toContain('buildPatrolSettingsReadinessFailure');
expect(patrolIntelligenceStateSource).toContain('getPatrolSavedReadinessWarning');
expect(patrolIntelligenceStateSource).not.toContain('recent_changes?.length');
expect(patrolIntelligenceStateSource).not.toContain('governed resource${');
expect(patrolInvestigationContextModelSource).toContain(