diff --git a/README.md b/README.md index 3bdf76a90..76ea24b85 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ Self-hosted tiers: |---|---:|---|---:|---| | Community | Free | Unlimited | 7 days | Full self-hosted monitoring | | Relay | $4.99/mo or $39/yr | Unlimited | 14 days | Remote access, mobile, and push notifications | -| Pro | $8.99/mo or $79/yr | Unlimited | 90 days | Root-cause analysis, safe auto-fix, and operations tooling | +| Pro | $8.99/mo or $79/yr | Unlimited | 90 days | Root-cause analysis, safe remediation workflows, and operations tooling | Pulse still counts top-level monitored systems once no matter how they are collected. VMs, containers, pods, disks, backups, and other child resources @@ -181,7 +181,7 @@ Runtime-aligned capability summary: | Pulse Patrol (Background Health Checks) | ✅ | ✅ | ✅ | ✅ | | Remote Access / Mobile / Push | — | ✅ | ✅ | ✅ | | Alert-Triggered Root-Cause Analysis | — | — | ✅ | ✅ | -| Pulse Patrol Auto-Fix | — | — | ✅ | ✅ | +| Safe Remediation Workflows | — | — | ✅ | ✅ | | Centralized Agent Profiles | — | — | ✅ | ✅ | | Update Alerts (Container/Package Updates) | ✅ | ✅ | ✅ | ✅ | | Basic SSO (OIDC) | ✅ | ✅ | ✅ | ✅ | @@ -207,7 +207,7 @@ Community installs continue with BYOK. Chat Assistant remains BYOK. Technical highlights: - Cross-system context (nodes, VMs, backups, containers, and metrics history) - LLM analysis with your provider plus alert-triggered root-cause investigations (Pro / hosted Cloud) -- Optional auto-fix with command safety policies and audit trail +- Optional safe remediation execution with command safety policies and audit trail - Centralized agent profiles for consistent fleet settings **[Try the live demo →](https://demo.pulserelay.pro)** or **[learn more at pulserelay.pro](https://pulserelay.pro)** diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index cd4893c14..1d5dcb8b1 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -602,7 +602,7 @@ type Config struct { DockerRuntime string // Force container runtime: docker, podman, or auto // Security - EnableCommands bool // Enable command execution for AI auto-fix (disabled by default) + EnableCommands bool // Enable command execution for Patrol remediation (disabled by default) // Enrollment Enroll bool // Exchange bootstrap token for runtime token on startup @@ -726,7 +726,7 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) { disableAutoUpdateFlag := fs.Bool("disable-auto-update", utils.ParseBool(envDisableAutoUpdate), "Disable automatic updates") disableDockerUpdateChecksFlag := fs.Bool("disable-docker-update-checks", utils.ParseBool(envDisableDockerUpdateChecks), "Disable Docker image update detection (avoids Docker Hub rate limits)") dockerRuntimeFlag := fs.String("docker-runtime", envDockerRuntime, "Container runtime: auto, docker, or podman (default: auto)") - enableCommandsFlag := fs.Bool("enable-commands", utils.ParseBool(envEnableCommands), "Enable command execution for AI auto-fix (disabled by default)") + enableCommandsFlag := fs.Bool("enable-commands", utils.ParseBool(envEnableCommands), "Enable command execution for Patrol remediation (disabled by default)") disableCommandsFlag := fs.Bool("disable-commands", false, "[DEPRECATED] Commands are now disabled by default; use --enable-commands to enable") enrollFlag := fs.Bool("enroll", false, "Exchange bootstrap token for runtime token (used by deploy wizard)") healthAddrFlag := fs.String("health-addr", defaultHealthAddr, "Health/metrics server address (empty to disable)") diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index 0967997a3..b8a905700 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -21,6 +21,21 @@ import ( "github.com/rs/zerolog" ) +func TestEnableCommandsHelpUsesPatrolRemediationCopy(t *testing.T) { + source, err := os.ReadFile("main.go") + if err != nil { + t.Fatalf("read pulse-agent main.go: %v", err) + } + text := string(source) + + if !strings.Contains(text, "Enable command execution for Patrol remediation (disabled by default)") { + t.Fatal("expected enable-commands help to describe Patrol remediation") + } + if strings.Contains(text, "Enable command execution for AI auto-fix") { + t.Fatal("enable-commands help must not revive AI auto-fix wording") + } +} + func TestGatherTags(t *testing.T) { tests := []struct { name string diff --git a/docs/AI_AUTONOMY.md b/docs/AI_AUTONOMY.md index af2f701fd..435a57d84 100644 --- a/docs/AI_AUTONOMY.md +++ b/docs/AI_AUTONOMY.md @@ -25,13 +25,13 @@ Patrol autonomy controls how aggressively Patrol responds to findings. |-------|-----|:------:|:-----------:|:-------------:|:--------------:|------| | **Monitor** | `monitor` | Yes | No | No | No | Community | | **Approval** | `approval` | Yes | Yes | Approval required | Approval required | Pro / legacy Pro+ / Cloud | -| **Assisted** | `assisted` | Yes | Yes | Auto-fix | Approval required | Pro / legacy Pro+ / Cloud | -| **Full** | `full` | Yes | Yes | Auto-fix | Auto-fix | Pro / legacy Pro+ / Cloud | +| **Assisted** | `assisted` | Yes | Yes | Execute automatically | Approval required | Pro / legacy Pro+ / Cloud | +| **Full** | `full` | Yes | Yes | Execute automatically | Execute automatically | Pro / legacy Pro+ / Cloud | - **Monitor** (default): Patrol creates findings but takes no action. This is the Community and Relay baseline. Suitable for learning what Patrol detects before enabling investigation or remediation. - **Approval** (Pro and above): Patrol investigates findings and proposes fixes. All fixes queue for manual approval before execution. -- **Assisted** (Pro and above): Warning-level findings are auto-fixed. Critical findings still require approval. This is the recommended starting point for most Pro and legacy Pro+ users who enable fix execution. -- **Full** (Pro and above): All findings are auto-fixed without approval. Requires an explicit toggle and a Pro, legacy Pro+, or Cloud license. Recommended only for environments with thorough alert coverage. +- **Assisted** (Pro and above): Warning-level safe remediation plans can execute automatically. Critical findings still require approval. This is the recommended starting point for most Pro and legacy Pro+ users who enable fix execution. +- **Full** (Pro and above): Safe remediation plans can execute without approval. Requires an explicit toggle and a Pro, legacy Pro+, or Cloud license. Recommended only for environments with thorough alert coverage. ### Configuration @@ -148,7 +148,7 @@ For new deployments, we recommend gradually increasing autonomy: 1. **Start with Monitor** — Run Patrol for a few cycles to see what it detects. Dismiss false positives. 2. **Move to Approval where available** — Enable investigation. Review proposed fixes to build confidence. -3. **Use Assisted when fix execution is enabled** — Let Patrol auto-fix warnings while you approve critical fixes. +3. **Use Assisted when fix execution is enabled** — Let Patrol execute warning-level remediation while you approve critical fixes. 4. **Consider Full** — Only if your environment has comprehensive alerting and you trust the fix patterns. --- diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index ebde916ef..ce9b20e54 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -259,6 +259,7 @@ the manifest's support-floor row. 7. Add or change the unified agent CLI entrypoint, version/help exit semantics, or startup argument/error routing through `cmd/pulse-agent/main.go`. The CLI entrypoint owns propagation of persistence context into runtime-owned helpers. When installer-selected state roots differ from the default, `cmd/pulse-agent/main.go` must pass that exact `StateDir` through both the host-agent runtime and updater startup paths instead of letting one path silently fall back to `/var/lib/pulse-agent`. The same runtime-owned boundary also owns Pulse control-plane URL validation for agent startup, remote config, updater continuity, and command transport. Non-loopback control-plane URLs remain HTTPS/WSS by default, but explicitly insecure agent/dev-runtime flows may use plain HTTP/WS for LAN development control planes; installer-persisted dev URLs must not be accepted by one runtime path and rejected by another. + The unified agent CLI copy follows the same Patrol remediation vocabulary as the install surface. `cmd/pulse-agent/main.go` may keep the `--enable-commands` flag name for compatibility, but the help text and inline comments must describe command execution as Patrol remediation rather than reviving AI auto-fix language. 8. Add or change installer flags, persisted service arguments, or upgrade-safe re-entry behavior through `scripts/install.sh` and `scripts/install.ps1`. Persistence-sensitive NAS targets must keep one canonical continuity model here: installer-owned bootstraps may use flash-backed or immutable-root launch hooks only as thin trampolines, while the durable wrapper, state, and reboot-surviving binary copy stay in the governed persistent state directory that updater continuity also refreshes. Approval-gated command execution must expose stable rejection reasons for diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 74d011db3..d2ccb8880 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -131,8 +131,9 @@ runtime cost control, and shared AI transport surfaces. 11. Keep AI runtime transport compatibility separate from operator-facing product copy. Existing Patrol payload fields such as `fixed_count`, `auto_fix_model`, and `patrol_auto_fix` may remain stable wire/API names, - but frontend comments, status labels, and commercial prompts that describe - the capability must use safe remediation or remediation wording. + but frontend comments, API denial messages, runtime logs, status labels, + CLI help, and commercial prompts that describe the capability must use safe + remediation or remediation wording. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 3c885b519..43f324f7b 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -338,11 +338,13 @@ the canonical monitored-system blocked payload. 25. Keep long-range workload chart transport time-proportional on the shared API surface: `internal/api/router.go`, `internal/api/contract_test.go`, and workload chart consumers must cap mixed-cadence workload history by equal-time buckets rather than raw point index for the per-workload and aggregate workload chart APIs, so 7-day and 30-day workload cards do not bunch recent samples at the right edge just because recent telemetry is stored more densely. 26. Keep chart timestamp precision canonical on that same shared API surface: when `internal/api/router.go` serializes monitoring history into infrastructure or workload chart payloads, it must preserve canonical millisecond timestamps from the shared monitoring timeline instead of rounding through whole-second conversion, so seeded mock history and live appends collapse onto one operator-visible timeline instead of appearing as duplicated tail samples. 27. Keep Patrol remediation payload naming backward-compatible without leaking - legacy automation-first wording into product copy. `frontend-modern/src/api/patrol.ts` - may continue to expose stable transport fields such as `fixed_count` and - legacy `auto_fix` capability names, but comments and presentation labels - layered on that API contract must describe the operator-visible capability - as remediation or safe remediation workflows. + legacy automation-first wording into product copy. `frontend-modern/src/api/patrol.ts`, + `internal/api/ai_handlers.go`, and `internal/api/router_routes_ai_relay.go` + may continue to expose stable transport fields such as `fixed_count`, + `auto_fix`, `autofix`, and the `ai_autofix` capability name, but comments, + API license-required messages, and presentation labels layered on that API + contract must describe the operator-visible capability as remediation or + safe remediation workflows. 27. Keep storage chart identity canonical on that same shared API surface: the shared storage charts endpoint must key pool and physical-disk series by the resolved unified-resource `MetricsTarget.ResourceID`, not by canonical resource IDs or page-local aliases, so storage rows, focused summary cards, sticky summary shells, and detail charts all address the same history series in live and mock mode. 28. Keep synthetic summary-chart fallback identity canonical on that same shared API surface: when `internal/api/router.go` has to synthesize mock summary history for infrastructure, workloads, or storage cards, it must derive the fallback from canonical `resourceType`, `resourceID`, and `metricType` ownership instead of raw min/max seed-prefix helpers, so range changes and runtime mock updates stay on one governed timeline. The same compact chart boundary also owns aggregate-only storage summary diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 446c41cbe..04c87deec 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -491,9 +491,10 @@ Community limit enforcement. may keep checkout, activation, recovery, and support-only trial plumbing available for explicit handoffs and entitled installs, but default self-hosted browser surfaces must honor `presentationPolicy.hideUpgrade` - and suppress Relay/Pro plan comparison, Pro trial CTAs, paid-only settings - navigation, and feature upsells unless hosted mode, direct intent, - activation/recovery state, or active entitlement makes them relevant. + and suppress Relay/Pro plan comparison, Pro trial CTAs, monitored-system + limit pressure, paid-only settings navigation, and feature upsells unless + hosted mode, direct intent, activation/recovery state, or active entitlement + makes them relevant. ## Current State @@ -504,9 +505,12 @@ self-hosted billing plan page instead of the Pulse Account purchase-start handoff. The purchase-start handoff requires a `PublicURL` and fails on local instances; routing these keys to the in-product billing plan keeps upgrades accessible from self-hosted environments. -The monitored-system app-shell warning CTA now follows that same self-hosted -commercial boundary by reviewing finite-policy usage on the usage ledger rather -than sending operators to the plan-selection surface with capacity-shaped copy. +The monitored-system app-shell warning CTA now follows that same commercial +boundary by rendering only in hosted mode. Ordinary self-hosted installs must +not see finite monitored-system pressure in the global app shell; when hosted +capacity policy is active, the banner reviews finite-policy usage on the usage +ledger rather than sending operators to the plan-selection surface with +capacity-shaped copy. Cloud paid readiness is materially behind architecture work. The main concern is contract coherence between pricing, entitlements, and runtime enforcement. @@ -1554,7 +1558,8 @@ for that feature's owning subsystem — those are user-initiated discovery paths, not blanket funnels, and are not required to be removed. Public AI and entitlement docs must use the same boundary: Community/Relay may describe Patrol background findings with BYOK, while investigation, proposed -remediation, auto-fix, and higher autonomy remain paid AI-operations features. +remediation, safe remediation execution, and higher autonomy remain paid +AI-operations features. Those docs should describe moving between available modes, not tell readers to "upgrade" as part of an ordinary safety progression. That same counted-unit boundary also owns the disclosure rule for retail copy: diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 9b2ad4f38..3088970e3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -795,12 +795,13 @@ work extends shared components instead of creating new local variants. takeover between tabs. 35. Keep self-hosted paid-service prompts opt-in at the shared shell layer. `settingsNavCatalog.ts`, `settingsNavVisibility.ts`, shared upgrade link - primitives, trial banners, and history-lock overlays must honor - `presentationPolicy.hideUpgrade` by hiding paid prompts by default on - ordinary self-hosted installs. Direct activation/recovery routes may - render their owned content, but sidebar discovery, trial CTAs, plan upsells, - and feature upgrade links must require hosted mode, explicit handoff, or - active entitlement. + primitives, trial banners, monitored-system warning banners, history-lock + overlays, and Patrol lock helpers must honor `presentationPolicy.hideUpgrade` + by hiding paid prompts by default on ordinary self-hosted installs. Direct + activation/recovery routes may render their owned content, but sidebar + discovery, trial CTAs, plan upsells, monitored-system limit pressure, and + feature upgrade links must require hosted mode, explicit handoff, or active + entitlement. ## Current State @@ -812,9 +813,10 @@ infrastructure area uses `InfrastructurePanelStep` in-page state. The shared monitored-system warning banner now uses a neutral policy-review CTA and `reviewPolicyDestination` state, keeping the render shell pointed at the usage-owned policy ledger instead of plan-selection or capacity wording. -It also follows the resolved session-presentation upgrade policy, so ordinary -self-hosted sessions with `presentationPolicy.hideUpgrade` do not render the -banner, its plan-review link, or its upgrade-impression telemetry. +It also requires hosted mode and follows the resolved session-presentation +upgrade policy, so ordinary self-hosted sessions do not render the banner, its +plan-review link, or its upgrade-impression telemetry even when stale finite +policy data is present. Shared alert presentation surfaces (`OverviewTab.tsx`, `HistoryTab.tsx`, `AlertOverviewActiveAlertsSection.tsx`, `AlertHistoryTableSection.tsx`, `AlertHistoryTableAlertRow.tsx`, `AlertOverviewAlertCard.tsx`) no longer accept @@ -1386,11 +1388,13 @@ parallel modal-stack bookkeeping. The shared history chart now follows the same owner shape. `frontend-modern/src/components/shared/HistoryChart.tsx` stays the render shell, `frontend-modern/src/components/shared/useHistoryChartState.ts` owns -license gating, trial actions, history fetch/refresh, canvas draw lifecycle, -and hover state, and `frontend-modern/src/components/shared/historyChartModel.ts` -owns tooltip formatting, scale and axis math, and closest-point selection. -Future history-chart work should extend those owners instead of pushing fetch, -license, or canvas math back into the shared component shell. +license gating, history fetch/refresh, canvas draw lifecycle, and hover state, +and `frontend-modern/src/components/shared/historyChartModel.ts` owns tooltip +formatting, scale and axis math, and closest-point selection. Lock overlays in +ordinary self-hosted surfaces must stay informational rather than presenting +trial-start or upgrade-link actions. Future history-chart work should extend +those owners instead of pushing fetch, license, commercial trial actions, or +canvas math back into the shared component shell. The remaining header, overlay, and tooltip render surfaces now live in `frontend-modern/src/components/shared/HistoryChartHeader.tsx`, `frontend-modern/src/components/shared/HistoryChartOverlay.tsx`, and @@ -2113,20 +2117,22 @@ agent-era banner filename or component name as the primary primitive. That shared monitored-system warning banner now also follows the shell/runtime/model owner split. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` stays the render shell, `frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts` -owns entitlement load, warning metric emission, migration click tracking, and -policy-review plus collector-link runtime, and +owns hosted-mode eligibility, entitlement load, warning metric emission, +migration click tracking, and policy-review plus collector-link runtime, and `frontend-modern/src/components/shared/monitoredSystemLimitWarningBannerModel.ts` owns monitored-system warning policy, count aggregation, and tone/text-class policy while sourcing customer-facing monitored-system copy from the canonical `frontend-modern/src/utils/monitoredSystemPresentation.ts` helper. Future warning-banner work should extend those owners instead of pushing entitlement -state or route selection back into the render shell. When the warning points at -the self-hosted commercial surface, the shared primitive must stay a compact +state, hosted-mode checks, or route selection back into the render shell. In +ordinary self-hosted mode the banner must remain absent rather than turning +stale finite policy data into monitored-system limit pressure. When hosted +capacity policy is active, the shared primitive must stay a compact policy-review pointer into the usage-owned ledger rather than a plan-selection -or "capacity" CTA. The banner may signal the current monitored-system posture -and link into the owned usage surface, but the longer over-plan or continuity -explanation belongs in the bounded legacy usage ledger and commercial detail -sections owned by `cloud-paid`, not in permanent app-shell banner copy. +or "capacity" CTA. The banner may signal the current hosted monitored-system +posture and link into the owned usage surface, but the longer over-plan or +continuity explanation belongs in the bounded usage ledger and commercial +detail sections owned by `cloud-paid`, not in permanent app-shell banner copy. That same shared warning boundary now also owns the monitored-system capacity posture vocabulary. Shared banners, plan summaries, and ledger headers must describe the canonical admission-freeze model from diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index e9a097a57..7547829a8 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -169,14 +169,16 @@ commercial destinations from the shared license boundary, but they must leave internal-versus-external navigation semantics to `frontend-primitives` once a Patrol feature can resolve to either product-owned routes or public pricing. That same Patrol-owned commercial boundary must also fail closed in public -demo runtimes. Patrol header and banner upgrade/trial affordances may render -for real customer workspaces, but the browser-owned trigger for that -suppression is now the shared resolved `presentationPolicy` from +demo and ordinary self-hosted runtimes. Patrol header, banner, approval, and +history-adjacent lock surfaces must remain informational unless the operator +is already in an entitled, hosted, or explicit commercial handoff context. The +browser-owned trigger for self-hosted suppression is the shared resolved +`presentationPolicy` from `/api/security/status`, seeded by the backend capability fact -`sessionCapabilities.demoMode`. Patrol surfaces must therefore suppress -upgrade CTAs, trial nudges, and Pro-only helper copy only from that shared -policy instead of reviving local demo heuristics or issuing early commercial -reads before the policy resolves. +`sessionCapabilities.demoMode` plus the self-hosted upgrade policy. Patrol +surfaces must therefore suppress upgrade CTAs, trial nudges, checkout links, +and Pro-only helper copy from that shared policy instead of reviving local demo +heuristics or issuing early commercial reads before the policy resolves. That same shared policy now also owns Patrol approval polling posture. `frontend-modern/src/stores/aiIntelligence.ts` must fail `loadPendingApprovals()` closed in public demo mode and return the canonical @@ -199,12 +201,12 @@ That same store-owned demo boundary also covers remediation artifacts. `/api/ai/remediation/plans` paywall probes after demo posture has resolved. That same posture split now also centralizes Patrol commercial bootstrap. `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` and -`frontend-modern/src/components/patrol/ApprovalSection.tsx` may consume the -resolved commercial-posture store when Patrol needs upgrade or trial context, -but they must not trigger their own mount-time `loadCommercialPosture()` -reads. Authenticated Patrol shells inherit that bootstrap from -`frontend-modern/src/useAppRuntimeState.ts`, so Patrol-specific hooks do not -quietly retake ownership of commercial fetch timing. +`frontend-modern/src/components/patrol/ApprovalSection.tsx` must not mount +commercial trial or upgrade state merely because an ordinary self-hosted +operator opens Patrol. Authenticated Patrol shells inherit any allowed +commercial bootstrap from `frontend-modern/src/useAppRuntimeState.ts`, so +Patrol-specific hooks do not quietly retake ownership of commercial fetch +timing or reintroduce local trial-start actions. Under ordinary self-hosted v6, Patrol commercial affordances must also honor the shared `presentationPolicy.hideUpgrade` contract. A free self-hosted install may show Patrol runtime availability and configuration gaps, but it diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 5fe29cf5b..50f6a3989 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -710,6 +710,12 @@ fields and nullable mode/kind metadata before presenting canonical item labels, while storage detail drawers and filter controls must route summary series IDs, source tones, and disk metrics through the shared storage helpers instead of reconstructing them from local table state. +Storage and recovery's adjacent `internal/api/` contract must also preserve +the product-facing remediation vocabulary used by shared API denials. When +storage/recovery-adjacent browser sessions encounter AI or Patrol remediation +license responses while sharing the app shell, those API messages must use +safe remediation wording and must not revive `Auto-Fix` as customer-facing +paid copy. That same adjacent `internal/api/` router boundary now also keeps usage-data transport descriptive-only for storage and recovery. Shared storage/recovery surfaces may coexist with `/api/upgrade-metrics/*` config reads and telemetry diff --git a/frontend-modern/src/components/patrol/ApprovalSection.tsx b/frontend-modern/src/components/patrol/ApprovalSection.tsx index cc69c9799..56a348312 100644 --- a/frontend-modern/src/components/patrol/ApprovalSection.tsx +++ b/frontend-modern/src/components/patrol/ApprovalSection.tsx @@ -9,12 +9,10 @@ import { Component, Show, createSignal, createResource, createMemo } from 'solid import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { notificationStore } from '@/stores/notifications'; import { aiChatStore } from '@/stores/aiChat'; -import { canStartCommercialTrial } from '@/stores/licenseCommercial'; import { hasFeature } from '@/stores/license'; import { AIAPI, type ApprovalRequest, type ApprovalExecutionResult } from '@/api/ai'; import { getApprovalRiskPresentation } from '@/utils/approvalRiskPresentation'; import { RemediationStatus } from './RemediationStatus'; -import { runStartProTrialAction } from '@/utils/trialStartAction'; interface ApprovalSectionProps { findingId: string; @@ -40,24 +38,6 @@ export const ApprovalSection: Component = (props) => { const canAutoFix = createMemo(() => hasFeature('ai_autofix')); - const [startingTrial, setStartingTrial] = createSignal(false); - const canStartTrial = createMemo(() => canStartCommercialTrial()); - - const handleStartTrial = async (e: Event) => { - e.stopPropagation(); - if (startingTrial()) return; - setStartingTrial(true); - try { - await runStartProTrialAction({ - branded: true, - showSuccess: notificationStore.success, - showError: notificationStore.error, - }); - } finally { - setStartingTrial(false); - } - }; - const handleFixWithAssistant = ( approval: ApprovalRequest | null, fix: { @@ -271,16 +251,6 @@ export const ApprovalSection: Component = (props) => { {assistantLabel} - - - ); @@ -380,16 +350,6 @@ export const ApprovalSection: Component = (props) => { Fix with Assistant - - - diff --git a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx index 392d58151..27ec87263 100644 --- a/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx +++ b/frontend-modern/src/components/patrol/__tests__/ApprovalSection.test.tsx @@ -22,7 +22,6 @@ const denyInvestigationFixMock = vi.hoisted(() => vi.fn()); const notificationSuccessMock = vi.hoisted(() => vi.fn()); const notificationErrorMock = vi.hoisted(() => vi.fn()); const openWithPromptMock = vi.hoisted(() => vi.fn()); -const startProTrialMock = vi.hoisted(() => vi.fn()); vi.mock('@/api/ai', () => ({ AIAPI: { @@ -62,13 +61,7 @@ vi.mock('@/stores/license', () => ({ })); vi.mock('@/stores/licenseCommercial', () => ({ - canStartCommercialTrial: () => { - const ent = state.entitlements; - if (!ent) return false; - if (ent.subscription_state === 'active' || ent.subscription_state === 'trial') return false; - return ent.trial_eligible !== false; - }, - startProTrial: (...args: unknown[]) => startProTrialMock(...args), + canStartCommercialTrial: () => false, })); vi.mock('../RemediationStatus', () => ({ @@ -93,15 +86,16 @@ describe('ApprovalSection', () => { notificationSuccessMock.mockReset(); notificationErrorMock.mockReset(); openWithPromptMock.mockReset(); - startProTrialMock.mockReset(); }); afterEach(() => { cleanup(); }); - it('keeps patrol trial gating on shared commercial selector helpers', () => { - expect(approvalSectionSource).toContain('canStartCommercialTrial'); + it('keeps fix approvals out of commercial trial prompts', () => { + expect(approvalSectionSource).not.toContain('canStartCommercialTrial'); + expect(approvalSectionSource).not.toContain('runStartProTrialAction'); + expect(approvalSectionSource).not.toContain('start a free 14-day trial'); expect(approvalSectionSource).not.toContain('commercialPosture'); }); diff --git a/frontend-modern/src/components/shared/HistoryChartOverlay.tsx b/frontend-modern/src/components/shared/HistoryChartOverlay.tsx index 490bc6c89..f044ac4f0 100644 --- a/frontend-modern/src/components/shared/HistoryChartOverlay.tsx +++ b/frontend-modern/src/components/shared/HistoryChartOverlay.tsx @@ -1,6 +1,4 @@ import { Component, Show } from 'solid-js'; -import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; -import { UpgradeLink } from './UpgradeLink'; import type { HistoryChartState } from './useHistoryChartState'; interface HistoryChartOverlayProps { @@ -74,41 +72,9 @@ export const HistoryChartOverlay: Component = (props)

{props.chart.lockDays()}-Day History

- - Historical data beyond {props.chart.lockDays()} days is not enabled on this - instance. -

- } - > -

