Build canonical Patrol attention workbench

This commit is contained in:
rcourtman
2026-07-19 03:35:59 +01:00
parent 93dbdd91cf
commit 07a4d05ca3
46 changed files with 4122 additions and 36 deletions
+28 -1
View File
@@ -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.
---
+23
View File
@@ -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`
(1200).
- 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`
@@ -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.
@@ -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"
}
]
},
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`,
@@ -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
@@ -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
@@ -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.
@@ -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"
]
},
{
@@ -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
@@ -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
@@ -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
+8 -8
View File
@@ -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,
},
@@ -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()');
});
});
@@ -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');
@@ -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');
});
});
+113
View File
@@ -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<AttentionListResponse> {
const search = new URLSearchParams({
filter,
page: String(page),
limit: String(limit),
});
return apiFetchJSON<AttentionListResponse>(`/api/ai/patrol/attention?${search.toString()}`);
}
export async function getPatrolAttentionSummary(): Promise<AttentionSummary> {
return apiFetchJSON<AttentionSummary>('/api/ai/patrol/attention/summary');
}
export async function getPatrolAttentionDetail(itemId: string): Promise<AttentionItemDetail> {
return apiFetchJSON<AttentionItemDetail>(
`/api/ai/patrol/attention/${encodeURIComponent(itemId)}`,
);
}
@@ -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<string, HTMLButtonElement>();
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 (
<section
aria-labelledby="patrol-attention-heading"
class="overflow-hidden rounded-lg border border-border bg-surface shadow-sm"
>
<div class="border-b border-border px-4 py-4 sm:px-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 id="patrol-attention-heading" class="text-base font-semibold text-base-content">
Needs attention
</h2>
<Show when={summary()}>
<MetadataBadge
tone={(summary()?.activeCount ?? 0) > 0 ? 'warning' : 'success'}
size="sm"
shape="rounded"
aria-label={activeCountLabel()}
>
{summary()?.activeCount ?? 0}
</MetadataBadge>
</Show>
</div>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
Current operational issues, ordered by urgency, affected resources, protection
concern, evidence quality, and age.
</p>
</div>
<Button
variant="secondary"
size="sm"
class="gap-1.5"
onClick={() => void loadCurrentFilter()}
disabled={patrolAttentionStore.loading()}
aria-label="Refresh Patrol attention"
>
<RefreshIcon
class={`h-4 w-4 ${patrolAttentionStore.loading() ? 'motion-safe:animate-spin' : ''}`}
aria-hidden="true"
/>
Refresh
</Button>
</div>
<div class="mt-4 flex gap-2 overflow-x-auto pb-1" role="group" aria-label="Attention filter">
<For each={FILTERS}>
{(option) => {
const selected = () => patrolAttentionStore.filter() === option.id;
const count = () => filterCount(option.id);
return (
<button
type="button"
class={`inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
selected()
? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-200'
: 'border-border bg-surface text-muted hover:bg-surface-hover hover:text-base-content'
}`}
aria-pressed={selected()}
onClick={() => changeFilter(option.id)}
>
{option.label}
<Show when={count() !== undefined}>
<span class="tabular-nums text-[10px]">{count()}</span>
</Show>
</button>
);
}}
</For>
</div>
</div>
<div
class={`grid min-w-0 ${selectedItemId() ? 'lg:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.2fr)]' : ''}`}
>
<div
class={`min-w-0 ${selectedItemId() ? 'border-b border-border lg:border-b-0 lg:border-r' : ''}`}
>
<AttentionList
selectedItemId={selectedItemId()}
itemButtons={itemButtons}
onSelect={selectItem}
/>
</div>
<Show when={selectedItemId()}>
<div ref={detailPanel} class="order-first min-w-0 scroll-mt-4 lg:order-none">
<AttentionDetail
detail={selectedDetail()}
loading={patrolAttentionStore.detailLoading()}
onClose={closeDetail}
/>
</div>
</Show>
</div>
</section>
);
}
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<string, HTMLButtonElement>;
onSelect: (itemId: string) => void;
}) {
return (
<div aria-live="polite">
<Show when={patrolAttentionStore.error()}>
{(message) => (
<div class="m-4 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-200">
<AlertTriangleIcon class="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
<div>
<h3 class="text-sm font-semibold">Patrol attention is unavailable</h3>
<p class="mt-1 text-xs leading-5">{message()}</p>
<p class="mt-1 text-xs leading-5">
Pulse has not inferred a calm or healthy state from this failure.
</p>
</div>
</div>
)}
</Show>
<Show
when={!patrolAttentionStore.loading() || patrolAttentionStore.items().length > 0}
fallback={
<div class="flex min-h-40 items-center justify-center gap-2 text-sm text-muted">
<LoadingSpinner size="sm" />
Loading current attention
</div>
}
>
<Show
when={patrolAttentionStore.items().length > 0}
fallback={<AttentionEmptyState />}
>
<ul class="divide-y divide-border" aria-label="Patrol attention items">
<For each={patrolAttentionStore.items()}>
{(item) => (
<li>
<button
ref={(element) => props.itemButtons.set(item.id, element)}
type="button"
class={`w-full px-4 py-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5 ${
props.selectedItemId === item.id
? 'bg-blue-50/80 dark:bg-blue-950/30'
: 'hover:bg-surface-hover'
}`}
aria-pressed={props.selectedItemId === item.id}
aria-label={`Open ${item.title}`}
onClick={() => props.onSelect(item.id)}
>
<div class="flex min-w-0 items-start gap-3">
<SeverityMarker item={item} />
<div class="min-w-0 flex-1">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<span class="truncate text-sm font-semibold text-base-content">
{item.title}
</span>
<StateBadge item={item} />
</div>
<p class="mt-1 line-clamp-2 text-xs leading-5 text-muted">
{item.plainLanguageSummary}
</p>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<span class="truncate font-medium text-base-content">
{item.subjectResourceName}
</span>
<EvidenceLabel item={item} />
<ProtectionLabel item={item} />
<span>{formatRelativeTime(item.firstObservedAt, { compact: true })}</span>
</div>
</div>
<ChevronRightIcon class="mt-1 h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
</div>
</button>
</li>
)}
</For>
</ul>
</Show>
</Show>
</div>
);
}
function AttentionEmptyState() {
const summary = () => patrolAttentionStore.summary();
const activeFilter = () => patrolAttentionStore.filter() === 'active';
const trustworthyCalm = () =>
activeFilter() &&
summary()?.calm === true &&
summary()?.coverageState === 'current' &&
!patrolAttentionStore.error();
return (
<div class="flex min-h-52 flex-col items-center justify-center px-6 py-10 text-center">
<Show
when={trustworthyCalm()}
fallback={
<>
<ClockIcon class="h-8 w-8 text-muted" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">
No items in this view
</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
{summary()?.coverageState === 'partial'
? 'The lifecycle queue is empty, but protection context is incomplete. Pulse is not treating that gap as proof of health.'
: 'Choose another lifecycle filter or refresh the current evaluation.'}
</p>
</>
}
>
<CheckCircleIcon class="h-9 w-9 text-emerald-500" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">
Nothing needs your attention
</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
The current operational lifecycle evaluation has no active items.
<Show when={summary()?.evaluatedAt}>
{(evaluatedAt) => ` Checked ${formatRelativeTime(evaluatedAt(), { compact: true })}.`}
</Show>
</p>
</Show>
</div>
);
}
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 (
<aside
class="min-w-0 bg-surface"
aria-labelledby="attention-detail-title"
aria-busy={props.loading}
>
<div class="flex items-start justify-between gap-3 border-b border-border px-4 py-3 sm:px-5">
<div class="min-w-0">
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted">
Attention detail
</p>
<h3 id="attention-detail-title" class="mt-1 text-sm font-semibold text-base-content">
{item()?.title ?? 'Loading attention item'}
</h3>
</div>
<button
type="button"
class="rounded p-1 text-muted hover:bg-surface-hover hover:text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Close attention detail"
onClick={props.onClose}
>
<XIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
<Show
when={!props.loading && detail()}
fallback={
<div class="flex min-h-48 items-center justify-center gap-2 text-sm text-muted">
<LoadingSpinner size="sm" />
Loading evidence
</div>
}
>
{(loaded) => (
<div class="space-y-5 px-4 py-4 sm:px-5">
<section>
<div class="flex flex-wrap items-center gap-2">
<StateBadge item={loaded().item} />
<EvidenceLabel item={loaded().item} badge />
<ProtectionLabel item={loaded().item} badge />
</div>
<p class="mt-3 text-sm leading-6 text-base-content">
{loaded().item.plainLanguageSummary}
</p>
</section>
<DetailSection title="Affected resource">
<p class="text-sm font-medium text-base-content">
{loaded().item.subjectResourceName}
</p>
<p class="mt-1 break-all text-xs text-muted">
{loaded().item.subjectResourceId}
</p>
<Show when={loaded().item.relatedResources.length > 0}>
<p class="mt-2 text-xs text-muted">
{loaded().item.relatedResources.length} related{' '}
{loaded().item.relatedResources.length === 1 ? 'resource' : 'resources'}
</p>
</Show>
</DetailSection>
<Show when={loaded().item.impact || loaded().item.recommendedNextStep}>
<DetailSection title="What to do">
<Show when={loaded().item.impact}>
{(impact) => <p class="text-xs leading-5 text-muted">Impact: {impact()}</p>}
</Show>
<Show when={loaded().item.recommendedNextStep}>
{(nextStep) => (
<p class="mt-2 text-sm font-medium leading-5 text-base-content">
{nextStep()}
</p>
)}
</Show>
</DetailSection>
</Show>
<DetailSection title="Evidence">
<Show
when={orderedEvidence().length > 0}
fallback={
<p class="text-xs leading-5 text-amber-700 dark:text-amber-300">
Evidence detail is unavailable. Pulse has not presented this as confirmed.
</p>
}
>
<Show when={olderEvidence().length > 0}>
<p class="mb-2 text-xs text-muted">
Showing the latest {primaryEvidence().length} of {orderedEvidence().length}{' '}
observations.
</p>
</Show>
<ul class="space-y-2">
<For each={primaryEvidence()}>
{(evidence) => <EvidenceObservation evidence={evidence} />}
</For>
</ul>
<Show when={olderEvidence().length > 0}>
<details class="mt-2 rounded-md border border-border-subtle bg-surface-alt/30">
<summary class="cursor-pointer px-3 py-2 text-xs font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500">
Show {olderEvidence().length} older{' '}
{olderEvidence().length === 1 ? 'observation' : 'observations'}
</summary>
<ul class="space-y-2 border-t border-border-subtle p-2">
<For each={olderEvidence()}>
{(evidence) => <EvidenceObservation evidence={evidence} />}
</For>
</ul>
</details>
</Show>
</Show>
</DetailSection>
<DetailSection title="Protection">
<Show
when={loaded().item.protectionPosture}
fallback={
<p class="text-xs leading-5 text-muted">
No current protection posture is attached to this resource. This means unknown,
not unprotected.
</p>
}
>
{(posture) => (
<>
<p class="text-xs leading-5 text-muted">{posture().explanation}</p>
<Show when={posture().providerStates.length > 0}>
<ul class="mt-2 space-y-2">
<For each={posture().providerStates}>
{(provider) => (
<li class="rounded-md border border-border-subtle px-3 py-2 text-xs">
<span class="font-medium text-base-content">
{formatProvider(provider.provider)}
</span>
<span class="text-muted">
{' '}
· {formatLabel(provider.jobState)} ·{' '}
{formatLabel(provider.historyCompleteness)}
</span>
</li>
)}
</For>
</ul>
</Show>
</>
)}
</Show>
</DetailSection>
<DetailSection title="Timeline">
<Show
when={loaded().timeline.length > 0}
fallback={
<p class="text-xs leading-5 text-muted">
No earlier lifecycle transitions are recorded. This is the first observed state.
</p>
}
>
<ol class="space-y-3">
<For each={loaded().timeline}>
{(transition) => (
<li class="border-l-2 border-border pl-3">
<p class="text-xs font-medium text-base-content">
{formatLabel(transition.from)} to {formatLabel(transition.to)}
</p>
<p class="mt-0.5 text-[11px] text-muted">
{formatRelativeTime(transition.at, { compact: true })} ·{' '}
{formatLabel(transition.cause)}
</p>
<Show when={transition.reason}>
{(reason) => <p class="mt-1 text-xs leading-5 text-muted">{reason()}</p>}
</Show>
</li>
)}
</For>
</ol>
</Show>
</DetailSection>
<div class="flex flex-wrap gap-2 border-t border-border pt-4">
<ButtonLink href={resourceHref()} variant="secondary" size="sm" class="gap-1.5">
<ExternalLinkIcon class="h-4 w-4" aria-hidden="true" />
Open resource
</ButtonLink>
<Show when={aiChatStore.enabled === true}>
<Button variant="secondary" size="sm" class="gap-1.5" onClick={openAssistant}>
<SparklesIcon class="h-4 w-4" aria-hidden="true" />
Explain with Assistant
</Button>
</Show>
</div>
</div>
)}
</Show>
</aside>
);
}
function DetailSection(props: { title: string; children: import('solid-js').JSX.Element }) {
return (
<section>
<h4 class="text-[11px] font-semibold uppercase tracking-wider text-muted">{props.title}</h4>
<div class="mt-2">{props.children}</div>
</section>
);
}
function EvidenceObservation(props: { evidence: EvidenceEnvelope }) {
return (
<li class="rounded-md border border-border-subtle bg-surface-alt/60 p-3">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold text-base-content">
{formatProvider(props.evidence.source.provider)}
</span>
<MetadataBadge
tone={props.evidence.completeness === 'complete' ? 'success' : 'warning'}
size="xs"
shape="rounded"
>
{formatLabel(props.evidence.completeness)}
</MetadataBadge>
<MetadataBadge
tone={props.evidence.confidence === 'confirmed' ? 'info' : 'muted'}
size="xs"
shape="rounded"
>
{formatLabel(props.evidence.confidence)}
</MetadataBadge>
</div>
<p class="mt-1 text-xs text-muted">
{props.evidence.source.collector} · observed{' '}
{formatRelativeTime(props.evidence.observedAt, { compact: true })}
</p>
<Show when={props.evidence.reason?.message}>
{(reason) => <p class="mt-1 text-xs leading-5 text-muted">{reason()}</p>}
</Show>
</li>
);
}
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 (
<span
class={`mt-1 h-2.5 w-2.5 shrink-0 rounded-full ${classes()}`}
title={`${formatLabel(props.item.severity)} severity`}
aria-hidden="true"
/>
);
}
function StateBadge(props: { item: AttentionItem }) {
return (
<MetadataBadge
tone={stateTone(props.item)}
size="xs"
shape="rounded"
appearance="outline"
>
{formatLabel(props.item.state)}
</MetadataBadge>
);
}
function EvidenceLabel(props: { item: AttentionItem; badge?: boolean }) {
const label = () =>
props.item.evidenceFreshness === 'fresh' &&
props.item.evidenceCompleteness === 'complete'
? 'Evidence current'
: `${formatLabel(props.item.evidenceFreshness)} / ${formatLabel(props.item.evidenceCompleteness)}`;
if (props.badge) {
return (
<MetadataBadge
tone={
props.item.evidenceFreshness === 'fresh' &&
props.item.evidenceCompleteness === 'complete'
? 'success'
: 'warning'
}
size="xs"
shape="rounded"
>
{label()}
</MetadataBadge>
);
}
return <span>{label()}</span>;
}
function ProtectionLabel(props: { item: AttentionItem; badge?: boolean }) {
const state = () => props.item.protectionPosture?.state ?? 'unknown';
const label = () => `Protection ${formatLabel(state())}`;
if (props.badge) {
return (
<MetadataBadge
tone={state() === 'protected' ? 'success' : state() === 'unknown' ? 'muted' : 'warning'}
size="xs"
shape="rounded"
>
{label()}
</MetadataBadge>
);
}
return <span>{label()}</span>;
}
function stateTone(item: AttentionItem): MetadataBadgeTone {
switch (item.state) {
case 'resolved':
return 'success';
case 'acknowledged':
return 'info';
case 'suppressed':
return 'muted';
case 'stale':
case 'unknown':
return 'warning';
default:
return item.severity === 'critical' ? 'danger' : 'warning';
}
}
function formatLabel(value: string): string {
const normalized = value.trim().replace(/[_-]+/g, ' ');
return normalized ? normalized.charAt(0).toUpperCase() + normalized.slice(1) : 'Unknown';
}
function formatProvider(value: string): string {
switch (value.trim().toLowerCase()) {
case 'pbs':
return 'Proxmox Backup Server';
case 'pve':
return 'Proxmox VE';
default:
return formatLabel(value);
}
}
@@ -2,6 +2,7 @@ import { usePatrolIntelligenceState } from './usePatrolIntelligenceState';
import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader';
import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners';
import { PatrolIntelligenceWorkspace } from './PatrolIntelligenceWorkspace';
import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench';
export function PatrolIntelligenceSurface() {
const state = usePatrolIntelligenceState();
@@ -10,12 +11,18 @@ export function PatrolIntelligenceSurface() {
<div class="space-y-6">
<PatrolIntelligenceHeader state={state} />
<PatrolIntelligenceBanners state={state} />
<PatrolAttentionWorkbench />
<div
class={`space-y-4 transition-opacity ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
>
<PatrolIntelligenceWorkspace state={state} />
</div>
<details class="rounded-lg border border-border bg-surface">
<summary class="cursor-pointer px-4 py-3 text-sm font-medium text-base-content focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:px-5">
Patrol checks, investigations, and run history
</summary>
<div
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
>
<PatrolIntelligenceWorkspace state={state} />
</div>
</details>
</div>
);
}
@@ -0,0 +1,230 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
AttentionItem,
AttentionItemDetail,
AttentionListResponse,
AttentionSummary,
} from '@/api/patrolAttention';
const apiMocks = vi.hoisted(() => ({
getList: vi.fn(),
getDetail: vi.fn(),
getSummary: vi.fn(),
}));
vi.mock('@/api/patrolAttention', async (importOriginal) => {
const original = await importOriginal<typeof import('@/api/patrolAttention')>();
return {
...original,
getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args),
getPatrolAttentionDetail: (...args: unknown[]) => apiMocks.getDetail(...args),
getPatrolAttentionSummary: (...args: unknown[]) => apiMocks.getSummary(...args),
};
});
import { PatrolAttentionWorkbench } from '../PatrolAttentionWorkbench';
import { patrolAttentionStore } from '@/stores/patrolAttention';
const evaluatedAt = '2026-07-19T08:00:00Z';
const summary = (overrides: Partial<AttentionSummary> = {}): AttentionSummary => ({
activeCount: 0,
openCount: 0,
acknowledgedCount: 0,
suppressedCount: 0,
uncertainCount: 0,
resolvedCount: 0,
calm: true,
coverageState: 'current',
evaluatedAt,
...overrides,
});
const item = (overrides: Partial<AttentionItem> = {}): AttentionItem => ({
id: 'record-1',
operationalRecordId: 'record-1',
subjectResourceId: 'pve:vm:101',
subjectResourceName: 'Database VM',
subjectResourceType: 'vm',
title: 'Disk pressure on Database VM',
plainLanguageSummary: 'The database disk is nearly full.',
severity: 'critical',
state: 'open',
firstObservedAt: '2026-07-19T07:00:00Z',
lastObservedAt: evaluatedAt,
evidenceFreshness: 'fresh',
evidenceCompleteness: 'complete',
impact: 'Writes may fail.',
protectionPosture: {
subjectResourceId: 'pve:vm:101',
state: 'attention',
freshness: 'current',
verification: 'unverified',
coverage: 'complete',
providerStates: [],
repositoryResourceIds: [],
evidenceIds: [],
explanation: 'The latest backup has not been verified.',
evaluatedAt,
},
relatedResources: [{ resourceId: 'pbs:datastore:main' }],
recommendedNextStep: 'Free disk space or expand the volume.',
availableActions: [],
verificationState: 'not_available',
...overrides,
});
const listResponse = (
items: AttentionItem[],
responseSummary: AttentionSummary,
): AttentionListResponse => ({
data: items,
summary: responseSummary,
meta: { page: 1, limit: 50, total: items.length, totalPages: items.length ? 1 : 0 },
});
const detail = (value: AttentionItem): AttentionItemDetail => ({
item: value,
operationalRecord: {
id: value.operationalRecordId,
canonicalSpecId: 'disk-pressure',
subjectResourceId: value.subjectResourceId,
state: value.state,
severity: value.severity,
firstObservedAt: value.firstObservedAt,
lastObservedAt: value.lastObservedAt,
stateChangedAt: value.lastObservedAt,
evidenceIds: ['evidence-1'],
causeKey: 'disk-pressure:pve:vm:101',
relatedResourceIds: value.relatedResources.map((resource) => resource.resourceId),
impactSummary: value.impact,
recommendedNextStep: value.recommendedNextStep,
},
timeline: [
{
id: 'transition-1',
operationalRecordId: value.operationalRecordId,
from: 'observing',
to: 'open',
at: value.firstObservedAt,
cause: 'detector_decision',
causeKey: 'disk-pressure:pve:vm:101',
evidenceIds: ['evidence-1'],
},
],
evidence: [
{
id: 'evidence-1',
source: { provider: 'pve', collector: 'resource-monitor' },
subject: { resourceId: value.subjectResourceId },
observedAt: value.lastObservedAt,
ingestedAt: value.lastObservedAt,
completeness: 'complete',
confidence: 'confirmed',
permissions: 'sufficient',
},
],
});
describe('PatrolAttentionWorkbench', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/patrol');
patrolAttentionStore.clear();
apiMocks.getList.mockReset();
apiMocks.getDetail.mockReset();
apiMocks.getSummary.mockReset();
});
afterEach(() => {
cleanup();
patrolAttentionStore.clear();
});
const renderWorkbench = () =>
render(() => (
<Router>
<Route path="/patrol" component={PatrolAttentionWorkbench} />
</Router>
));
it('renders a plain trustworthy calm state without a proof strip', async () => {
const calm = summary();
apiMocks.getList.mockResolvedValue(listResponse([], calm));
renderWorkbench();
expect(await screen.findByRole('heading', { name: 'Nothing needs your attention' }))
.toBeInTheDocument();
expect(screen.getByText(/current operational lifecycle evaluation/i)).toBeInTheDocument();
expect(screen.queryByText(/trust score/i)).not.toBeInTheDocument();
expect(screen.queryByText(/auto-resolved/i)).not.toBeInTheDocument();
});
it('opens deepest typed evidence, protection, and timeline detail from one queue item', async () => {
const active = item();
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(detail(active));
renderWorkbench();
const openButton = await screen.findByRole('button', {
name: 'Open Disk pressure on Database VM',
});
fireEvent.click(openButton);
const detailRegion = await screen.findByRole('complementary', {
name: 'Disk pressure on Database VM',
});
expect(within(detailRegion).getByText(/Impact: Writes may fail\./)).toBeInTheDocument();
expect(within(detailRegion).getByText(/latest backup has not been verified/i))
.toBeInTheDocument();
expect(within(detailRegion).getByText('Proxmox VE')).toBeInTheDocument();
expect(within(detailRegion).getByText('Observing to Open')).toBeInTheDocument();
expect(window.location.search).toBe('?attention=record-1');
});
it('keeps repeated evidence observations available without overwhelming the default detail', async () => {
const active = item();
const expandedDetail = detail(active);
expandedDetail.evidence = Array.from({ length: 5 }, (_, index) => ({
...expandedDetail.evidence[0],
id: `evidence-${index + 1}`,
observedAt: new Date(Date.parse(active.lastObservedAt) - index * 60_000).toISOString(),
}));
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail.mockResolvedValue(expandedDetail);
renderWorkbench();
fireEvent.click(
await screen.findByRole('button', {
name: 'Open Disk pressure on Database VM',
}),
);
const detailRegion = await screen.findByRole('complementary', {
name: 'Disk pressure on Database VM',
});
expect(
within(detailRegion).getByText('Showing the latest 3 of 5 observations.'),
).toBeInTheDocument();
expect(within(detailRegion).getByText('Show 2 older observations')).toBeInTheDocument();
});
it('does not turn partial coverage and an empty queue into a healthy claim', async () => {
apiMocks.getList.mockResolvedValue(
listResponse([], summary({ coverageState: 'partial', calm: true })),
);
renderWorkbench();
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'No items in this view' })).toBeInTheDocument();
});
expect(screen.getByText(/protection context is incomplete/i)).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Nothing needs your attention' }))
.not.toBeInTheDocument();
});
});
@@ -1916,7 +1916,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByTestId('patrol-status-bar')).not.toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Open work' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Active' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Active' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'All' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Resolved' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Details' })).not.toBeInTheDocument();
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
buildPatrolAttentionPath,
API_TOKEN_CREATE_ANCHOR,
API_TOKEN_PRESET_QUERY_PARAM,
DOCKER_PATH,
@@ -53,6 +54,7 @@ import {
buildVmwarePath,
buildWorkloadsRouteSearch,
parseRecoveryLinkSearch,
parsePatrolAttentionItemId,
parsePatrolControlStarter,
parsePatrolOperationsLoopStarter,
parseStorageLinkSearch,
@@ -415,4 +417,10 @@ describe('resource link routing contract', () => {
expect(url.searchParams.get('range')).toBe('30');
expect(parseRecoveryLinkSearch('?range=90')).toMatchObject({ range: '90' });
});
it('deep-links one stable Patrol attention item without changing the route owner', () => {
expect(buildPatrolAttentionPath('record/one')).toBe('/patrol?attention=record%2Fone');
expect(buildPatrolAttentionPath('')).toBe('/patrol');
expect(parsePatrolAttentionItemId('?attention=record%2Fone')).toBe('record/one');
});
});
@@ -30,6 +30,7 @@ export const VMWARE_PATH = '/vmware';
export const VMWARE_DEFAULT_TAB = 'overview';
export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway';
export const PATROL_PATH = '/patrol';
export const PATROL_ATTENTION_QUERY_PARAM = 'attention';
export const ACTIONS_PATH = '/actions';
export const PATROL_CONTROL_ANCHOR = 'patrol-control';
export const PATROL_CONTROL_STARTER_QUERY_PARAM = 'patrolControlStarter';
@@ -223,6 +224,18 @@ export const buildPatrolControlPath = (
export const buildPatrolOperationsLoopPath = buildPatrolControlPath;
export const buildPatrolAttentionPath = (attentionItemId: string): string => {
const params = new URLSearchParams();
const normalized = normalizeQueryValue(attentionItemId);
if (normalized) {
params.set(PATROL_ATTENTION_QUERY_PARAM, normalized);
}
return `${PATROL_PATH}${serializedRouteSearch(params)}`;
};
export const parsePatrolAttentionItemId = (search: string): string =>
normalizeQueryValue(new URLSearchParams(search).get(PATROL_ATTENTION_QUERY_PARAM));
export const PATROL_CONTROL_PATH_WITH_STARTER = buildPatrolControlPath({
starter: PATROL_CONTROL_STARTER,
});
@@ -0,0 +1,102 @@
import { createSignal } from 'solid-js';
import {
getPatrolAttention,
getPatrolAttentionDetail,
getPatrolAttentionSummary,
type AttentionFilter,
type AttentionItem,
type AttentionItemDetail,
type AttentionSummary,
} from '@/api/patrolAttention';
const [summary, setSummary] = createSignal<AttentionSummary | null>(null);
const [items, setItems] = createSignal<AttentionItem[]>([]);
const [selectedDetail, setSelectedDetail] = createSignal<AttentionItemDetail | null>(null);
const [loading, setLoading] = createSignal(false);
const [detailLoading, setDetailLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [filter, setFilter] = createSignal<AttentionFilter>('active');
let listRequest = 0;
let detailRequest = 0;
export const patrolAttentionStore = {
summary,
items,
selectedDetail,
loading,
detailLoading,
error,
filter,
setFilter,
async loadSummary() {
try {
const value = await getPatrolAttentionSummary();
setSummary(value);
return value;
} catch (cause) {
setSummary(null);
throw cause;
}
},
async load(nextFilter: AttentionFilter = filter()) {
const request = ++listRequest;
setFilter(nextFilter);
setLoading(true);
setError(null);
try {
const response = await getPatrolAttention(nextFilter);
if (request !== listRequest) return;
setItems(response.data);
setSummary(response.summary);
} catch (cause) {
if (request !== listRequest) return;
setItems([]);
setSummary(null);
setError(cause instanceof Error ? cause.message : 'Patrol attention is unavailable.');
} finally {
if (request === listRequest) {
setLoading(false);
}
}
},
async select(itemId: string | null) {
const request = ++detailRequest;
if (!itemId) {
setSelectedDetail(null);
setDetailLoading(false);
return;
}
setDetailLoading(true);
try {
const detail = await getPatrolAttentionDetail(itemId);
if (request === detailRequest) {
setSelectedDetail(detail);
}
} catch {
if (request === detailRequest) {
setSelectedDetail(null);
}
} finally {
if (request === detailRequest) {
setDetailLoading(false);
}
}
},
clear() {
listRequest++;
detailRequest++;
setSummary(null);
setItems([]);
setSelectedDetail(null);
setLoading(false);
setDetailLoading(false);
setError(null);
setFilter('active');
},
};
@@ -28,6 +28,7 @@ import { updateStore } from '@/stores/updates';
import { useAlertsActivation } from '@/stores/alertsActivation';
import { actionInboxStore } from '@/stores/actionInbox';
import { aiIntelligenceStore } from '@/stores/aiIntelligence';
import { patrolAttentionStore } from '@/stores/patrolAttention';
import {
applyThemeClass,
computeIsDark,
@@ -520,6 +521,7 @@ export const useAppRuntimeState = () => {
void Promise.allSettled([
aiIntelligenceStore.loadPatrolFindings(),
aiIntelligenceStore.loadPendingApprovals(),
patrolAttentionStore.loadSummary(),
actionInboxStore.loadPendingActionCount(),
]);
};
+539
View File
@@ -0,0 +1,539 @@
package ai
import (
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
recoverymodel "github.com/rcourtman/pulse-go-rewrite/internal/recovery/model"
)
const (
DefaultAttentionPageSize = 50
MaxAttentionPageSize = 200
)
type AttentionFilter string
const (
AttentionFilterActive AttentionFilter = "active"
AttentionFilterOpen AttentionFilter = "open"
AttentionFilterAcknowledged AttentionFilter = "acknowledged"
AttentionFilterSuppressed AttentionFilter = "suppressed"
AttentionFilterUncertain AttentionFilter = "stale_unknown"
AttentionFilterResolved AttentionFilter = "resolved"
AttentionFilterAll AttentionFilter = "all"
)
func (filter AttentionFilter) Valid() bool {
switch filter {
case AttentionFilterActive,
AttentionFilterOpen,
AttentionFilterAcknowledged,
AttentionFilterSuppressed,
AttentionFilterUncertain,
AttentionFilterResolved,
AttentionFilterAll:
return true
default:
return false
}
}
type AttentionVerificationState string
const (
AttentionVerificationNotAvailable AttentionVerificationState = "not_available"
AttentionVerificationPending AttentionVerificationState = "pending"
AttentionVerificationSucceeded AttentionVerificationState = "succeeded"
AttentionVerificationFailed AttentionVerificationState = "failed"
AttentionVerificationUnknown AttentionVerificationState = "unknown"
)
type AttentionActionOffer struct {
Capability string `json:"capability"`
Label string `json:"label"`
Risk string `json:"risk"`
RequiresApproval bool `json:"requiresApproval"`
}
type AttentionResource struct {
ResourceID string `json:"resourceId"`
}
type AttentionItem struct {
ID string `json:"id"`
OperationalRecordID string `json:"operationalRecordId"`
SubjectResourceID string `json:"subjectResourceId"`
SubjectResourceName string `json:"subjectResourceName"`
SubjectResourceType string `json:"subjectResourceType,omitempty"`
Title string `json:"title"`
PlainLanguageSummary string `json:"plainLanguageSummary"`
Severity operationaltrust.OperationalSeverity `json:"severity"`
State operationaltrust.OperationalState `json:"state"`
FirstObservedAt time.Time `json:"firstObservedAt"`
LastObservedAt time.Time `json:"lastObservedAt"`
EvidenceFreshness operationaltrust.EvidenceFreshness `json:"evidenceFreshness"`
EvidenceCompleteness operationaltrust.EvidenceCompleteness `json:"evidenceCompleteness"`
Impact string `json:"impact,omitempty"`
ProtectionPosture *recoverymodel.ProtectionPosture `json:"protectionPosture,omitempty"`
RelatedResources []AttentionResource `json:"relatedResources"`
RecommendedNextStep string `json:"recommendedNextStep,omitempty"`
AvailableActions []AttentionActionOffer `json:"availableActions"`
VerificationState AttentionVerificationState `json:"verificationState"`
}
type AttentionItemDetail struct {
Item AttentionItem `json:"item"`
OperationalRecord operationaltrust.OperationalRecord `json:"operationalRecord"`
Timeline []operationaltrust.LifecycleTransition `json:"timeline"`
Evidence []operationaltrust.EvidenceEnvelope `json:"evidence"`
}
type AttentionSummary struct {
ActiveCount int `json:"activeCount"`
OpenCount int `json:"openCount"`
AcknowledgedCount int `json:"acknowledgedCount"`
SuppressedCount int `json:"suppressedCount"`
UncertainCount int `json:"uncertainCount"`
ResolvedCount int `json:"resolvedCount"`
Calm bool `json:"calm"`
CoverageState string `json:"coverageState"`
EvaluatedAt time.Time `json:"evaluatedAt"`
}
type AttentionProjection struct {
Details []AttentionItemDetail `json:"details"`
Summary AttentionSummary `json:"summary"`
}
// ProjectAttentionItems is the single Patrol read-model projection over the
// canonical alert lifecycle. It does not inspect loose alert metadata to
// invent work and it never creates a second writable lifecycle.
func ProjectAttentionItems(
active []alerts.Alert,
history []alerts.Alert,
postures map[string]recoverymodel.ProtectionPosture,
now time.Time,
) AttentionProjection {
if now.IsZero() {
now = time.Now().UTC()
}
now = now.UTC()
records := make(map[string]alerts.Alert, len(active)+len(history))
for _, alert := range history {
addAttentionAlert(records, alert, false)
}
for _, alert := range active {
addAttentionAlert(records, alert, true)
}
details := make([]AttentionItemDetail, 0, len(records))
for _, alert := range records {
detail, ok := projectAttentionAlert(alert, postures, now)
if !ok {
continue
}
details = append(details, detail)
}
sort.SliceStable(details, func(i, j int) bool {
return attentionItemLess(details[i].Item, details[j].Item)
})
return AttentionProjection{
Details: details,
Summary: summarizeAttention(details, now),
}
}
func addAttentionAlert(records map[string]alerts.Alert, alert alerts.Alert, active bool) {
if alert.OperationalRecord == nil {
return
}
if !active && alert.OperationalRecord.State != operationaltrust.OperationalResolved {
return
}
recordID := strings.TrimSpace(alert.OperationalRecord.ID)
if recordID == "" {
return
}
existing, found := records[recordID]
if !found || active || attentionAlertNewer(alert, existing) {
records[recordID] = *alert.Clone()
}
}
func attentionAlertNewer(candidate, existing alerts.Alert) bool {
if candidate.OperationalRecord == nil {
return false
}
if existing.OperationalRecord == nil {
return true
}
if !candidate.OperationalRecord.StateChangedAt.Equal(existing.OperationalRecord.StateChangedAt) {
return candidate.OperationalRecord.StateChangedAt.After(existing.OperationalRecord.StateChangedAt)
}
return candidate.OperationalRecord.LastObservedAt.After(existing.OperationalRecord.LastObservedAt)
}
func projectAttentionAlert(
alert alerts.Alert,
postures map[string]recoverymodel.ProtectionPosture,
now time.Time,
) (AttentionItemDetail, bool) {
if alert.OperationalRecord == nil {
return AttentionItemDetail{}, false
}
record := alert.OperationalRecord.Clone()
if err := record.Validate(); err != nil {
return AttentionItemDetail{}, false
}
evidence := cloneAttentionEvidence(alert.Evidence)
timeline := cloneAttentionTimeline(alert.Transitions)
freshness, completeness := summarizeAttentionEvidence(record.State, evidence, now)
resourceName := firstAttentionText(alert.ResourceName, alert.Instance, record.SubjectResourceID)
title := attentionTitle(alert, resourceName)
summary := firstAttentionText(alert.Message, record.ImpactSummary, title)
related := make([]AttentionResource, 0, len(record.RelatedResourceIDs))
for _, resourceID := range canonicalAttentionIDs(record.RelatedResourceIDs) {
related = append(related, AttentionResource{ResourceID: resourceID})
}
var posture *recoverymodel.ProtectionPosture
if candidate, found := postures[record.SubjectResourceID]; found {
value := candidate.Clone()
posture = &value
}
item := AttentionItem{
ID: record.ID,
OperationalRecordID: record.ID,
SubjectResourceID: record.SubjectResourceID,
SubjectResourceName: resourceName,
SubjectResourceType: attentionResourceType(alert),
Title: title,
PlainLanguageSummary: summary,
Severity: record.Severity,
State: record.State,
FirstObservedAt: record.FirstObservedAt,
LastObservedAt: record.LastObservedAt,
EvidenceFreshness: freshness,
EvidenceCompleteness: completeness,
Impact: record.ImpactSummary,
ProtectionPosture: posture,
RelatedResources: related,
RecommendedNextStep: record.RecommendedNextStep,
AvailableActions: []AttentionActionOffer{},
VerificationState: AttentionVerificationNotAvailable,
}
return AttentionItemDetail{
Item: item,
OperationalRecord: record,
Timeline: timeline,
Evidence: evidence,
}, true
}
func summarizeAttentionEvidence(
state operationaltrust.OperationalState,
evidence []operationaltrust.EvidenceEnvelope,
now time.Time,
) (operationaltrust.EvidenceFreshness, operationaltrust.EvidenceCompleteness) {
if state == operationaltrust.OperationalStale {
return operationaltrust.EvidenceStale, worstAttentionCompleteness(evidence)
}
if state == operationaltrust.OperationalUnknown {
return operationaltrust.EvidenceFreshnessUnknown, worstAttentionCompleteness(evidence)
}
if len(evidence) == 0 {
return operationaltrust.EvidenceFreshnessUnknown, operationaltrust.EvidenceUnavailable
}
freshness := operationaltrust.EvidenceFresh
completeness := operationaltrust.EvidenceComplete
for _, envelope := range evidence {
switch envelope.FreshnessAt(now) {
case operationaltrust.EvidenceStale:
freshness = operationaltrust.EvidenceStale
case operationaltrust.EvidenceFreshnessUnknown:
if freshness != operationaltrust.EvidenceStale {
freshness = operationaltrust.EvidenceFreshnessUnknown
}
}
switch envelope.Completeness {
case operationaltrust.EvidenceUnavailable:
completeness = operationaltrust.EvidenceUnavailable
case operationaltrust.EvidencePartial:
if completeness != operationaltrust.EvidenceUnavailable {
completeness = operationaltrust.EvidencePartial
}
}
}
return freshness, completeness
}
func worstAttentionCompleteness(
evidence []operationaltrust.EvidenceEnvelope,
) operationaltrust.EvidenceCompleteness {
if len(evidence) == 0 {
return operationaltrust.EvidenceUnavailable
}
completeness := operationaltrust.EvidenceComplete
for _, envelope := range evidence {
if envelope.Completeness == operationaltrust.EvidenceUnavailable {
return operationaltrust.EvidenceUnavailable
}
if envelope.Completeness == operationaltrust.EvidencePartial {
completeness = operationaltrust.EvidencePartial
}
}
return completeness
}
func FilterAttentionDetails(
details []AttentionItemDetail,
filter AttentionFilter,
) ([]AttentionItemDetail, error) {
if filter == "" {
filter = AttentionFilterActive
}
if !filter.Valid() {
return nil, fmt.Errorf("invalid attention filter %q", filter)
}
filtered := make([]AttentionItemDetail, 0, len(details))
for _, detail := range details {
if attentionStateMatches(detail.Item.State, filter) {
filtered = append(filtered, detail)
}
}
return filtered, nil
}
func PaginateAttentionDetails(
details []AttentionItemDetail,
page int,
limit int,
) ([]AttentionItemDetail, error) {
if page < 1 {
return nil, errors.New("attention page must be at least one")
}
if limit < 1 || limit > MaxAttentionPageSize {
return nil, fmt.Errorf("attention limit must be between 1 and %d", MaxAttentionPageSize)
}
start := (page - 1) * limit
if start >= len(details) {
return []AttentionItemDetail{}, nil
}
end := start + limit
if end > len(details) {
end = len(details)
}
return append([]AttentionItemDetail(nil), details[start:end]...), nil
}
func attentionStateMatches(state operationaltrust.OperationalState, filter AttentionFilter) bool {
switch filter {
case AttentionFilterActive:
return attentionStateCountsAsActive(state)
case AttentionFilterOpen:
return state == operationaltrust.OperationalOpen ||
state == operationaltrust.OperationalObserving ||
state == operationaltrust.OperationalResolving
case AttentionFilterAcknowledged:
return state == operationaltrust.OperationalAcknowledged
case AttentionFilterSuppressed:
return state == operationaltrust.OperationalSuppressed
case AttentionFilterUncertain:
return state == operationaltrust.OperationalStale ||
state == operationaltrust.OperationalUnknown
case AttentionFilterResolved:
return state == operationaltrust.OperationalResolved
case AttentionFilterAll:
return true
default:
return false
}
}
func attentionStateCountsAsActive(state operationaltrust.OperationalState) bool {
switch state {
case operationaltrust.OperationalObserving,
operationaltrust.OperationalOpen,
operationaltrust.OperationalResolving,
operationaltrust.OperationalStale,
operationaltrust.OperationalUnknown:
return true
default:
return false
}
}
func summarizeAttention(details []AttentionItemDetail, now time.Time) AttentionSummary {
summary := AttentionSummary{
CoverageState: "current",
EvaluatedAt: now,
}
for _, detail := range details {
switch detail.Item.State {
case operationaltrust.OperationalAcknowledged:
summary.AcknowledgedCount++
case operationaltrust.OperationalSuppressed:
summary.SuppressedCount++
case operationaltrust.OperationalStale, operationaltrust.OperationalUnknown:
summary.UncertainCount++
summary.ActiveCount++
case operationaltrust.OperationalResolved:
summary.ResolvedCount++
default:
if attentionStateCountsAsActive(detail.Item.State) {
summary.ActiveCount++
summary.OpenCount++
}
}
}
summary.Calm = summary.ActiveCount == 0
return summary
}
func attentionItemLess(left, right AttentionItem) bool {
if l, r := attentionSeverityRank(left.Severity), attentionSeverityRank(right.Severity); l != r {
return l > r
}
if l, r := len(left.RelatedResources), len(right.RelatedResources); l != r {
return l > r
}
if l, r := attentionProtectionRank(left.ProtectionPosture), attentionProtectionRank(right.ProtectionPosture); l != r {
return l > r
}
if l, r := attentionFreshnessRank(left.EvidenceFreshness), attentionFreshnessRank(right.EvidenceFreshness); l != r {
return l > r
}
if !left.FirstObservedAt.Equal(right.FirstObservedAt) {
return left.FirstObservedAt.Before(right.FirstObservedAt)
}
return left.ID < right.ID
}
func attentionSeverityRank(severity operationaltrust.OperationalSeverity) int {
switch severity {
case operationaltrust.SeverityCritical:
return 3
case operationaltrust.SeverityWarning:
return 2
case operationaltrust.SeverityInfo:
return 1
default:
return 0
}
}
func attentionProtectionRank(posture *recoverymodel.ProtectionPosture) int {
if posture == nil {
return 2
}
switch posture.State {
case recoverymodel.ProtectionStateAttention:
return 4
case recoverymodel.ProtectionStateUnprotected:
return 3
case recoverymodel.ProtectionStateUnknown:
return 2
default:
return 0
}
}
func attentionFreshnessRank(freshness operationaltrust.EvidenceFreshness) int {
switch freshness {
case operationaltrust.EvidenceFresh:
return 2
case operationaltrust.EvidenceStale:
return 1
default:
return 0
}
}
func attentionTitle(alert alerts.Alert, resourceName string) string {
alertType := strings.TrimSpace(alert.Type)
if alertType == "" {
return "Issue on " + resourceName
}
words := strings.Fields(strings.NewReplacer("-", " ", "_", " ").Replace(alertType))
for i := range words {
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
}
return strings.Join(words, " ") + " on " + resourceName
}
func attentionResourceType(alert alerts.Alert) string {
if alert.Metadata != nil {
if value, ok := alert.Metadata["resourceType"].(string); ok {
return strings.TrimSpace(value)
}
}
return ""
}
func firstAttentionText(values ...string) string {
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}
func canonicalAttentionIDs(values []string) []string {
unique := make(map[string]struct{}, len(values))
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
unique[normalized] = struct{}{}
}
}
result := make([]string, 0, len(unique))
for value := range unique {
result = append(result, value)
}
sort.Strings(result)
return result
}
func cloneAttentionEvidence(
values []operationaltrust.EvidenceEnvelope,
) []operationaltrust.EvidenceEnvelope {
result := make([]operationaltrust.EvidenceEnvelope, len(values))
for i := range values {
result[i] = values[i].Clone()
}
sort.Slice(result, func(i, j int) bool {
if !result[i].ObservedAt.Equal(result[j].ObservedAt) {
return result[i].ObservedAt.Before(result[j].ObservedAt)
}
return result[i].ID < result[j].ID
})
return result
}
func cloneAttentionTimeline(
values []operationaltrust.LifecycleTransition,
) []operationaltrust.LifecycleTransition {
result := make([]operationaltrust.LifecycleTransition, len(values))
for i := range values {
result[i] = values[i].Clone()
}
sort.Slice(result, func(i, j int) bool {
if !result[i].At.Equal(result[j].At) {
return result[i].At.Before(result[j].At)
}
return result[i].ID < result[j].ID
})
return result
}
+48
View File
@@ -0,0 +1,48 @@
package ai
import (
"fmt"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
)
func TestAttentionProjectionLargeCardinalityKeepsResponseBounded(t *testing.T) {
const cardinality = 10_000
now := time.Date(2026, 7, 19, 9, 0, 0, 0, time.UTC)
active := make([]alerts.Alert, cardinality)
for index := range active {
active[index] = attentionTestAlert(
fmt.Sprintf("record-%05d", index),
operationaltrust.OperationalOpen,
operationaltrust.SeverityWarning,
now.Add(-time.Duration(index)*time.Second),
now,
)
}
startedAt := time.Now()
projection := ProjectAttentionItems(active, nil, nil, now)
elapsed := time.Since(startedAt)
if projection.Summary.ActiveCount != cardinality {
t.Fatalf(
"ActiveCount = %d, want %d",
projection.Summary.ActiveCount,
cardinality,
)
}
page, err := PaginateAttentionDetails(projection.Details, 1, MaxAttentionPageSize)
if err != nil {
t.Fatalf("PaginateAttentionDetails(): %v", err)
}
if len(page) != MaxAttentionPageSize {
t.Fatalf("page size = %d, want %d", len(page), MaxAttentionPageSize)
}
// This is deliberately generous enough for shared CI while still catching
// accidental quadratic projection or an external request per item.
if elapsed > 5*time.Second {
t.Fatalf("10k attention projection took %s, want <= 5s", elapsed)
}
}
+320
View File
@@ -0,0 +1,320 @@
package ai
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
recoverymodel "github.com/rcourtman/pulse-go-rewrite/internal/recovery/model"
)
func TestProjectAttentionItemsUsesCanonicalLifecycleAndStableOrdering(t *testing.T) {
now := time.Date(2026, 7, 19, 1, 0, 0, 0, time.UTC)
active := []alerts.Alert{
attentionTestAlert("warning-old", operationaltrust.OperationalOpen, operationaltrust.SeverityWarning, now.Add(-2*time.Hour), now),
attentionTestAlert("critical", operationaltrust.OperationalOpen, operationaltrust.SeverityCritical, now.Add(-time.Hour), now),
attentionTestAlert("warning-protected", operationaltrust.OperationalOpen, operationaltrust.SeverityWarning, now.Add(-3*time.Hour), now),
}
postures := map[string]recoverymodel.ProtectionPosture{
"resource-warning-old": attentionTestPosture("resource-warning-old", recoverymodel.ProtectionStateUnprotected, now),
"resource-warning-protected": attentionTestPosture(
"resource-warning-protected",
recoverymodel.ProtectionStateProtected,
now,
),
}
projected := ProjectAttentionItems(active, nil, postures, now)
if projected.Summary.ActiveCount != 3 {
t.Fatalf("ActiveCount = %d, want 3", projected.Summary.ActiveCount)
}
if projected.Summary.Calm {
t.Fatal("active projection reported a calm state")
}
if len(projected.Details) != 3 {
t.Fatalf("details = %d, want 3", len(projected.Details))
}
got := []string{
projected.Details[0].Item.ID,
projected.Details[1].Item.ID,
projected.Details[2].Item.ID,
}
want := []string{"critical", "warning-old", "warning-protected"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
if projected.Details[1].Item.OperationalRecordID != "warning-old" {
t.Fatal("attention item did not retain canonical operational record identity")
}
if projected.Details[1].Item.ProtectionPosture == nil ||
projected.Details[1].Item.ProtectionPosture.State != recoverymodel.ProtectionStateUnprotected {
t.Fatal("attention item did not receive canonical protection posture")
}
}
func TestProjectAttentionItemsDeduplicatesHistoryAndActiveByOperationalRecord(t *testing.T) {
now := time.Date(2026, 7, 19, 2, 0, 0, 0, time.UTC)
historical := attentionTestAlert(
"shared-record",
operationaltrust.OperationalResolved,
operationaltrust.SeverityWarning,
now.Add(-3*time.Hour),
now.Add(-time.Hour),
)
resolvedAt := now.Add(-time.Hour)
historical.OperationalRecord.ResolvedAt = &resolvedAt
active := attentionTestAlert(
"shared-record",
operationaltrust.OperationalOpen,
operationaltrust.SeverityWarning,
now.Add(-3*time.Hour),
now,
)
projected := ProjectAttentionItems([]alerts.Alert{active}, []alerts.Alert{historical}, nil, now)
if len(projected.Details) != 1 {
t.Fatalf("details = %d, want one canonical item", len(projected.Details))
}
if projected.Details[0].Item.State != operationaltrust.OperationalOpen {
t.Fatalf("state = %q, want active lifecycle state", projected.Details[0].Item.State)
}
}
func TestProjectAttentionItemsDoesNotReviveNonTerminalHistorySnapshots(t *testing.T) {
now := time.Date(2026, 7, 19, 2, 30, 0, 0, time.UTC)
orphanedOpen := attentionTestAlert(
"historical-open",
operationaltrust.OperationalOpen,
operationaltrust.SeverityCritical,
now.Add(-3*time.Hour),
now.Add(-2*time.Hour),
)
resolved := attentionTestAlert(
"historical-resolved",
operationaltrust.OperationalResolved,
operationaltrust.SeverityWarning,
now.Add(-4*time.Hour),
now.Add(-time.Hour),
)
resolvedAt := now.Add(-time.Hour)
resolved.OperationalRecord.ResolvedAt = &resolvedAt
projected := ProjectAttentionItems(
nil,
[]alerts.Alert{orphanedOpen, resolved},
nil,
now,
)
if len(projected.Details) != 1 {
t.Fatalf("details = %d, want only the resolved historical record", len(projected.Details))
}
if got := projected.Details[0].Item.ID; got != "historical-resolved" {
t.Fatalf("historical item = %q, want resolved record only", got)
}
if projected.Summary.ActiveCount != 0 || !projected.Summary.Calm {
t.Fatalf("summary = %+v, historical open snapshot revived active work", projected.Summary)
}
}
func TestProjectAttentionItemsRepresentsUncertainEvidenceHonestly(t *testing.T) {
now := time.Date(2026, 7, 19, 3, 0, 0, 0, time.UTC)
stale := attentionTestAlert(
"stale",
operationaltrust.OperationalStale,
operationaltrust.SeverityCritical,
now.Add(-time.Hour),
now,
)
stale.Evidence = nil
unknown := attentionTestAlert(
"unknown",
operationaltrust.OperationalUnknown,
operationaltrust.SeverityWarning,
now.Add(-time.Hour),
now,
)
unknown.Evidence = nil
projected := ProjectAttentionItems([]alerts.Alert{stale, unknown}, nil, nil, now)
if projected.Summary.UncertainCount != 2 || projected.Summary.ActiveCount != 2 {
t.Fatalf("summary = %+v, want two uncertain active items", projected.Summary)
}
if projected.Details[0].Item.EvidenceFreshness != operationaltrust.EvidenceStale {
t.Fatalf(
"stale freshness = %q",
projected.Details[0].Item.EvidenceFreshness,
)
}
if projected.Details[1].Item.EvidenceFreshness != operationaltrust.EvidenceFreshnessUnknown {
t.Fatalf(
"unknown freshness = %q",
projected.Details[1].Item.EvidenceFreshness,
)
}
for _, detail := range projected.Details {
if detail.Item.EvidenceCompleteness != operationaltrust.EvidenceUnavailable {
t.Fatalf("completeness = %q, want unavailable", detail.Item.EvidenceCompleteness)
}
}
}
func TestAttentionFiltersAndPaginationCoverLifecycleStates(t *testing.T) {
now := time.Date(2026, 7, 19, 4, 0, 0, 0, time.UTC)
states := []operationaltrust.OperationalState{
operationaltrust.OperationalOpen,
operationaltrust.OperationalAcknowledged,
operationaltrust.OperationalSuppressed,
operationaltrust.OperationalStale,
operationaltrust.OperationalUnknown,
operationaltrust.OperationalResolved,
}
active := make([]alerts.Alert, 0, len(states)-1)
history := make([]alerts.Alert, 0, 1)
for index, state := range states {
alert := attentionTestAlert(
string(state),
state,
operationaltrust.SeverityWarning,
now.Add(-time.Duration(index+1)*time.Minute),
now,
)
switch state {
case operationaltrust.OperationalAcknowledged:
alert.OperationalRecord.Acknowledgement = &operationaltrust.Acknowledgement{
At: now,
By: "operator",
}
case operationaltrust.OperationalSuppressed:
alert.OperationalRecord.Suppression = &operationaltrust.Suppression{
At: now,
By: "operator",
Reason: "maintenance",
}
case operationaltrust.OperationalResolved:
resolvedAt := now
alert.OperationalRecord.ResolvedAt = &resolvedAt
history = append(history, alert)
continue
}
active = append(active, alert)
}
projected := ProjectAttentionItems(active, history, nil, now)
checks := map[AttentionFilter]int{
AttentionFilterActive: 3,
AttentionFilterOpen: 1,
AttentionFilterAcknowledged: 1,
AttentionFilterSuppressed: 1,
AttentionFilterUncertain: 2,
AttentionFilterResolved: 1,
AttentionFilterAll: 6,
}
for filter, want := range checks {
got, err := FilterAttentionDetails(projected.Details, filter)
if err != nil {
t.Fatalf("FilterAttentionDetails(%q): %v", filter, err)
}
if len(got) != want {
t.Fatalf("FilterAttentionDetails(%q) = %d, want %d", filter, len(got), want)
}
}
page, err := PaginateAttentionDetails(projected.Details, 2, 2)
if err != nil {
t.Fatalf("PaginateAttentionDetails(): %v", err)
}
if len(page) != 2 {
t.Fatalf("page length = %d, want 2", len(page))
}
}
func TestProjectAttentionItemsProducesCurrentCalmEvaluation(t *testing.T) {
now := time.Date(2026, 7, 19, 5, 0, 0, 0, time.UTC)
projected := ProjectAttentionItems(nil, nil, nil, now)
if !projected.Summary.Calm || projected.Summary.ActiveCount != 0 {
t.Fatalf("summary = %+v, want current calm evaluation", projected.Summary)
}
if projected.Summary.CoverageState != "current" ||
!projected.Summary.EvaluatedAt.Equal(now) {
t.Fatalf("summary = %+v, want bounded current evaluation", projected.Summary)
}
}
func attentionTestAlert(
id string,
state operationaltrust.OperationalState,
severity operationaltrust.OperationalSeverity,
firstObservedAt time.Time,
lastObservedAt time.Time,
) alerts.Alert {
resourceID := "resource-" + id
evidence := attentionTestEvidence(resourceID, lastObservedAt)
record := operationaltrust.OperationalRecord{
ID: id,
CanonicalSpecID: "spec-" + id,
SubjectResourceID: resourceID,
State: state,
Severity: severity,
FirstObservedAt: firstObservedAt,
LastObservedAt: lastObservedAt,
StateChangedAt: lastObservedAt,
EvidenceIDs: []string{evidence.ID},
CauseKey: "cause-" + id,
RelatedResourceIDs: []string{"related-" + id},
ImpactSummary: "Service may be interrupted.",
RecommendedNextStep: "Review the source evidence.",
}
return alerts.Alert{
ID: "alert-" + id,
Type: "service-health",
Level: alerts.AlertLevelWarning,
ResourceID: resourceID,
ResourceName: "Resource " + id,
Message: "The service health check needs attention.",
StartTime: firstObservedAt,
LastSeen: lastObservedAt,
OperationalRecord: &record,
Evidence: []operationaltrust.EvidenceEnvelope{evidence},
}
}
func attentionTestEvidence(
resourceID string,
observedAt time.Time,
) operationaltrust.EvidenceEnvelope {
validUntil := observedAt.Add(time.Hour)
return operationaltrust.EvidenceEnvelope{
ID: "evidence-" + resourceID,
Source: operationaltrust.EvidenceSource{
Provider: "test",
Collector: "test-collector",
},
Subject: operationaltrust.EvidenceSubject{ResourceID: resourceID},
ObservedAt: observedAt,
IngestedAt: observedAt,
ValidUntil: &validUntil,
Completeness: operationaltrust.EvidenceComplete,
Confidence: operationaltrust.EvidenceConfirmed,
Permissions: operationaltrust.EvidencePermissionsSufficient,
}
}
func attentionTestPosture(
resourceID string,
state recoverymodel.ProtectionState,
evaluatedAt time.Time,
) recoverymodel.ProtectionPosture {
return recoverymodel.ProtectionPosture{
SubjectResourceID: resourceID,
State: state,
Freshness: recoverymodel.ProtectionFreshnessCurrent,
Verification: recoverymodel.ProtectionVerificationVerified,
Coverage: recoverymodel.ProtectionCoverageComplete,
ProviderStates: []recoverymodel.ProtectionProviderState{},
EvidenceIDs: []string{},
Explanation: "Test posture.",
EvaluatedAt: evaluatedAt,
}
}
+97
View File
@@ -2,8 +2,10 @@ package ai
import (
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
)
// PatrolMetrics manages Prometheus instrumentation for patrol decisions.
@@ -22,6 +24,10 @@ type PatrolMetrics struct {
streamReplayEvents prometheus.Counter
streamReplayBatch prometheus.Histogram
streamSubscriberDrop *prometheus.CounterVec
attentionItems *prometheus.GaugeVec
attentionOldestAge prometheus.Gauge
attentionAckLatest prometheus.Gauge
calmEvaluationTime prometheus.Gauge
}
var (
@@ -160,6 +166,39 @@ func newPatrolMetrics() *PatrolMetrics {
},
[]string{"reason"},
),
attentionItems: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Namespace: "pulse",
Subsystem: "patrol",
Name: "attention_items",
Help: "Current Patrol attention item count by canonical lifecycle state",
},
[]string{"state"},
),
attentionOldestAge: prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "pulse",
Subsystem: "patrol",
Name: "attention_oldest_age_seconds",
Help: "Age in seconds of the oldest active Patrol attention item",
},
),
attentionAckLatest: prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "pulse",
Subsystem: "patrol",
Name: "attention_acknowledgement_latest_seconds",
Help: "Elapsed seconds from first observation to the newest current acknowledgement",
},
),
calmEvaluationTime: prometheus.NewGauge(
prometheus.GaugeOpts{
Namespace: "pulse",
Subsystem: "patrol",
Name: "calm_evaluation_timestamp_seconds",
Help: "Unix timestamp of the latest canonical Patrol calm-day evaluation",
},
),
}
prometheus.MustRegister(
@@ -177,6 +216,10 @@ func newPatrolMetrics() *PatrolMetrics {
m.streamReplayEvents,
m.streamReplayBatch,
m.streamSubscriberDrop,
m.attentionItems,
m.attentionOldestAge,
m.attentionAckLatest,
m.calmEvaluationTime,
)
return m
@@ -257,3 +300,57 @@ func (m *PatrolMetrics) RecordStreamSubscriberDrop(reason string) {
}
m.streamSubscriberDrop.WithLabelValues(reason).Inc()
}
// ObserveAttentionProjection records bounded low-cardinality queue health.
// Resource and record identifiers are deliberately excluded from labels.
func (m *PatrolMetrics) ObserveAttentionProjection(
projection AttentionProjection,
now time.Time,
) {
if m == nil {
return
}
if now.IsZero() {
now = time.Now().UTC()
}
states := []operationaltrust.OperationalState{
operationaltrust.OperationalObserving,
operationaltrust.OperationalOpen,
operationaltrust.OperationalAcknowledged,
operationaltrust.OperationalSuppressed,
operationaltrust.OperationalResolving,
operationaltrust.OperationalResolved,
operationaltrust.OperationalStale,
operationaltrust.OperationalUnknown,
}
counts := make(map[operationaltrust.OperationalState]int, len(states))
oldestAge := time.Duration(0)
latestAcknowledgementAt := time.Time{}
latestAcknowledgementElapsed := time.Duration(0)
for _, detail := range projection.Details {
counts[detail.Item.State]++
if attentionStateCountsAsActive(detail.Item.State) {
age := now.Sub(detail.Item.FirstObservedAt)
if age > oldestAge {
oldestAge = age
}
}
if detail.Item.State == operationaltrust.OperationalAcknowledged &&
detail.OperationalRecord.Acknowledgement != nil {
elapsed := detail.OperationalRecord.Acknowledgement.At.Sub(
detail.Item.FirstObservedAt,
)
if elapsed >= 0 &&
detail.OperationalRecord.Acknowledgement.At.After(latestAcknowledgementAt) {
latestAcknowledgementAt = detail.OperationalRecord.Acknowledgement.At
latestAcknowledgementElapsed = elapsed
}
}
}
for _, state := range states {
m.attentionItems.WithLabelValues(string(state)).Set(float64(counts[state]))
}
m.attentionOldestAge.Set(oldestAge.Seconds())
m.attentionAckLatest.Set(latestAcknowledgementElapsed.Seconds())
m.calmEvaluationTime.Set(float64(projection.Summary.EvaluatedAt.Unix()))
}
+15
View File
@@ -89,6 +89,9 @@ func ensureOperationalContract(alert *Alert, ingestedAt time.Time) {
record.EvidenceIDs = evidenceIDs
record.CauseKey = causeKey
record.ImpactSummary = strings.TrimSpace(alert.Message)
if strings.TrimSpace(record.RecommendedNextStep) == "" {
record.RecommendedNextStep = operationalRecommendedNextStep(alert)
}
if state != operationaltrust.OperationalResolved {
record.ResolvedAt = nil
}
@@ -137,6 +140,18 @@ func ensureOperationalContract(alert *Alert, ingestedAt time.Time) {
}
}
func operationalRecommendedNextStep(alert *Alert) string {
if alert == nil {
return ""
}
if action, ok := alert.Metadata["incidentAction"].(string); ok {
if action = strings.TrimSpace(action); action != "" {
return action
}
}
return "Open the affected resource and verify its current state before making changes."
}
func mergeOperationalRecurrence(
target *Alert,
previous *Alert,
@@ -70,6 +70,35 @@ func TestEnsureOperationalContractBackfillsLegacyAlertHonestly(t *testing.T) {
alert.Transitions[0].ID != alert.LatestTransition.ID {
t.Fatalf("transition timeline = %+v, want initial transition", alert.Transitions)
}
if got, want := alert.OperationalRecord.RecommendedNextStep, "Open the affected resource and verify its current state before making changes."; got != want {
t.Fatalf("RecommendedNextStep = %q, want %q", got, want)
}
}
func TestEnsureOperationalContractUsesCanonicalIncidentAction(t *testing.T) {
now := time.Date(2026, 7, 19, 1, 0, 0, 0, time.UTC)
alert := &Alert{
ID: "storage:pool-1",
Type: "storage-incident",
Level: AlertLevelCritical,
ResourceID: "pool-1",
ResourceName: "Pool 1",
Message: "Pool health is degraded",
StartTime: now.Add(-time.Minute),
LastSeen: now,
Metadata: map[string]interface{}{
"incidentAction": "Inspect the degraded devices and restore redundancy",
},
}
ensureOperationalContract(alert, now)
if alert.OperationalRecord == nil {
t.Fatal("expected operational record")
}
if got, want := alert.OperationalRecord.RecommendedNextStep, "Inspect the degraded devices and restore redundancy"; got != want {
t.Fatalf("RecommendedNextStep = %q, want %q", got, want)
}
}
func TestCanonicalAlertEvidenceBuildsConfirmedEnvelope(t *testing.T) {
+358
View File
@@ -0,0 +1,358 @@
package api
import (
"context"
"fmt"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
"github.com/rcourtman/pulse-go-rewrite/internal/recovery"
recoverymanager "github.com/rcourtman/pulse-go-rewrite/internal/recovery/manager"
recoverymodel "github.com/rcourtman/pulse-go-rewrite/internal/recovery/model"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rs/zerolog/log"
)
const (
attentionHistoryReadLimit = 500
attentionPostureBatchSize = 200
)
type attentionAlertSnapshot func(context.Context) ([]alerts.Alert, []alerts.Alert, error)
type AttentionHandlers struct {
readAlerts attentionAlertSnapshot
recoveryManager *recoverymanager.Manager
}
func NewAttentionHandlers(
getMonitor func(context.Context) *monitoring.Monitor,
recoveryManager *recoverymanager.Manager,
) *AttentionHandlers {
return &AttentionHandlers{
readAlerts: func(ctx context.Context) ([]alerts.Alert, []alerts.Alert, error) {
if getMonitor == nil {
return nil, nil, fmt.Errorf("monitor is not configured")
}
monitor := getMonitor(ctx)
if monitor == nil || monitor.GetAlertManager() == nil {
return nil, nil, fmt.Errorf("alert lifecycle is not available")
}
manager := monitor.GetAlertManager()
return manager.GetActiveAlerts(), manager.GetAlertHistory(attentionHistoryReadLimit), nil
},
recoveryManager: recoveryManager,
}
}
type attentionListResponse struct {
Data []ai.AttentionItem `json:"data"`
Summary ai.AttentionSummary `json:"summary"`
Meta struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int `json:"total"`
TotalPages int `json:"totalPages"`
} `json:"meta"`
}
func (h *AttentionHandlers) HandleAttention(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/attention")
switch {
case path == "" || path == "/":
h.handleAttentionList(w, r)
case path == "/summary":
h.handleAttentionSummary(w, r)
case strings.HasPrefix(path, "/"):
h.handleAttentionDetail(w, r, strings.TrimPrefix(path, "/"))
default:
http.NotFound(w, r)
}
}
func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.Request) {
filter, page, limit, ok := parseAttentionListQuery(w, r.URL.Query())
if !ok {
return
}
projection, err := h.project(r.Context(), true)
if err != nil {
writeAttentionUnavailable(w, err)
return
}
filtered, err := ai.FilterAttentionDetails(projection.Details, filter)
if err != nil {
writeErrorResponse(
w,
http.StatusBadRequest,
"invalid_attention_filter",
err.Error(),
map[string]string{"filter": string(filter)},
)
return
}
paged, err := ai.PaginateAttentionDetails(filtered, page, limit)
if err != nil {
writeErrorResponse(
w,
http.StatusBadRequest,
"invalid_attention_page",
err.Error(),
nil,
)
return
}
response := attentionListResponse{
Data: make([]ai.AttentionItem, 0, len(paged)),
Summary: projection.Summary,
}
for _, detail := range paged {
response.Data = append(response.Data, detail.Item)
}
response.Meta.Page = page
response.Meta.Limit = limit
response.Meta.Total = len(filtered)
if len(filtered) > 0 {
response.Meta.TotalPages = (len(filtered) + limit - 1) / limit
}
if err := utils.WriteJSONResponse(w, response); err != nil {
log.Error().Err(err).Msg("Failed to serialize Patrol attention list")
}
}
func (h *AttentionHandlers) handleAttentionSummary(w http.ResponseWriter, r *http.Request) {
projection, err := h.project(r.Context(), false)
if err != nil {
writeAttentionUnavailable(w, err)
return
}
if err := utils.WriteJSONResponse(w, projection.Summary); err != nil {
log.Error().Err(err).Msg("Failed to serialize Patrol attention summary")
}
}
func (h *AttentionHandlers) handleAttentionDetail(
w http.ResponseWriter,
r *http.Request,
rawID string,
) {
itemID, err := url.PathUnescape(rawID)
if err != nil || strings.TrimSpace(itemID) == "" {
writeErrorResponse(
w,
http.StatusBadRequest,
"invalid_attention_id",
"Attention item ID is invalid.",
nil,
)
return
}
projection, err := h.project(r.Context(), true)
if err != nil {
writeAttentionUnavailable(w, err)
return
}
for _, detail := range projection.Details {
if detail.Item.ID == itemID {
if err := utils.WriteJSONResponse(w, detail); err != nil {
log.Error().Err(err).Msg("Failed to serialize Patrol attention detail")
}
return
}
}
writeErrorResponse(
w,
http.StatusNotFound,
"attention_item_not_found",
"Attention item was not found.",
nil,
)
}
func (h *AttentionHandlers) project(
ctx context.Context,
includeProtectionPosture bool,
) (ai.AttentionProjection, error) {
if h == nil || h.readAlerts == nil {
return ai.AttentionProjection{}, fmt.Errorf("attention lifecycle source is not configured")
}
active, history, err := h.readAlerts(ctx)
if err != nil {
return ai.AttentionProjection{}, err
}
postures := map[string]recoverymodel.ProtectionPosture{}
postureComplete := true
if includeProtectionPosture {
resourceIDs := attentionResourceIDs(active, history)
postures, postureComplete = h.loadProtectionPostures(
ctx,
GetOrgID(ctx),
resourceIDs,
)
}
projection := ai.ProjectAttentionItems(active, history, postures, time.Now().UTC())
if !postureComplete {
projection.Summary.CoverageState = "partial"
}
ai.GetPatrolMetrics().ObserveAttentionProjection(projection, projection.Summary.EvaluatedAt)
return projection, nil
}
func (h *AttentionHandlers) loadProtectionPostures(
ctx context.Context,
orgID string,
resourceIDs []string,
) (map[string]recoverymodel.ProtectionPosture, bool) {
result := make(map[string]recoverymodel.ProtectionPosture, len(resourceIDs))
if len(resourceIDs) == 0 {
return result, true
}
if h == nil || h.recoveryManager == nil {
return result, false
}
store, err := h.recoveryManager.StoreForOrg(orgID)
if err != nil {
log.Debug().Err(err).Msg("Patrol attention protection posture is unavailable")
return result, false
}
complete := true
for start := 0; start < len(resourceIDs); start += attentionPostureBatchSize {
end := start + attentionPostureBatchSize
if end > len(resourceIDs) {
end = len(resourceIDs)
}
batch := resourceIDs[start:end]
postures, _, listErr := store.ListProtectionPostures(ctx, recovery.ProtectionPostureQuery{
SubjectResourceIDs: batch,
Page: 1,
Limit: len(batch),
})
if listErr != nil {
log.Debug().Err(listErr).Msg("Patrol attention posture batch is unavailable")
complete = false
continue
}
for _, posture := range postures {
result[posture.SubjectResourceID] = posture
}
}
return result, complete
}
func parseAttentionListQuery(
w http.ResponseWriter,
query url.Values,
) (ai.AttentionFilter, int, int, bool) {
filter := ai.AttentionFilter(strings.TrimSpace(query.Get("filter")))
if filter == "" {
filter = ai.AttentionFilterActive
}
if !filter.Valid() {
writeErrorResponse(
w,
http.StatusBadRequest,
"invalid_attention_filter",
"Filter must be active, open, acknowledged, suppressed, stale_unknown, resolved, or all.",
map[string]string{"filter": string(filter)},
)
return "", 0, 0, false
}
page, valid := parsePositiveAttentionInt(w, query.Get("page"), 1, "page")
if !valid {
return "", 0, 0, false
}
limit, valid := parsePositiveAttentionInt(
w,
query.Get("limit"),
ai.DefaultAttentionPageSize,
"limit",
)
if !valid {
return "", 0, 0, false
}
if limit > ai.MaxAttentionPageSize {
writeErrorResponse(
w,
http.StatusBadRequest,
"attention_limit_exceeded",
fmt.Sprintf("Attention limit must not exceed %d.", ai.MaxAttentionPageSize),
map[string]string{"limit": strconv.Itoa(ai.MaxAttentionPageSize)},
)
return "", 0, 0, false
}
return filter, page, limit, true
}
func parsePositiveAttentionInt(
w http.ResponseWriter,
raw string,
fallback int,
field string,
) (int, bool) {
if strings.TrimSpace(raw) == "" {
return fallback, true
}
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
writeErrorResponse(
w,
http.StatusBadRequest,
"invalid_attention_"+field,
"Attention "+field+" must be a positive integer.",
map[string]string{field: raw},
)
return 0, false
}
return value, true
}
func attentionResourceIDs(active, history []alerts.Alert) []string {
unique := make(map[string]struct{}, len(active)+len(history))
add := func(values []alerts.Alert, history bool) {
for _, alert := range values {
if alert.OperationalRecord == nil {
continue
}
if history && alert.OperationalRecord.State != operationaltrust.OperationalResolved {
continue
}
if resourceID := strings.TrimSpace(alert.OperationalRecord.SubjectResourceID); resourceID != "" {
unique[resourceID] = struct{}{}
}
}
}
add(active, false)
add(history, true)
result := make([]string, 0, len(unique))
for resourceID := range unique {
result = append(result, resourceID)
}
sort.Strings(result)
return result
}
func writeAttentionUnavailable(w http.ResponseWriter, err error) {
log.Warn().Err(err).Msg("Patrol attention read model is unavailable")
writeErrorResponse(
w,
http.StatusServiceUnavailable,
"attention_read_model_unavailable",
"Patrol attention is temporarily unavailable. No calm or healthy state has been inferred.",
nil,
)
}
+237
View File
@@ -0,0 +1,237 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
)
func TestAttentionHandlersListUsesCanonicalCountAndFilters(t *testing.T) {
now := time.Date(2026, 7, 19, 6, 0, 0, 0, time.UTC)
handler := &AttentionHandlers{
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
return []alerts.Alert{
attentionHandlerAlert("open", operationaltrust.OperationalOpen, now),
attentionHandlerAlert("ack", operationaltrust.OperationalAcknowledged, now),
attentionHandlerAlert("stale", operationaltrust.OperationalStale, now),
}, nil, nil
},
}
request := httptest.NewRequest(
http.MethodGet,
"/api/ai/patrol/attention?filter=acknowledged&limit=10",
nil,
)
response := httptest.NewRecorder()
handler.HandleAttention(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
var payload attentionListResponse
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(payload.Data) != 1 || payload.Data[0].State != operationaltrust.OperationalAcknowledged {
t.Fatalf("data = %+v, want acknowledged item", payload.Data)
}
if payload.Summary.ActiveCount != 2 ||
payload.Summary.AcknowledgedCount != 1 ||
payload.Summary.UncertainCount != 1 {
t.Fatalf("summary = %+v", payload.Summary)
}
if payload.Summary.CoverageState != "partial" {
t.Fatalf("coverage = %q, want partial without posture source", payload.Summary.CoverageState)
}
}
func TestAttentionHandlersSummaryAndDetailShareOneProjection(t *testing.T) {
now := time.Date(2026, 7, 19, 7, 0, 0, 0, time.UTC)
handler := &AttentionHandlers{
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
return []alerts.Alert{
attentionHandlerAlert("record-1", operationaltrust.OperationalOpen, now),
}, nil, nil
},
}
summaryRequest := httptest.NewRequest(
http.MethodGet,
"/api/ai/patrol/attention/summary",
nil,
)
summaryResponse := httptest.NewRecorder()
handler.HandleAttention(summaryResponse, summaryRequest)
if summaryResponse.Code != http.StatusOK {
t.Fatalf("summary status = %d body=%s", summaryResponse.Code, summaryResponse.Body.String())
}
var summary ai.AttentionSummary
if err := json.Unmarshal(summaryResponse.Body.Bytes(), &summary); err != nil {
t.Fatalf("decode summary: %v", err)
}
if summary.ActiveCount != 1 {
t.Fatalf("ActiveCount = %d, want 1", summary.ActiveCount)
}
detailRequest := httptest.NewRequest(
http.MethodGet,
"/api/ai/patrol/attention/record-1",
nil,
)
detailResponse := httptest.NewRecorder()
handler.HandleAttention(detailResponse, detailRequest)
if detailResponse.Code != http.StatusOK {
t.Fatalf("detail status = %d body=%s", detailResponse.Code, detailResponse.Body.String())
}
var detail ai.AttentionItemDetail
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Item.ID != "record-1" ||
detail.OperationalRecord.ID != detail.Item.OperationalRecordID ||
len(detail.Evidence) != 1 {
t.Fatalf("detail = %+v", detail)
}
}
func TestAttentionHandlersDetailSupportsCanonicalIDsContainingSlashes(t *testing.T) {
now := time.Date(2026, 7, 19, 7, 30, 0, 0, time.UTC)
const recordID = "agent:node-1/disk:mnt-disk2::metric-threshold:disk"
handler := &AttentionHandlers{
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
return []alerts.Alert{
attentionHandlerAlert(recordID, operationaltrust.OperationalOpen, now),
}, nil, nil
},
}
request := httptest.NewRequest(
http.MethodGet,
"/api/ai/patrol/attention/agent%3Anode-1%2Fdisk%3Amnt-disk2%3A%3Ametric-threshold%3Adisk",
nil,
)
response := httptest.NewRecorder()
handler.HandleAttention(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
var detail ai.AttentionItemDetail
if err := json.Unmarshal(response.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if detail.Item.ID != recordID {
t.Fatalf("detail ID = %q, want %q", detail.Item.ID, recordID)
}
}
func TestAttentionHandlersFailClosedWhenLifecycleUnavailable(t *testing.T) {
handler := &AttentionHandlers{
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
return nil, nil, errors.New("collector state unavailable")
},
}
request := httptest.NewRequest(
http.MethodGet,
"/api/ai/patrol/attention/summary",
nil,
)
response := httptest.NewRecorder()
handler.HandleAttention(response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d body=%s", response.Code, response.Body.String())
}
if body := response.Body.String(); body == "" ||
!json.Valid(response.Body.Bytes()) {
t.Fatalf("expected typed unavailable response, got %q", body)
}
}
func TestAttentionHandlersRejectInvalidOrUnboundedQueries(t *testing.T) {
handler := &AttentionHandlers{
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
return nil, nil, nil
},
}
for _, path := range []string{
"/api/ai/patrol/attention?filter=healthy",
"/api/ai/patrol/attention?page=0",
"/api/ai/patrol/attention?limit=201",
"/api/ai/patrol/attention/%20",
} {
request := httptest.NewRequest(http.MethodGet, path, nil)
response := httptest.NewRecorder()
handler.HandleAttention(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("%s status = %d body=%s", path, response.Code, response.Body.String())
}
}
}
func attentionHandlerAlert(
id string,
state operationaltrust.OperationalState,
now time.Time,
) alerts.Alert {
resourceID := "resource-" + id
validUntil := now.Add(time.Hour)
evidence := operationaltrust.EvidenceEnvelope{
ID: "evidence-" + id,
Source: operationaltrust.EvidenceSource{
Provider: "test",
Collector: "test",
},
Subject: operationaltrust.EvidenceSubject{ResourceID: resourceID},
ObservedAt: now,
IngestedAt: now,
ValidUntil: &validUntil,
Completeness: operationaltrust.EvidenceComplete,
Confidence: operationaltrust.EvidenceConfirmed,
Permissions: operationaltrust.EvidencePermissionsSufficient,
}
record := operationaltrust.OperationalRecord{
ID: id,
CanonicalSpecID: "spec-" + id,
SubjectResourceID: resourceID,
State: state,
Severity: operationaltrust.SeverityWarning,
FirstObservedAt: now.Add(-time.Hour),
LastObservedAt: now,
StateChangedAt: now,
EvidenceIDs: []string{evidence.ID},
CauseKey: "cause-" + id,
ImpactSummary: "Service interruption is possible.",
RelatedResourceIDs: []string{},
}
switch state {
case operationaltrust.OperationalAcknowledged:
record.Acknowledgement = &operationaltrust.Acknowledgement{At: now, By: "operator"}
case operationaltrust.OperationalSuppressed:
record.Suppression = &operationaltrust.Suppression{
At: now,
By: "operator",
Reason: "maintenance",
}
case operationaltrust.OperationalResolved:
record.ResolvedAt = &now
}
return alerts.Alert{
ID: "alert-" + id,
Type: "service-health",
Level: alerts.AlertLevelWarning,
ResourceID: resourceID,
ResourceName: "Resource " + id,
Message: "Service health needs attention.",
StartTime: now.Add(-time.Hour),
LastSeen: now,
OperationalRecord: &record,
Evidence: []operationaltrust.EvidenceEnvelope{evidence},
}
}
+2
View File
@@ -621,6 +621,8 @@ var allRouteAllowlist = []string{
"/api/ai/patrol/status",
"/api/ai/patrol/stream",
"/api/ai/patrol/findings",
"/api/ai/patrol/attention",
"/api/ai/patrol/attention/",
"/api/ai/patrol/history",
"/api/ai/patrol/run",
"/api/ai/patrol/preflight",
+2
View File
@@ -106,6 +106,7 @@ type Router struct {
unifiedAgentHandlers *UnifiedAgentHandlers
systemSettingsHandler *SystemSettingsHandler
aiSettingsHandler *AISettingsHandler
attentionHandlers *AttentionHandlers
aiHandler *AIHandler // AI chat handler
discoveryHandlers *DiscoveryHandlers
resourceHandlers *ResourceHandlers
@@ -464,6 +465,7 @@ func (r *Router) setupRoutes() {
)
recoveryManager := recoverymanager.New(r.multiTenant)
r.recoveryHandlers = NewRecoveryHandlers(recoveryManager)
r.attentionHandlers = NewAttentionHandlers(r.configHandlers.getMonitor, recoveryManager)
if r.mtMonitor != nil {
r.mtMonitor.SetRecoveryManager(recoveryManager)
}
+2
View File
@@ -112,6 +112,8 @@ func (r *Router) registerAIRelayRoutesGroup() {
r.mux.HandleFunc("/api/ai/patrol/status", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolStatus)))
r.mux.HandleFunc("/api/ai/patrol/stream", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandlePatrolStream)))
r.mux.HandleFunc("/api/ai/patrol/findings", RequireAuth(r.config, r.routeAIPatrolFindings))
r.mux.HandleFunc("/api/ai/patrol/attention", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.attentionHandlers.HandleAttention)))
r.mux.HandleFunc("/api/ai/patrol/attention/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.attentionHandlers.HandleAttention)))
// SECURITY: AI Patrol read endpoints - require ai:execute scope
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetFindingsHistory)))
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleForcePatrol)))
+1 -1
View File
@@ -384,7 +384,7 @@ else
# mock run) must NOT win here, or real mode would keep reading the mock dir.
HOT_DEV_MOCK_FLAG=""
if [[ -f "${ROOT_DIR}/tmp/dev-config/.env" ]]; then
HOT_DEV_MOCK_FLAG="$(grep -E '^[[:space:]]*PULSE_MOCK_MODE=' "${ROOT_DIR}/tmp/dev-config/.env" | tail -1 | cut -d= -f2 | tr -dc 'a-z')"
HOT_DEV_MOCK_FLAG="$(grep -E '^[[:space:]]*PULSE_MOCK_MODE=' "${ROOT_DIR}/tmp/dev-config/.env" | tail -1 | cut -d= -f2 | tr -dc 'a-z' || true)"
fi
if [[ -z "${HOT_DEV_MOCK_FLAG}" ]]; then
HOT_DEV_MOCK_FLAG="${PULSE_MOCK_MODE:-}"
@@ -59,6 +59,11 @@ class AIRuntimeDocsPolicyTest(unittest.TestCase):
self.assertIn("privacy-allowlisted community evidence export", normalized_content)
self.assertIn("Every active finding shown or returned to a Patrol run", content)
self.assertIn("Silence is not an all-clear signal", normalized_content)
self.assertIn("### The Patrol attention queue", content)
self.assertIn(
"it cannot create lifecycle truth, invent action authority, or hide uncertainty",
normalized_content,
)
self.assertIn(
"The Assistant model matrix below proves Assistant orchestration only",
normalized_content,
@@ -12,7 +12,9 @@ RECOVERY_PRODUCT_SURFACE_EXACT_FILES = [
]
PATROL_PAGE_AND_STATE_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__/PatrolIntelligenceHeader.test.ts",
"frontend-modern/src/features/patrol/__tests__/patrolControlPresentation.test.ts",
"frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts",
@@ -24,6 +26,7 @@ PATROL_PAGE_AND_STATE_EXACT_FILES = [
"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/91-operational-trust-attention-workbench.spec.ts",
]
PLATFORM_CONNECTIONS_WORKSPACE_EXACT_FILES = [
@@ -2970,8 +2973,8 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 1244,
"heading_line": 155,
"line": 1247,
"heading_line": 157,
}
],
)
+11
View File
@@ -129,6 +129,16 @@ test_hot_dev_keeps_backend_launch_errors_in_debug_log() {
assert_contains "backend LOG_FILE follows debug log override" "${output}" "LOG_FILE=\"\${BACKEND_DEBUG_LOG}\""
}
test_hot_dev_tolerates_missing_mock_mode_setting() {
local output
output="$(sed -n '360,420p' "${HOT_DEV}")"
assert_contains \
"hot-dev falls back when the canonical dev env has no mock-mode setting" \
"${output}" \
"tr -dc 'a-z' || true"
}
test_hot_dev_preserves_proxmox_guest_docker_env() {
local output
output="$(sed -n '1,760p' "${HOT_DEV}")"
@@ -328,6 +338,7 @@ test_pulse_process_count_handles_zero_matches_under_pipefail
test_pulse_process_count_counts_matching_processes
test_hot_dev_uses_resilient_backend_process_count
test_hot_dev_keeps_backend_launch_errors_in_debug_log
test_hot_dev_tolerates_missing_mock_mode_setting
test_hot_dev_preserves_proxmox_guest_docker_env
test_hot_dev_avoids_self_killing_npm_wrapper
test_hot_dev_network_defaults_are_local_first_with_explicit_lan_opt_in
@@ -0,0 +1,719 @@
import { expect, test, type Page, type Route } from "@playwright/test";
type AttentionMode = "active" | "calm" | "failed";
test.beforeEach(async ({ page }) => {
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") {
consoleErrors.push(message.text());
}
});
await page.addInitScript(() => {
window.sessionStorage.setItem(
"pulse_auth",
JSON.stringify({
type: "token",
value: "attention-workbench-test-token",
}),
);
window.sessionStorage.setItem("pulse_auth_user", "operator");
});
await page.route("**/*", async (route) => {
if (!new URL(route.request().url()).pathname.startsWith("/api/")) {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: "{}",
});
});
await page.route("**/api/state", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
connectedInfrastructure: [],
metrics: [],
performance: {
apiCallDuration: {},
lastPollDuration: 0,
pollingStartTime: "",
totalApiCalls: 0,
failedApiCalls: 0,
cacheHits: 0,
cacheMisses: 0,
},
connectionHealth: {},
stats: {
startTime: evaluatedAt,
uptime: 3600,
pollingCycles: 1,
webSocketClients: 0,
version: "6.1.0-test",
},
activeAlerts: [],
recentlyResolved: [],
pveTagColors: {},
pveTagStyles: {},
lastUpdate: Date.parse(evaluatedAt),
resources: [],
}),
});
});
await page.route("**/api/version", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
version: "6.1.0-test",
buildTime: evaluatedAt,
gitCommit: "test",
isDevelopment: true,
isSourceBuild: true,
}),
});
});
await page.route("**/api/security/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
detailLevel: "authenticated",
hasAuthentication: true,
requiresAuth: true,
authUsername: "operator",
sessionCapabilities: {
demoMode: false,
assistantEnabled: true,
},
presentationPolicy: {
demoMode: false,
readOnly: false,
hideCommercial: false,
hideUpgrade: false,
},
}),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForTimeout(100);
expect(pageErrors, "The Patrol shell raised a browser error").toEqual([]);
expect(consoleErrors, "The Patrol shell logged a browser error").toEqual([]);
const shellText = (await page.locator("body").innerText()).trim();
expect(shellText, `The authenticated shell was blank at ${page.url()}`).not.toBe("");
await expect(page.getByRole("tab", { name: /Patrol/ })).toBeVisible();
});
const now = new Date();
const firstObservedAt = new Date(now.getTime() - 42 * 60_000).toISOString();
const lastObservedAt = new Date(now.getTime() - 2 * 60_000).toISOString();
const evaluatedAt = now.toISOString();
const attentionID = "node-1::metric-threshold:cpu";
const protectionPosture = {
subjectResourceId: "node-1",
state: "attention",
lastAttemptAt: lastObservedAt,
lastSuccessfulPointAt: firstObservedAt,
lastVerifiedAt: firstObservedAt,
freshness: "current",
verification: "stale",
coverage: "complete",
providerStates: [
{
provider: "pbs",
source: "recovery-points",
scope: "primary",
jobState: "success",
historyCompleteness: "complete",
permissions: "sufficient",
lastAttemptAt: lastObservedAt,
lastSuccessAt: firstObservedAt,
lastVerifiedAt: firstObservedAt,
evidenceIds: ["backup-evidence-1"],
verificationExpected: true,
},
],
repositoryResourceIds: ["pbs:repository:primary"],
evidenceIds: ["backup-evidence-1"],
explanation:
"A current backup exists, but its verification is outside the verification window.",
evaluatedAt,
};
const openAttentionItem = {
id: attentionID,
operationalRecordId: attentionID,
subjectResourceId: "node-1",
subjectResourceName: "pve-main",
subjectResourceType: "agent",
title: "CPU pressure on pve-main",
plainLanguageSummary:
"CPU has remained above the configured threshold for two collection cycles.",
severity: "critical",
state: "open",
firstObservedAt,
lastObservedAt,
evidenceFreshness: "fresh",
evidenceCompleteness: "complete",
impact: "Workloads on this node may respond slowly.",
protectionPosture,
relatedResources: [{ resourceId: "vm-101" }],
recommendedNextStep:
"Open the node and verify which workload is consuming CPU before making changes.",
availableActions: [],
verificationState: "not_available",
};
const uncertainAttentionItem = {
...openAttentionItem,
id: "node-2::connectivity",
operationalRecordId: "node-2::connectivity",
subjectResourceId: "node-2",
subjectResourceName: "pve-edge",
title: "Connection state unknown for pve-edge",
plainLanguageSummary:
"Pulse does not have recent enough evidence to report this node as healthy.",
severity: "warning",
state: "unknown",
evidenceFreshness: "unknown",
evidenceCompleteness: "partial",
protectionPosture: undefined,
relatedResources: [],
recommendedNextStep:
"Check the provider connection and collect current evidence before deciding whether the node is offline.",
};
const acknowledgedAttentionItem = {
...openAttentionItem,
id: "node-3::memory",
operationalRecordId: "node-3::memory",
subjectResourceId: "node-3",
subjectResourceName: "pve-lab",
title: "Memory pressure acknowledged on pve-lab",
state: "acknowledged",
};
const suppressedAttentionItem = {
...openAttentionItem,
id: "node-4::maintenance",
operationalRecordId: "node-4::maintenance",
subjectResourceId: "node-4",
subjectResourceName: "pve-maintenance",
title: "Maintenance alert suppressed on pve-maintenance",
state: "suppressed",
};
const resolvedAttentionItem = {
...openAttentionItem,
id: "node-5::storage",
operationalRecordId: "node-5::storage",
subjectResourceId: "node-5",
subjectResourceName: "pve-recovered",
title: "Storage pressure resolved on pve-recovered",
state: "resolved",
};
const activeSummary = {
activeCount: 2,
openCount: 1,
acknowledgedCount: 1,
suppressedCount: 1,
uncertainCount: 1,
resolvedCount: 1,
calm: false,
coverageState: "current",
evaluatedAt,
};
const detail = {
item: openAttentionItem,
operationalRecord: {
id: attentionID,
canonicalSpecId: "metric-threshold:cpu",
subjectResourceId: "node-1",
state: "open",
severity: "critical",
firstObservedAt,
lastObservedAt,
stateChangedAt: firstObservedAt,
evidenceIds: ["metric-evidence-1"],
causeKey: attentionID,
relatedResourceIds: ["vm-101"],
impactSummary: openAttentionItem.impact,
recommendedNextStep: openAttentionItem.recommendedNextStep,
},
timeline: [
{
id: "transition-1",
operationalRecordId: attentionID,
from: "observing",
to: "open",
at: firstObservedAt,
cause: "detector_decision",
causeKey: attentionID,
evidenceIds: ["metric-evidence-1"],
reason:
"The threshold remained breached for the required confirmation window.",
},
],
evidence: [
{
id: "metric-evidence-1",
source: {
provider: "proxmox",
collector: "node-metrics",
instance: "homelab",
},
subject: { resourceId: "node-1" },
observedAt: lastObservedAt,
ingestedAt: lastObservedAt,
completeness: "complete",
confidence: "confirmed",
permissions: "sufficient",
reason: {
code: "threshold_breach",
message: "Two consecutive samples were above 90%.",
},
},
],
};
async function mockAttention(
page: Page,
initialMode: AttentionMode,
): Promise<{ setMode: (mode: AttentionMode) => void }> {
let mode = initialMode;
await page.route("**/api/resources**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname !== "/api/resources") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [],
meta: { page: 1, limit: 100, total: 0, totalPages: 0 },
links: { next: null },
}),
});
});
await page.route("**/api/replication/jobs", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
});
});
await page.route("**/api/ai/patrol/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
runtime_state: "active",
running: false,
enabled: true,
last_patrol_at: lastObservedAt,
next_patrol_at: new Date(now.getTime() + 6 * 60 * 60_000).toISOString(),
last_duration_ms: 42_000,
resources_checked: 2,
findings_count: 0,
error_count: 0,
healthy: true,
interval_ms: 21_600_000,
fixed_count: 0,
blocked_reason: "",
blocked_at: "",
license_required: false,
license_status: "active",
summary: { critical: 0, warning: 0, watch: 0, info: 0 },
}),
});
});
await page.route("**/api/ai/patrol/runs*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
});
});
await page.route("**/api/ai/patrol/autonomy", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
autonomy_level: "monitor",
requested_autonomy_level: "monitor",
effective_autonomy_level: "monitor",
full_mode_unlocked: false,
autopilot_acknowledgement: {
code: "not_requested",
active: false,
currentVersion: 1,
acceptedScope: [],
acceptedLimits: {
policyAllowlistRequired: true,
emergencyStopHonored: true,
approvalFloorsHonored: true,
verificationReconciledWhenSupported: true,
evidenceClassDisclosed: true,
inconclusiveOutcomeAllowed: true,
executionSuccessIsNotOutcomeTruth: true,
},
},
investigation_budget: 15,
investigation_timeout_sec: 300,
}),
});
});
await page.route("**/api/ai/patrol/findings*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
});
});
await page.route("**/api/ai/unified/findings*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ findings: [], count: 0, active_count: 0 }),
});
});
await page.route("**/api/ai/intelligence", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
timestamp: evaluatedAt,
overall_health: {
score: 100,
grade: "A",
trend: "stable",
factors: [],
prediction: "No legacy Patrol findings are waiting.",
},
findings_count: {
critical: 0,
warning: 0,
watch: 0,
info: 0,
total: 0,
},
predictions_count: 0,
recent_changes_count: 0,
recent_changes: [],
learning: {
resources_with_knowledge: 0,
total_notes: 0,
resources_with_baselines: 0,
patterns_detected: 0,
correlations_learned: 0,
incidents_tracked: 0,
},
}),
});
});
await page.route("**/api/ai/intelligence/correlations*", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ correlations: [], count: 0 }),
});
});
await page.route("**/api/ai/circuit/status", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
state: "closed",
can_patrol: true,
consecutive_failures: 0,
total_successes: 1,
total_failures: 0,
}),
});
});
await page.route("**/api/ai/approvals", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ approvals: [] }),
});
});
await page.route("**/api/settings/ai", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
patrol_enabled: true,
patrol_interval_minutes: 360,
patrol_model: "",
model: "",
alert_triggered_analysis: false,
patrol_alert_triggers_enabled: true,
patrol_anomaly_triggers_enabled: false,
patrol_event_triggers_enabled: true,
patrol_auto_fix: false,
auto_fix_model: "",
}),
});
});
await page.route("**/api/ai/models", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ models: [] }),
});
});
await page.route("**/api/ai/patrol/attention**", async (route: Route) => {
if (mode === "failed") {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({
error: {
code: "operational_lifecycle_unavailable",
message: "Current lifecycle evidence is unavailable.",
},
}),
});
return;
}
const url = new URL(route.request().url());
if (url.pathname.endsWith("/summary")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
mode === "calm"
? {
activeCount: 0,
openCount: 0,
acknowledgedCount: 0,
suppressedCount: 0,
uncertainCount: 0,
resolvedCount: 1,
calm: true,
coverageState: "current",
evaluatedAt,
}
: activeSummary,
),
});
return;
}
if (!url.pathname.endsWith("/attention")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(detail),
});
return;
}
const filter = url.searchParams.get("filter") ?? "active";
const data =
mode === "calm"
? []
: filter === "stale_unknown"
? [uncertainAttentionItem]
: filter === "open"
? [openAttentionItem]
: filter === "acknowledged"
? [acknowledgedAttentionItem]
: filter === "suppressed"
? [suppressedAttentionItem]
: filter === "resolved"
? [resolvedAttentionItem]
: [openAttentionItem, uncertainAttentionItem];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data,
summary:
mode === "calm"
? {
activeCount: 0,
openCount: 0,
acknowledgedCount: 0,
suppressedCount: 0,
uncertainCount: 0,
resolvedCount: 1,
calm: true,
coverageState: "current",
evaluatedAt,
}
: activeSummary,
meta: {
page: 1,
limit: 50,
total: data.length,
totalPages: data.length > 0 ? 1 : 0,
},
}),
});
});
return {
setMode: (nextMode) => {
mode = nextMode;
},
};
}
test("makes active operational work primary and preserves the evidence boundary", async ({
page,
}) => {
await mockAttention(page, "active");
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("tab", { name: "Patrol: 2 active attention items" }),
).toBeVisible();
const queue = page.getByRole("region", { name: "Needs attention" });
await expect(queue.getByLabel("2 active attention items")).toBeVisible();
await expect(
queue.getByText(
"CPU has remained above the configured threshold for two collection cycles.",
),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Explain with Assistant" }),
).toHaveCount(0);
await queue
.getByRole("button", { name: "Acknowledged 1", exact: true })
.click();
await expect(
queue.getByText("Memory pressure acknowledged on pve-lab"),
).toBeVisible();
await queue
.getByRole("button", { name: "Suppressed 1", exact: true })
.click();
await expect(
queue.getByText("Maintenance alert suppressed on pve-maintenance"),
).toBeVisible();
await queue
.getByRole("button", { name: "Stale or unknown 1", exact: true })
.click();
await expect(
queue.getByText("Connection state unknown for pve-edge"),
).toBeVisible();
await queue
.getByRole("button", { name: "Recent resolved 1", exact: true })
.click();
await expect(
queue.getByText("Storage pressure resolved on pve-recovered"),
).toBeVisible();
await queue.getByRole("button", { name: "Active 2", exact: true }).click();
const itemButton = queue.getByRole("button", {
name: "Open CPU pressure on pve-main",
});
await itemButton.focus();
await page.keyboard.press("Enter");
const detailPanel = page.getByRole("complementary", {
name: "CPU pressure on pve-main",
});
await expect(detailPanel).toBeVisible();
await expect(
detailPanel.getByText("Workloads on this node may respond slowly."),
).toBeVisible();
await expect(
detailPanel.getByText(
"Open the node and verify which workload is consuming CPU before making changes.",
),
).toBeVisible();
await expect(
detailPanel.getByText("Two consecutive samples were above 90%."),
).toBeVisible();
await expect(
detailPanel.getByText(
"A current backup exists, but its verification is outside the verification window.",
),
).toBeVisible();
await expect(detailPanel.getByText("Observing to Open")).toBeVisible();
await expect(page).toHaveURL(
new RegExp(`attention=${encodeURIComponent(attentionID)}`),
);
await detailPanel
.getByRole("button", { name: "Close attention detail" })
.click();
await expect(itemButton).toBeFocused();
await expect(page).not.toHaveURL(/attention=/);
});
test("puts the selected detail in view on a phone without page overflow", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce" });
await mockAttention(page, "active");
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
const itemButton = page.getByRole("button", {
name: "Open CPU pressure on pve-main",
});
await itemButton.click();
const detailPanel = page.getByRole("complementary", {
name: "CPU pressure on pve-main",
});
await expect(detailPanel).toBeInViewport();
await expect(
detailPanel.getByText(
"Open the node and verify which workload is consuming CPU before making changes.",
),
).toBeVisible();
const overflows = await page.evaluate(
() =>
document.documentElement.scrollWidth >
document.documentElement.clientWidth,
);
expect(overflows).toBeFalsy();
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.getByRole("complementary", { name: "CPU pressure on pve-main" }),
).toBeInViewport();
});
test("shows calm only with current coverage and never converts failure into health", async ({
page,
}) => {
const fixture = await mockAttention(page, "calm");
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await expect(page.getByText("Nothing needs your attention")).toBeVisible();
await expect(
page.getByText(
"The current operational lifecycle evaluation has no active items.",
{
exact: false,
},
),
).toBeVisible();
fixture.setMode("failed");
await page.getByRole("button", { name: "Refresh Patrol attention" }).click();
await expect(page.getByText("Patrol attention is unavailable")).toBeVisible();
await expect(
page.getByText(
"Pulse has not inferred a calm or healthy state from this failure.",
),
).toBeVisible();
await expect(page.getByText("Nothing needs your attention")).toHaveCount(0);
});