Explain why the Container update risk switch is locked (#2541)

Patrol settings showed the Container update risk switch dimmed on
installs without ai_alerts and gave no reason, so a free install could
not tell a plan limit from a broken setting.

The card now renders the canonical Patrol availability presentation.
patrolAutonomyAvailability.ts keeps one precedence and the only action
labels, while Patrol mode and container update risk each supply their
lock state and copy. Links appear only when the session allows upgrade
prompts. The explanation is tied to the switch with aria-describedby,
because the shared Toggle does not render its locked/lockedMessage
props.

The second commit makes hidden commercial surfaces win over the runtime
lock, and the Patrol header and banners now pass the session's real
presentation policy instead of a constant. Demo and white-label sessions
on the community runtime previously saw "Pulse Pro runtime required" on
the card and "Install the Pulse Pro runtime" inside the header's mode
disclosure; they now get the neutral lock. Sessions that show commercial
surfaces keep the runtime prompt and download link unchanged.
This commit is contained in:
rcourtman
2026-10-05 23:57:17 +01:00
committed by GitHub
16 changed files with 700 additions and 35 deletions
@@ -3187,6 +3187,14 @@ a new API state machine, queue contract, or verification-accounting field.
proof. The default API-backed presentation must stay on the selected control
level and its plain summary; it must not render a separate Limits disclosure,
hard-limit matrix, or always-on explanatory matrix beside the mode picker.
and the Patrol container update risk lock, so `/api/settings/ai`
`alert_triggered_analysis` stays the licence-masked API value while the
runtime-capabilities `ai_alerts` grant or `paid_runtime_required` block
decides which lock explanation the Patrol settings card renders, without a
new API field. A `paid_runtime_required` block is presentation input only:
when the security-status presentation policy hides commercial surfaces,
Patrol mode and container update risk both render their neutral lock and
the browser must not surface the block's Pro download URL
and the Patrol run-record copy, so `finding_ids` remains the API-owned
fail-closed scoping input while the frontend presents selected history as a
Patrol run record instead of a generic findings filter or snapshot workflow
@@ -4738,6 +4738,16 @@ stable accessible label and route unconfigured installs into the canonical
provider setup modal instead of falling back to generic "first pressed toggle"
selectors, provider-model-load heuristics, legacy hosted-model enablement, or
in-app trial acquisition.
A settings toggle disabled by a plan or runtime lock must explain the lock in
the card that owns it, not through the shared `Toggle` `locked` and
`lockedMessage` props, which the primitive does not render. `AISettings.tsx`
renders the Container update risk lock from the canonical Patrol availability
presentation and links that explanation to the switch with `ariaDescribedBy`.
The shared Patrol feature shells (`PatrolIntelligenceHeader.tsx` and
`PatrolIntelligenceBanners.tsx`) and that card pass the session's real
commercial and upgrade presentation policy into the presentation, never a
constant, so a session that hides commercial surfaces gets the neutral lock
line and never Pulse Pro runtime wording or a download link.
That same route-owned presentation rule also governs Patrol findings empty
states: shared section shells under `frontend-modern/src/features/patrol/`
must not render a green healthy empty state from `0 active findings` alone
@@ -725,6 +725,21 @@ attention`, `approval needed`, `outcome verified`, `no active work`) instead
sentence across the header and mode control, repeatedly restate
infrastructure-unchanged caveats in the default view, or imply Patrol's main
value is a manual review chore.
Plan-gated Patrol capabilities share that one availability presentation:
`patrolAutonomyAvailability.ts` owns the precedence (hidden commercial
surfaces, then runtime lock, then plan lock) and the only action labels
(`Open Pro downloads`, `Plans & Billing`), while each capability supplies
its lock state and copy. Hidden commercial surfaces win over the runtime
lock, so demo and white-label sessions never see Pulse Pro runtime wording,
and every caller (the Patrol header, the Patrol banners and Patrol
settings) passes the session's real `presentationPolicyHidesCommercialSurfaces()`
and `presentationPolicyHidesUpgradePrompts()` values instead of a constant.
The Patrol settings Container update risk trigger (`ai_alerts`) uses
`getPatrolAlertAnalysisAvailabilityPresentation`, so a locked install sees
why the trigger is unavailable beside the disabled switch: a higher
license plan, a missing Pulse Pro runtime, or, when commercial surfaces are
hidden, a neutral not-available line with no plan or Pro wording. Actions
appear only when upgrade prompts are allowed.
5. Add or change Patrol header schedule and runtime presentation through `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, `frontend-modern/src/utils/aiPatrolSchedulePresentation.ts`, and `frontend-modern/src/utils/patrolRuntimePresentation.ts`.
Patrol must not surface retired hosted-model credit badges or trial-like activation prompts in the normal self-hosted GA app, even when legacy transport fields are still present.
6. Keep Patrol and chat identifier-label presentation aligned through the shared `frontend-modern/src/utils/textPresentation.ts`
@@ -0,0 +1,42 @@
{
"version": 1,
"base_sha": "357b1a505e80bb1cafbfc9cb02f36f96182c7b57",
"verified_at": "2026-10-05T21:37:58Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts"
],
"content_sha256": {
"frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx": "89bce4990f82104e8724e4e099a3ae06a275cf1a2d5ab875670f9bc3b4cefcff",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "6b899aaebeba642fd5bb792146f27302d29542a4f4ced548f71168398101faf3",
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "3d334beb4135c9c53027b79debd869c403db627f207bfb54ab004d5d191f99b0"
},
"routes": [
"/settings/pulse-intelligence/patrol",
"/patrol"
],
"viewports": [
{
"width": 1280,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private community-runtime stack built from this tree (backend 7782, Vite 5282 serving this checkout), mock data, dev-signed licences switched live on the same backend. Playwright Chromium at 1280x900 dark and 390x844 light.",
"Enterprise licence on the community runtime (white-label: hideCommercial and hideUpgrade true; ai_alerts and ai_autofix blocked as paid_runtime_required): the Container update risk card reads 'Not available. This install does not include container update risk.' with no link, and the Patrol header shows the plain Watch only line with no Mode and automation disclosure, no paid-mode picker, no Pulse Pro runtime wording and no download link.",
"Same stack and licence with the previous patrolAutonomyAvailability.ts, PatrolIntelligenceHeader.tsx and PatrolIntelligenceBanners.tsx swapped back in under the running Vite: the card showed 'Pulse Pro runtime required...' and the expanded Mode and automation disclosure showed 'Install the Pulse Pro runtime to use Patrol modes' beside a disabled paid-mode picker. Restored afterwards.",
"Pro licence on the community runtime (commercial and upgrade prompts visible): unchanged. Card shows the runtime lock with Open Pro downloads; the Patrol page still shows the runtime prompt and the https://pulserelay.pro/download.html link with the mode picker.",
"Free community install (licence cleared; hideUpgrade true, hideCommercial false): unchanged. Card shows the higher-license-plan line with no link; Patrol header shows plain Watch only.",
"No horizontal overflow and no page errors in any state."
],
"interactions": [
"Expanded the Patrol header 'Mode and automation' disclosure where present to read the runtime note and mode picker.",
"Activated an Enterprise licence, a Pro licence and cleared the licence on the same backend, reloading both routes for each state."
]
}
@@ -0,0 +1,42 @@
{
"version": 1,
"base_sha": "1dcf9418aae87f9427dbfc6bcae67dbde114bf8a",
"verified_at": "2026-10-05T21:16:11Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Settings/AISettings.tsx",
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts"
],
"content_sha256": {
"frontend-modern/src/components/Settings/AISettings.tsx": "d3966224e497b9f8f3cd701bea1661212c5f32c9cc15ff795c4001f851fae6ca",
"frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts": "240fe0f1988d881da4344811aa3fdcc8e452182da24414ca6c941ea72e8e545f"
},
"routes": [
"/settings/pulse-intelligence/patrol",
"/settings/pulse-intelligence/billing/plan"
],
"viewports": [
{
"width": 1280,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"Private stacks built from this tree: community runtime (7782/5282), community DEMO_MODE runtime (7783/5283) and a Pro runtime built from pulse-enterprise against this tree (7784/5284), mock data, dev-signed Pro and Enterprise licences. Playwright Chromium at 1280x900 dark and 390x844 light, plus the in-app browser pane.",
"Free community install (no licence, hideUpgrade true, hideCommercial false, ai_alerts absent, /api/settings/ai alert_triggered_analysis false via a real persisted save): switch disabled and off, card reads 'Higher license plan required. This install's plan does not include container update risk.' with no link; the switch's aria-describedby points at that line.",
"Unlicensed Pro runtime (prompts allowed, ai_alerts not granted, backend licence checker masks the setting to false): same plan-lock line plus a Plans & Billing link to /settings/pulse-intelligence/billing/plan.",
"Pro licence on the community runtime (ai_alerts blocked as paid_runtime_required): 'Pulse Pro runtime required. This install is running Pulse Community runtime. Install the Pulse Pro runtime to use container update risk.' plus Open Pro downloads (https://pulserelay.pro/download.html, new tab, noopener).",
"Hidden commercial surfaces via DEMO_MODE (hideCommercial and hideUpgrade true; read-only demo, so GET /api/settings/ai intercepted to alert_triggered_analysis false): 'Not available. This install does not include container update risk.' with no plan or Pro wording and no link.",
"Enterprise licence on the community runtime (white-label, hideCommercial true, ai_alerts runtime-blocked): runtime lock copy without the download link, which is the canonical runtime-before-commercial precedence shared with Patrol mode.",
"Pro licence on the Pro runtime: switch enabled, no lock line, no aria-describedby. No horizontal overflow and no page errors in any state; the disabled track styling is the pre-#2535 shared Toggle and is unchanged here."
],
"interactions": [
"Clicked the Plans & Billing link from the locked card in the browser pane; it routed in-app to /settings/pulse-intelligence/billing/plan.",
"On the licensed Pro runtime, turned the container update risk switch on and saved Patrol settings; GET /api/settings/ai then returned alert_triggered_analysis true.",
"Switched licence state live (activate Pro, activate Enterprise, clear) against the same backend and reloaded the page for each state."
]
}
@@ -1,4 +1,4 @@
import { Component, For, createMemo, Show } from 'solid-js';
import { Component, For, createMemo, createUniqueId, Show } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { AIChatMaintenanceSection } from '@/components/Settings/AIChatMaintenanceSection';
import { AISettingsDialogs } from '@/components/Settings/AISettingsDialogs';
@@ -18,8 +18,13 @@ import { FormSelect } from '@/components/shared/FormSelect';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
import { UpgradeLink } from '@/components/shared/UpgradeLink';
import { PATROL_PATH } from '@/routing/resourceLinks';
import { getPatrolAutonomyAvailabilityPresentation } from '@/features/patrol/patrolAutonomyAvailability';
import {
PATROL_ALERT_ANALYSIS_FEATURE_KEY,
getPatrolAlertAnalysisAvailabilityPresentation,
getPatrolAutonomyAvailabilityPresentation,
} from '@/features/patrol/patrolAutonomyAvailability';
import { getRuntimeCapabilityBlock, hasFeature, runtimeCapabilities } from '@/stores/license';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import {
@@ -115,6 +120,17 @@ const PatrolSettingsContent: Component<{ state: ReturnType<typeof useAISettingsS
}),
);
const isPlanLockedPatrol = createMemo(() => patrolModeAvailability().kind === 'plan_locked');
const alertAnalysisAvailability = createMemo(() =>
getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: props.state.alertAnalysisLocked(),
commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),
upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(),
runtimeCapabilityBlock: getRuntimeCapabilityBlock(PATROL_ALERT_ANALYSIS_FEATURE_KEY),
runtime: runtimeCapabilities()?.runtime,
planUpgradeDestination: getUpgradeActionDestination(PATROL_ALERT_ANALYSIS_FEATURE_KEY),
}),
);
const alertAnalysisLockId = createUniqueId();
const intervalOptions = [
{ value: 0, label: 'Manual only' },
{ value: 60, label: 'Every hour' },
@@ -270,14 +286,38 @@ const PatrolSettingsContent: Component<{ state: ReturnType<typeof useAISettingsS
<p class="mt-1 text-xs text-muted">
Assess risk when container-update alerts fire.
</p>
<Show when={alertAnalysisAvailability().locked}>
<p id={alertAnalysisLockId} class="mt-1 text-xs text-muted">
<span class="font-medium text-base-content">
{alertAnalysisAvailability().title}.
</span>{' '}
{alertAnalysisAvailability().body}
<Show when={alertAnalysisAvailability().destination}>
{(destination) => (
<>
{' '}
<UpgradeLink
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
destination={destination()}
>
{alertAnalysisAvailability().actionLabel}
</UpgradeLink>
</>
)}
</Show>
</p>
</Show>
</div>
<Toggle
checked={props.state.form.alertTriggeredAnalysis}
onChange={(event) =>
props.state.setForm('alertTriggeredAnalysis', event.currentTarget.checked)
}
disabled={props.state.saving() || props.state.alertAnalysisLocked()}
disabled={props.state.saving() || alertAnalysisAvailability().locked}
ariaLabel="Enable container update risk analysis"
ariaDescribedBy={
alertAnalysisAvailability().locked ? alertAnalysisLockId : undefined
}
/>
</div>
</div>
@@ -37,6 +37,8 @@ const loadCommercialPostureMock = vi.fn();
const commercialPostureMock = vi.fn();
const entitlementsMock = vi.fn();
const presentationPolicyHidesUpgradePromptsMock = vi.fn();
const presentationPolicyHidesCommercialSurfacesMock = vi.fn();
const getRuntimeCapabilityBlockMock = vi.fn();
vi.mock('@/api/ai', () => ({
AIAPI: {
@@ -95,7 +97,7 @@ vi.mock('@/utils/logger', () => ({
vi.mock('@/stores/license', () => ({
hasFeature: (...args: unknown[]) => hasFeatureMock(...args),
getRuntimeCapabilityBlock: () => undefined,
getRuntimeCapabilityBlock: (...args: unknown[]) => getRuntimeCapabilityBlockMock(...args),
loadRuntimeCapabilities: (...args: unknown[]) => loadLicenseStatusMock(...args),
runtimeCapabilities: () => ({ capabilities: [], runtime: undefined }),
}));
@@ -110,7 +112,7 @@ vi.mock('@/stores/licenseCommercial', () => ({
}));
vi.mock('@/stores/sessionPresentationPolicy', () => ({
presentationPolicyHidesCommercialSurfaces: () => false,
presentationPolicyHidesCommercialSurfaces: () => presentationPolicyHidesCommercialSurfacesMock(),
presentationPolicyHidesUpgradePrompts: () => presentationPolicyHidesUpgradePromptsMock(),
}));
@@ -181,6 +183,8 @@ const resetAllMocks = () => {
commercialPostureMock.mockReset();
entitlementsMock.mockReset();
presentationPolicyHidesUpgradePromptsMock.mockReset();
presentationPolicyHidesCommercialSurfacesMock.mockReset();
getRuntimeCapabilityBlockMock.mockReset();
};
const setupDefaultMocks = () => {
@@ -212,6 +216,8 @@ const setupDefaultMocks = () => {
listSessionsMock.mockResolvedValue([]);
summarizeSessionMock.mockResolvedValue(undefined);
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(false);
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(false);
getRuntimeCapabilityBlockMock.mockReturnValue(undefined);
};
describe('AISettings model loading error states', () => {
@@ -350,6 +356,132 @@ describe('AISettings model loading error states', () => {
expect(screen.getByRole('button', { name: /Open Patrol/i })).toBeInTheDocument();
});
describe('container update risk lock', () => {
const lockedPatrolSettings = () => ({
...baseSettings(),
enabled: true,
configured: true,
// The backend masks this to false when the install lacks ai_alerts.
alert_triggered_analysis: false,
});
const containerRiskToggle = () =>
screen.getByLabelText('Enable container update risk analysis');
const lockExplanation = () => {
const describedBy = containerRiskToggle().getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
return document.getElementById(describedBy!)!;
};
it('explains a plan lock without an upgrade link on a free install', async () => {
hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts');
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
getSettingsMock.mockResolvedValue(lockedPatrolSettings());
renderComponent('patrol');
await screen.findByLabelText('Enable container update risk analysis');
expect(containerRiskToggle()).toBeDisabled();
expect(lockExplanation()).toHaveTextContent(
"Higher license plan required. This install's plan does not include container update risk.",
);
expect(within(lockExplanation()).queryByRole('link')).toBeNull();
expect(screen.queryByRole('link', { name: 'Plans & Billing' })).toBeNull();
});
it('links Plans & Billing when upgrade prompts are allowed', async () => {
hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts');
getSettingsMock.mockResolvedValue(lockedPatrolSettings());
renderComponent('patrol');
await screen.findByLabelText('Enable container update risk analysis');
expect(containerRiskToggle()).toBeDisabled();
const link = within(lockExplanation()).getByRole('link', { name: 'Plans & Billing' });
expect(link).toHaveAttribute('href', 'https://example.com/upgrade');
});
it('drops plan and Pro wording when commercial surfaces are hidden', async () => {
hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts');
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true);
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
getSettingsMock.mockResolvedValue(lockedPatrolSettings());
renderComponent('patrol');
await screen.findByLabelText('Enable container update risk analysis');
expect(containerRiskToggle()).toBeDisabled();
expect(lockExplanation()).toHaveTextContent(
'Not available. This install does not include container update risk.',
);
expect(lockExplanation().textContent).not.toMatch(/plan|Pro\b/i);
expect(within(lockExplanation()).queryByRole('link')).toBeNull();
});
it('points a licensed community runtime at the Pro runtime download', async () => {
hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts');
getRuntimeCapabilityBlockMock.mockImplementation((feature: string) =>
feature === 'ai_alerts'
? {
key: 'ai_alerts',
reason: 'paid_runtime_required',
action_url: 'https://pulserelay.pro/download.html',
}
: undefined,
);
getSettingsMock.mockResolvedValue(lockedPatrolSettings());
renderComponent('patrol');
await screen.findByLabelText('Enable container update risk analysis');
expect(lockExplanation()).toHaveTextContent(
'Pulse Pro runtime required. This install is running this runtime. Install the Pulse Pro runtime to use container update risk.',
);
expect(
within(lockExplanation()).getByRole('link', { name: 'Open Pro downloads' }),
).toHaveAttribute('href', 'https://pulserelay.pro/download.html');
});
it('keeps Pro runtime wording out when commercial surfaces are hidden', async () => {
hasFeatureMock.mockImplementation((feature: string) => feature !== 'ai_alerts');
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true);
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
getRuntimeCapabilityBlockMock.mockImplementation((feature: string) =>
feature === 'ai_alerts'
? {
key: 'ai_alerts',
reason: 'paid_runtime_required',
action_url: 'https://pulserelay.pro/download.html',
}
: undefined,
);
getSettingsMock.mockResolvedValue(lockedPatrolSettings());
renderComponent('patrol');
await screen.findByLabelText('Enable container update risk analysis');
expect(containerRiskToggle()).toBeDisabled();
expect(lockExplanation()).toHaveTextContent(
'Not available. This install does not include container update risk.',
);
expect(lockExplanation().textContent).not.toMatch(/\bPro\b|runtime|plan/i);
expect(within(lockExplanation()).queryByRole('link')).toBeNull();
});
it('shows no lock explanation when the install includes the feature', async () => {
getSettingsMock.mockResolvedValue({
...lockedPatrolSettings(),
alert_triggered_analysis: true,
});
renderComponent('patrol');
await screen.findByLabelText('Enable container update risk analysis');
expect(containerRiskToggle()).toBeEnabled();
expect(containerRiskToggle()).not.toHaveAttribute('aria-describedby');
expect(screen.queryByText(/Higher license plan required/)).toBeNull();
});
});
it('saves Patrol trigger settings from Pulse Intelligence Patrol settings', async () => {
getSettingsMock.mockResolvedValue({
...baseSettings(),
@@ -1459,6 +1459,35 @@ describe('settings architecture guardrails', () => {
);
});
it('explains the container update risk lock through the canonical Patrol availability policy', () => {
expect(aiSettingsSource).toContain('getPatrolAlertAnalysisAvailabilityPresentation({');
expect(aiSettingsSource).toContain(
'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),',
);
expect(aiSettingsSource).toContain(
'upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(),',
);
expect(aiSettingsSource).toContain(
'runtimeCapabilityBlock: getRuntimeCapabilityBlock(PATROL_ALERT_ANALYSIS_FEATURE_KEY),',
);
expect(aiSettingsSource).toContain(
'planUpgradeDestination: getUpgradeActionDestination(PATROL_ALERT_ANALYSIS_FEATURE_KEY),',
);
expect(aiSettingsSource).toContain(
'disabled={props.state.saving() || alertAnalysisAvailability().locked}',
);
expect(aiSettingsSource).toContain(
'<p id={alertAnalysisLockId} class="mt-1 text-xs text-muted">',
);
expect(aiSettingsSource).toContain(
'alertAnalysisAvailability().locked ? alertAnalysisLockId : undefined',
);
// The shared Toggle declares locked/lockedMessage but never renders them,
// so the card must carry the explanation itself.
expect(aiSettingsSource).not.toContain('lockedMessage=');
expect(aiSettingsSource).not.toContain('Requires Pulse Pro');
});
it('keeps system AI save feedback tied to provider and Patrol readiness context', () => {
expect(aiSettingsSource).toContain('Choose a Patrol mode on the Patrol page.');
expect(aiSettingsSource).toContain('Open Patrol');
@@ -6,7 +6,10 @@ import SettingsIcon from 'lucide-solid/icons/settings';
import { AnimatedNumber } from '@/components/shared/AnimatedNumber';
import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import {
presentationPolicyHidesCommercialSurfaces,
presentationPolicyHidesUpgradePrompts,
} from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { getAIProviderDisplayName } from '@/utils/aiProviderPresentation';
import { getPatrolSetupAction } from '@/utils/patrolRuntimeActions';
@@ -35,6 +38,8 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat
const autonomyAvailability = createMemo(() =>
getPatrolAutonomyAvailabilityPresentation({
autoFixLocked: state.autoFixLocked(),
commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),
upgradePromptsHidden: presentationPolicyHidesUpgradePrompts(),
runtimeCapabilityBlock: state.autoFixCapabilityBlock(),
runtime: state.licenseRuntimeIdentity(),
planUpgradeDestination: getUpgradeActionDestination('ai_autofix'),
@@ -13,7 +13,10 @@ import { UpgradeButtonLink } from '@/components/shared/UpgradeLink';
import type { PatrolAutonomyLevel } from '@/api/patrol';
import { settingsTabPath } from '@/components/Settings/settingsNavigationModel';
import { getUpgradeActionDestination } from '@/stores/licenseCommercial';
import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy';
import {
presentationPolicyHidesCommercialSurfaces,
presentationPolicyHidesUpgradePrompts,
} from '@/stores/sessionPresentationPolicy';
import { formatRelativeTime } from '@/utils/format';
import { getPatrolPageHeaderMeta } from '@/utils/patrolPagePresentation';
import { getPatrolTriggerStatusSummary } from '@/utils/patrolRunPresentation';
@@ -146,7 +149,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
getPatrolAutonomyAvailabilityPresentation({
autoFixLocked: state.autoFixLocked(),
upgradePromptsHidden: upgradePromptsHidden(),
commercialSurfacesHidden: true,
commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),
runtimeCapabilityBlock: state.autoFixCapabilityBlock(),
runtime: state.licenseRuntimeIdentity(),
planUpgradeDestination: getUpgradeActionDestination('ai_autofix'),
@@ -8,6 +8,7 @@ import {
getPatrolConfigurationFailureInlineDetails,
} from '../PatrolIntelligenceHeader';
import {
getPatrolAlertAnalysisAvailabilityPresentation,
getPatrolAutonomyAvailabilityPresentation,
PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON,
} from '../patrolAutonomyAvailability';
@@ -250,7 +251,10 @@ describe('PatrolIntelligenceHeader', () => {
external: false,
},
});
expect(headerSource).toContain('commercialSurfacesHidden: true');
expect(headerSource).toContain(
'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),',
);
expect(headerSource).not.toContain('commercialSurfacesHidden: true');
expect(headerSource).not.toContain('showAutonomyPlanBillingAction');
expect(headerSource).not.toContain('CreditCardIcon');
expect(headerSource).not.toContain('Plans & Billing');
@@ -312,6 +316,43 @@ describe('PatrolIntelligenceHeader', () => {
});
});
it('explains the container update risk lock through the same presentation policy', () => {
const planUpgradeDestination = {
href: '/settings/pulse-intelligence/billing/plan',
external: false,
};
expect(
getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: true,
planUpgradeDestination,
}),
).toMatchObject({
kind: 'plan_locked',
title: 'Higher license plan required',
actionLabel: 'Plans & Billing',
destination: planUpgradeDestination,
});
expect(
getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: true,
upgradePromptsHidden: true,
planUpgradeDestination,
}),
).not.toHaveProperty('actionLabel');
expect(
getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: true,
commercialSurfacesHidden: true,
planUpgradeDestination,
}),
).toEqual({
kind: 'plan_locked',
locked: true,
title: 'Not available',
body: 'This install does not include container update risk.',
});
});
it('keeps trust counters out of the page header chrome', () => {
// Needs Attention and deliberate history review own trust
// state. The header should stay focused on title, recency, and controls.
@@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest';
import type { LicenseRuntimeCapabilityBlock, LicenseRuntimeIdentity } from '@/api/license';
import {
PATROL_ALERT_ANALYSIS_FEATURE_KEY,
PATROL_AUTONOMY_FEATURE_KEY,
PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON,
getPatrolAlertAnalysisAvailabilityPresentation,
getPatrolAutonomyAvailabilityPresentation,
} from '../patrolAutonomyAvailability';
import type { UpgradeDestination } from '@/utils/upgradeNavigation';
@@ -53,6 +55,10 @@ describe('patrolAutonomyAvailability', () => {
expect(PATROL_AUTONOMY_FEATURE_KEY).toBe('ai_autofix');
});
it('exposes the ai_alerts feature key', () => {
expect(PATROL_ALERT_ANALYSIS_FEATURE_KEY).toBe('ai_alerts');
});
it('exposes the paid_runtime_required reason', () => {
expect(PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON).toBe('paid_runtime_required');
});
@@ -87,7 +93,7 @@ describe('patrolAutonomyAvailability', () => {
it.each([
[
'runtime_locked beats commercialSurfacesHidden',
'commercialSurfacesHidden beats runtime_locked',
{
autoFixLocked: true,
runtimeCapabilityBlock: runtimeBlock(),
@@ -96,6 +102,17 @@ describe('patrolAutonomyAvailability', () => {
upgradePromptsHidden: true,
planUpgradeDestination: planDestination,
},
'plan_locked',
],
[
'runtime_locked when commercial surfaces are visible',
{
autoFixLocked: true,
runtimeCapabilityBlock: runtimeBlock(),
runtime: runtime(),
upgradePromptsHidden: true,
planUpgradeDestination: planDestination,
},
'runtime_locked',
],
[
@@ -319,4 +336,152 @@ describe('patrolAutonomyAvailability', () => {
});
});
});
describe('getPatrolAlertAnalysisAvailabilityPresentation', () => {
const alertRuntimeBlock = runtimeBlock({ key: PATROL_ALERT_ANALYSIS_FEATURE_KEY });
it('is available when alert analysis is not locked, ignoring every other field', () => {
expect(
getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: false,
runtimeCapabilityBlock: alertRuntimeBlock,
commercialSurfacesHidden: true,
planUpgradeDestination: planDestination,
}),
).toEqual({
kind: 'available',
locked: false,
title: 'Container update risk available',
body: 'Assess risk when container-update alerts fire.',
});
});
it('explains a plan lock and offers Plans & Billing when surfaces are visible', () => {
expect(
getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: true,
planUpgradeDestination: planDestination,
}),
).toEqual({
kind: 'plan_locked',
locked: true,
title: 'Higher license plan required',
body: "This install's plan does not include container update risk.",
actionLabel: 'Plans & Billing',
destination: planDestination,
});
});
it('keeps the plan explanation but drops the action when upgrade prompts are hidden', () => {
const result = getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: true,
upgradePromptsHidden: true,
planUpgradeDestination: planDestination,
});
expect(result).toEqual({
kind: 'plan_locked',
locked: true,
title: 'Higher license plan required',
body: "This install's plan does not include container update risk.",
});
expect(result).not.toHaveProperty('actionLabel');
expect(result).not.toHaveProperty('destination');
});
it('uses neutral copy with no action when commercial surfaces are hidden', () => {
const result = getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: true,
commercialSurfacesHidden: true,
planUpgradeDestination: planDestination,
});
expect(result).toEqual({
kind: 'plan_locked',
locked: true,
title: 'Not available',
body: 'This install does not include container update risk.',
});
expect(`${result.title} ${result.body}`).not.toMatch(/plan|Pro\b/i);
});
it('shares the canonical runtime lock with Patrol mode', () => {
const input = {
runtimeCapabilityBlock: runtimeBlock({
key: PATROL_ALERT_ANALYSIS_FEATURE_KEY,
action_url: 'https://pro.example/downloads',
}),
runtime: runtime({ label: 'Pulse Community runtime' }),
planUpgradeDestination: planDestination,
};
const alertAnalysis = getPatrolAlertAnalysisAvailabilityPresentation({
...input,
alertAnalysisLocked: true,
});
const patrolMode = getPatrolAutonomyAvailabilityPresentation({
...input,
autoFixLocked: true,
});
expect(alertAnalysis).toEqual({
kind: 'runtime_locked',
locked: true,
title: 'Pulse Pro runtime required',
body: 'This install is running Pulse Community runtime. Install the Pulse Pro runtime to use container update risk.',
actionLabel: 'Open Pro downloads',
destination: externalDestination('https://pro.example/downloads'),
});
expect(alertAnalysis.title).toBe(patrolMode.title);
expect(alertAnalysis.actionLabel).toBe(patrolMode.actionLabel);
expect(alertAnalysis.destination).toEqual(patrolMode.destination);
});
it('keeps Pro runtime wording out of hidden-commercial sessions for both capabilities', () => {
const input = {
commercialSurfacesHidden: true,
upgradePromptsHidden: true,
runtimeCapabilityBlock: runtimeBlock({ action_url: 'https://pro.example/downloads' }),
runtime: runtime({ label: 'Pulse Community runtime' }),
planUpgradeDestination: planDestination,
};
const alertAnalysis = getPatrolAlertAnalysisAvailabilityPresentation({
...input,
alertAnalysisLocked: true,
});
const patrolMode = getPatrolAutonomyAvailabilityPresentation({
...input,
autoFixLocked: true,
});
expect(alertAnalysis).toEqual({
kind: 'plan_locked',
locked: true,
title: 'Not available',
body: 'This install does not include container update risk.',
});
expect(patrolMode).toEqual({
kind: 'plan_locked',
locked: true,
title: 'Watch only',
body: 'This install watches infrastructure and shows issues.',
});
for (const result of [alertAnalysis, patrolMode]) {
expect(`${result.title} ${result.body}`).not.toMatch(/\bPro\b|runtime|plan/i);
}
});
it('omits the runtime download action when upgrade prompts are hidden', () => {
const result = getPatrolAlertAnalysisAvailabilityPresentation({
alertAnalysisLocked: true,
upgradePromptsHidden: true,
runtimeCapabilityBlock: alertRuntimeBlock,
runtime: runtime(),
planUpgradeDestination: planDestination,
});
expect(result.kind).toBe('runtime_locked');
expect(result).not.toHaveProperty('actionLabel');
expect(result).not.toHaveProperty('destination');
});
});
});
@@ -16,7 +16,13 @@ describe('patrol commercial boundary', () => {
expect(patrolIntelligenceHeaderSource).toContain(
"autonomyAvailability().kind === 'runtime_locked'",
);
expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true');
expect(patrolIntelligenceHeaderSource).toContain(
'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),',
);
expect(patrolIntelligenceHeaderSource).not.toContain('commercialSurfacesHidden: true');
expect(patrolIntelligenceBannersSource).toContain(
'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),',
);
expect(patrolIntelligenceHeaderSource).not.toContain('showAutonomyPlanBillingAction');
expect(patrolAutonomyAvailabilitySource).toContain('Plans & Billing');
expect(patrolAutonomyAvailabilitySource).toContain('input.upgradePromptsHidden');
@@ -3,12 +3,17 @@ import type { UpgradeDestination } from '@/utils/upgradeNavigation';
import { resolveUpgradeDestination } from '@/utils/upgradeNavigation';
export const PATROL_AUTONOMY_FEATURE_KEY = 'ai_autofix';
export const PATROL_ALERT_ANALYSIS_FEATURE_KEY = 'ai_alerts';
export const PATROL_AUTONOMY_RUNTIME_REQUIRED_REASON = 'paid_runtime_required';
export type PatrolAutonomyAvailabilityKind = 'available' | 'plan_locked' | 'runtime_locked';
export type PatrolFeatureAvailabilityKind = 'available' | 'plan_locked' | 'runtime_locked';
export interface PatrolAutonomyAvailabilityInput {
autoFixLocked: boolean;
// Presentation policy shared by every plan-gated Patrol capability. Each
// capability supplies only its lock state and its own copy; the precedence
// (hidden commercial surfaces, then runtime lock, then plan lock) and the
// action labels stay canonical here. Hidden commercial surfaces win over the
// runtime lock so demo and white-label sessions never see Pro wording.
interface PatrolFeatureAvailabilityPolicy {
upgradePromptsHidden?: boolean;
commercialSurfacesHidden?: boolean;
runtimeCapabilityBlock?: LicenseRuntimeCapabilityBlock;
@@ -16,8 +21,16 @@ export interface PatrolAutonomyAvailabilityInput {
planUpgradeDestination: UpgradeDestination;
}
export interface PatrolAutonomyAvailabilityPresentation {
kind: PatrolAutonomyAvailabilityKind;
export interface PatrolAutonomyAvailabilityInput extends PatrolFeatureAvailabilityPolicy {
autoFixLocked: boolean;
}
export interface PatrolAlertAnalysisAvailabilityInput extends PatrolFeatureAvailabilityPolicy {
alertAnalysisLocked: boolean;
}
export interface PatrolFeatureAvailabilityPresentation {
kind: PatrolFeatureAvailabilityKind;
locked: boolean;
title: string;
body: string;
@@ -25,6 +38,44 @@ export interface PatrolAutonomyAvailabilityPresentation {
destination?: UpgradeDestination;
}
interface PatrolFeatureAvailabilityCopy {
available: { title: string; body: string };
planLocked: { title: string; body: string };
// Plan-locked copy for sessions that hide commercial surfaces. Defaults to
// planLocked, so it is only needed when that copy mentions plans.
planLockedCommercialHidden?: { title: string; body: string };
// Completes "Install the Pulse Pro runtime to use ...".
runtimeFeatureLabel: string;
}
const PATROL_AUTONOMY_COPY: PatrolFeatureAvailabilityCopy = {
available: {
title: 'Patrol mode available',
body: 'Choose the mode for this install.',
},
planLocked: {
title: 'Watch only',
body: 'This install watches infrastructure and shows issues.',
},
runtimeFeatureLabel: 'Patrol modes',
};
const PATROL_ALERT_ANALYSIS_COPY: PatrolFeatureAvailabilityCopy = {
available: {
title: 'Container update risk available',
body: 'Assess risk when container-update alerts fire.',
},
planLocked: {
title: 'Higher license plan required',
body: "This install's plan does not include container update risk.",
},
planLockedCommercialHidden: {
title: 'Not available',
body: 'This install does not include container update risk.',
},
runtimeFeatureLabel: 'container update risk',
};
function getRuntimeDownloadDestination(
block: LicenseRuntimeCapabilityBlock | undefined,
runtime: LicenseRuntimeIdentity | undefined,
@@ -37,15 +88,24 @@ function getRuntimeLabel(runtime: LicenseRuntimeIdentity | undefined): string {
return runtime?.label?.trim() || 'this runtime';
}
export function getPatrolAutonomyAvailabilityPresentation(
input: PatrolAutonomyAvailabilityInput,
): PatrolAutonomyAvailabilityPresentation {
if (!input.autoFixLocked) {
function getPatrolFeatureAvailabilityPresentation(
locked: boolean,
input: PatrolFeatureAvailabilityPolicy,
copy: PatrolFeatureAvailabilityCopy,
): PatrolFeatureAvailabilityPresentation {
if (!locked) {
return {
kind: 'available',
locked: false,
title: 'Patrol mode available',
body: 'Choose the mode for this install.',
...copy.available,
};
}
if (input.commercialSurfacesHidden) {
return {
kind: 'plan_locked',
locked: true,
...(copy.planLockedCommercialHidden ?? copy.planLocked),
};
}
@@ -54,7 +114,7 @@ export function getPatrolAutonomyAvailabilityPresentation(
kind: 'runtime_locked',
locked: true,
title: 'Pulse Pro runtime required',
body: `This install is running ${getRuntimeLabel(input.runtime)}. Install the Pulse Pro runtime to use Patrol modes.`,
body: `This install is running ${getRuntimeLabel(input.runtime)}. Install the Pulse Pro runtime to use ${copy.runtimeFeatureLabel}.`,
...(input.upgradePromptsHidden
? {}
: {
@@ -66,20 +126,10 @@ export function getPatrolAutonomyAvailabilityPresentation(
};
}
if (input.commercialSurfacesHidden) {
return {
kind: 'plan_locked',
locked: true,
title: 'Watch only',
body: 'This install watches infrastructure and shows issues.',
};
}
return {
kind: 'plan_locked',
locked: true,
title: 'Watch only',
body: 'This install watches infrastructure and shows issues.',
...copy.planLocked,
...(input.upgradePromptsHidden
? {}
: {
@@ -88,3 +138,19 @@ export function getPatrolAutonomyAvailabilityPresentation(
}),
};
}
export function getPatrolAutonomyAvailabilityPresentation(
input: PatrolAutonomyAvailabilityInput,
): PatrolFeatureAvailabilityPresentation {
return getPatrolFeatureAvailabilityPresentation(input.autoFixLocked, input, PATROL_AUTONOMY_COPY);
}
export function getPatrolAlertAnalysisAvailabilityPresentation(
input: PatrolAlertAnalysisAvailabilityInput,
): PatrolFeatureAvailabilityPresentation {
return getPatrolFeatureAvailabilityPresentation(
input.alertAnalysisLocked,
input,
PATROL_ALERT_ANALYSIS_COPY,
);
}
@@ -1259,6 +1259,64 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByRole('button', { name: /start free trial/i })).not.toBeInTheDocument();
});
describe('Patrol mode runtime lock', () => {
const useLicensedCommunityRuntime = () => {
getRuntimeCapabilityBlockMock.mockImplementation((feature: string) =>
feature === 'ai_autofix'
? {
key: 'ai_autofix',
reason: 'paid_runtime_required',
action_url: 'https://pulserelay.pro/download.html',
}
: undefined,
);
runtimeCapabilitiesMock.mockReturnValue({
runtime: { build: 'community', label: 'Pulse Community runtime' },
capabilities: ['ai_intelligence'],
blocked_capabilities: [
{ key: 'ai_autofix', reason: 'paid_runtime_required' },
{ key: 'ai_alerts', reason: 'paid_runtime_required' },
],
});
getPatrolStatusMock.mockResolvedValue(defaultPatrolStatus({ license_required: true }));
};
it('points a licensed community runtime at the Pro runtime download', async () => {
useLicensedCommunityRuntime();
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getAllByText(/Pulse Pro runtime required/).length).toBeGreaterThan(0);
});
const downloads = screen.getAllByRole('link', { name: 'Open Pro downloads' });
expect(downloads.length).toBeGreaterThan(0);
for (const link of downloads) {
expect(link).toHaveAttribute('href', 'https://pulserelay.pro/download.html');
}
});
it('keeps Pro runtime wording off the page when commercial surfaces are hidden', async () => {
useLicensedCommunityRuntime();
presentationPolicyHidesCommercialSurfacesMock.mockReturnValue(true);
presentationPolicyHidesUpgradePromptsMock.mockReturnValue(true);
render(() => <AIIntelligence />);
await waitFor(() => {
expect(getPatrolStatusMock).toHaveBeenCalled();
});
const patrolControlAnchor = document.getElementById(PATROL_CONTROL_ANCHOR);
expect(patrolControlAnchor).not.toBeNull();
const patrolControl = within(patrolControlAnchor!);
expect(patrolControl.getAllByText('Watch only').length).toBeGreaterThan(0);
expect(patrolControl.queryByRole('group', { name: 'Patrol mode' })).not.toBeInTheDocument();
expect(screen.queryByText(/Pulse Pro runtime/)).not.toBeInTheDocument();
expect(screen.queryByText(/Install the Pulse Pro runtime/)).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Open Pro downloads' })).not.toBeInTheDocument();
});
});
it('keeps the Patrol model catalog out of the operator page', async () => {
apiFetchJSONMock.mockImplementation(async (path: string) => {
if (path === '/api/ai/models') {
@@ -3596,7 +3596,10 @@ describe('frontend resource type boundaries', () => {
expect(patrolIntelligenceBannersSource).toContain('state.licenseRequired()');
expect(patrolIntelligenceHeaderSource).toContain('presentationPolicyHidesUpgradePrompts');
expect(patrolIntelligenceHeaderSource).toContain('!presentationPolicyHidesUpgradePrompts()');
expect(patrolIntelligenceHeaderSource).toContain('commercialSurfacesHidden: true');
expect(patrolIntelligenceHeaderSource).toContain(
'commercialSurfacesHidden: presentationPolicyHidesCommercialSurfaces(),',
);
expect(patrolIntelligenceHeaderSource).not.toContain('commercialSurfacesHidden: true');
expect(patrolIntelligenceHeaderSource).not.toContain('Plans & Billing');
expect(patrolIntelligenceHeaderSource).toContain(
"from '@/components/shared/FilterButtonGroup'",