From 2373ace02636c895e533afb90de12964f432c4e3 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 14 Aug 2026 03:08:20 +0100 Subject: [PATCH] Complete Patrol retained objective loop --- docs/PRIVACY.md | 2 +- .../v6/internal/subsystems/agent-lifecycle.md | 8 + .../v6/internal/subsystems/ai-runtime.md | 20 +- .../v6/internal/subsystems/api-contracts.md | 7 + .../subsystems/frontend-primitives.md | 9 + .../subsystems/patrol-intelligence.md | 35 +- .../v6/internal/subsystems/registry.json | 3 + .../internal/subsystems/security-privacy.md | 3 + .../internal/subsystems/storage-recovery.md | 8 + frontend-modern/browser-verification.json | 81 +-- frontend-modern/public/docs/AI.md | 20 + frontend-modern/public/docs/PRIVACY.md | 2 +- .../src/api/__tests__/patrol.test.ts | 30 + frontend-modern/src/api/patrol.ts | 78 +++ ...sourceOperatorStateSection.render.test.tsx | 2 +- .../patrol/PatrolIntelligenceSurface.tsx | 2 + .../features/patrol/PatrolObjectivesPanel.tsx | 413 +++++++++++ .../__tests__/PatrolObjectivesPanel.test.tsx | 107 +++ .../pages/__tests__/AIIntelligence.test.tsx | 9 + .../stores/__tests__/systemSettings.test.ts | 5 + internal/ai/patrol_ai.go | 72 +- internal/ai/patrol_init.go | 55 ++ internal/ai/patrol_init_additional_test.go | 37 + internal/ai/patrol_observer_runtime.go | 646 +++++++++++++++++- internal/ai/patrol_observer_runtime_test.go | 259 +++++++ internal/ai/patrol_triggers.go | 3 +- internal/ai/patrol_triggers_test.go | 42 ++ internal/ai/tools/tools_patrol.go | 6 +- .../ai_handlers_cost_reset_additional_test.go | 20 +- internal/api/patrol_objectives.go | 15 + internal/api/patrol_objectives_test.go | 7 + .../release_control/subsystem_lookup_test.py | 1 + 32 files changed, 1896 insertions(+), 111 deletions(-) create mode 100644 frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx create mode 100644 frontend-modern/src/features/patrol/__tests__/PatrolObjectivesPanel.test.tsx diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 94c86df73..48db52c8c 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -256,6 +256,6 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern Pulse can make outbound connections when you enable specific features: -- **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. A provider may return a model-authored observer proposal for an uncovered objective; Pulse encrypts that artifact with the retained objective, excludes it from public objective reads and later prompt seeds, and does not include its content in usage telemetry or audit messages. Saving an objective by itself does not call a model. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. +- **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. Retained objective text and observer artifacts are encrypted at rest in the local organization data directory; objective text is not included in Pulse usage telemetry. A provider may return a model-authored observer proposal for an uncovered objective; Pulse encrypts that artifact with the retained objective, excludes it from public objective reads and later prompt seeds, and does not include its content in usage telemetry or audit messages. Saving an objective by itself does not call a model. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. - **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable secure remote web access, Pulse Mobile pairing for handoff, and push notifications. See Settings → Remote Access. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index d8a611308..3c2768bbd 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -2548,6 +2548,14 @@ Agent` secondary handoff against the live setup wizard instead of relying ## Current State +### Retained Patrol objectives do not expand agent lifecycle authority + +The shared `internal/api` retained-objective endpoints can queue read-only +Patrol coverage planning, but they cannot enroll, upgrade, restart, revoke, or +otherwise mutate an agent. Metric and application observers consume canonical +reported/discovered evidence; any later repair still crosses the existing +agent capability, preflight, approval, dispatch, and verification boundaries. + ### Local command and REST custom metrics are a bounded reporting module The host agent now accepts an optional private version-1 YAML file through diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a4bf529eb..f325c506d 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -6990,10 +6990,19 @@ still contain `auto_fix_count` load cleanly; the unknown key is ignored. `internal/ai/patrol_observer_runtime.go` owns the executable observer ABIs for retained Patrol objectives. The model may author the meaning and predicate, but -it cannot author executable code, network authority, or lifecycle authority. +it cannot author executable code, an HTTP origin, secret values, mutation +authority, or lifecycle authority. Core strictly decodes `pulse-resource-state/v1` for canonical resource status and `pulse-availability-state/v1` for the outcome of an existing canonical -agentless availability target. Both require explicit canonical resource scope, +agentless availability target. `pulse-resource-metric/v1` adds bounded +comparisons over fresh canonical CPU, memory, disk, and temperature evidence. +`pulse-http-json/v1` adds bounded concurrent GET-only JSON-pointer assertions +whose origin and optional authentication value are resolved from an exact +in-scope encrypted discovery record. It accepts a relative path and a secret +reference name, never an origin or credential value; restricted outbound HTTP +blocks metadata/link-local targets and cross-origin redirects, while timeout +and response size are capped. The ABIs require canonical resource scope (with +estate-wide objectives dynamically expanded through unified resources), an interval trigger, an empty external-requirements object, a 10-to-300-second local sample interval, and a bounded consecutive-failure window. The availability ABI accepts only an exact canonical target ID and @@ -7006,6 +7015,13 @@ runtimes, triggers, requirements, paths, operators, or values also fail closed and persist a safe machine reason in the `rejected` or `degraded` state while coverage remains truthful. +Creating or materially changing an active retained objective queues one +`objective_changed` scoped run carrying its exact ID, revision, brief and +optional context. Distinct objectives never deduplicate together; estate-wide +objectives expand to current canonical resource IDs before the run. This wakes +the model once to design coverage rather than waiting for the next scheduled +Patrol cycle. + Accepted observers transition through proposed, validated, and installed under core authority. Resource observers evaluate Pulse's canonical `ReadState` status locally, falling back to the existing Patrol snapshot only where diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 23c92faa1..c94d877a4 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1977,6 +1977,13 @@ tracks the durable inbox/detail, policy provenance, dispatch, and `frontend-modern/src/api/patrol.ts` owns Autopilot acknowledgement create, activation, effective-mode, and revocation consumption. These mirrors must use backend JSON names and closed enums rather than frontend authority dialects. +The same client owns the retained-objective list/create/update/delete mirror for +`/api/ai/patrol/objectives`. Objective writes carry optimistic revisions and +may author only the bounded brief, optional context, resource IDs, and status; +coverage and observer artifacts remain server-owned. Successful active create +or material update requests immediately ask the tenant Patrol service to plan +coverage, while the HTTP response remains the durable objective truth and does +not claim that queue acceptance means coverage. The route-backed Actions review consumes an exact typed action id through its browser query state and resolves lifecycle state from `GET /api/actions/{id}`; Patrol may link to that identity but must not infer Open versus History from diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4c27c9421..a2a99c955 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2654,6 +2654,15 @@ verification. ## Current State +### Patrol objectives reuse the shared dialog, button, badge, and resource picker contracts + +The Patrol retained-objective surface composes the existing shared `Dialog`, +`Button`, `MetadataBadge`, and `ResourcePicker` primitives. It does not add a +Patrol-only overlay, selector, badge vocabulary, or focus model. The modal keeps +the standard backdrop, Escape, focus trap/return, bounded viewport, scrolling, +and responsive footer behavior while the feature owns only objective-specific +copy and orchestration. + ### System member rows are source-type aware The Infrastructure source manager's member composition primitives now label diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 3146b14c0..794d40106 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -61,6 +61,7 @@ sources, and retains the note as operator context. 33. `frontend-modern/src/stores/patrolAttention.ts` 34. `tests/integration/tests/91-operational-trust-attention-workbench.spec.ts` 35. `frontend-modern/src/api/patrolAttention.ts` +36. `frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx` ## Shared Boundaries @@ -142,14 +143,25 @@ artifact is excluded from public objective reads and later prompt seeds. The public API cannot attach an observer or author coverage, and the model-facing tool cannot validate, install, execute, lease, or advance its proposal. -Core currently validates and installs one generic declarative ABI, -`pulse-resource-state/v1`, for an objective with explicit canonical resource -scope. It compares the canonical resource `status` with a bounded `equals` or -`not_equals` predicate at a 10-to-300-second local interval, requires an empty -external-requirements object, rejects unknown JSON fields, arbitrary code, -network, filesystem, and secret requirements, and wakes the model only after a -bounded consecutive-failure window. The installer is sandboxed by construction: -it registers typed data, not a process or script. Its runtime persists a +Core validates and installs four generic declarative ABIs. `pulse-resource-state/v1` +compares canonical resource status; `pulse-resource-metric/v1` compares fresh +canonical CPU, memory, disk, or temperature telemetry; and +`pulse-availability-state/v1` consumes an existing enabled canonical target. +`pulse-http-json/v1` performs a bounded GET and typed JSON-pointer assertion +against an exact in-scope discovery's core-owned Suggested Web URL. The model +may supply only the relative path, selector, predicate, bounded timing, and an +optional encrypted-discovery secret reference; it cannot supply an origin, +credential value, request body, redirect authority, or executable code. Core +includes only the names of credential references from scoped discovery records +in objective-planning model context; encrypted values never enter the prompt. +Core limits concurrency, timeout and response size, blocks metadata/link-local +targets and cross-origin redirects, and fails closed when discovery, scope, +freshness, telemetry, secret reference, or response evidence is missing. +All ABIs use a 10-to-300-second local interval, an empty external-requirements +object, strict unknown-field rejection, and a bounded consecutive-failure +window. Estate-wide objectives bind to the current canonical unified-resource +set at evaluation time. The installer is sandboxed by construction: it +registers typed data, not a process or script. Its runtime persists a renewable health lease without incrementing the operator objective revision, batches every due lease in a sweep into one encrypted persistence transaction, and queues one scoped `objective_evidence` Patrol check on the transition into @@ -159,6 +171,13 @@ currently breached. Editing the retained brief, optional context, or resource scope disables the existing observer and clears its lease, because an artifact validated against old intent cannot remain proof of coverage for new intent. +Creating or materially updating an active objective queues an immediate, +objective-identity-deduplicated coverage-planning Patrol run. The first-party +`PatrolObjectivesPanel` exposes one outcome statement, optional context and +optional resource scope, then shows the server-owned covered, degraded, or +uncovered truth with pause, resume, edit, and delete controls. Saving text is +never presented as equivalent to active protection. + Unsupported trigger or probe designs transition to `rejected` with an explicit machine validation reason and uncovered coverage. A rejected or degraded version may be replaced by a new model proposal, while core may reconsider an diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 1ae3e02a5..6a280e356 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -6126,6 +6126,7 @@ "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx", "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts", + "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx", "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts", "frontend-modern/src/pages/AIIntelligence.tsx", "frontend-modern/src/stores/aiIntelligence.ts", @@ -6173,6 +6174,7 @@ "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", "frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx", "frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts", + "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx", "frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts", "frontend-modern/src/pages/AIIntelligence.tsx", "frontend-modern/src/stores/aiIntelligence.ts", @@ -6190,6 +6192,7 @@ "frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts", "frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceHeader.test.ts", "frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts", + "frontend-modern/src/features/patrol/__tests__/PatrolObjectivesPanel.test.tsx", "frontend-modern/src/features/patrol/__tests__/patrolRunAcceptance.test.ts", "frontend-modern/src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts", "frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx", diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index e8548cf3f..3d9d580dc 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -66,6 +66,9 @@ content-free. The proposal is not executable and carries no infrastructure mutation authority; any future validator or installer must preserve this confidentiality boundary while enforcing declared secret references rather than accepting secret values. +The canonical and shipped privacy documents must state this retained-data and +telemetry boundary explicitly and remain byte-for-byte synchronized, so the +promise visible inside the product cannot drift from the repository policy. ## Canonical Files diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a2f369217..086d4dda8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -2146,6 +2146,14 @@ capability over storage or recovery data. ## Current State +### Retained Patrol objectives do not create recovery authority + +The shared `internal/api` retained-objective endpoints may scope an outcome to +storage or recovery resources and queue read-only observer planning, but saving +an objective does not create backup, snapshot, prune, restore, deletion, or +filesystem authority. Any response to observer evidence still uses the +canonical governed action and verification path. + ### Provider-neutral libvirt VM facts are observation-only The shared unified-resource type now includes a provider-neutral diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index d962957d1..4ab4df24c 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,74 +1,49 @@ { "version": 1, - "base_sha": "903b579f8aa24e90ee00c4cac6f7aa8393f69584", - "verified_at": "2026-08-13T23:13:56Z", + "base_sha": "6f09bc46768e809ec882f4c8c6782fae1e2ab710", + "verified_at": "2026-08-14T02:02:23Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/runtimeInventorySources.ts", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx", - "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts", - "frontend-modern/src/components/Workloads/GuestDrawer.tsx", - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx", - "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx", - "frontend-modern/src/components/Workloads/GuestRow.tsx", - "frontend-modern/src/components/Workloads/NodeDrawer.tsx", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts", - "frontend-modern/src/components/Workloads/nodeDrawerModel.ts", - "frontend-modern/src/components/Workloads/useGuestDrawerState.ts", - "frontend-modern/src/components/Workloads/useWorkloadsState.ts", - "frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts", - "frontend-modern/src/features/docker/DockerHostDrawer.tsx", - "frontend-modern/src/features/docker/dockerHostDrawerModel.ts", - "frontend-modern/src/features/vmware/VmwarePageSurface.tsx", - "frontend-modern/src/hooks/useWorkloads.ts", - "frontend-modern/src/types/workloads.ts" + "frontend-modern/src/api/patrol.ts", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", + "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx" ], "content_sha256": { - "frontend-modern/src/api/runtimeInventorySources.ts": "c14c416bc12ff77f53aa7623a774e00129e813771e9e8bbcd295ca5698615f2c", - "frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx": "effbe9e1a206e6fc4aeb8d0f4f45c4ebe15d983fc6e98db5285f9cf623ea654b", - "frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "b21cbb340be34ee833503eab8bcdbc8a75e14e9bbc8bd6e0be8c1d8aa5d26cf1", - "frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts": "4ae7d3a10b0850edd6193ccd2002275fb623deed8948c0dbbb2cd3055381e927", - "frontend-modern/src/components/Workloads/GuestDrawer.tsx": "f6de7da0928440d8edaacd34adc64de70577c88056cdaf88b409a91cc1954663", - "frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "a4f0c6e11a51b37f6f8fc5602ae7fb22f58b4217426c5cb1cf57e49936a92073", - "frontend-modern/src/components/Workloads/GuestDrawerOverview.tsx": "30e2a4f60244a0a07017562bef58d1d735f03a7ddba72f49586ccbf86387a4fb", - "frontend-modern/src/components/Workloads/GuestRow.tsx": "8db3148e7711f6bcdce117c4c58b75ebf214717056a034273a39d90475cac337", - "frontend-modern/src/components/Workloads/NodeDrawer.tsx": "9bdb7203eb698b7705914357b45245f17252aa135ef5b94eec331396fc04ec82", - "frontend-modern/src/components/Workloads/guestDrawerModel.ts": "18058d98f8909893e4b2e62c7aae569ed1e57c1dd8432f7a1efeaf6edc8c270f", - "frontend-modern/src/components/Workloads/nodeDrawerModel.ts": "5ba1927c2f050b26d67a6998f34cabbddba9fa79df98ec1e2ecc9b311633eeaa", - "frontend-modern/src/components/Workloads/useGuestDrawerState.ts": "dc0578bd3ea010412673a821291e6bd77a5fb229cb67e3493d4c4842af5e0345", - "frontend-modern/src/components/Workloads/useWorkloadsState.ts": "487eed98aba85f2dad8f504694ebf24c8faff861eeb19d729c741cb5cc3367b5", - "frontend-modern/src/components/Workloads/workloadInventorySourceIssues.ts": "abe71a344a7ff660e62b1cde6cafac72567e485827c08481003567ed3e7c3363", - "frontend-modern/src/features/docker/DockerHostDrawer.tsx": "e1d36317c1cefa262cfec544e8220608d53dd2fb546d8e058fa26caa5c0012d5", - "frontend-modern/src/features/docker/dockerHostDrawerModel.ts": "7162841abb5e5061cf89342d265c8e170243ddf41477985da350e85126eea594", - "frontend-modern/src/features/vmware/VmwarePageSurface.tsx": "2e44e78339c0bbd17dc1d2d75a8beef934393d79f72a105b54a25d3a0cbe84cb", - "frontend-modern/src/hooks/useWorkloads.ts": "856b6e05e39f0188b371878cc5361fe72f4a05b77b8505b00e0f19d3fedcfd46", - "frontend-modern/src/types/workloads.ts": "d3d5958364ebb71740ceee7fb804dd54594297d0a1f173556450772fcefc252c" + "frontend-modern/src/api/patrol.ts": "59e86aab6a4eb168de3563589cccb912d0ed6c275054d55544620598ab690496", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "8a0e42b07fa23cfd1a9c308986cff36bfe377dc2c1d2d84d0286af06709489b1", + "frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "44856c28de9cc1bb81900517876791666cb5e86fdde3e3dd759617a691c99b44" }, "routes": [ - "/vmware/overview" + "/patrol" ], "viewports": [ { - "width": 1280, + "width": 1440, "height": 900 }, { - "width": 700, - "height": 900 + "width": 390, + "height": 844 } ], "states": [ - "desktop vSphere host and workload tables without unsupported backup status columns or failure icons, with API-backed VM CPU allocation values visible", - "etl-batch-01 guest drawer overview showing the explicitly identified Pulse Agent presentation", - "etl-batch-01 history tab showing current readings alongside Collecting history with no fabricated chart geometry", - "700px compact vSphere tables with no horizontal body overflow or unsupported backup artifacts" + "initial loading and empty objective list", + "add-objective dialog open and closed", + "resource-scoped objective with disabled submit until an outcome is entered", + "persisted objective after reload with truthful uncovered coverage", + "paused and resumed objective states", + "edit-objective dialog kept open across background polling", + "clean empty state after objective deletion", + "desktop and narrow layouts without horizontal overflow, clipping, or console errors" ], "interactions": [ - "expanded etl-batch-01 and opened its guest drawer", - "switched the guest drawer to History", - "inspected all three history chart groups and confirmed zero SVG trend paths while no stored samples were available", - "verified the desktop and 700px layouts had no console errors", - "verified the 700px document scroll width matched the viewport width" + "opened the add-objective dialog, selected a real resource, entered an outcome, and saved it", + "reloaded the Patrol route and confirmed the objective persisted", + "paused and resumed the objective", + "opened the edit dialog and confirmed polling did not dismiss or overwrite it", + "dismissed dialogs with Escape and confirmed focus returned to the triggering control", + "confirmed body scrolling locked while the narrow dialog was open and unlocked on dismissal", + "deleted the temporary objective and confirmed the empty state returned", + "verified document scroll width matched the 390px viewport" ] } diff --git a/frontend-modern/public/docs/AI.md b/frontend-modern/public/docs/AI.md index 09d5dcb4a..968c0b092 100644 --- a/frontend-modern/public/docs/AI.md +++ b/frontend-modern/public/docs/AI.md @@ -96,6 +96,26 @@ model-reported findings ── validated, deduplicated, stored MaybeInvestigateFinding() ── model investigation + governed fix planning/execution ``` +### Operational objectives and model-authored observers + +Patrol objectives retain an operator's desired outcome—for example, “keep +camera streams available”—instead of requiring the operator or Pulse to encode +every application-specific check. Active objectives are included as +value-oriented context in applicable Patrol runs. When an objective has no +observer, the configured model can use `patrol_propose_observer` to translate +that outcome into a bounded, versioned read-only observer proposal using the +estate context and tools available on that installation. + +The proposal boundary is intentionally not an execution boundary. Pulse owns +the observer identity, revision, SHA-256 digest, encrypted local persistence, +declared trigger kind, and read-only posture. A model cannot mark its proposal +validated, install it, give it action authority, or claim that monitoring is +active. Coverage remains `uncovered` until a core-owned validator, sandbox +installer, runtime, and health lease have actually accepted the artifact. This +keeps the intelligence in the model while keeping continuity and authority in +Pulse—and avoids calling a model repeatedly when a future cheap local observer +can handle the steady-state signal. + ### The Patrol attention queue The first thing Patrol shows is **Needs attention**, a single operator queue diff --git a/frontend-modern/public/docs/PRIVACY.md b/frontend-modern/public/docs/PRIVACY.md index 94c86df73..48db52c8c 100644 --- a/frontend-modern/public/docs/PRIVACY.md +++ b/frontend-modern/public/docs/PRIVACY.md @@ -256,6 +256,6 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern Pulse can make outbound connections when you enable specific features: -- **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. A provider may return a model-authored observer proposal for an uncovered objective; Pulse encrypts that artifact with the retained objective, excludes it from public objective reads and later prompt seeds, and does not include its content in usage telemetry or audit messages. Saving an objective by itself does not call a model. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. +- **AI providers**: when AI features are configured, Pulse sends only the context required for your request to the provider you chose. This can include active Patrol objective briefs and their optional operator context when they apply to a Patrol run. Retained objective text and observer artifacts are encrypted at rest in the local organization data directory; objective text is not included in Pulse usage telemetry. A provider may return a model-authored observer proposal for an uncovered objective; Pulse encrypts that artifact with the retained objective, excludes it from public objective reads and later prompt seeds, and does not include its content in usage telemetry or audit messages. Saving an objective by itself does not call a model. Local providers stay on your network; non-local hosted providers receive provider-bound context directly from your Pulse instance. AI prompts from self-managed installs do not transit Pulse infrastructure. Before non-local model requests leave the instance, governed resource details use the same resource-policy redaction shown in Data Handling: local-only resource details are omitted from detailed prompt sections or replaced with policy-safe summaries, and known restricted resource identifiers are redacted where they appear in provider-bound context. See `docs/AI.md`. - **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable secure remote web access, Pulse Mobile pairing for handoff, and push notifications. See Settings → Remote Access. - **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration. diff --git a/frontend-modern/src/api/__tests__/patrol.test.ts b/frontend-modern/src/api/__tests__/patrol.test.ts index a5adca28b..f9a63f401 100644 --- a/frontend-modern/src/api/__tests__/patrol.test.ts +++ b/frontend-modern/src/api/__tests__/patrol.test.ts @@ -18,6 +18,10 @@ import { createPatrolAutopilotAcknowledgement, revokePatrolAutopilotAcknowledgement, updatePatrolAutonomySettings, + getPatrolObjectives, + createPatrolObjective, + updatePatrolObjective, + deletePatrolObjective, type Finding as PatrolFinding, } from '@/api/patrol'; import { apiFetchJSON } from '@/utils/apiClient'; @@ -30,6 +34,32 @@ describe('patrol api', () => { apiFetchJSONMock.mockResolvedValue([] as any); }); + it('uses the retained-objective contract with encoded identities and revisions', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ objectives: [] } as any); + await expect(getPatrolObjectives()).resolves.toEqual([]); + expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/objectives'); + + await createPatrolObjective({ brief: 'Keep cameras available', resource_ids: ['camera-1'] }); + expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/objectives', { + method: 'POST', + body: JSON.stringify({ brief: 'Keep cameras available', resource_ids: ['camera-1'] }), + headers: { 'Content-Type': 'application/json' }, + }); + + await updatePatrolObjective('objective/one', { revision: 4, status: 'paused' }); + expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/objectives/objective%2Fone', { + method: 'PATCH', + body: JSON.stringify({ revision: 4, status: 'paused' }), + headers: { 'Content-Type': 'application/json' }, + }); + + await deletePatrolObjective('objective/one', 5); + expect(apiFetchJSONMock).toHaveBeenLastCalledWith( + '/api/ai/patrol/objectives/objective%2Fone?revision=5', + { method: 'DELETE' }, + ); + }); + it('uses server acknowledgement and activation endpoints for Autopilot', async () => { await createPatrolAutopilotAcknowledgement('ack/one'); expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/autonomy/acknowledgements', { diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index 4b249cdf1..ce31d3ede 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -99,6 +99,46 @@ export type InvestigationOutcome = | 'fix_verification_failed' | 'fix_verification_unknown'; export type PatrolAutonomyLevel = 'monitor' | 'approval' | 'assisted' | 'full'; +export type PatrolObjectiveStatus = 'active' | 'paused' | 'archived'; +export type PatrolObjectiveCoverageState = 'covered' | 'degraded' | 'uncovered'; + +export interface PatrolObjectiveCoverage { + state: PatrolObjectiveCoverageState; + reason_code: string; + summary: string; + observer_id?: string; + observer_version?: number; + valid_until?: string; + last_evidence_at?: string; +} + +export interface PatrolObjective { + id: string; + brief: string; + optional_context?: string; + scope: { resource_ids: string[] }; + status: PatrolObjectiveStatus; + coverage: PatrolObjectiveCoverage; + revision: number; + created_by?: string; + updated_by?: string; + created_at: string; + updated_at: string; +} + +export interface PatrolObjectiveCreate { + brief: string; + optional_context?: string; + resource_ids?: string[]; +} + +export interface PatrolObjectiveUpdate { + revision: number; + brief?: string; + optional_context?: string; + resource_ids?: string[]; + status?: PatrolObjectiveStatus; +} export type PatrolAutopilotStatusCode = | 'active' @@ -143,6 +183,44 @@ export interface PatrolAutopilotStatus { acceptedLimits: PatrolAutopilotAcceptedLimits; } +export async function getPatrolObjectives(): Promise { + const response = await apiFetchJSON<{ objectives?: PatrolObjective[] }>( + '/api/ai/patrol/objectives', + ); + return arrayOrEmpty(response.objectives); +} + +export async function createPatrolObjective( + input: PatrolObjectiveCreate, +): Promise { + return apiFetchJSON('/api/ai/patrol/objectives', { + method: 'POST', + body: JSON.stringify(input), + headers: { 'Content-Type': 'application/json' }, + }); +} + +export async function updatePatrolObjective( + objectiveId: string, + input: PatrolObjectiveUpdate, +): Promise { + return apiFetchJSON( + `/api/ai/patrol/objectives/${encodeURIComponent(objectiveId)}`, + { + method: 'PATCH', + body: JSON.stringify(input), + headers: { 'Content-Type': 'application/json' }, + }, + ); +} + +export async function deletePatrolObjective(objectiveId: string, revision: number): Promise { + await apiFetchJSON( + `/api/ai/patrol/objectives/${encodeURIComponent(objectiveId)}?revision=${encodeURIComponent(String(revision))}`, + { method: 'DELETE' }, + ); +} + export interface PatrolAutonomySettings { autonomy_level: PatrolAutonomyLevel; requested_autonomy_level: PatrolAutonomyLevel; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.render.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.render.test.tsx index 0efbfb167..b482b0c19 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.render.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.render.test.tsx @@ -88,7 +88,7 @@ describe('ResourceOperatorStateSection render with capabilityNames: null', () => // The automatic-actions block renders (the resource has an eligible // capability) with its toggle off and no phantom selection derived // from the null allowlist. - expect(screen.getByText('Automatic actions')).toBeTruthy(); + expect(screen.getByLabelText('Automatic actions')).toBeTruthy(); expect(screen.queryByText('Allowed actions')).toBeNull(); }); }); diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx index 7241a5b78..aa548150c 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -6,6 +6,7 @@ import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader'; import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners'; import { PatrolIntelligenceWorkspace } from './PatrolIntelligenceWorkspace'; import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench'; +import { PatrolObjectivesPanel } from './PatrolObjectivesPanel'; export function PatrolIntelligenceSurface() { const state = usePatrolIntelligenceState(); @@ -24,6 +25,7 @@ export function PatrolIntelligenceSurface() {
+
{ + switch (state) { + case 'covered': + return { label: 'Watching in background', tone: 'success' }; + case 'degraded': + return { label: 'Monitoring needs attention', tone: 'warning' }; + default: + return { label: 'Not monitored yet', tone: 'neutral' }; + } +}; + +const formatObjectiveError = (error: unknown): string => + error instanceof Error ? error.message : 'The objective could not be saved.'; + +export const PatrolObjectivesPanel: Component = () => { + let dialogReturnFocus: HTMLElement | null = null; + const { resources } = useResources(); + const [objectives, setObjectives] = createSignal([]); + const [loading, setLoading] = createSignal(true); + const [loadError, setLoadError] = createSignal(''); + const [dialogOpen, setDialogOpen] = createSignal(false); + const [editing, setEditing] = createSignal(null); + const [brief, setBrief] = createSignal(''); + const [context, setContext] = createSignal(''); + const [selectedResources, setSelectedResources] = createSignal([]); + const [scopeOpen, setScopeOpen] = createSignal(false); + const [saving, setSaving] = createSignal(false); + const [mutatingId, setMutatingId] = createSignal(''); + + const resourceById = createMemo( + () => new Map(resources().map((resource) => [resource.id, resource])), + ); + + const loadObjectives = async (quiet = false) => { + if (!quiet) setLoading(true); + try { + setObjectives(await getPatrolObjectives()); + setLoadError(''); + } catch (error) { + setLoadError(formatObjectiveError(error)); + } finally { + if (!quiet) setLoading(false); + } + }; + + onMount(() => { + void loadObjectives(); + const refresh = () => { + if (document.visibilityState === 'visible' && !dialogOpen()) void loadObjectives(true); + }; + const timer = window.setInterval(refresh, 15_000); + document.addEventListener('visibilitychange', refresh); + onCleanup(() => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', refresh); + }); + }); + + const resetForm = () => { + setEditing(null); + setBrief(''); + setContext(''); + setSelectedResources([]); + setScopeOpen(false); + setSaving(false); + }; + + const restoreDialogFocus = () => { + const target = dialogReturnFocus; + dialogReturnFocus = null; + queueMicrotask(() => { + if (target && document.contains(target)) target.focus(); + }); + }; + + const closeDialog = () => { + if (saving()) return; + setDialogOpen(false); + resetForm(); + restoreDialogFocus(); + }; + + const openCreate = (trigger: HTMLElement) => { + dialogReturnFocus = trigger; + resetForm(); + setDialogOpen(true); + }; + + const openEdit = (objective: PatrolObjective, trigger: HTMLElement) => { + dialogReturnFocus = trigger; + setEditing(objective); + setBrief(objective.brief); + setContext(objective.optional_context ?? ''); + const selected = objective.scope.resource_ids.map((id) => { + const resource = resourceById().get(id); + return { + id, + type: resource?.type ?? 'agent', + name: resource ? getPreferredInfrastructureDisplayName(resource) : id, + } satisfies SelectedResource; + }); + setSelectedResources(selected); + setScopeOpen(selected.length > 0); + setDialogOpen(true); + }; + + const saveObjective = async () => { + const normalizedBrief = brief().trim(); + if (!normalizedBrief) return; + setSaving(true); + try { + const current = editing(); + const payload = { + brief: normalizedBrief, + optional_context: context().trim(), + resource_ids: selectedResources().map((resource) => resource.id), + }; + if (current) { + await updatePatrolObjective(current.id, { revision: current.revision, ...payload }); + showSuccess( + 'Patrol objective updated', + 'Patrol is rebuilding truthful background coverage.', + ); + } else { + await createPatrolObjective(payload); + showSuccess('Patrol objective added', 'Patrol is setting up background monitoring.'); + } + setDialogOpen(false); + resetForm(); + restoreDialogFocus(); + await loadObjectives(true); + } catch (error) { + showError('Could not save Patrol objective', formatObjectiveError(error)); + setSaving(false); + } + }; + + const setObjectiveStatus = async (objective: PatrolObjective, status: 'active' | 'paused') => { + setMutatingId(objective.id); + try { + await updatePatrolObjective(objective.id, { revision: objective.revision, status }); + await loadObjectives(true); + } catch (error) { + showError('Could not update Patrol objective', formatObjectiveError(error)); + } finally { + setMutatingId(''); + } + }; + + const removeObjective = async (objective: PatrolObjective) => { + if (!window.confirm(`Delete “${objective.brief}”? Patrol will stop watching this outcome.`)) + return; + setMutatingId(objective.id); + try { + await deletePatrolObjective(objective.id, objective.revision); + setObjectives((current) => current.filter((item) => item.id !== objective.id)); + showSuccess('Patrol objective deleted'); + } catch (error) { + showError('Could not delete Patrol objective', formatObjectiveError(error)); + } finally { + setMutatingId(''); + } + }; + + return ( +
+
+
+

+ What Patrol should keep true +

+

+ Describe the outcome. Patrol chooses a cheap local signal, wakes the model only when + evidence changes, and handles any fix according to your Patrol mode. +

+
+ +
+ +
+ Loading objectives…

} + > + +

Patrol objectives could not be loaded.

+ +
+ } + > + 0} + fallback={ +
+

No retained outcomes yet

+

+ Try “Keep my cameras available” or “Keep disk use below 85%”. You set the + outcome; Patrol works out how to observe it safely. +

+
+ } + > +
+ + {(objective) => { + const presentation = () => coveragePresentation(objective.coverage.state); + const scopeLabel = () => + objective.scope.resource_ids.length === 0 + ? 'Entire estate' + : `${objective.scope.resource_ids.length} selected ${objective.scope.resource_ids.length === 1 ? 'resource' : 'resources'}`; + return ( +
+
+
+

+ {objective.brief} +

+ + + Paused + + + + {presentation().label} + +
+

{objective.coverage.summary}

+

{scopeLabel()}

+
+
+ + + +
+
+ ); + }} +
+
+
+ + +
+ + +
+
+
+

+ {editing() ? 'Edit Patrol objective' : 'Add a Patrol objective'} +

+

+ State the outcome in your own words. Avoid writing commands or implementation steps. +

+
+ +
+
+