- Upgrade to {props.chart.lockTierLabel()} to unlock {props.chart.lockDays()} days of - historical data retention. -

-
- - props.chart.trackUpgradeClicked('history_chart', 'long_term_metrics') - } - class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-md shadow-sm transition-colors" - > - Unlock {props.chart.lockTierLabel()} Features - - - - -
-
+

+ Historical data beyond {props.chart.lockDays()} days is not enabled on this instance. +

diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 97a0f64a4..94ece0965 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -663,6 +663,7 @@ describe('shared primitive guardrails', () => { expect(monitoredSystemLimitWarningBannerStateSource).toContain( 'getRuntimeMonitoredSystemCapacity', ); + expect(monitoredSystemLimitWarningBannerStateSource).toContain('isHostedModeEnabled'); expect(monitoredSystemLimitWarningBannerStateSource).toContain( 'presentationPolicyHidesCommercialSurfaces', ); @@ -919,7 +920,11 @@ describe('shared primitive guardrails', () => { expect(historyChartHeaderSource).not.toContain('setupCanvasDPR'); expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.'); - expect(historyChartOverlaySource).toContain('Unlock {props.chart.lockTierLabel()} Features'); + expect(historyChartOverlaySource).toContain( + 'Historical data beyond {props.chart.lockDays()} days is not enabled on this instance.', + ); + expect(historyChartOverlaySource).not.toContain('Unlock {props.chart.lockTierLabel()} Features'); + expect(historyChartOverlaySource).not.toContain('free 14-day trial'); expect(historyChartOverlaySource).not.toContain('ChartsAPI.getMetricsHistory'); expect(historyChartOverlaySource).not.toContain('setupCanvasDPR'); diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index 2b6b6a28b..ea8798ddd 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -41,16 +41,6 @@ vi.mock('@/stores/license', () => ({ maxHistoryDays: () => 30, })); -vi.mock('@/stores/licenseCommercial', () => ({ - canStartCommercialTrial: () => false, - getUpgradeActionDestination: () => ({ href: 'https://example.com/upgrade', external: true }), - startProTrial: vi.fn(), -})); - -vi.mock('@/stores/sessionPresentationPolicy', () => ({ - presentationPolicyHidesUpgradePrompts: () => false, -})); - vi.mock('@/api/charts', () => ({ ChartsAPI: { getMetricsHistory: vi.fn().mockResolvedValue({ points: [], source: 'store' }), @@ -76,10 +66,8 @@ describe('HistoryChart', () => { expect(historyChartStateSource).toContain('export function useHistoryChartState'); expect(historyChartStateSource).toContain('HISTORY_CHART_RANGES'); expect(historyChartStateSource).toContain("'mock_synthetic' | null"); - expect(historyChartStateSource).toContain( - 'const canStartTrial = createMemo(() => canStartCommercialTrial());', - ); - expect(historyChartStateSource).toContain('runStartProTrialAction({'); + expect(historyChartStateSource).not.toContain('canStartCommercialTrial'); + expect(historyChartStateSource).not.toContain('runStartProTrialAction({'); expect(historyChartStateSource).not.toContain('startProTrial()'); expect(historyChartStateSource).not.toContain('getTrialAlreadyUsedMessage()'); expect(historyChartStateSource).not.toContain('getTrialTryAgainLaterMessage()'); @@ -95,8 +83,12 @@ describe('HistoryChart', () => { expect(historyChartHeaderSource).not.toContain('setupCanvasDPR'); expect(historyChartOverlaySource).toContain('Collecting data... History will appear here.'); - expect(historyChartOverlaySource).toContain('Unlock {props.chart.lockTierLabel()} Features'); - expect(historyChartOverlaySource).toContain('presentationPolicyHidesUpgradePrompts'); + expect(historyChartOverlaySource).toContain( + 'Historical data beyond {props.chart.lockDays()} days is not enabled on this instance.', + ); + expect(historyChartOverlaySource).not.toContain('Unlock {props.chart.lockTierLabel()} Features'); + expect(historyChartOverlaySource).not.toContain('presentationPolicyHidesUpgradePrompts'); + expect(historyChartOverlaySource).not.toContain('free 14-day trial'); expect(historyChartOverlaySource).toContain('is not enabled on this'); expect(historyChartOverlaySource).not.toContain('ChartsAPI.getMetricsHistory'); expect(historyChartOverlaySource).not.toContain('setupCanvasDPR'); diff --git a/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx b/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx index 763538704..c75c6c698 100644 --- a/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/MonitoredSystemLimitWarningBanner.test.tsx @@ -23,6 +23,7 @@ const mockGetLimit = vi.hoisted(() => vi.fn<(key: string) => MockLimitRecord | undefined>(() => undefined), ); const mockGetMonitoredSystemCapacity = vi.hoisted(() => vi.fn(() => undefined)); +const mockIsHostedModeEnabled = vi.hoisted(() => vi.fn(() => false)); const mockHasMigrationGap = vi.hoisted(() => vi.fn(() => false)); const mockLegacyConnections = vi.hoisted(() => vi.fn(() => ({ @@ -42,6 +43,7 @@ const mockGetUpgradeActionUrlOrFallback = vi.hoisted(() => vi.fn()); vi.mock('@/stores/license', () => ({ getRuntimeLimit: (key: string) => mockGetLimit(key), getRuntimeMonitoredSystemCapacity: () => mockGetMonitoredSystemCapacity(), + isHostedModeEnabled: () => mockIsHostedModeEnabled(), loadRuntimeCapabilities: (force?: boolean) => mockLoadRuntimeLicenseStatus(force), })); @@ -78,6 +80,8 @@ describe('MonitoredSystemLimitWarningBanner', () => { }); mockGetMonitoredSystemCapacity.mockReset(); mockGetMonitoredSystemCapacity.mockReturnValue(undefined); + mockIsHostedModeEnabled.mockReset(); + mockIsHostedModeEnabled.mockReturnValue(false); mockHasMigrationGap.mockReturnValue(false); mockLegacyConnections.mockReturnValue({ proxmox_nodes: 0, @@ -129,6 +133,7 @@ describe('MonitoredSystemLimitWarningBanner', () => { expect(monitoredSystemLimitWarningBannerStateSource).toContain( 'presentationPolicyHidesCommercialSurfaces', ); + expect(monitoredSystemLimitWarningBannerStateSource).toContain('isHostedModeEnabled'); expect(monitoredSystemLimitWarningBannerStateSource).toContain( 'presentationPolicyHidesUpgradePrompts', ); @@ -196,6 +201,7 @@ describe('MonitoredSystemLimitWarningBanner', () => { }); it('keeps urgent limit warnings visible even without migration gap', async () => { + mockIsHostedModeEnabled.mockReturnValue(true); mockGetLimit.mockReturnValue({ key: 'max_monitored_systems', limit: 6, @@ -242,6 +248,7 @@ describe('MonitoredSystemLimitWarningBanner', () => { }); it('keeps urgent limit warnings visible with migration context', async () => { + mockIsHostedModeEnabled.mockReturnValue(true); mockHasMigrationGap.mockReturnValue(true); mockEntitlements.mockReturnValue({ overflow_days_remaining: 14 }); mockGetLimit.mockReturnValue({ @@ -273,6 +280,7 @@ describe('MonitoredSystemLimitWarningBanner', () => { }); it('stays hidden in demo mode even when usage is urgent', async () => { + mockIsHostedModeEnabled.mockReturnValue(true); mockPresentationPolicyHidesCommercialSurfaces.mockReturnValue(true); mockGetLimit.mockReturnValue({ key: 'max_monitored_systems', @@ -295,6 +303,7 @@ describe('MonitoredSystemLimitWarningBanner', () => { }); it('stays hidden when self-hosted upgrade prompts are suppressed', async () => { + mockIsHostedModeEnabled.mockReturnValue(true); mockPresentationPolicyHidesUpgradePrompts.mockReturnValue(true); mockGetLimit.mockReturnValue({ key: 'max_monitored_systems', @@ -315,4 +324,25 @@ describe('MonitoredSystemLimitWarningBanner', () => { ).not.toBeInTheDocument(); expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled(); }); + + it('stays hidden for self-hosted installs even when a stale finite policy is urgent', async () => { + mockGetLimit.mockReturnValue({ + key: 'max_monitored_systems', + limit: 6, + current: 5, + state: 'warning', + }); + + const mod = await import('../MonitoredSystemLimitWarningBanner'); + render(() => ( + + + + )); + + expect( + screen.queryByText('1 remaining. 5 monitored, 6 included.'), + ).not.toBeInTheDocument(); + expect(mockTrackUpgradeMetricEvent).not.toHaveBeenCalled(); + }); }); diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index 1027fcb09..d78a48b42 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -1,13 +1,8 @@ import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import { ChartsAPI, type HistoryTimeRange } from '@/api/charts'; import { isRangeLocked, loadRuntimeCapabilities, maxHistoryDays } from '@/stores/license'; -import { canStartCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial'; -import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { calculateOptimalPoints } from '@/utils/downsample'; import { setupCanvasDPR } from '@/utils/canvasRenderQueue'; -import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; -import { notificationStore } from '@/stores/notifications'; -import { runStartProTrialAction } from '@/utils/trialStartAction'; import { HISTORY_CHART_RANGES, createHistoryChartGeometry, @@ -40,27 +35,10 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar const [refreshTick, setRefreshTick] = createSignal(0); const [hasLoadedOnce, setHasLoadedOnce] = createSignal(false); const [cursorX, setCursorX] = createSignal(null); - const [startingTrial, setStartingTrial] = createSignal(false); const [hoveredPoint, setHoveredPoint] = createSignal(null); const [chartWidth, setChartWidth] = createSignal(300); const chartHeight = createMemo(() => props.height || 200); - const canStartTrial = createMemo(() => canStartCommercialTrial()); - - const handleStartTrial = async () => { - if (startingTrial()) return; - setStartingTrial(true); - try { - await runStartProTrialAction({ - branded: true, - showSuccess: notificationStore.success, - showError: notificationStore.error, - }); - } finally { - setStartingTrial(false); - } - }; - const refreshIntervalMs = createMemo(() => getHistoryChartRefreshIntervalMs(range())); onMount(() => { @@ -95,14 +73,6 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar return 'Pro'; }); - createEffect((wasVisible) => { - const visible = isLocked() && !props.hideLock; - if (!presentationPolicyHidesUpgradePrompts() && visible && !wasVisible) { - trackPaywallViewed('long_term_metrics', 'history_chart'); - } - return visible; - }, false); - const dataMin = createMemo(() => getHistoryChartDataMin(data())); const dataMax = createMemo(() => getHistoryChartDataMax(data())); @@ -373,15 +343,12 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar }; return { - canStartTrial, data, dataMax, dataMin, error, - getUpgradeActionDestination, handleMouseLeave, handleMouseMove, - handleStartTrial, chartHeight, chartWidth, hoveredPoint, @@ -392,8 +359,6 @@ export function useHistoryChartState(props: HistoryChartProps, refs: HistoryChar range, ranges: HISTORY_CHART_RANGES, source, - startingTrial, - trackUpgradeClicked, updateRange, }; } diff --git a/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts b/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts index 3d07c62f2..8bd044c7c 100644 --- a/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts +++ b/frontend-modern/src/components/shared/useMonitoredSystemLimitWarningBannerState.ts @@ -6,6 +6,7 @@ import { import { getRuntimeMonitoredSystemCapacity, getRuntimeLimit, + isHostedModeEnabled, loadRuntimeCapabilities, } from '@/stores/license'; import { hasMigrationGap } from '@/stores/licenseCommercial'; @@ -37,6 +38,7 @@ export function useMonitoredSystemLimitWarningBannerState() { ); const showBanner = createMemo( () => + isHostedModeEnabled() && !presentationPolicyHidesCommercialSurfaces() && !presentationPolicyHidesUpgradePrompts() && shouldShowMonitoredSystemLimitBanner(monitoredSystemLimit(), monitoredSystemCapacity()), diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx index 18695852a..74bbad138 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx @@ -1,11 +1,8 @@ import { Show } from 'solid-js'; import ShieldAlertIcon from 'lucide-solid/icons/shield-alert'; import SettingsIcon from 'lucide-solid/icons/settings'; -import SparklesIcon from 'lucide-solid/icons/sparkles'; -import { UpgradeLink } from '@/components/shared/UpgradeLink'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { formatRelativeTime } from '@/utils/format'; -import { trackUpgradeClicked } from '@/utils/upgradeMetrics'; import type { PatrolIntelligenceState } from './usePatrolIntelligenceState'; export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceState }) { @@ -47,14 +44,8 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat

- trackUpgradeClicked('ai_intelligence_banner', 'ai_autofix')} - > - Upgrade to Pro - {' '} - to unlock safe remediation workflows and alert-triggered root-cause analysis. + Safe remediation workflows and alert-triggered root-cause analysis are not enabled + on this plan.

@@ -89,15 +80,6 @@ export function PatrolIntelligenceBanners(props: { state: PatrolIntelligenceStat Open Patrol provider settings - - - - Upgrade - - diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx index 1f1d957c2..40a75a175 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx @@ -6,7 +6,6 @@ import XIcon from 'lucide-solid/icons/x'; import SettingsIcon from 'lucide-solid/icons/settings'; import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo'; import { PageHeader } from '@/components/shared/PageHeader'; -import { UpgradeLink } from '@/components/shared/UpgradeLink'; import { Toggle, TogglePrimitive } from '@/components/shared/Toggle'; import { CountdownTimer } from '@/components/patrol'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; @@ -274,8 +273,8 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState title={ !presentationPolicyHidesUpgradePrompts() && isProLocked() ? level === 'approval' - ? 'Upgrade to Pro to investigate findings' - : 'Upgrade to Pro for safe remediation workflows' + ? 'Investigation is not enabled on this plan' + : 'Safe remediation workflows are not enabled on this plan' : undefined } class={`flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200 ${isActive() ? ' text-blue-600 dark:text-blue-400 shadow-[0_1px_3px_rgba(0,0,0,0.1)]' : isDisabled() ? ' ' : 'text-muted hover:text-base-content hover:bg-surface-hover'} ${isDisabled() ? 'opacity-50 cursor-not-allowed' : ''}`} @@ -292,24 +291,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState
- - Upgrade to Pro - {' '} - to unlock investigation and safe remediation workflows. - - {' '} - - + Investigation and safe remediation workflows are not enabled on this plan.
@@ -337,23 +319,7 @@ export function PatrolIntelligenceHeader(props: { state: PatrolIntelligenceState when={!presentationPolicyHidesUpgradePrompts() && state.alertAnalysisLocked()} >
- - Upgrade - {' '} - to enable. - - - + Alert-triggered analysis is not enabled on this plan.
diff --git a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts index b6634d8e0..91ed6c34d 100644 --- a/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts +++ b/frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts @@ -24,13 +24,9 @@ import { hasTriggeringAlert } from '@/utils/findingAlertIdentity'; import { usePatrolStream } from '@/hooks/usePatrolStream'; import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery'; import { hasFeature, loadRuntimeCapabilities } from '@/stores/license'; -import { canStartCommercialTrial, getUpgradeActionDestination } from '@/stores/licenseCommercial'; -import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import type { AISettings } from '@/types/ai'; import { getCanonicalScopeResourceIds } from '@/utils/patrolFormat'; import { buildPatrolInvestigationContextSummary } from './patrolInvestigationContextModel'; -import { trackPaywallViewed } from '@/utils/upgradeMetrics'; -import { runStartProTrialAction } from '@/utils/trialStartAction'; type PatrolTab = 'findings' | 'history'; @@ -63,7 +59,6 @@ export function usePatrolIntelligenceState() { const [alertTriggeredAnalysis, setAlertTriggeredAnalysis] = createSignal(false); const [patrolAlertTriggers, setPatrolAlertTriggers] = createSignal(true); const [patrolAnomalyTriggers, setPatrolAnomalyTriggers] = createSignal(true); - const [startingTrial, setStartingTrial] = createSignal(false); const [selectedRun, setSelectedRun] = createSignal(null); let advancedSettingsRef: HTMLDivElement | undefined; @@ -152,22 +147,6 @@ export function usePatrolIntelligenceState() { const alertAnalysisLocked = createMemo(() => !hasFeature('ai_alerts')); const autoFixLocked = createMemo(() => !hasFeature('ai_autofix')); - const canStartTrial = createMemo(() => canStartCommercialTrial()); - - async function handleStartTrial() { - if (startingTrial()) return; - setStartingTrial(true); - try { - await runStartProTrialAction({ - branded: true, - showSuccess: notificationStore.success, - showError: notificationStore.error, - }); - } finally { - setStartingTrial(false); - } - } - const applyPatrolAISettings = (data: AISettings | null | undefined) => { setPatrolModel(data?.patrol_model || ''); setDefaultModel(data?.model || ''); @@ -348,10 +327,6 @@ export function usePatrolIntelligenceState() { }); const licenseRequired = createMemo(() => patrolStatus()?.license_required ?? false); - const upgradeDestination = createMemo(() => getUpgradeActionDestination('ai_autofix')); - const alertAnalysisUpgradeDestination = createMemo(() => - getUpgradeActionDestination('ai_alerts'), - ); const runtimeState = createMemo(() => { if (!patrolEnabledLocal()) return 'disabled'; return patrolStatus()?.runtime_state ?? 'active'; @@ -368,38 +343,6 @@ export function usePatrolIntelligenceState() { return ''; }); - createEffect((wasAutoFixLocked) => { - const isAutoFixLocked = autoFixLocked(); - if (!presentationPolicyHidesUpgradePrompts() && isAutoFixLocked && !wasAutoFixLocked) { - trackPaywallViewed('ai_autofix', 'ai_intelligence'); - } - return isAutoFixLocked; - }, false); - - createEffect((wasAlertAnalysisLocked) => { - const isAlertAnalysisLocked = alertAnalysisLocked(); - if ( - !presentationPolicyHidesUpgradePrompts() && - isAlertAnalysisLocked && - !wasAlertAnalysisLocked - ) { - trackPaywallViewed('ai_alerts', 'ai_intelligence'); - } - return isAlertAnalysisLocked; - }, false); - - createEffect((wasLicenseBannerVisible) => { - const isLicenseBannerVisible = licenseRequired() && !showBlockedBanner(); - if ( - !presentationPolicyHidesUpgradePrompts() && - isLicenseBannerVisible && - !wasLicenseBannerVisible - ) { - trackPaywallViewed('ai_autofix', 'ai_intelligence_banner'); - } - return isLicenseBannerVisible; - }, false); - const shouldShowLiveRun = createMemo( () => patrolEnabledLocal() && @@ -694,7 +637,6 @@ export function usePatrolIntelligenceState() { activeTab, activePatrolFindings, activityRefreshTrigger, - alertAnalysisUpgradeDestination, alertAnalysisLocked, alertTriggeredAnalysis, autonomyLevel, @@ -702,7 +644,6 @@ export function usePatrolIntelligenceState() { availableModels, blockedAt, blockedReason, - canStartTrial, canTriggerPatrol, circuitBreakerStatus, correlationTotal, @@ -721,7 +662,6 @@ export function usePatrolIntelligenceState() { handlePatrolAlertTriggersChange, handlePatrolAnomalyTriggersChange, handleRunPatrol, - handleStartTrial, handleTogglePatrol, hasInvestigationContext, initialSurfaceReady, @@ -769,10 +709,8 @@ export function usePatrolIntelligenceState() { showBlockedBanner, showInvestigationContext, shouldSurfaceInvestigationContext, - startingTrial, summaryStats, triggerPatrolDisabledReason, - upgradeDestination, }; } diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index ce0eb2861..8dabd44ea 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -555,7 +555,7 @@ describe('AIIntelligence entitlement gating', () => { cleanup(); }); - it('locks paid patrol controls and shows upgrade paths for free entitlements', async () => { + it('locks paid patrol controls without promoting checkout from ordinary Patrol workflows', async () => { getPatrolStatusMock.mockResolvedValue( defaultPatrolStatus({ license_required: true, @@ -576,20 +576,22 @@ describe('AIIntelligence entitlement gating', () => { expect(screen.getByRole('button', { name: 'Investigate' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Remediate' })).toBeDisabled(); expect( - screen - .getAllByRole('link', { name: 'Upgrade to Pro' }) - .some((link) => link.getAttribute('href') === getPublicPricingUrl('ai_autofix')), - ).toBe(true); - expect(screen.getByRole('link', { name: 'Upgrade' })).toHaveAttribute( - 'href', - getPublicPricingUrl('ai_alerts'), - ); - - await waitFor(() => { - expect(trackPaywallViewedMock).toHaveBeenCalledWith('ai_autofix', 'ai_intelligence'); - expect(trackPaywallViewedMock).toHaveBeenCalledWith('ai_alerts', 'ai_intelligence'); - expect(trackPaywallViewedMock).toHaveBeenCalledWith('ai_autofix', 'ai_intelligence_banner'); - }); + screen.getByText( + 'Safe remediation workflows and alert-triggered root-cause analysis are not enabled on this plan.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Investigation and safe remediation workflows are not enabled on this plan.', + ), + ).toBeInTheDocument(); + expect( + screen.getByText('Alert-triggered analysis is not enabled on this plan.'), + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Upgrade to Pro' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Upgrade' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /start free trial/i })).not.toBeInTheDocument(); + expect(trackPaywallViewedMock).not.toHaveBeenCalled(); }); it('locks paid patrol controls without upgrade prompts in default self-hosted mode', async () => { diff --git a/internal/ai/service.go b/internal/ai/service.go index 816f3104a..a03610dbe 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -1006,9 +1006,9 @@ func (s *Service) StartPatrol(ctx context.Context) { return } - // Check license for auto-fix feature (Pro only) - patrol itself is free with BYOK + // Check license for safe remediation workflows (Pro only) - patrol itself is free with BYOK if licenseChecker != nil && !licenseChecker.HasFeature(FeatureAIAutoFix) { - log.Info().Msg("AI Patrol Auto-Fix requires Pulse Pro license - fixes will require manual approval") + log.Info().Msg("Patrol safe remediation requires Pulse Pro license - fixes will require manual approval") } // Configure patrol from AI config (preserve defaults for resource types not in AI config) diff --git a/internal/ai/service_extended_test.go b/internal/ai/service_extended_test.go index e0f4ed049..32ec4e1bb 100644 --- a/internal/ai/service_extended_test.go +++ b/internal/ai/service_extended_test.go @@ -20,6 +20,21 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) +func TestServiceCommercialLogsUseSafeRemediationCopy(t *testing.T) { + source, err := os.ReadFile("service.go") + if err != nil { + t.Fatalf("read service.go: %v", err) + } + text := string(source) + + if !strings.Contains(text, "Patrol safe remediation requires Pulse Pro license") { + t.Fatal("expected Patrol remediation license log copy") + } + if strings.Contains(text, "AI Patrol Auto-Fix requires Pulse Pro license") { + t.Fatal("runtime logs must not revive Patrol Auto-Fix commercial copy") + } +} + func TestService_GetToolInputDisplay(t *testing.T) { svc := NewService(nil, nil) diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 1fc9afd02..a4f82b51e 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -2519,9 +2519,9 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt } if req.PatrolAutoFix != nil { - // Auto-fix requires Pro license with ai_autofix feature + // Safe remediation requires Pro license with the ai_autofix feature. if *req.PatrolAutoFix && !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { - WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro") + WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro") return } settings.PatrolAutoFix = *req.PatrolAutoFix @@ -3227,7 +3227,7 @@ func (h *AISettingsHandler) HandleExecute(w http.ResponseWriter, r *http.Request useCase := strings.ToLower(strings.TrimSpace(req.UseCase)) if useCase == "autofix" || useCase == "remediation" { if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { - WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro") + WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro") return } } @@ -3408,7 +3408,7 @@ func (h *AISettingsHandler) HandleExecuteStream(w http.ResponseWriter, r *http.R useCase := strings.ToLower(strings.TrimSpace(req.UseCase)) if useCase == "autofix" || useCase == "remediation" { if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { - WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro") + WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro") return } } @@ -3672,10 +3672,10 @@ func (h *AISettingsHandler) HandleRunCommand(w http.ResponseWriter, r *http.Requ return } - // Gated for AI Auto-Fix (Pro feature). Request shape is validated before the + // Gated for safe remediation workflows (Pro feature). Request shape is validated before the // entitlement check so clients get deterministic 400s for malformed calls. if !h.GetAIService(r.Context()).HasLicenseFeature(ai.FeatureAIAutoFix) { - WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Pulse Patrol Auto-Fix requires Pulse Pro") + WriteLicenseRequired(w, ai.FeatureAIAutoFix, "Safe remediation workflows require Pulse Pro") return } @@ -4880,7 +4880,7 @@ type PatrolStatusResponse struct { ErrorCount int `json:"error_count"` Healthy bool `json:"healthy"` IntervalMs int64 `json:"interval_ms"` // Patrol interval in milliseconds - FixedCount int `json:"fixed_count"` // Number of issues auto-fixed by Patrol + FixedCount int `json:"fixed_count"` // Number of issues remediated by Patrol BlockedReason string `json:"blocked_reason,omitempty"` BlockedAt *time.Time `json:"blocked_at,omitempty"` // Quickstart credit info for Patrol quickstart mode @@ -6591,13 +6591,13 @@ func (h *AISettingsHandler) HandleApproveCommand(w http.ResponseWriter, r *http. return } - // Investigation fix approvals are gated by the AI auto-fix extension point. + // Investigation fix approvals are gated by the safe remediation extension point. // The free adapter returns 402; the enterprise adapter executes the fix. if existingReq.ToolID == "investigation_fix" { if h.aiAutoFixEndpoints != nil { h.aiAutoFixEndpoints.HandleApproveInvestigationFix(w, r) } else { - WriteLicenseRequired(w, featureAIAutoFixValue, "Pulse Patrol Auto-Fix feature requires Pulse Pro") + WriteLicenseRequired(w, featureAIAutoFixValue, "Safe remediation workflows require Pulse Pro") } return } diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index f13d303bb..4629839e4 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -91,6 +91,17 @@ func persistQuickstartActivationState(t *testing.T, persistence *config.ConfigPe })) } +func TestAIHandlersUseSafeRemediationCommercialCopy(t *testing.T) { + files := []string{"ai_handlers.go", "router_routes_ai_relay.go"} + for _, file := range files { + source, err := os.ReadFile(file) + require.NoError(t, err) + text := string(source) + require.NotContains(t, text, "Pulse Patrol Auto-Fix requires Pulse Pro") + require.NotContains(t, text, "Auto-Fix requires Pulse Pro") + } +} + func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) { t.Parallel() diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 9c5c70607..f4231124d 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -57,6 +57,21 @@ type resourceContractSnapshot struct { Type string } +func TestPatrolRemediationCommercialCopyUsesSafeRemediationWording(t *testing.T) { + files := []string{"ai_handlers.go", "router_routes_ai_relay.go"} + for _, file := range files { + source, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + text := string(source) + if strings.Contains(text, "Pulse Patrol Auto-Fix requires Pulse Pro") || + strings.Contains(text, "Auto-Fix requires Pulse Pro") { + t.Fatalf("%s must not expose legacy Auto-Fix license copy", file) + } + } +} + func sortedVMChartKeys(values map[string]VMChartData) []string { keys := make([]string, 0, len(values)) for key := range values { diff --git a/internal/api/router_routes_ai_relay.go b/internal/api/router_routes_ai_relay.go index 3eee6575d..447dac6dd 100644 --- a/internal/api/router_routes_ai_relay.go +++ b/internal/api/router_routes_ai_relay.go @@ -214,7 +214,7 @@ func (r *Router) registerAIRelayRoutesGroup() { r.aiSettingsHandler.SetAIAutoFixEndpoints(r.aiAutoFixEndpoints) } -// --- AI Auto-Fix free-tier adapter --- +// --- Safe remediation free-tier adapter --- // All methods return 402 "requires Pulse Pro". Enterprise binders replace this // with real handler implementations. @@ -257,7 +257,7 @@ func (aiAutoFixFreeAdapter) HandleRollbackRemediationPlan(w http.ResponseWriter, } func (aiAutoFixFreeAdapter) HandleApproveInvestigationFix(w http.ResponseWriter, _ *http.Request) { - WriteLicenseRequired(w, featureAIAutoFixKey, "Auto-Fix requires Pulse Pro") + WriteLicenseRequired(w, featureAIAutoFixKey, "Safe remediation workflows require Pulse Pro") } func (a aiAutoFixFreeAdapter) HandleListApprovals(w http.ResponseWriter, req *http.Request) { diff --git a/pkg/server/server.go b/pkg/server/server.go index e4f265265..a046dc813 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -68,7 +68,7 @@ type BusinessHooks struct { BindReportingAdminEndpoints extensions.BindReportingAdminEndpointsFunc // BindAIAutoFixEndpoints allows enterprise modules to replace or decorate - // AI auto-fix endpoints (investigation, remediation, autonomy, fix execution). + // safe remediation endpoints (investigation, remediation, autonomy, fix execution). BindAIAutoFixEndpoints extensions.BindAIAutoFixEndpointsFunc // BindAIAlertAnalysisEndpoints allows enterprise modules to replace or decorate