From 07a4d05ca371e428f4c3b8381ace820e240f161e Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 19 Jul 2026 03:35:59 +0100 Subject: [PATCH] Build canonical Patrol attention workbench --- docs/AI.md | 29 +- docs/API.md | 23 + ...t-patrol-attention-workbench-2026-07-19.md | 185 ++++ docs/release-control/v6/internal/status.json | 10 + .../v6/internal/subsystems/agent-lifecycle.md | 5 + .../v6/internal/subsystems/ai-runtime.md | 17 + .../v6/internal/subsystems/alerts.md | 13 + .../v6/internal/subsystems/api-contracts.md | 21 + .../v6/internal/subsystems/cloud-paid.md | 5 + .../subsystems/deployment-installability.md | 4 + .../subsystems/frontend-primitives.md | 16 + .../subsystems/patrol-intelligence.md | 24 +- .../subsystems/performance-and-scalability.md | 5 + .../v6/internal/subsystems/registry.json | 19 +- .../internal/subsystems/security-privacy.md | 6 + .../internal/subsystems/storage-recovery.md | 13 + .../internal/subsystems/unified-resources.md | 6 + frontend-modern/src/AppLayout.tsx | 16 +- .../src/__tests__/App.architecture.test.ts | 10 +- .../src/__tests__/AppLayout.test.tsx | 24 +- .../src/api/__tests__/patrolAttention.test.ts | 36 + frontend-modern/src/api/patrolAttention.ts | 113 +++ .../patrol/PatrolAttentionWorkbench.tsx | 791 ++++++++++++++++++ .../patrol/PatrolIntelligenceSurface.tsx | 17 +- .../PatrolAttentionWorkbench.test.tsx | 230 +++++ .../pages/__tests__/AIIntelligence.test.tsx | 2 +- .../routing/__tests__/resourceLinks.test.ts | 8 + frontend-modern/src/routing/resourceLinks.ts | 13 + frontend-modern/src/stores/patrolAttention.ts | 102 +++ frontend-modern/src/useAppRuntimeState.ts | 2 + internal/ai/attention.go | 539 ++++++++++++ internal/ai/attention_performance_test.go | 48 ++ internal/ai/attention_test.go | 320 +++++++ internal/ai/patrol_metrics.go | 97 +++ internal/alerts/operational_contract.go | 15 + internal/alerts/operational_contract_test.go | 29 + internal/api/attention_handlers.go | 358 ++++++++ internal/api/attention_handlers_test.go | 237 ++++++ internal/api/route_inventory_test.go | 2 + internal/api/router.go | 2 + internal/api/router_routes_ai_relay.go | 2 + scripts/hot-dev.sh | 2 +- .../ai_runtime_docs_policy_test.py | 5 + .../release_control/subsystem_lookup_test.py | 7 +- scripts/tests/test-hot-dev-runtime.sh | 11 + ...rational-trust-attention-workbench.spec.ts | 719 ++++++++++++++++ 46 files changed, 4122 insertions(+), 36 deletions(-) create mode 100644 docs/release-control/v6/internal/records/operational-trust-patrol-attention-workbench-2026-07-19.md create mode 100644 frontend-modern/src/api/__tests__/patrolAttention.test.ts create mode 100644 frontend-modern/src/api/patrolAttention.ts create mode 100644 frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx create mode 100644 frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx create mode 100644 frontend-modern/src/stores/patrolAttention.ts create mode 100644 internal/ai/attention.go create mode 100644 internal/ai/attention_performance_test.go create mode 100644 internal/ai/attention_test.go create mode 100644 internal/api/attention_handlers.go create mode 100644 internal/api/attention_handlers_test.go create mode 100644 tests/integration/tests/91-operational-trust-attention-workbench.spec.ts diff --git a/docs/AI.md b/docs/AI.md index 1464ca16a..0db5677fc 100644 --- a/docs/AI.md +++ b/docs/AI.md @@ -96,6 +96,31 @@ model-reported findings ── validated, deduplicated, stored MaybeInvestigateFinding() ── model investigation + governed fix planning/execution ``` +### The Patrol attention queue + +The first thing Patrol shows is **Needs attention**, a single operator queue +projected from Pulse's canonical alert lifecycle. It combines current +operational state with evidence quality and protection context; it does not +create a second finding lifecycle. + +- **Active** contains open work plus stale or unknown collection states that + still require a decision. +- **Acknowledged** and **Suppressed** remain inspectable without being counted + as active work. +- **Recent resolved** preserves the explanation and transition history without + presenting old work as live. +- **Stale or unknown** means Pulse lacks current enough evidence. It is never + shown as healthy or resolved. +- A calm message appears only when the lifecycle evaluation succeeded, coverage + is current, and no active item exists. An unavailable evaluation says so + explicitly. + +Select an item to see the affected resource, impact, next step, typed evidence, +protection posture, and lifecycle timeline. **Explain with Assistant** appears +only inside selected context and receives policy-shaped summaries and +references. Assistant can explain the item, but it cannot create lifecycle +truth, invent action authority, or hide uncertainty. + ### What Patrol Sees Every patrol run passes the LLM comprehensive context about your environment: @@ -500,7 +525,9 @@ Pulse includes a model-context layer that aggregates evidence from AI runtime su ### Health Scoring -The Patrol UI can show an operational score (A-F) based on active findings, Patrol coverage, runtime errors, and structured evidence. This score is a presentation aid, not a replacement for model diagnosis. +Historical Patrol checks and the legacy operational score remain available in +the collapsed supporting-context section. They are not the primary daily +monitoring answer and do not replace the canonical attention queue. --- diff --git a/docs/API.md b/docs/API.md index ad75ee62c..e57e3101f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1038,6 +1038,29 @@ Kubernetes-specific analysis as a standalone plan pillar. Runs a focused investigation for an alert payload (used by the UI). ### Patrol +- `GET /api/ai/patrol/attention` + - Returns the typed Patrol attention queue projected from canonical + operational lifecycle records. This is the active-count and queue source + used by both navigation and Patrol. + - Query params: `filter` (`active` | `open` | `acknowledged` | `suppressed` | + `stale_unknown` | `resolved` | `all`), `page` (minimum 1), and `limit` + (1–200). + - The response includes `data`, a lifecycle-wide `summary`, and bounded + pagination `meta`. Protection context is joined in one bounded batch, not + fetched per item. +- `GET /api/ai/patrol/attention/summary` + - Returns the canonical active, open, acknowledged, suppressed, + stale/unknown, and recent-resolved counts plus `calm`, `coverageState`, and + `evaluatedAt`. + - A lifecycle-read failure returns a typed unavailable error. It never + returns a synthetic zero or healthy state. +- `GET /api/ai/patrol/attention/{id}` + - Returns one attention item with its operational record, lifecycle + timeline, typed evidence, recommended next step, relationships, and + protection posture. +- All three attention routes require `monitoring:read`. They are read-side + projections only; alert lifecycle mutations remain owned by the canonical + alert routes. - `GET /api/ai/patrol/autonomy` - `PUT /api/ai/patrol/autonomy` - `GET /api/ai/patrol/status` diff --git a/docs/release-control/v6/internal/records/operational-trust-patrol-attention-workbench-2026-07-19.md b/docs/release-control/v6/internal/records/operational-trust-patrol-attention-workbench-2026-07-19.md new file mode 100644 index 000000000..9f4aa023f --- /dev/null +++ b/docs/release-control/v6/internal/records/operational-trust-patrol-attention-workbench-2026-07-19.md @@ -0,0 +1,185 @@ +# Operational Trust: Patrol Attention Workbench + +Date: 2026-07-19 +Specification: +`docs/release-control/v6/internal/OPERATIONAL_TRUST_IMPLEMENTATION_SPEC.md` +Phase: 3, Patrol attention workbench +Candidate: `protection-posture-attention-queue` + +## Outcome + +Phase 3 is a product-grade canonical read path over the Phase 1 lifecycle and +Phase 2 protection posture: + +1. `internal/ai/attention.go` projects canonical alert operational records, + evidence, transitions, and recovery-owned posture into typed attention + items. It owns ordering, lifecycle filters, pagination, summary, and honest + calm evaluation, not writable truth. +2. `internal/api/attention_handlers.go` exposes bounded list, summary, and + detail routes protected by `monitoring:read`. The summary avoids recovery + history; list posture joins are batched at 200 subjects. +3. `frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx` is the + primary Patrol surface. Navigation and queue counts use the same summary. + Legacy checks, investigations, and run history are demoted to collapsed + supporting context. +4. Selected detail carries affected and related resources, impact, + recommended next step, typed evidence and limitations, protection posture, + timeline, owning-resource navigation, and an explanation-only Assistant + handoff. +5. Lifecycle failure remains unavailable, posture failure remains partial, and + neither can become a false zero, calm, healthy, resolved, or protected + state. +6. Historical snapshots contribute recent resolved context only. A + non-terminal history entry without a matching active record cannot revive + false current work. Canonical record IDs containing `/` remain valid, + percent-encoded deep-link identities. + +The queue contains no action-execution affordance in this phase. Governed +offers, approval, execution, and verification remain Phase 5 work. + +## User Lens + +Operator job, in the least-expert plausible user's words: + +> Show me what needs attention now, why it matters, what it affects, whether it +> is protected, and what I should do next. + +Live exercise: + +1. The authenticated launch remains monitor-first. Patrol is one navigation + action away. +2. Patrol answers the job in its first bordered section: one heading/count, + six lifecycle filters, an ordered queue, and refresh. +3. One item selection reaches the deepest state. It exposes impact, resource, + evidence, protection, timeline, next step, and supporting links. +4. A 390-by-844 viewport has no document-width overflow. Selection and a + direct deep-link bring detail into view; close restores focus to the item. +5. Reduced-motion, keyboard activation, stable screen-reader names, calm, + partial/unavailable, acknowledged, suppressed, stale/unknown, and recent + resolved states are covered by browser proof. +6. The live deepest state exposed 64 repeated observations for one disk + incident. The default detail now shows the latest three and collapses the + remaining 61 under an explicit older-observations disclosure, preserving + forensic evidence without turning the monitor into a raw event browser. + +Default-visible element decisions: + +| Element | Operator action | Decision | +| --- | --- | --- | +| Active count | Judge current workload and open Patrol | Keep | +| Lifecycle filters | Inspect work without losing acknowledged, suppressed, uncertain, or resolved history | Keep | +| Ordered item row | Choose the next issue and open evidence | Keep | +| Resource, evidence, protection, age labels | Decide urgency and whether the item is trustworthy enough to act on | Keep | +| Refresh | Request a new evaluation | Keep | +| Provider evidence and timeline | Explain one selected item | Demote to selected detail | +| Repeated older evidence observations | Inspect forensic history when needed | Demote to an explicit disclosure inside selected detail | +| Patrol run history, investigations, score | Forensic or historical context | Demote to collapsed supporting context | +| Large Assistant prompt | No action before an item is selected | Cut | +| Generic trust score/proof strip | No direct operational action | Cut from the primary queue | + +Vocabulary: + +- `Stale or unknown` replaces implementation vocabulary about collectors and + explicitly says that evidence is insufficient for health. +- `Protection unknown` means no complete subject-linked provider history, not + unprotected. +- Provider/collector identity appears only in detail. + +GitHub issue comparison: + +1. [#1244](https://github.com/rcourtman/Pulse/issues/1244) asks for findings + “listed at a glance”; the queue is the primary at-a-glance destination. +2. [#1234](https://github.com/rcourtman/Pulse/issues/1234) says recommendations + are “really difficult to find”; selected detail keeps the next step beside + impact and evidence. +3. [#1580](https://github.com/rcourtman/Pulse/issues/1580) reports confusion + after acknowledging stale backup alerts; lifecycle filters keep + acknowledged and stale/unknown distinct and inspectable. +4. [#1553](https://github.com/rcourtman/Pulse/issues/1553) identifies recovery + notification noise without a preceding alert; the queue consumes the + canonical lifecycle rather than notification delivery. +5. [#1056](https://github.com/rcourtman/Pulse/issues/1056) asks not to be + bothered by intentionally archived backups; suppressed work leaves the + default active queue but remains inspectable. +6. [#1215](https://github.com/rcourtman/Pulse/issues/1215) reports incorrect + Patrol reachability information; evidence freshness, completeness, + confidence, and limiting caveats are visible before Assistant explanation. +7. [#1223](https://github.com/rcourtman/Pulse/issues/1223) and + [#1131](https://github.com/rcourtman/Pulse/issues/1131) document mobile + scaling failures; phone-width deepest-state proof is now explicit. + +Verdict: `product`. The primary surface answers the operator job without a +second lifecycle, generic object browser, proof strip, or Assistant-first +detour. + +## Proof + +Backend: + +```text +go test ./internal/alerts ./internal/ai ./internal/api \ + -run 'OperationalContract|Attention|PatrolMetrics|RouteInventory' -count=1 +``` + +Focused migration and route regressions additionally prove that orphaned +non-terminal history is excluded, resolved history remains available, and +canonical IDs containing encoded slashes open their detail route. + +Frontend: + +```text +npm run type-check +npm run lint +npm exec vitest run \ + src/api/__tests__/patrolAttention.test.ts \ + src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx \ + src/__tests__/App.architecture.test.ts \ + src/__tests__/AppLayout.test.tsx \ + src/pages/__tests__/AIIntelligence.test.tsx \ + src/routing/__tests__/resourceLinks.test.ts +bash scripts/tests/test-hot-dev-runtime.sh +``` + +Browser: + +```text +PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173 \ + npx playwright test \ + tests/91-operational-trust-attention-workbench.spec.ts \ + --project=chromium +``` + +The browser proof renders the real frontend and uses bounded deterministic API +evidence without a live credential or mutable first-run setup. It passes active +work, every Phase 3 lifecycle filter, evidence/protection/timeline detail, +navigation-count consistency, keyboard/focus, narrow viewport, reduced motion, +calm, and unavailable-without-false-health. + +`npm test -- tests/91-operational-trust-attention-workbench.spec.ts +--project=chromium` was not the correct managed-runtime invocation because that +wrapper requires Docker, which was not running. A broader `npm run dev:verify` +also encountered the pre-existing first-session setup-wizard helper timeout in +`16-dev-runtime-recovery.spec.ts`; the Phase 3 feature proof above avoids that +unrelated setup mutation and passes directly against the managed runtime. + +The managed live runtime was then rebuilt from the same Development-SSD source +tree. Its live contract reported one current active item, a current +non-calm coverage state, and the migrated generic next step. The selected disk +item deep-linked successfully on a 390-by-844 viewport with a 390-pixel +document width; its default evidence section showed the latest 3 of 64 +observations with 61 older observations collapsed. + +Performance proof: + +- `internal/ai/attention_performance_test.go` projects 10,000 lifecycle records + and proves a bounded 200-item page. +- The summary route performs no posture-store read. +- The list performs one bounded posture batch and no per-item fetch. + +## Remaining Specification Work + +This record accepts Phase 3 only. It does not close the overall operational +trust goal, candidate lane, or coverage gap. Availability attachment (Phase +4), governed actions and verification (Phase 5), and rollout hardening (Phase +6) remain required before the specification's 14 completion criteria can +close. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index d25624f18..ec57782a9 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -8732,6 +8732,11 @@ "path": "docs/release-control/v6/internal/records/operational-trust-lifecycle-evidence-notification-linkage-2026-07-19.md", "kind": "file" }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/operational-trust-patrol-attention-workbench-2026-07-19.md", + "kind": "file" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/records/operational-trust-protection-posture-2026-07-19.md", @@ -8801,6 +8806,11 @@ "repo": "pulse", "path": "tests/integration/tests/90-operational-trust-protection-posture.spec.ts", "kind": "file" + }, + { + "repo": "pulse", + "path": "tests/integration/tests/91-operational-trust-attention-workbench.spec.ts", + "kind": "file" } ] }, diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 46fd1b9e9..7dfc0afc2 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -4625,3 +4625,8 @@ lifecycle boundary. Protection posture is a read-only `monitoring:read` projection over recovery points and provider collection evidence. It does not register agents, issue or rotate credentials, interpret observer responses, or grant backup, restore, command, or remote-configuration authority. + +The adjacent Patrol attention handlers and shared router registration also do +not widen agent authority. They read the canonical alert lifecycle under +`monitoring:read`; they do not accept agent reports, mint credentials, deliver +commands, or reinterpret an observer response as configuration. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 742d4b34a..2b24dfbeb 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -369,6 +369,8 @@ without requiring the model to understand an internal goroutine schedule. ## Canonical Files 1. `internal/ai/` + 1e. `internal/ai/attention.go` + 1f. `internal/ai/patrol_metrics.go` 1a. `cmd/pulse-mcp/main.go` 1b. `cmd/pulse-mcp/README.md` 1c. `internal/agentcapabilities/` @@ -3868,6 +3870,21 @@ resolve canonical/source IDs and unique aliases before collection, reject so Patrol approval/finding handoffs and alert-investigation handoffs are named by their source rather than as generic dashboard briefs. +### Operational attention projection boundary + +`internal/ai/attention.go` is the canonical Patrol attention read-model +projection over alert-owned operational records, transition timelines, +evidence envelopes, and recovery-owned protection postures. It may order, +filter, paginate, summarize, and shape selected Assistant context, but it must +not create a second writable lifecycle or infer a healthy state when lifecycle +coverage is unavailable. `internal/ai/patrol_metrics.go` exports only +low-cardinality lifecycle-state counts, queue age, acknowledgement time, and +calm-evaluation age; raw resource IDs are forbidden as metric labels. + +Assistant receives a selected attention item only after the operator opens it. +The handoff is explanation-only and cannot infer a capability, approve an +action, or replace evidence and lifecycle truth. + ## Current State First-session assistant discoverability is now a contract concern. Successful diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 39c77a8fc..2032b69bc 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -213,6 +213,19 @@ transition references recovery evidence separate from its trigger evidence. 3. Route runtime changes through the explicit alert proof policies in `registry.json`; default fallback proof routing is not allowed 4. Tighten or add guardrails when an old alert path is removed +### Attention projection source contract + +Canonical alert operational records, evidence envelopes, and lifecycle +transitions are the only writable source for Patrol attention. The legacy alert +adapter must preserve an existing provider-authored recommended next step, use +the canonical `incidentAction` when present, and otherwise add the safe +operator instruction to open the affected resource and verify current state +before changing it. This is migration guidance, not action authority. + +The attention read model may project and filter alert lifecycle state, but it +must not reinterpret acknowledgement as resolution, omit suppressed state from +inspectability, or convert missing/stale evidence into health. + ## Current State The alert resource-incident panel diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2d1685e99..911773cf6 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -32,6 +32,7 @@ product API routes free of maintainer commercial analytics. 2. `internal/api/resources.go` 3. `internal/api/discovery_handlers.go` 4. `internal/api/alerts.go` + 4a. `internal/api/attention_handlers.go` 5. `internal/api/activity_audit_handlers.go` 6. `internal/api/actions.go` 5a. `internal/api/action_executor.go` @@ -50,6 +51,7 @@ product API routes free of maintainer commercial analytics. 12a. `frontend-modern/src/types/operationalTrust.ts` 13. `frontend-modern/src/types/actionAudit.ts` 14. `frontend-modern/src/api/actionAudit.ts` + 14a. `frontend-modern/src/api/patrolAttention.ts` 7a. `frontend-modern/src/api/resourceActions.ts` 7b. `frontend-modern/src/api/agentCapabilities.ts` 7c. `frontend-modern/src/api/generated/agentCapabilities.ts` @@ -999,6 +1001,7 @@ payload shape change when the portal presents compact client rows. bounded freshness window, but it must not mint a second freshness endpoint, rewrite the stable Patrol payload, or present old run evidence as a current healthy all-clear. +12. `frontend-modern/src/api/patrolAttention.ts` shared with `patrol-intelligence`: the Patrol attention client is both the Patrol read-model transport and a canonical typed API boundary. 12. `frontend-modern/src/api/rbac.ts` shared with `organization-settings`: the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary. 13. `frontend-modern/src/api/security.ts` shared with `security-privacy`: the security frontend client is both a security/privacy control surface and a canonical API payload contract boundary. 14. `frontend-modern/src/api/updates.ts` shared with `deployment-installability`: the updates frontend client is both a deployment-installability control surface and a canonical API payload contract boundary. @@ -3466,6 +3469,24 @@ successful targeted check from a queued response alone. API outcome instead of panicking or leaving browser-visible state half rewired. +### Patrol attention transport + +`internal/api/attention_handlers.go` and +`frontend-modern/src/api/patrolAttention.ts` own the typed read transport for +`GET /api/ai/patrol/attention`, +`GET /api/ai/patrol/attention/summary`, and +`GET /api/ai/patrol/attention/{id}`. All routes require `monitoring:read`. +Lists use bounded pagination with a maximum of 200 records and one bounded +protection-posture batch. The summary path does not read recovery history. + +Lifecycle read failure returns a typed unavailable response and never a +synthetic zero or calm state. Protection lookup failure may return lifecycle +work only with `coverageState: partial`; it cannot erase work. Alert lifecycle +mutations remain on their canonical alert routes. Route, payload, and failure +proof lives in `internal/api/attention_handlers_test.go`, +`frontend-modern/src/api/__tests__/patrolAttention.test.ts`, and +`internal/api/route_inventory_test.go`. + ## Current State The public Patrol investigation boundary now carries independent diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 85c14256f..0882f83b1 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -1511,6 +1511,11 @@ the shared monitored-system presentation helper instead of branching on demo/billing state inside settings panels or inventing a second mock-only license explanation path. +The app-layout Patrol attention count is a license-neutral operational read. +It may show canonical current work after authenticated bootstrap, but it must +not infer a paid entitlement, expose an upgrade prompt, or convert missing +commercial state into feature access. + Hosted organization and Billing Admin browser clients must prefer an active `pulse_session` cookie when the same browser also retains an API token from first-run setup. Platform-admin and session-required organization operations diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index d53a1276f..5e1b6f58a 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1753,6 +1753,10 @@ same installability boundary. points for a coherent local Pulse runtime, so frontend shell health, proxy health, backend health, and listener ownership diagnostics may not drift into ad hoc shell snippets or undocumented operator lore outside those scripts. +The managed launcher must tolerate a canonical dev environment file that does +not yet contain `PULSE_MOCK_MODE`. Missing mock-mode configuration falls back +to the existing environment/default instead of terminating under `set -e`; +`scripts/tests/test-hot-dev-runtime.sh` pins this startup contract. Root and frontend workspace dependency manifests, their lockfiles, the frontend build config, and the Go module graph are canonical inputs to that developer/runtime bootstrap. Changes to `package.json`, `package-lock.json`, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 8131b28b3..dad5e2696 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2323,6 +2323,22 @@ default` instead of fusing provider and badge text such as and the parameterized search cases in `frontend-modern/src/components/Settings/__tests__/useSettingsAccess.test.tsx`. +### Attention workbench shell contract + +The app shell and Patrol attention workbench share the same canonical summary +for desktop and mobile accessible navigation counts. The label remains +`Patrol`; its accessible description may add the active attention count. +`frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx` uses native +buttons, visible focus, selected-state semantics, focus restoration, narrow +viewport ordering, and reduced-motion-safe behavior. Detail deep links use +`frontend-modern/src/routing/resourceLinks.ts` and remain stable across reload. + +The default queue may show resource, impact, evidence, protection, next-step, +and age facts. Provider evidence and lifecycle history belong in selected +detail, while legacy Patrol analytics belong in collapsed supporting context. +Unavailable and partial states must use explicit copy rather than success +styling. + ## Current State Assistant availability in the app shell is derived from the diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 5ac0668be..489f17738 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -52,10 +52,14 @@ Patrol-specific presentation helpers. 29. `frontend-modern/src/utils/textPresentation.ts` 30. `tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts` 31. `tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts` +32. `frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx` +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` ## Shared Boundaries -1. None. +1. `frontend-modern/src/api/patrolAttention.ts` shared with `api-contracts`: the Patrol attention client is both the Patrol read-model transport and a canonical typed API boundary. ## Extension Points @@ -937,6 +941,24 @@ fix`, or `Explain` based on current finding state), while secondary owning Findings, Runs, or `Details` surfaces instead of reopening the compact strip into a sparse status panel. +### Canonical operational attention workbench + +The primary Patrol workspace is now +`frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx`. It consumes +`frontend-modern/src/stores/patrolAttention.ts` and the typed attention API; it +must not derive lifecycle state from legacy Patrol findings. Active, open, +acknowledged, suppressed, stale/unknown, recent-resolved, calm, partial, and +unavailable states all come from the same backend projection. Navigation and +the queue consume the same active summary. + +The default view owns only the ordered action queue. Legacy Patrol checks, +investigations, and run history remain available under collapsed supporting +context. The selected detail owns impact, related resources, evidence quality, +protection posture, timeline, next step, resource navigation, and the +contextual Assistant handoff. Assistant is absent before selection and receives +explanation-only typed context without action or approval authority. Browser +proof is `tests/integration/tests/91-operational-trust-attention-workbench.spec.ts`. + ## Current State The active Patrol queue now uses compact severity-accented rows for diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 3ca6d1556..d1e7b15fc 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -1920,3 +1920,8 @@ boundary. Exact retries do not append evidence or rewrite timestamps. This is a per-tenant, single-process serialized config boundary, not a claim of distributed multi-writer CAS. Provider-wide inheritance, distributed budgets, and cross-process policy mutation remain outside this backend prerequisite. + +The authenticated app bootstrap performs one bounded Patrol attention summary +read and stores only low-cardinality counts. Queue list reads are paginated to +at most 200 items, posture joins batch at 200 subjects, and the summary path +does not scan recovery history or perform per-resource reads. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 60b4b37d7..29808d3b5 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -89,6 +89,14 @@ "api-contracts" ] }, + { + "path": "frontend-modern/src/api/patrolAttention.ts", + "rationale": "the Patrol attention client is both the Patrol read-model transport and a canonical typed API boundary", + "subsystems": [ + "api-contracts", + "patrol-intelligence" + ] + }, { "path": "frontend-modern/src/api/rbac.ts", "rationale": "the RBAC frontend client is both an organization settings control surface and a canonical API payload contract boundary", @@ -5461,6 +5469,7 @@ "contract": "docs/release-control/v6/internal/subsystems/patrol-intelligence.md", "owned_prefixes": [], "owned_files": [ + "frontend-modern/src/api/patrolAttention.ts", "frontend-modern/src/components/AI/FindingsPanel.tsx", "frontend-modern/src/components/Brand/PulsePatrolLogo.tsx", "frontend-modern/src/components/patrol/ApprovalBanner.tsx", @@ -5473,6 +5482,7 @@ "frontend-modern/src/components/patrol/RunHistoryEntry.tsx", "frontend-modern/src/components/patrol/RunHistoryPanel.tsx", "frontend-modern/src/components/patrol/RunToolCallTrace.tsx", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts", "frontend-modern/src/features/patrol/patrolControlPresentation.ts", "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx", @@ -5484,6 +5494,7 @@ "frontend-modern/src/pages/AIIntelligence.tsx", "frontend-modern/src/stores/aiIntelligence.ts", "frontend-modern/src/stores/aiIntelligenceSummaryModel.ts", + "frontend-modern/src/stores/patrolAttention.ts", "frontend-modern/src/types/aiIntelligence.ts", "frontend-modern/src/utils/aiFindingPresentation.ts", "frontend-modern/src/utils/aiPatrolSchedulePresentation.ts", @@ -5515,8 +5526,10 @@ "label": "patrol page and state proof", "match_prefixes": [], "match_files": [ + "frontend-modern/src/api/patrolAttention.ts", "frontend-modern/src/components/Brand/PulsePatrolLogo.tsx", "frontend-modern/src/components/patrol/index.ts", + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", "frontend-modern/src/features/patrol/patrolAutonomyAvailability.ts", "frontend-modern/src/features/patrol/patrolControlPresentation.ts", "frontend-modern/src/features/patrol/PatrolIntelligenceBanners.tsx", @@ -5528,13 +5541,16 @@ "frontend-modern/src/pages/AIIntelligence.tsx", "frontend-modern/src/stores/aiIntelligence.ts", "frontend-modern/src/stores/aiIntelligenceSummaryModel.ts", + "frontend-modern/src/stores/patrolAttention.ts", "frontend-modern/src/types/aiIntelligence.ts", "frontend-modern/src/utils/patrolPagePresentation.ts" ], "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "frontend-modern/src/api/__tests__/patrolAttention.test.ts", "frontend-modern/src/components/Brand/__tests__/PulsePatrolLogo.test.tsx", + "frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx", "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", @@ -5545,7 +5561,8 @@ "frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts", "tests/integration/tests/18-patrol-runtime-state.spec.ts", "tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts", - "tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts" + "tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts", + "tests/integration/tests/91-operational-trust-attention-workbench.spec.ts" ] }, { diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index b45fdae6e..adc5a4ad5 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -179,6 +179,12 @@ resource or alert metadata. Those fields are operational usage telemetry only; they must not be expanded into command lines, environment variables, secret material, or unbounded container inspection output at the API boundary. +Patrol attention list, summary, and detail routes require `monitoring:read`. +They expose bounded canonical evidence and resource references only; no route +grants action authority, returns credentials, or turns Assistant explanation +into approval. Canonical record IDs are compared as opaque identities even +when their percent-encoded route representation contains `/`. + Scheduled report management under `/api/admin/reports/schedules` is a settings/reporting control surface, not a new public data export. It must reuse the existing reporting feature gate and settings read/write scopes, persist diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index c5cfefced..8d3d7fa72 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1802,6 +1802,19 @@ must not treat starter so adjacent browser surfaces inherit a fail-closed API response instead of a panic after the archive import succeeds. +### Attention posture join + +Patrol attention reads protection posture through the canonical recovery store +as a bounded subject-ID batch. Recovery remains the sole owner of protected, +attention, unprotected, and unknown derivation and its provider evidence. +Attention projection may order and display that posture but cannot recompute it +or call the store once per item. + +If posture lookup fails, lifecycle work remains visible with partial coverage. +If no subject-linked posture exists, the UI says protection is unknown rather +than unprotected. The summary-only navigation path does not query recovery +history. + ## Current State Shared `internal/api/ai_handlers.go` now projects separate Patrol investigation diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index a22572f16..3b81f7f2c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1790,6 +1790,12 @@ through the canonical resource model, but unified-resource consumers must not reintroduce removed workload aliases or feature-local resource-type shims just to satisfy one table, drawer, or badge surface. +Patrol attention resource navigation carries the canonical subject resource ID +through shared route builders as an opaque query value. Attention deep links +carry the canonical operational-record ID separately. Neither link may derive +a replacement resource identity from display text, provider labels, or alert +metadata. + ### Protection posture identity consumer `ProxmoxCoverageTable` remains a unified-resource identity consumer while diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index af88deed0..2cce278dd 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -52,7 +52,7 @@ import { updateStore } from '@/stores/updates'; import { aiChatStore } from '@/stores/aiChat'; import { getActionApprovalBadgePresentation } from '@/features/actions/actionPresentation'; import { actionInboxStore } from '@/stores/actionInbox'; -import { aiIntelligenceStore } from '@/stores/aiIntelligence'; +import { patrolAttentionStore } from '@/stores/patrolAttention'; import { isPro } from '@/stores/licenseCommercial'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { getAssistantPageContext } from '@/utils/assistantPageContext'; @@ -401,11 +401,11 @@ export function AppLayout(props: AppLayoutProps) { const actionApprovalBadge = createMemo(() => getActionApprovalBadgePresentation(actionInboxStore.pendingActionCount), ); - const patrolOpenWorkCount = createMemo(() => aiIntelligenceStore.patrolOpenWorkCount); - const patrolOpenWorkCountLabel = createMemo(() => { - const count = patrolOpenWorkCount(); + const patrolAttentionCount = createMemo(() => patrolAttentionStore.summary()?.activeCount ?? 0); + const patrolAttentionCountLabel = createMemo(() => { + const count = patrolAttentionCount(); if (count <= 0) return undefined; - return `${count} open work ${count === 1 ? 'item' : 'items'}`; + return `${count} active attention ${count === 1 ? 'item' : 'items'}`; }); // Platform/runtime nav is resource-admitted. A platform or runtime lens only @@ -534,10 +534,10 @@ export function AppLayout(props: AppLayoutProps) { id: 'ai', label: 'Patrol', route: '/patrol', - tooltip: 'Review Patrol checks, findings, and approvals', + tooltip: 'Review active operational attention and recent Patrol checks', badge: null, - count: patrolOpenWorkCount() > 0 ? patrolOpenWorkCount() : undefined, - countLabel: patrolOpenWorkCountLabel(), + count: patrolAttentionCount() > 0 ? patrolAttentionCount() : undefined, + countLabel: patrolAttentionCountLabel(), breakdown: undefined, icon: PulsePatrolLogo, }, diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 71a06a7a6..a31a27808 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -321,9 +321,11 @@ describe('App architecture', () => { expect(appLayoutSource).not.toContain("'/operations', '/patrol', '/ai'"); expect(appLayoutSource).toContain("route: '/patrol',"); expect(appLayoutSource).toContain("label: 'Patrol'"); - expect(appLayoutSource).toContain("tooltip: 'Review Patrol checks, findings, and approvals'"); - expect(appLayoutSource).toContain('const patrolOpenWorkCount = createMemo('); - expect(appLayoutSource).toContain('countLabel: patrolOpenWorkCountLabel()'); + expect(appLayoutSource).toContain( + "tooltip: 'Review active operational attention and recent Patrol checks'", + ); + expect(appLayoutSource).toContain('const patrolAttentionCount = createMemo('); + expect(appLayoutSource).toContain('countLabel: patrolAttentionCountLabel()'); expect(appLayoutSource).not.toContain("label: 'Needs Attention'"); expect(appLayoutSource).not.toContain("route: '/operations',"); expect(appLayoutSource).not.toContain('props.connected()'); @@ -503,6 +505,6 @@ describe('App architecture', () => { expect(runtimeHomeSource).not.toContain('aiIntelligenceStore'); expect(runtimeHomeSource).not.toContain('patrolOpenWork'); expect(appLayoutSource).toContain("label: 'Patrol'"); - expect(appLayoutSource).toContain('countLabel: patrolOpenWorkCountLabel()'); + expect(appLayoutSource).toContain('countLabel: patrolAttentionCountLabel()'); }); }); diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 0961051d0..e876fb647 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -10,15 +10,15 @@ import { aiChatStore } from '@/stores/aiChat'; HTMLElement.prototype.scrollIntoView = vi.fn(); window.scrollTo = vi.fn(); -const aiIntelligenceMockState = vi.hoisted(() => ({ - patrolOpenWorkCount: 0, +const patrolAttentionMockState = vi.hoisted(() => ({ + activeCount: 0, })); -vi.mock('@/stores/aiIntelligence', () => ({ - aiIntelligenceStore: { - get patrolOpenWorkCount() { - return aiIntelligenceMockState.patrolOpenWorkCount; - }, +vi.mock('@/stores/patrolAttention', () => ({ + patrolAttentionStore: { + summary: () => ({ + activeCount: patrolAttentionMockState.activeCount, + }), }, })); @@ -30,7 +30,7 @@ describe('AppLayout navigation icons', () => { beforeEach(() => { window.history.replaceState({}, '', '/settings/infrastructure'); resetPrimaryNavigationRouteMemory(); - aiIntelligenceMockState.patrolOpenWorkCount = 0; + patrolAttentionMockState.activeCount = 0; aiChatStore.close(); aiChatStore.setEnabled(true); }); @@ -170,8 +170,8 @@ describe('AppLayout navigation icons', () => { expect(container).toHaveTextContent('Infrastructure body'); }); - it('surfaces Patrol open work as a count without renaming Patrol', () => { - aiIntelligenceMockState.patrolOpenWorkCount = 2; + it('surfaces canonical Patrol attention as a count without renaming Patrol', () => { + patrolAttentionMockState.activeCount = 2; renderLayout(); const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' }); @@ -179,7 +179,7 @@ describe('AppLayout navigation icons', () => { expect(systemGroup).toBeTruthy(); const desktopPatrolTab = within(systemGroup as HTMLElement).getByRole('tab', { - name: 'Patrol: 2 open work items', + name: 'Patrol: 2 active attention items', }); expect(desktopPatrolTab).toHaveTextContent('Patrol'); expect(desktopPatrolTab).toHaveTextContent('2'); @@ -187,7 +187,7 @@ describe('AppLayout navigation icons', () => { const mobileTablist = screen.getByRole('tablist', { name: 'Mobile navigation' }); const mobilePatrolTab = within(mobileTablist).getByRole('button', { - name: 'Patrol: 2 open work items', + name: 'Patrol: 2 active attention items', }); expect(mobilePatrolTab).toHaveTextContent('Patrol'); expect(mobilePatrolTab).toHaveTextContent('2'); diff --git a/frontend-modern/src/api/__tests__/patrolAttention.test.ts b/frontend-modern/src/api/__tests__/patrolAttention.test.ts new file mode 100644 index 000000000..a428541ae --- /dev/null +++ b/frontend-modern/src/api/__tests__/patrolAttention.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +import { + getPatrolAttention, + getPatrolAttentionDetail, + getPatrolAttentionSummary, +} from '@/api/patrolAttention'; +import { apiFetchJSON } from '@/utils/apiClient'; + +describe('Patrol attention API', () => { + const fetchMock = vi.mocked(apiFetchJSON); + + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue({}); + }); + + it('uses one bounded typed list query', async () => { + await getPatrolAttention('stale_unknown', 2, 40); + expect(fetchMock).toHaveBeenCalledWith( + '/api/ai/patrol/attention?filter=stale_unknown&page=2&limit=40', + ); + }); + + it('uses the canonical summary and encoded stable item detail routes', async () => { + await getPatrolAttentionSummary(); + expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/summary'); + + await getPatrolAttentionDetail('record/one'); + expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/record%2Fone'); + }); +}); diff --git a/frontend-modern/src/api/patrolAttention.ts b/frontend-modern/src/api/patrolAttention.ts new file mode 100644 index 000000000..73e0ed809 --- /dev/null +++ b/frontend-modern/src/api/patrolAttention.ts @@ -0,0 +1,113 @@ +import { apiFetchJSON } from '@/utils/apiClient'; +import type { + EvidenceCompleteness, + EvidenceEnvelope, + EvidenceFreshness, + LifecycleTransition, + OperationalRecord, + OperationalSeverity, + OperationalState, +} from '@/types/operationalTrust'; +import type { ProtectionPosture } from '@/types/recovery'; + +export type AttentionFilter = + | 'active' + | 'open' + | 'acknowledged' + | 'suppressed' + | 'stale_unknown' + | 'resolved' + | 'all'; + +export type AttentionVerificationState = + | 'not_available' + | 'pending' + | 'succeeded' + | 'failed' + | 'unknown'; + +export interface AttentionActionOffer { + capability: string; + label: string; + risk: string; + requiresApproval: boolean; +} + +export interface AttentionResource { + resourceId: string; +} + +export interface AttentionItem { + id: string; + operationalRecordId: string; + subjectResourceId: string; + subjectResourceName: string; + subjectResourceType?: string; + title: string; + plainLanguageSummary: string; + severity: OperationalSeverity; + state: OperationalState; + firstObservedAt: string; + lastObservedAt: string; + evidenceFreshness: EvidenceFreshness; + evidenceCompleteness: EvidenceCompleteness; + impact?: string; + protectionPosture?: ProtectionPosture; + relatedResources: AttentionResource[]; + recommendedNextStep?: string; + availableActions: AttentionActionOffer[]; + verificationState: AttentionVerificationState; +} + +export interface AttentionItemDetail { + item: AttentionItem; + operationalRecord: OperationalRecord; + timeline: LifecycleTransition[]; + evidence: EvidenceEnvelope[]; +} + +export interface AttentionSummary { + activeCount: number; + openCount: number; + acknowledgedCount: number; + suppressedCount: number; + uncertainCount: number; + resolvedCount: number; + calm: boolean; + coverageState: 'current' | 'partial' | 'unavailable'; + evaluatedAt: string; +} + +export interface AttentionListResponse { + data: AttentionItem[]; + summary: AttentionSummary; + meta: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +export async function getPatrolAttention( + filter: AttentionFilter = 'active', + page = 1, + limit = 50, +): Promise { + const search = new URLSearchParams({ + filter, + page: String(page), + limit: String(limit), + }); + return apiFetchJSON(`/api/ai/patrol/attention?${search.toString()}`); +} + +export async function getPatrolAttentionSummary(): Promise { + return apiFetchJSON('/api/ai/patrol/attention/summary'); +} + +export async function getPatrolAttentionDetail(itemId: string): Promise { + return apiFetchJSON( + `/api/ai/patrol/attention/${encodeURIComponent(itemId)}`, + ); +} diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx new file mode 100644 index 000000000..8b2bc282f --- /dev/null +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -0,0 +1,791 @@ +import { useLocation } from '@solidjs/router'; +import { + createEffect, + createMemo, + createSignal, + For, + onCleanup, + onMount, + Show, + untrack, +} from 'solid-js'; +import AlertTriangleIcon from 'lucide-solid/icons/triangle-alert'; +import CheckCircleIcon from 'lucide-solid/icons/circle-check'; +import ChevronRightIcon from 'lucide-solid/icons/chevron-right'; +import ClockIcon from 'lucide-solid/icons/clock'; +import ExternalLinkIcon from 'lucide-solid/icons/external-link'; +import RefreshIcon from 'lucide-solid/icons/refresh-cw'; +import SparklesIcon from 'lucide-solid/icons/sparkles'; +import XIcon from 'lucide-solid/icons/x'; +import type { + AttentionFilter, + AttentionItem, + AttentionItemDetail, +} from '@/api/patrolAttention'; +import { Button, ButtonLink } from '@/components/shared/Button'; +import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; +import { MetadataBadge, type MetadataBadgeTone } from '@/components/shared/MetadataBadge'; +import { aiChatStore } from '@/stores/aiChat'; +import { patrolAttentionStore } from '@/stores/patrolAttention'; +import { + buildPatrolAttentionPath, + buildStandalonePath, + buildWorkloadsRouteSearch, + parsePatrolAttentionItemId, +} from '@/routing/resourceLinks'; +import type { EvidenceEnvelope } from '@/types/operationalTrust'; +import { formatRelativeTime } from '@/utils/format'; + +const PRIMARY_EVIDENCE_LIMIT = 3; + +const FILTERS: Array<{ id: AttentionFilter; label: string }> = [ + { id: 'active', label: 'Active' }, + { id: 'open', label: 'Open' }, + { id: 'acknowledged', label: 'Acknowledged' }, + { id: 'suppressed', label: 'Suppressed' }, + { id: 'stale_unknown', label: 'Stale or unknown' }, + { id: 'resolved', label: 'Recent resolved' }, +]; + +export function PatrolAttentionWorkbench() { + const location = useLocation(); + const [selectedItemId, setSelectedItemId] = createSignal(''); + const itemButtons = new Map(); + let detailPanel: HTMLDivElement | undefined; + + const selectedDetail = () => patrolAttentionStore.selectedDetail(); + const summary = () => patrolAttentionStore.summary(); + const filterCount = (filter: AttentionFilter): number | undefined => { + const value = summary(); + if (!value) return undefined; + switch (filter) { + case 'active': + return value.activeCount; + case 'open': + return value.openCount; + case 'acknowledged': + return value.acknowledgedCount; + case 'suppressed': + return value.suppressedCount; + case 'stale_unknown': + return value.uncertainCount; + case 'resolved': + return value.resolvedCount; + default: + return undefined; + } + }; + + const loadCurrentFilter = () => patrolAttentionStore.load(patrolAttentionStore.filter()); + const scrollDetailIntoView = () => { + queueMicrotask(() => { + if (window.matchMedia?.('(max-width: 1023px)').matches) { + detailPanel?.scrollIntoView?.({ block: 'start' }); + } + }); + }; + const selectItem = (itemId: string) => { + setSelectedItemId(itemId); + replaceAttentionLocation(itemId); + void patrolAttentionStore.select(itemId); + scrollDetailIntoView(); + }; + const closeDetail = () => { + const previous = selectedItemId(); + setSelectedItemId(''); + replaceAttentionLocation(''); + void patrolAttentionStore.select(null); + queueMicrotask(() => itemButtons.get(previous)?.focus()); + }; + const changeFilter = (filter: AttentionFilter) => { + closeDetail(); + void patrolAttentionStore.load(filter); + }; + + onMount(() => { + void patrolAttentionStore.load('active'); + const interval = window.setInterval(loadCurrentFilter, 30000); + onCleanup(() => window.clearInterval(interval)); + }); + + createEffect(() => { + const deepLinkedItem = parsePatrolAttentionItemId(location.search); + const currentItem = untrack(selectedItemId); + if (deepLinkedItem && deepLinkedItem !== currentItem) { + setSelectedItemId(deepLinkedItem); + void patrolAttentionStore.select(deepLinkedItem); + scrollDetailIntoView(); + } else if (!deepLinkedItem && currentItem) { + setSelectedItemId(''); + void patrolAttentionStore.select(null); + } + }); + + const activeCountLabel = createMemo(() => { + const count = summary()?.activeCount; + if (count === undefined) return 'Attention count unavailable'; + return `${count} active attention ${count === 1 ? 'item' : 'items'}`; + }); + + return ( +
+
+
+
+
+

+ Needs attention +

+ + 0 ? 'warning' : 'success'} + size="sm" + shape="rounded" + aria-label={activeCountLabel()} + > + {summary()?.activeCount ?? 0} + + +
+

+ Current operational issues, ordered by urgency, affected resources, protection + concern, evidence quality, and age. +

+
+ +
+ +
+ + {(option) => { + const selected = () => patrolAttentionStore.filter() === option.id; + const count = () => filterCount(option.id); + return ( + + ); + }} + +
+
+ +
+
+ +
+ +
+ +
+
+
+
+ ); +} + +function replaceAttentionLocation(itemId: string) { + const nextPath = buildPatrolAttentionPath(itemId); + if (typeof window !== 'undefined') { + window.history.replaceState(window.history.state, '', nextPath); + } +} + +function AttentionList(props: { + selectedItemId: string; + itemButtons: Map; + onSelect: (itemId: string) => void; +}) { + return ( +
+ + {(message) => ( +
+
+ )} +
+ + 0} + fallback={ +
+ + Loading current attention +
+ } + > + 0} + fallback={} + > +
    + + {(item) => ( +
  • + +
  • + )} +
    +
+
+
+
+ ); +} + +function AttentionEmptyState() { + const summary = () => patrolAttentionStore.summary(); + const activeFilter = () => patrolAttentionStore.filter() === 'active'; + const trustworthyCalm = () => + activeFilter() && + summary()?.calm === true && + summary()?.coverageState === 'current' && + !patrolAttentionStore.error(); + + return ( +
+ + +
+ ); +} + +function AttentionDetail(props: { + detail: AttentionItemDetail | null; + loading: boolean; + onClose: () => void; +}) { + const detail = () => props.detail; + const item = () => detail()?.item; + const orderedEvidence = createMemo(() => + [...(detail()?.evidence ?? [])].sort( + (left, right) => + new Date(right.observedAt).getTime() - new Date(left.observedAt).getTime(), + ), + ); + const primaryEvidence = createMemo(() => orderedEvidence().slice(0, PRIMARY_EVIDENCE_LIMIT)); + const olderEvidence = createMemo(() => orderedEvidence().slice(PRIMARY_EVIDENCE_LIMIT)); + const resourceHref = () => { + const value = item(); + if (!value) return buildStandalonePath('machines'); + return `${buildStandalonePath('machines')}${buildWorkloadsRouteSearch({ + resource: value.subjectResourceId, + })}`; + }; + const openAssistant = () => { + const value = detail(); + if (!value) return; + const current = value.item; + const evidence = value.evidence.map( + (entry) => + `${entry.source.provider}/${entry.source.collector}: ${entry.completeness}, ${entry.confidence}, observed ${entry.observedAt}`, + ); + aiChatStore.open({ + targetType: current.subjectResourceType || 'resource', + targetId: current.subjectResourceId, + autonomousMode: false, + handoffResources: [ + { + id: current.subjectResourceId, + name: current.subjectResourceName, + type: current.subjectResourceType, + }, + ], + briefing: { + sourceLabel: 'Pulse Patrol', + title: 'Selected attention item', + subject: current.title, + statusLabel: `${formatLabel(current.severity)} · ${formatLabel(current.state)}`, + detailLines: [ + current.plainLanguageSummary, + current.impact ? `Impact: ${current.impact}` : undefined, + current.recommendedNextStep + ? `Next step: ${current.recommendedNextStep}` + : undefined, + ].filter((line): line is string => Boolean(line)), + evidence: evidence.slice(0, 5), + actionLabel: `Explain ${current.title}`, + safetyNote: + 'This context explains evidence only. It does not grant approval or action authority.', + }, + handoffContext: [ + `Attention Item: ${current.id}`, + `Operational Record: ${current.operationalRecordId}`, + `Resource: ${current.subjectResourceName} (${current.subjectResourceId})`, + `State: ${current.state}`, + `Severity: ${current.severity}`, + `Summary: ${current.plainLanguageSummary}`, + `Evidence: ${current.evidenceFreshness}/${current.evidenceCompleteness}`, + current.impact ? `Impact: ${current.impact}` : '', + current.recommendedNextStep ? `Recommended Next Step: ${current.recommendedNextStep}` : '', + 'Authority Boundary: Explain selected evidence only. Do not infer capabilities or bypass approval.', + ] + .filter(Boolean) + .join('\n'), + context: { + attentionItemId: current.id, + operationalRecordId: current.operationalRecordId, + lifecycleState: current.state, + evidenceFreshness: current.evidenceFreshness, + evidenceCompleteness: current.evidenceCompleteness, + protectionPosture: current.protectionPosture, + }, + }); + }; + + return ( + + ); +} + +function DetailSection(props: { title: string; children: import('solid-js').JSX.Element }) { + return ( +
+

{props.title}

+
{props.children}
+
+ ); +} + +function EvidenceObservation(props: { evidence: EvidenceEnvelope }) { + return ( +
  • +
    + + {formatProvider(props.evidence.source.provider)} + + + {formatLabel(props.evidence.completeness)} + + + {formatLabel(props.evidence.confidence)} + +
    +

    + {props.evidence.source.collector} · observed{' '} + {formatRelativeTime(props.evidence.observedAt, { compact: true })} +

    + + {(reason) =>

    {reason()}

    } +
    +
  • + ); +} + +function SeverityMarker(props: { item: AttentionItem }) { + const classes = () => { + switch (props.item.severity) { + case 'critical': + return 'bg-red-500'; + case 'warning': + return 'bg-amber-500'; + case 'info': + return 'bg-blue-500'; + default: + return 'bg-slate-400'; + } + }; + return ( +