mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-13 11:03:30 +00:00
parent
69533c7afb
commit
cd75722da3
28 changed files with 2505 additions and 94 deletions
|
|
@ -0,0 +1,208 @@
|
|||
# Operational Trust: Governed Docker Restart
|
||||
|
||||
Date: 2026-07-19
|
||||
Specification:
|
||||
`docs/release-control/v6/internal/OPERATIONAL_TRUST_IMPLEMENTATION_SPEC.md`
|
||||
Phase: 5, governed actions and verification
|
||||
Candidate: `protection-posture-attention-queue`
|
||||
|
||||
## Decision
|
||||
|
||||
The first Operational Trust mutation is the existing declared Docker container
|
||||
`restart` capability, offered only from a canonical
|
||||
`docker-container-health` operational record.
|
||||
|
||||
Pulse does not create a Patrol-local executor. The attention surface projects
|
||||
eligibility into the existing action lifecycle, then the shared Actions review
|
||||
owns approval, execution, audit, receipt, and verification. The action origin
|
||||
binds the exact operational record and evidence IDs. The stable request ID is
|
||||
derived from the record and capability, so repeated planning and execution
|
||||
replay the same durable action rather than sending the mutation twice.
|
||||
|
||||
The offer fails closed unless all of the following remain true:
|
||||
|
||||
1. the lifecycle record is open or acknowledged
|
||||
2. every contributing evidence envelope is fresh, complete, confirmed,
|
||||
sufficiently permitted, and bound unambiguously to the same canonical
|
||||
container
|
||||
3. the current unified resource still declares the exact `restart` capability,
|
||||
admin approval floor, and Docker lifecycle handler
|
||||
4. the live executor reports the capability ready
|
||||
5. the current operator can plan, approve, and execute the action
|
||||
|
||||
An existing origin-bound action remains reviewable as durable history after the
|
||||
operational record resolves, subject to current authorization.
|
||||
|
||||
## Verification Boundary
|
||||
|
||||
The Docker executor performs its existing typed readback after the provider
|
||||
accepts the restart. That readback may confirm or contradict the action
|
||||
postcondition, and its trust class remains visible. A successful command or
|
||||
confirmed running-state readback does not close the operational issue.
|
||||
|
||||
Only a later detector-owned, fresh healthy observation may transition the
|
||||
canonical operational record to resolved. The Patrol detail therefore
|
||||
distinguishes:
|
||||
|
||||
- pending execution or verification
|
||||
- confirmed restart postcondition, with the issue still open
|
||||
- contradicted postcondition, with the issue still open
|
||||
- inconclusive verification, with the issue still open
|
||||
|
||||
Provider timeout, callback loss, and server restart retain the durable dispatch
|
||||
attempt and reconcile a correlated receipt without redispatch.
|
||||
|
||||
## User Lens
|
||||
|
||||
Operator job:
|
||||
|
||||
> This container is unhealthy. Show me the one safe action Pulse can actually
|
||||
> perform, let me review it before anything is sent, and tell me whether it
|
||||
> worked without pretending the original health problem is fixed.
|
||||
|
||||
Live deterministic exercise:
|
||||
|
||||
1. Open Patrol from the monitor-first shell.
|
||||
2. Select the unhealthy API container.
|
||||
3. Read the impact, fresh evidence posture, bounded restart postcondition, and
|
||||
explicit approval boundary in the selected detail.
|
||||
4. Open the shared governed-action review.
|
||||
5. Expand planning-time policy evidence.
|
||||
6. Approve, perform a second deliberate Run action, and close the review.
|
||||
7. Read either confirmed or contradicted verification beside the still-open
|
||||
operational issue.
|
||||
|
||||
Distance to the answer is one Patrol navigation, one item selection, and one
|
||||
action review. The default queue carries no mutation button. The selected
|
||||
detail contains one bounded action; exact policy, approval, delivery, and
|
||||
verification forensics remain in the shared review.
|
||||
|
||||
Keep / demote / cut:
|
||||
|
||||
- Keep one eligible safe action beside the selected issue.
|
||||
- Keep the expected postcondition and approval warning before opening review.
|
||||
- Keep execution versus verification truth explicit after the action.
|
||||
- Demote policy authorities, exact audit identity, delivery receipt, and raw
|
||||
verification evidence to the shared review.
|
||||
- Cut actions for stale, partial, permission-limited, ambiguous, unsupported,
|
||||
or currently unavailable evidence.
|
||||
- Cut provider success as a synonym for operational recovery.
|
||||
- Cut duplicate execute controls and any Patrol-local action lifecycle.
|
||||
|
||||
Vocabulary:
|
||||
|
||||
- `Review and approve` states the next operator decision.
|
||||
- `Review action` opens durable history after a plan exists.
|
||||
- `Postcondition confirmed` says what the action proved; it does not say the
|
||||
service is healthy.
|
||||
- `Issue stays open until fresh health evidence` explains why the queue item
|
||||
remains.
|
||||
|
||||
Verdict: `product`. The flow is bounded, deliberate, auditable, responsive,
|
||||
keyboard operable, and honest about both failed verification and the separate
|
||||
detector-owned recovery boundary.
|
||||
|
||||
## User and Comparative Evidence
|
||||
|
||||
- [#1034: Docker container start/stop/restart option](https://github.com/rcourtman/Pulse/issues/1034)
|
||||
directly requests a container restart control in Pulse. Operational Trust
|
||||
places it in selected issue context rather than adding an unaudited hover
|
||||
mutation.
|
||||
- [#1564: failed one-click container update can leave a service stopped](https://github.com/rcourtman/Pulse/issues/1564)
|
||||
demonstrates why provider mutation success, rollback posture, visible
|
||||
failure, and postcondition truth must remain distinct.
|
||||
- [#1586: command execution token rejected despite command-enabled setup](https://github.com/rcourtman/Pulse/issues/1586)
|
||||
demonstrates that current executor readiness and authorization must be
|
||||
checked before an action is offered.
|
||||
- [Docker Engine API: restart a container](https://docs.docker.com/reference/api/engine/version/v1.46/#tag/Container/operation/ContainerRestart)
|
||||
defines restart as a narrow container-scoped provider operation and returns
|
||||
transport/provider acceptance, not proof of application health.
|
||||
- [Docker Engine API](https://docs.docker.com/reference/api/engine/) is
|
||||
versioned and exposes separate inspect/event state, supporting a readback
|
||||
boundary rather than treating the restart response as verification.
|
||||
- [Kubernetes probes](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/)
|
||||
likewise separate container lifecycle state from readiness and workload
|
||||
health. Pulse keeps the same distinction even though this first capability is
|
||||
Docker-specific.
|
||||
|
||||
The selected capability is intentionally smaller than update/recreate,
|
||||
package-cache cleanup, or broad autonomous remediation. It directly answers a
|
||||
public request while fitting the already-declared capability, approval,
|
||||
delivery, receipt, and verification contracts.
|
||||
|
||||
## Runtime and API Result
|
||||
|
||||
- `internal/ai/attention_actions.go` owns the pure eligibility and offer
|
||||
projection.
|
||||
- `internal/api/attention_actions.go` owns bounded enrichment and
|
||||
`POST /api/ai/patrol/attention/{id}/actions/restart/plan`.
|
||||
- Planning re-evaluates lifecycle evidence, resource capability, live executor
|
||||
readiness, and operator authority immediately before calling the canonical
|
||||
action lifecycle.
|
||||
- `ActionOrigin` additively carries `operationalRecordId` and sorted,
|
||||
deduplicated `evidenceIds`.
|
||||
- Memory and SQLite stores expose bounded latest-action lookup by operational
|
||||
record. SQLite uses an indexed JSON-origin query; a 200-record batch is one
|
||||
store read.
|
||||
- List enrichment happens after pagination. Detail enriches only the selected
|
||||
item. Summary performs no action enrichment.
|
||||
- The browser client can request only the fixed zero-parameter plan. It cannot
|
||||
supply origin, target, evidence IDs, handler, actor, or authority.
|
||||
- Existing action plan/decision/execute/detail routes and
|
||||
`ActionReviewDialog` remain the only approval and execution lifecycle.
|
||||
|
||||
## Proof
|
||||
|
||||
Backend contract and integration:
|
||||
|
||||
```text
|
||||
go test ./internal/actionlifecycle ./internal/actionplanner \
|
||||
./internal/unifiedresources ./internal/ai ./internal/api -count=1
|
||||
```
|
||||
|
||||
Focused proofs cover every fail-closed eligibility reason, origin/evidence
|
||||
binding, slash-containing record IDs, plan replay, approval, exactly-once
|
||||
execution, confirmed and contradicted verification, action-without-resolution,
|
||||
fresh detector recovery, timeout after send, correlated late receipt, and
|
||||
SQLite restart reconciliation without resend.
|
||||
|
||||
Frontend:
|
||||
|
||||
```text
|
||||
npm run type-check
|
||||
npm run test -- \
|
||||
src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx \
|
||||
src/features/actions/__tests__/ActionReviewDialog.test.tsx
|
||||
npx eslint \
|
||||
src/api/patrolAttention.ts \
|
||||
src/features/patrol/PatrolAttentionWorkbench.tsx \
|
||||
src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx
|
||||
```
|
||||
|
||||
Browser:
|
||||
|
||||
```text
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173 \
|
||||
npx playwright test \
|
||||
tests/91-operational-trust-attention-workbench.spec.ts \
|
||||
--project=chromium
|
||||
|
||||
PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173 \
|
||||
npx playwright test \
|
||||
tests/91-operational-trust-attention-workbench.spec.ts \
|
||||
--project=mobile-chrome
|
||||
```
|
||||
|
||||
Both five-journey matrices pass. The governed-action journeys prove policy
|
||||
disclosure, approval, exactly one execute request, confirmed and contradicted
|
||||
verification copy, still-open lifecycle truth, focus restoration after the
|
||||
detail node is refreshed, screen-reader names, 390-pixel layout, no document
|
||||
overflow, and reduced motion.
|
||||
|
||||
## Remaining Specification Work
|
||||
|
||||
This record accepts Phase 5 only. It does not close the overall Operational
|
||||
Trust goal or primary candidate. Phase 6 still owns the complete compatibility,
|
||||
migration, concurrency, failure, retention, load, telemetry, documentation,
|
||||
upgrade, and final cross-repository governance matrix required by all fourteen
|
||||
completion criteria.
|
||||
|
|
@ -8727,6 +8727,11 @@
|
|||
"path": "docs/release-control/v6/internal/records/operational-trust-alert-state-boundary-2026-07-18.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/operational-trust-governed-docker-restart-2026-07-19.md",
|
||||
"kind": "file"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/operational-trust-lifecycle-evidence-notification-linkage-2026-07-19.md",
|
||||
|
|
|
|||
|
|
@ -4625,13 +4625,38 @@ operator approve or reject an agent action while preserving the agent token's
|
|||
separate reporting and command authority; invalid credentials must never reach
|
||||
the action handler or acquire an operator principal.
|
||||
|
||||
### Multi-destination reporting authority
|
||||
|
||||
The Unified Agent may fan one collected host, Docker/Podman, and Kubernetes
|
||||
snapshot out to one primary Pulse destination and zero or more report-only
|
||||
observers. Exactly one destination is authoritative. Only that primary may
|
||||
provide remote configuration, commands, enrollment, update selection, or
|
||||
canonical agent identity. Observer response bodies cannot enter any control
|
||||
path. Delivery queues, authentication failures, and persisted host-report
|
||||
buffers are destination-scoped; failure of one destination cannot block or
|
||||
replay reports to another.
|
||||
|
||||
Observer configuration is explicit, versioned, and file-backed. It contains no
|
||||
raw token values and resolves each token from a separate private absolute-path
|
||||
file. Proxmox registration is also destination-scoped: the primary retains its
|
||||
legacy token name for upgrade continuity, observers use distinct token names
|
||||
and state markers, and every setup path must obtain a successful registration
|
||||
state response before any create, delete, or rotation command is executed.
|
||||
|
||||
The adjacent recovery handlers under `internal/api/` do not widen this agent
|
||||
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.
|
||||
The adjacent Patrol attention read handlers and shared router registration do
|
||||
not widen agent authority. Reads consume the canonical alert lifecycle under
|
||||
`monitoring:read`; they do not accept agent reports, mint credentials, or
|
||||
reinterpret an observer response as configuration. Phase 5 may plan one
|
||||
evidence-gated Docker restart only after current plan/approve/execute authority,
|
||||
declared capability, and executor readiness checks. That attention handler
|
||||
binds an internal origin and enters the existing action lifecycle; it does not
|
||||
deliver a command itself or add an agent wire shape. Actual dispatch retains
|
||||
the canonical action executor, exact agent/resource binding, command-enabled
|
||||
token policy, durable attempt/receipt, timeout, and restart-reconciliation
|
||||
boundaries.
|
||||
|
|
|
|||
|
|
@ -6618,3 +6618,20 @@ and pre/post inventory equality. Legacy `pulse-qual-${run_id}` names remain
|
|||
accepted for external manifest compatibility, but governed remediation
|
||||
qualification uses neutral identities so a model is scored on infrastructure
|
||||
evidence rather than recognizing that it is inside a benchmark.
|
||||
|
||||
### Operational Trust action eligibility
|
||||
|
||||
`internal/ai/attention_actions.go` is the canonical pure projection for the
|
||||
first attention-owned action offer. It maps only a
|
||||
`docker-container-health` lifecycle item on an app-container to the existing
|
||||
declared Docker `restart` capability. Freshness, completeness, confidence,
|
||||
permissions, canonical subject binding, unambiguous correlation, live resource
|
||||
capability, exact internal handler, approval floor, executor readiness, and
|
||||
operator authority all fail closed through typed reasons. Frontend and API
|
||||
callers must not reproduce those gates from finding text or loose metadata.
|
||||
|
||||
An origin-bound existing action may be projected for authorized historical
|
||||
review. Verification presentation is derived only from the durable canonical
|
||||
action state and `ActionResultV2`: confirmed, contradicted, inconclusive, and
|
||||
not-attempted remain distinct. The projection has no authority to resolve the
|
||||
operational record.
|
||||
|
|
|
|||
|
|
@ -7821,3 +7821,26 @@ boundaries. Recovery points now expose provider scope and typed evidence, and
|
|||
rollups expose their already-owned verification intent and last-verification
|
||||
time. Supported legacy provider, subject, and display aliases remain readable;
|
||||
they do not create a parallel posture authority.
|
||||
|
||||
### Operational Trust attention action transport
|
||||
|
||||
Attention list and detail payloads add typed action offers and verification
|
||||
state without changing supported lifecycle fields. Offer enrichment occurs
|
||||
only after bounded list pagination or for one selected detail. It performs one
|
||||
registry build, one store acquisition, one authority evaluation, and one
|
||||
bounded latest-action batch read for the visible page; summary performs none.
|
||||
|
||||
`POST /api/ai/patrol/attention/{id}/actions/restart/plan` is the only
|
||||
Operational Trust mutation route introduced in Phase 5. The server parses
|
||||
record IDs containing slashes, reloads the canonical projection, and
|
||||
re-evaluates lifecycle evidence, subject identity, resource capability,
|
||||
executor readiness, and current plan/approve/execute authority. The public
|
||||
request supplies no target, parameters, evidence IDs, handler, actor, or
|
||||
origin. Planning stamps the internal `operational_trust_attention` origin,
|
||||
exact operational record ID, sorted evidence IDs, and a stable record-bound
|
||||
request ID before using the existing action lifecycle. A prior origin-bound
|
||||
plan is replayed rather than duplicated.
|
||||
|
||||
Decision, execution, receipt, reconciliation, result, and verification remain
|
||||
on the existing `/api/actions` contract. Provider success does not mutate the
|
||||
operational record; only fresh detector recovery evidence may resolve it.
|
||||
|
|
|
|||
|
|
@ -2339,6 +2339,17 @@ detail, while legacy Patrol analytics belong in collapsed supporting context.
|
|||
Unavailable and partial states must use explicit copy rather than success
|
||||
styling.
|
||||
|
||||
The selected attention detail may compose the shared Actions review for an
|
||||
eligible backend-authored offer. The detail owns only the expected
|
||||
postcondition, explicit-review warning, verification summary, and one review
|
||||
trigger. `ActionReviewDialog` remains the sole approve/reject/run and durable
|
||||
outcome primitive. After a decision or execution refreshes and replaces the
|
||||
detail subtree, dialog close must resolve and focus the current action trigger,
|
||||
not a detached element reference. Browser proof covers desktop and
|
||||
390-pixel mobile layouts, reduced motion, screen-reader names, exactly one run
|
||||
request, and focus restoration for both confirmed and contradicted
|
||||
verification.
|
||||
|
||||
## Current State
|
||||
|
||||
Assistant availability in the app shell is derived from the
|
||||
|
|
|
|||
|
|
@ -968,6 +968,24 @@ observation remains stale or unknown and must not be presented as recovered.
|
|||
The attached-facet browser proof is
|
||||
`tests/integration/tests/92-operational-trust-availability-facet.spec.ts`.
|
||||
|
||||
Phase 5 adds one deliberately narrow mutation without changing that ownership
|
||||
model. A selected canonical `docker-container-health` item may show the shared
|
||||
AI-runtime offer for the resource's declared Docker `restart` capability.
|
||||
`PatrolAttentionWorkbench` may plan or reopen that exact action, but all
|
||||
approval, execution, policy evidence, delivery, audit, and verification
|
||||
controls remain in the shared `ActionReviewDialog`. Patrol never reconstructs
|
||||
eligibility from copy or metadata and never closes the issue because the
|
||||
action command or running-state postcondition succeeded. It reports confirmed,
|
||||
contradicted, or inconclusive verification beside the still-open lifecycle
|
||||
record until fresh detector-owned recovery evidence resolves it.
|
||||
|
||||
The review trigger must restore focus to the current rendered control after
|
||||
action refresh replaces the selected detail node. Desktop and mobile browser
|
||||
proof, including failed verification and reduced motion, remains
|
||||
`tests/integration/tests/91-operational-trust-attention-workbench.spec.ts`.
|
||||
The durable decision and proof record is
|
||||
`internal/records/operational-trust-governed-docker-restart-2026-07-19.md`.
|
||||
|
||||
## Current State
|
||||
|
||||
The active Patrol queue now uses compact severity-accented rows for
|
||||
|
|
|
|||
|
|
@ -1932,3 +1932,12 @@ 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.
|
||||
|
||||
Phase 5 action enrichment preserves that bound. It runs only after attention
|
||||
pagination (or for one selected detail), builds the resource registry once,
|
||||
opens the action store once, evaluates actor authority once, and performs one
|
||||
latest-action batch read for at most 200 operational record IDs. SQLite uses
|
||||
the indexed `origin_json` operational-record expression; MemoryStore performs
|
||||
one bounded pass. The summary path performs no registry, action-store,
|
||||
executor-readiness, or action-origin work. Readiness checks remain local to
|
||||
the already-bounded visible page and do not issue browser requests per row.
|
||||
|
|
|
|||
|
|
@ -1426,3 +1426,21 @@ The apply route accepts that opaque token only and re-verifies authoritative
|
|||
Stripe ownership and current state before mutation. Downgrade artifact cleanup
|
||||
must stay within the tenant `reports/generated` directory and skip symlinks so
|
||||
commercial retention cannot become an arbitrary-file deletion primitive.
|
||||
|
||||
Multi-destination Unified Agent configuration preserves authority separation.
|
||||
The primary token is never reused for an observer, observer tokens are loaded
|
||||
only from private regular non-symlink files, and observer URLs independently
|
||||
enforce TLS, CA, fingerprint, and explicit plaintext policy. Observer payload
|
||||
responses are report acknowledgements only and cannot authorize configuration,
|
||||
commands, enrollment, or updates. Per-destination Proxmox tokens prevent one
|
||||
Pulse instance from rotating credentials used by another.
|
||||
|
||||
Operational Trust action offers enforce current plan, approve, and execute
|
||||
authority before a mutating affordance is returned. Planning repeats those
|
||||
checks after reloading the selected canonical record and before entering the
|
||||
shared action lifecycle; execution retains the existing fresh authorization,
|
||||
plan-hash, policy, and delivery gates. The browser cannot claim a first-party
|
||||
origin, target, evidence set, handler, actor, or parameters. The server binds
|
||||
the exact operational record and policy-shaped evidence IDs internally.
|
||||
Unauthorized, stale, partial, permission-limited, ambiguous, unsupported, or
|
||||
executor-unready records return no offer and no cross-resource detail.
|
||||
|
|
|
|||
|
|
@ -2180,6 +2180,16 @@ while storage detail drawers and filter controls must route summary series IDs,
|
|||
source tones, and disk metrics through the shared storage helpers instead of
|
||||
reconstructing them from local table state.
|
||||
|
||||
The adjacent Operational Trust attention action transport does not turn
|
||||
protection posture into mutation authority. The selected item may display
|
||||
storage/recovery-owned posture beside a separately declared Docker restart
|
||||
offer, but recovery points, provider job evidence, backup age, verification
|
||||
intent, and posture state cannot authorize that restart. The Phase 5 route
|
||||
adds no backup, restore, retention, or recovery execution API and does not
|
||||
change storage/recovery lifecycle ownership; it enters the existing governed
|
||||
action lifecycle only after the action subsystem's own current evidence,
|
||||
capability, executor-readiness, and operator-authority gates pass.
|
||||
|
||||
### Canonical protection posture
|
||||
|
||||
`ProtectionPosture` is the storage/recovery-owned subject read model over
|
||||
|
|
|
|||
|
|
@ -1471,7 +1471,8 @@ AI-only summary payloads, or page-local heuristics.
|
|||
`internal/unifiedresources/registry_test.go`.
|
||||
26. Keep action-audit origin metadata broker-owned. `ActionAuditRecord`
|
||||
carries an optional `Origin *ActionOrigin`
|
||||
(`surface`/`findingId`/`investigationId`/`proposalId`), persisted in
|
||||
(`surface`/`findingId`/`investigationId`/`proposalId`/
|
||||
`operationalRecordId`/`evidenceIds`), persisted in
|
||||
the `action_audits.origin_json` column (added by
|
||||
`migrateActionAuditsSchema`) and round-tripped through
|
||||
`scanActionAuditRecord`. Origin identifies which internal surface
|
||||
|
|
@ -1480,8 +1481,9 @@ AI-only summary payloads, or page-local heuristics.
|
|||
in-process planning callers through the action lifecycle service's
|
||||
plan options; the public `POST /api/actions/plan` body must never be
|
||||
able to claim a first-party origin. `NormalizeActionOrigin` trims
|
||||
fields and collapses an all-empty origin to nil so absent metadata
|
||||
never persists as an empty object. Origin fields are Pulse-produced
|
||||
fields, sorts and deduplicates evidence IDs, and collapses an all-empty
|
||||
origin to nil so absent metadata never persists as an empty object.
|
||||
Origin fields are Pulse-produced
|
||||
identifiers, not operator text, and stay outside the redaction set.
|
||||
Downstream reconciliation of origin-tagged records rides the shared
|
||||
lifecycle service's org-scoped `OnActionTransition` hook (wired via
|
||||
|
|
@ -1489,8 +1491,17 @@ AI-only summary payloads, or page-local heuristics.
|
|||
only after the corresponding store write succeeds, so a subscriber
|
||||
keyed by org ID never observes a state this store could still lose
|
||||
or apply it to the wrong tenant.
|
||||
Regression coverage: `TestSQLiteStoreActionAuditOriginRoundTrip` in
|
||||
`internal/unifiedresources/store_test.go`.
|
||||
Operational Trust attention continuity uses the optional singular and
|
||||
bounded batch operational-record readers. Memory and SQLite return the
|
||||
latest action per record with the same clone semantics. SQLite guards the
|
||||
JSON expression, uses the
|
||||
`idx_action_audits_origin_operational_record_updated` index, and resolves
|
||||
at most 200 requested records in one query so Patrol pagination cannot
|
||||
become a per-row store pattern.
|
||||
Regression coverage:
|
||||
`TestSQLiteStoreActionAuditOriginRoundTrip`,
|
||||
`TestSQLiteActionAuditOriginOperationalRecordReader`, and the matching
|
||||
memory/batch reader tests in `internal/unifiedresources/store_test.go`.
|
||||
27. Keep API-added TrueNAS systems keyed by the configured connection,
|
||||
never by snapshot-reported identity. `systemSourceID` in
|
||||
`internal/truenas/provider.go` scopes the system source ID (and every
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
getPatrolAttention,
|
||||
getPatrolAttentionDetail,
|
||||
getPatrolAttentionSummary,
|
||||
planPatrolAttentionAction,
|
||||
} from '@/api/patrolAttention';
|
||||
import { apiFetchJSON } from '@/utils/apiClient';
|
||||
|
||||
|
|
@ -33,4 +34,16 @@ describe('Patrol attention API', () => {
|
|||
await getPatrolAttentionDetail('record/one');
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/record%2Fone');
|
||||
});
|
||||
|
||||
it('plans the fixed attention capability without accepting public action authority', async () => {
|
||||
await planPatrolAttentionAction('record/one', 'restart');
|
||||
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(
|
||||
'/api/ai/patrol/attention/record%2Fone/actions/restart/plan',
|
||||
{
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,27 +9,28 @@ import type {
|
|||
OperationalState,
|
||||
} from '@/types/operationalTrust';
|
||||
import type { ProtectionPosture } from '@/types/recovery';
|
||||
import type { ActionAuditPlan } from '@/types/actionAudit';
|
||||
|
||||
export type AttentionFilter =
|
||||
| 'active'
|
||||
| 'open'
|
||||
| 'acknowledged'
|
||||
| 'suppressed'
|
||||
| 'stale_unknown'
|
||||
| 'resolved'
|
||||
| 'all';
|
||||
'active' | 'open' | 'acknowledged' | 'suppressed' | 'stale_unknown' | 'resolved' | 'all';
|
||||
|
||||
export type AttentionVerificationState =
|
||||
| 'not_available'
|
||||
| 'pending'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'unknown';
|
||||
'not_available' | 'pending' | 'succeeded' | 'failed' | 'unknown';
|
||||
|
||||
export interface AttentionActionOffer {
|
||||
actionId?: string;
|
||||
targetResourceId: string;
|
||||
capability: string;
|
||||
kind: string;
|
||||
label: string;
|
||||
mode: 'plan' | 'dry-run' | 'execute';
|
||||
risk: string;
|
||||
approval: 'not-required' | 'required' | 'granted' | 'denied';
|
||||
eligibility: 'eligible' | 'ineligible' | 'unknown';
|
||||
reasons: string[];
|
||||
evidenceIds: string[];
|
||||
expectedPostcondition: string;
|
||||
verificationPolicy: string;
|
||||
requiresApproval: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +44,7 @@ export interface AttentionItem {
|
|||
subjectResourceId: string;
|
||||
subjectResourceName: string;
|
||||
subjectResourceType?: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
plainLanguageSummary: string;
|
||||
severity: OperationalSeverity;
|
||||
|
|
@ -111,3 +113,16 @@ export async function getPatrolAttentionDetail(itemId: string): Promise<Attentio
|
|||
`/api/ai/patrol/attention/${encodeURIComponent(itemId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function planPatrolAttentionAction(
|
||||
itemId: string,
|
||||
capability: string,
|
||||
): Promise<ActionAuditPlan> {
|
||||
return apiFetchJSON<ActionAuditPlan>(
|
||||
`/api/ai/patrol/attention/${encodeURIComponent(itemId)}/actions/${encodeURIComponent(capability)}/plan`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,16 +15,21 @@ 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 RotateCwIcon from 'lucide-solid/icons/rotate-cw';
|
||||
import SparklesIcon from 'lucide-solid/icons/sparkles';
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import type {
|
||||
AttentionFilter,
|
||||
AttentionActionOffer,
|
||||
AttentionItem,
|
||||
AttentionItemDetail,
|
||||
} from '@/api/patrolAttention';
|
||||
import { planPatrolAttentionAction } from '@/api/patrolAttention';
|
||||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
|
||||
import { MetadataBadge, type MetadataBadgeTone } from '@/components/shared/MetadataBadge';
|
||||
import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
|
||||
import { aiChatStore } from '@/stores/aiChat';
|
||||
import { patrolAttentionStore } from '@/stores/patrolAttention';
|
||||
import {
|
||||
|
|
@ -34,6 +39,7 @@ import {
|
|||
parsePatrolAttentionItemId,
|
||||
} from '@/routing/resourceLinks';
|
||||
import type { EvidenceEnvelope } from '@/types/operationalTrust';
|
||||
import type { ActionDetailResponse } from '@/types/actionAudit';
|
||||
import { formatRelativeTime } from '@/utils/format';
|
||||
|
||||
const PRIMARY_EVIDENCE_LIMIT = 3;
|
||||
|
|
@ -50,8 +56,12 @@ const FILTERS: Array<{ id: AttentionFilter; label: string }> = [
|
|||
export function PatrolAttentionWorkbench() {
|
||||
const location = useLocation();
|
||||
const [selectedItemId, setSelectedItemId] = createSignal('');
|
||||
const [actionDetail, setActionDetail] = createSignal<ActionDetailResponse | null>(null);
|
||||
const [actionBusy, setActionBusy] = createSignal(false);
|
||||
const [actionError, setActionError] = createSignal('');
|
||||
const itemButtons = new Map<string, HTMLButtonElement>();
|
||||
let detailPanel: HTMLDivElement | undefined;
|
||||
let actionTrigger: HTMLButtonElement | undefined;
|
||||
|
||||
const selectedDetail = () => patrolAttentionStore.selectedDetail();
|
||||
const summary = () => patrolAttentionStore.summary();
|
||||
|
|
@ -101,6 +111,44 @@ export function PatrolAttentionWorkbench() {
|
|||
closeDetail();
|
||||
void patrolAttentionStore.load(filter);
|
||||
};
|
||||
const reviewAction = async (
|
||||
item: AttentionItem,
|
||||
offer: AttentionActionOffer,
|
||||
trigger: HTMLButtonElement,
|
||||
) => {
|
||||
if (actionBusy()) return;
|
||||
actionTrigger = trigger;
|
||||
setActionBusy(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const actionId =
|
||||
offer.actionId || (await planPatrolAttentionAction(item.id, offer.capability)).actionId;
|
||||
setActionDetail(await ResourceActionsAPI.getAction(actionId));
|
||||
} catch (cause) {
|
||||
setActionError(
|
||||
cause instanceof Error ? cause.message : 'The governed action could not be opened.',
|
||||
);
|
||||
} finally {
|
||||
setActionBusy(false);
|
||||
}
|
||||
};
|
||||
const closeActionReview = () => {
|
||||
setActionDetail(null);
|
||||
queueMicrotask(() => {
|
||||
const currentTrigger = detailPanel?.querySelector<HTMLButtonElement>(
|
||||
'[data-patrol-action-trigger]',
|
||||
);
|
||||
(currentTrigger ?? actionTrigger)?.focus();
|
||||
});
|
||||
};
|
||||
const actionChanged = async (next: ActionDetailResponse) => {
|
||||
setActionDetail(next);
|
||||
const selected = selectedItemId();
|
||||
await Promise.all([
|
||||
selected ? patrolAttentionStore.select(selected) : Promise.resolve(),
|
||||
patrolAttentionStore.load(patrolAttentionStore.filter()),
|
||||
]);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
void patrolAttentionStore.load('active');
|
||||
|
|
@ -171,7 +219,11 @@ export function PatrolAttentionWorkbench() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2 overflow-x-auto pb-1" role="group" aria-label="Attention filter">
|
||||
<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;
|
||||
|
|
@ -216,10 +268,18 @@ export function PatrolAttentionWorkbench() {
|
|||
detail={selectedDetail()}
|
||||
loading={patrolAttentionStore.detailLoading()}
|
||||
onClose={closeDetail}
|
||||
actionBusy={actionBusy()}
|
||||
actionError={actionError()}
|
||||
onReviewAction={reviewAction}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<ActionReviewDialog
|
||||
detail={actionDetail()}
|
||||
onClose={closeActionReview}
|
||||
onChanged={actionChanged}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -262,10 +322,7 @@ function AttentionList(props: {
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={patrolAttentionStore.items().length > 0}
|
||||
fallback={<AttentionEmptyState />}
|
||||
>
|
||||
<Show when={patrolAttentionStore.items().length > 0} fallback={<AttentionEmptyState />}>
|
||||
<ul class="divide-y divide-border" aria-label="Patrol attention items">
|
||||
<For each={patrolAttentionStore.items()}>
|
||||
{(item) => (
|
||||
|
|
@ -303,7 +360,10 @@ function AttentionList(props: {
|
|||
<span>{formatRelativeTime(item.firstObservedAt, { compact: true })}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon class="mt-1 h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
<ChevronRightIcon
|
||||
class="mt-1 h-4 w-4 shrink-0 text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -332,9 +392,7 @@ function AttentionEmptyState() {
|
|||
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>
|
||||
<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.'
|
||||
|
|
@ -344,9 +402,7 @@ function AttentionEmptyState() {
|
|||
}
|
||||
>
|
||||
<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>
|
||||
<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}>
|
||||
|
|
@ -362,13 +418,19 @@ function AttentionDetail(props: {
|
|||
detail: AttentionItemDetail | null;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
actionBusy: boolean;
|
||||
actionError: string;
|
||||
onReviewAction: (
|
||||
item: AttentionItem,
|
||||
offer: AttentionActionOffer,
|
||||
trigger: HTMLButtonElement,
|
||||
) => 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(),
|
||||
(left, right) => new Date(right.observedAt).getTime() - new Date(left.observedAt).getTime(),
|
||||
),
|
||||
);
|
||||
const primaryEvidence = createMemo(() => orderedEvidence().slice(0, PRIMARY_EVIDENCE_LIMIT));
|
||||
|
|
@ -407,9 +469,7 @@ function AttentionDetail(props: {
|
|||
detailLines: [
|
||||
current.plainLanguageSummary,
|
||||
current.impact ? `Impact: ${current.impact}` : undefined,
|
||||
current.recommendedNextStep
|
||||
? `Next step: ${current.recommendedNextStep}`
|
||||
: undefined,
|
||||
current.recommendedNextStep ? `Next step: ${current.recommendedNextStep}` : undefined,
|
||||
].filter((line): line is string => Boolean(line)),
|
||||
evidence: evidence.slice(0, 5),
|
||||
actionLabel: `Explain ${current.title}`,
|
||||
|
|
@ -492,9 +552,7 @@ function AttentionDetail(props: {
|
|||
<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>
|
||||
<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{' '}
|
||||
|
|
@ -510,14 +568,56 @@ function AttentionDetail(props: {
|
|||
</Show>
|
||||
<Show when={loaded().item.recommendedNextStep}>
|
||||
{(nextStep) => (
|
||||
<p class="mt-2 text-sm font-medium leading-5 text-base-content">
|
||||
{nextStep()}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-medium leading-5 text-base-content">{nextStep()}</p>
|
||||
)}
|
||||
</Show>
|
||||
</DetailSection>
|
||||
</Show>
|
||||
|
||||
<Show when={loaded().item.availableActions[0]}>
|
||||
{(offer) => (
|
||||
<DetailSection title="Safe action">
|
||||
<div class="rounded-md border border-blue-200 bg-blue-50/70 p-3 dark:border-blue-900 dark:bg-blue-950/30">
|
||||
<div class="flex items-start gap-2">
|
||||
<RotateCwIcon
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-300"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-base-content">{offer().label}</p>
|
||||
<p class="mt-1 text-xs leading-5 text-muted">
|
||||
{offer().expectedPostcondition}{' '}
|
||||
{attentionActionGuidance(loaded().item, offer())}
|
||||
</p>
|
||||
<ActionVerificationMessage state={loaded().item.verificationState} />
|
||||
<Show when={props.actionError}>
|
||||
<p
|
||||
role="alert"
|
||||
class="mt-2 text-xs leading-5 text-red-700 dark:text-red-300"
|
||||
>
|
||||
{props.actionError}
|
||||
</p>
|
||||
</Show>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="mt-3 gap-1.5"
|
||||
data-patrol-action-trigger
|
||||
isLoading={props.actionBusy}
|
||||
onClick={(event) =>
|
||||
props.onReviewAction(loaded().item, offer(), event.currentTarget)
|
||||
}
|
||||
>
|
||||
<RotateCwIcon class="h-4 w-4" aria-hidden="true" />
|
||||
{offer().actionId ? 'Review action' : 'Review and approve'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DetailSection>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<DetailSection title="Evidence">
|
||||
<Show
|
||||
when={orderedEvidence().length > 0}
|
||||
|
|
@ -639,6 +739,44 @@ function AttentionDetail(props: {
|
|||
);
|
||||
}
|
||||
|
||||
function ActionVerificationMessage(props: { state: AttentionItem['verificationState'] }) {
|
||||
const message = () => {
|
||||
switch (props.state) {
|
||||
case 'pending':
|
||||
return 'The action is awaiting a decision, execution, or verification.';
|
||||
case 'succeeded':
|
||||
return 'The restart postcondition was confirmed. This issue stays open until fresh health evidence shows the container is healthy.';
|
||||
case 'failed':
|
||||
return 'The restart did not satisfy its postcondition. The issue remains open.';
|
||||
case 'unknown':
|
||||
return 'Pulse could not conclusively verify the restart. The issue remains open.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Show when={message()}>
|
||||
{(value) => <p class="mt-2 text-xs font-medium leading-5 text-base-content">{value()}</p>}
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
|
||||
function attentionActionGuidance(item: AttentionItem, offer: AttentionActionOffer): string {
|
||||
if (!offer.actionId) {
|
||||
return 'This requires an explicit review and approval before Pulse sends anything.';
|
||||
}
|
||||
switch (item.verificationState) {
|
||||
case 'pending':
|
||||
return 'Open the existing review to continue the governed action.';
|
||||
case 'succeeded':
|
||||
case 'failed':
|
||||
case 'unknown':
|
||||
return 'Pulse recorded the action result below. Open the review for the full audit.';
|
||||
default:
|
||||
return 'Open the existing review to inspect the recorded decision.';
|
||||
}
|
||||
}
|
||||
|
||||
function DetailSection(props: { title: string; children: import('solid-js').JSX.Element }) {
|
||||
return (
|
||||
<section>
|
||||
|
|
@ -705,12 +843,7 @@ function SeverityMarker(props: { item: AttentionItem }) {
|
|||
|
||||
function StateBadge(props: { item: AttentionItem }) {
|
||||
return (
|
||||
<MetadataBadge
|
||||
tone={stateTone(props.item)}
|
||||
size="xs"
|
||||
shape="rounded"
|
||||
appearance="outline"
|
||||
>
|
||||
<MetadataBadge tone={stateTone(props.item)} size="xs" shape="rounded" appearance="outline">
|
||||
{formatLabel(props.item.state)}
|
||||
</MetadataBadge>
|
||||
);
|
||||
|
|
@ -718,16 +851,14 @@ function StateBadge(props: { item: AttentionItem }) {
|
|||
|
||||
function EvidenceLabel(props: { item: AttentionItem; badge?: boolean }) {
|
||||
const label = () =>
|
||||
props.item.evidenceFreshness === 'fresh' &&
|
||||
props.item.evidenceCompleteness === 'complete'
|
||||
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'
|
||||
props.item.evidenceFreshness === 'fresh' && props.item.evidenceCompleteness === 'complete'
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const apiMocks = vi.hoisted(() => ({
|
|||
getList: vi.fn(),
|
||||
getDetail: vi.fn(),
|
||||
getSummary: vi.fn(),
|
||||
planAction: vi.fn(),
|
||||
getAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/patrolAttention', async (importOriginal) => {
|
||||
|
|
@ -21,6 +23,38 @@ vi.mock('@/api/patrolAttention', async (importOriginal) => {
|
|||
getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args),
|
||||
getPatrolAttentionDetail: (...args: unknown[]) => apiMocks.getDetail(...args),
|
||||
getPatrolAttentionSummary: (...args: unknown[]) => apiMocks.getSummary(...args),
|
||||
planPatrolAttentionAction: (...args: unknown[]) => apiMocks.planAction(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/resourceActions', () => ({
|
||||
ResourceActionsAPI: {
|
||||
getAction: (...args: unknown[]) => apiMocks.getAction(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/actions/ActionReviewDialog', async () => {
|
||||
const { Show } = await import('solid-js');
|
||||
return {
|
||||
ActionReviewDialog: (props: {
|
||||
detail: { audit?: { id?: string } } | null;
|
||||
onClose: () => void;
|
||||
onChanged: (next: { audit?: { id?: string } }) => Promise<void> | void;
|
||||
}) => (
|
||||
<Show when={props.detail}>
|
||||
{(value) => (
|
||||
<div role="dialog" aria-label="Governed action review">
|
||||
<span>{value().audit?.id}</span>
|
||||
<button type="button" onClick={() => void props.onChanged(value())}>
|
||||
Complete action
|
||||
</button>
|
||||
<button type="button" onClick={props.onClose}>
|
||||
Close action review
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -48,6 +82,7 @@ const item = (overrides: Partial<AttentionItem> = {}): AttentionItem => ({
|
|||
subjectResourceId: 'pve:vm:101',
|
||||
subjectResourceName: 'Database VM',
|
||||
subjectResourceType: 'vm',
|
||||
kind: 'disk',
|
||||
title: 'Disk pressure on Database VM',
|
||||
plainLanguageSummary: 'The database disk is nearly full.',
|
||||
severity: 'critical',
|
||||
|
|
@ -135,6 +170,8 @@ describe('PatrolAttentionWorkbench', () => {
|
|||
apiMocks.getList.mockReset();
|
||||
apiMocks.getDetail.mockReset();
|
||||
apiMocks.getSummary.mockReset();
|
||||
apiMocks.planAction.mockReset();
|
||||
apiMocks.getAction.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -154,8 +191,9 @@ describe('PatrolAttentionWorkbench', () => {
|
|||
apiMocks.getList.mockResolvedValue(listResponse([], calm));
|
||||
renderWorkbench();
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Nothing needs your attention' }))
|
||||
.toBeInTheDocument();
|
||||
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();
|
||||
|
|
@ -178,8 +216,9 @@ describe('PatrolAttentionWorkbench', () => {
|
|||
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(/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');
|
||||
|
|
@ -224,7 +263,77 @@ describe('PatrolAttentionWorkbench', () => {
|
|||
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();
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'Nothing needs your attention' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the canonical governed action review from an eligible attention item', async () => {
|
||||
const actionOffer = {
|
||||
targetResourceId: 'docker:host-1/container-1',
|
||||
capability: 'restart',
|
||||
kind: 'container_restart',
|
||||
label: 'Restart this container',
|
||||
mode: 'plan' as const,
|
||||
risk: 'low' as const,
|
||||
approval: 'required' as const,
|
||||
eligibility: 'eligible' as const,
|
||||
reasons: ['fresh_confirmed_unhealthy_container'],
|
||||
evidenceIds: ['evidence-1'],
|
||||
expectedPostcondition: 'The same container is observed running after the restart.',
|
||||
verificationPolicy: 'Pulse requires a fresh container readback.',
|
||||
requiresApproval: true,
|
||||
};
|
||||
const active = item({
|
||||
subjectResourceId: 'docker:host-1/container-1',
|
||||
subjectResourceName: 'API container',
|
||||
subjectResourceType: 'app-container',
|
||||
kind: 'docker-container-health',
|
||||
title: 'API container is unhealthy',
|
||||
availableActions: [actionOffer],
|
||||
});
|
||||
const completed = item({
|
||||
...active,
|
||||
availableActions: [{ ...actionOffer, actionId: 'act-attention-restart' }],
|
||||
verificationState: 'succeeded',
|
||||
});
|
||||
apiMocks.getList.mockResolvedValue(
|
||||
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
|
||||
);
|
||||
apiMocks.getDetail
|
||||
.mockResolvedValueOnce(detail(active))
|
||||
.mockResolvedValueOnce(detail(completed));
|
||||
apiMocks.planAction.mockResolvedValue({ actionId: 'act-attention-restart' });
|
||||
apiMocks.getAction.mockResolvedValue({ audit: { id: 'act-attention-restart' } });
|
||||
renderWorkbench();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Open API container is unhealthy',
|
||||
}),
|
||||
);
|
||||
const trigger = await screen.findByRole('button', { name: 'Review and approve' });
|
||||
expect(
|
||||
screen.getByText(/explicit review and approval before Pulse sends anything/i),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(trigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiMocks.planAction).toHaveBeenCalledWith('record-1', 'restart');
|
||||
expect(apiMocks.getAction).toHaveBeenCalledWith('act-attention-restart');
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: 'Governed action review' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Complete action' }));
|
||||
const currentTrigger = await screen.findByRole('button', { name: 'Review action' });
|
||||
expect(currentTrigger).not.toBe(trigger);
|
||||
expect(screen.getByText(/recorded the action result below/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/explicit review and approval before Pulse sends anything/i),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close action review' }));
|
||||
await waitFor(() => expect(currentTrigger).toHaveFocus());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,10 +55,20 @@ const (
|
|||
)
|
||||
|
||||
type AttentionActionOffer struct {
|
||||
Capability string `json:"capability"`
|
||||
Label string `json:"label"`
|
||||
Risk string `json:"risk"`
|
||||
RequiresApproval bool `json:"requiresApproval"`
|
||||
ActionID string `json:"actionId,omitempty"`
|
||||
TargetResourceID string `json:"targetResourceId"`
|
||||
Capability string `json:"capability"`
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Mode string `json:"mode"`
|
||||
Risk string `json:"risk"`
|
||||
Approval string `json:"approval"`
|
||||
Eligibility string `json:"eligibility"`
|
||||
Reasons []string `json:"reasons"`
|
||||
EvidenceIDs []string `json:"evidenceIds"`
|
||||
ExpectedPostcondition string `json:"expectedPostcondition"`
|
||||
VerificationPolicy string `json:"verificationPolicy"`
|
||||
RequiresApproval bool `json:"requiresApproval"`
|
||||
}
|
||||
|
||||
type AttentionResource struct {
|
||||
|
|
@ -71,6 +81,7 @@ type AttentionItem struct {
|
|||
SubjectResourceID string `json:"subjectResourceId"`
|
||||
SubjectResourceName string `json:"subjectResourceName"`
|
||||
SubjectResourceType string `json:"subjectResourceType,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
PlainLanguageSummary string `json:"plainLanguageSummary"`
|
||||
Severity operationaltrust.OperationalSeverity `json:"severity"`
|
||||
|
|
@ -218,6 +229,7 @@ func projectAttentionAlert(
|
|||
SubjectResourceID: record.SubjectResourceID,
|
||||
SubjectResourceName: resourceName,
|
||||
SubjectResourceType: attentionResourceType(alert),
|
||||
Kind: strings.TrimSpace(alert.Type),
|
||||
Title: title,
|
||||
PlainLanguageSummary: summary,
|
||||
Severity: record.Severity,
|
||||
|
|
|
|||
237
internal/ai/attention_actions.go
Normal file
237
internal/ai/attention_actions.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
const (
|
||||
AttentionDockerHealthKind = "docker-container-health"
|
||||
AttentionDockerRestartCapability = "restart"
|
||||
AttentionDockerLifecycleHandler = "docker.container.lifecycle"
|
||||
)
|
||||
|
||||
type AttentionActionEligibilityReason string
|
||||
|
||||
const (
|
||||
AttentionActionEligible AttentionActionEligibilityReason = "eligible"
|
||||
AttentionActionUnsupportedCondition AttentionActionEligibilityReason = "unsupported_condition"
|
||||
AttentionActionLifecycleInactive AttentionActionEligibilityReason = "lifecycle_inactive"
|
||||
AttentionActionEvidenceMissing AttentionActionEligibilityReason = "evidence_missing"
|
||||
AttentionActionEvidenceStale AttentionActionEligibilityReason = "evidence_stale"
|
||||
AttentionActionEvidenceIncomplete AttentionActionEligibilityReason = "evidence_incomplete"
|
||||
AttentionActionEvidenceUnconfirmed AttentionActionEligibilityReason = "evidence_unconfirmed"
|
||||
AttentionActionEvidencePermissionLimited AttentionActionEligibilityReason = "evidence_permission_limited"
|
||||
AttentionActionEvidenceSubjectMismatch AttentionActionEligibilityReason = "evidence_subject_mismatch"
|
||||
AttentionActionResourceUnavailable AttentionActionEligibilityReason = "resource_unavailable"
|
||||
AttentionActionCapabilityUnavailable AttentionActionEligibilityReason = "capability_unavailable"
|
||||
AttentionActionExecutionUnavailable AttentionActionEligibilityReason = "execution_unavailable"
|
||||
AttentionActionOperatorUnauthorized AttentionActionEligibilityReason = "operator_unauthorized"
|
||||
)
|
||||
|
||||
type AttentionActionCandidate struct {
|
||||
Resource *unified.Resource
|
||||
Readiness unified.ResourceActionReadiness
|
||||
Authorized bool
|
||||
Action *unified.ActionAuditRecord
|
||||
}
|
||||
|
||||
// ProjectAttentionAction evaluates the one deliberately narrow first action
|
||||
// against canonical lifecycle evidence and live resource capability state.
|
||||
// API and UI callers must not reconstruct these gates from labels or metadata.
|
||||
func ProjectAttentionAction(
|
||||
detail *AttentionItemDetail,
|
||||
candidate AttentionActionCandidate,
|
||||
now time.Time,
|
||||
) (AttentionActionOffer, AttentionActionEligibilityReason) {
|
||||
if detail == nil {
|
||||
return AttentionActionOffer{}, AttentionActionUnsupportedCondition
|
||||
}
|
||||
item := &detail.Item
|
||||
if existing := candidate.Action; AttentionActionMatchesItem(*item, existing) {
|
||||
if !candidate.Authorized {
|
||||
return AttentionActionOffer{}, AttentionActionOperatorUnauthorized
|
||||
}
|
||||
return attentionDockerRestartOffer(*item, detail.Evidence, existing), AttentionActionEligible
|
||||
}
|
||||
if item.Kind != AttentionDockerHealthKind ||
|
||||
item.SubjectResourceType != string(unified.ResourceTypeAppContainer) {
|
||||
return AttentionActionOffer{}, AttentionActionUnsupportedCondition
|
||||
}
|
||||
if item.State != operationaltrust.OperationalOpen &&
|
||||
item.State != operationaltrust.OperationalAcknowledged {
|
||||
return AttentionActionOffer{}, AttentionActionLifecycleInactive
|
||||
}
|
||||
if len(detail.Evidence) == 0 {
|
||||
return AttentionActionOffer{}, AttentionActionEvidenceMissing
|
||||
}
|
||||
for _, evidence := range detail.Evidence {
|
||||
if evidence.FreshnessAt(now) != operationaltrust.EvidenceFresh {
|
||||
return AttentionActionOffer{}, AttentionActionEvidenceStale
|
||||
}
|
||||
if evidence.Completeness != operationaltrust.EvidenceComplete {
|
||||
return AttentionActionOffer{}, AttentionActionEvidenceIncomplete
|
||||
}
|
||||
if evidence.Confidence != operationaltrust.EvidenceConfirmed {
|
||||
return AttentionActionOffer{}, AttentionActionEvidenceUnconfirmed
|
||||
}
|
||||
if evidence.Permissions != operationaltrust.EvidencePermissionsSufficient {
|
||||
return AttentionActionOffer{}, AttentionActionEvidencePermissionLimited
|
||||
}
|
||||
if unified.CanonicalResourceID(evidence.Subject.ResourceID) !=
|
||||
unified.CanonicalResourceID(item.SubjectResourceID) {
|
||||
return AttentionActionOffer{}, AttentionActionEvidenceSubjectMismatch
|
||||
}
|
||||
if evidence.Correlation != nil && evidence.Correlation.CandidateCount != 1 {
|
||||
return AttentionActionOffer{}, AttentionActionEvidenceSubjectMismatch
|
||||
}
|
||||
}
|
||||
if candidate.Resource == nil ||
|
||||
unified.CanonicalResourceID(candidate.Resource.ID) !=
|
||||
unified.CanonicalResourceID(item.SubjectResourceID) {
|
||||
return AttentionActionOffer{}, AttentionActionResourceUnavailable
|
||||
}
|
||||
found := false
|
||||
for _, capability := range candidate.Resource.Capabilities {
|
||||
if strings.TrimSpace(capability.Name) == AttentionDockerRestartCapability &&
|
||||
strings.TrimSpace(capability.InternalHandler) == AttentionDockerLifecycleHandler &&
|
||||
capability.MinimumApprovalLevel == unified.ApprovalAdmin {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return AttentionActionOffer{}, AttentionActionCapabilityUnavailable
|
||||
}
|
||||
if !candidate.Readiness.Available ||
|
||||
strings.TrimSpace(candidate.Readiness.Name) != AttentionDockerRestartCapability {
|
||||
return AttentionActionOffer{}, AttentionActionExecutionUnavailable
|
||||
}
|
||||
if !candidate.Authorized {
|
||||
return AttentionActionOffer{}, AttentionActionOperatorUnauthorized
|
||||
}
|
||||
return attentionDockerRestartOffer(*item, detail.Evidence, nil), AttentionActionEligible
|
||||
}
|
||||
|
||||
func attentionDockerRestartOffer(
|
||||
item AttentionItem,
|
||||
evidence []operationaltrust.EvidenceEnvelope,
|
||||
action *unified.ActionAuditRecord,
|
||||
) AttentionActionOffer {
|
||||
evidenceIDs := make([]string, 0, len(evidence))
|
||||
if action != nil && action.Origin != nil {
|
||||
evidenceIDs = append(evidenceIDs, action.Origin.EvidenceIDs...)
|
||||
} else {
|
||||
for _, envelope := range evidence {
|
||||
evidenceIDs = append(evidenceIDs, envelope.ID)
|
||||
}
|
||||
}
|
||||
evidenceIDs = uniqueSortedAttentionEvidenceIDs(evidenceIDs)
|
||||
offer := AttentionActionOffer{
|
||||
TargetResourceID: item.SubjectResourceID,
|
||||
Capability: AttentionDockerRestartCapability,
|
||||
Kind: "container_restart",
|
||||
Label: "Restart this container",
|
||||
Mode: "plan",
|
||||
Risk: "low",
|
||||
Approval: "required",
|
||||
Eligibility: "eligible",
|
||||
Reasons: []string{"fresh_confirmed_unhealthy_container", "declared_live_capability"},
|
||||
EvidenceIDs: evidenceIDs,
|
||||
ExpectedPostcondition: "The same container is observed running after the restart.",
|
||||
VerificationPolicy: "Pulse requires a fresh container readback and records whether it is agent-attested or independently observed.",
|
||||
RequiresApproval: true,
|
||||
}
|
||||
if action == nil {
|
||||
return offer
|
||||
}
|
||||
offer.ActionID = action.ID
|
||||
offer.Mode = "execute"
|
||||
offer.Approval = attentionActionApproval(*action)
|
||||
return offer
|
||||
}
|
||||
|
||||
// AttentionActionMatchesItem validates that a durable action belongs to the
|
||||
// exact canonical record, resource, and bounded capability being projected.
|
||||
func AttentionActionMatchesItem(
|
||||
item AttentionItem,
|
||||
action *unified.ActionAuditRecord,
|
||||
) bool {
|
||||
if action == nil || action.Origin == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(action.Origin.OperationalRecordID) ==
|
||||
strings.TrimSpace(item.OperationalRecordID) &&
|
||||
unified.CanonicalResourceID(action.Request.ResourceID) ==
|
||||
unified.CanonicalResourceID(item.SubjectResourceID) &&
|
||||
strings.TrimSpace(action.Request.CapabilityName) ==
|
||||
AttentionDockerRestartCapability
|
||||
}
|
||||
|
||||
func uniqueSortedAttentionEvidenceIDs(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := seen[value]; found {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func attentionActionApproval(action unified.ActionAuditRecord) string {
|
||||
switch action.State {
|
||||
case unified.ActionStateApproved,
|
||||
unified.ActionStateExecuting,
|
||||
unified.ActionStateCompleted,
|
||||
unified.ActionStateFailed:
|
||||
return "granted"
|
||||
case unified.ActionStateRejected:
|
||||
return "denied"
|
||||
default:
|
||||
return "required"
|
||||
}
|
||||
}
|
||||
|
||||
func AttentionActionVerificationState(
|
||||
action *unified.ActionAuditRecord,
|
||||
) AttentionVerificationState {
|
||||
if action == nil {
|
||||
return AttentionVerificationNotAvailable
|
||||
}
|
||||
switch action.State {
|
||||
case unified.ActionStatePlanned,
|
||||
unified.ActionStatePending,
|
||||
unified.ActionStateApproved,
|
||||
unified.ActionStateExecuting:
|
||||
return AttentionVerificationPending
|
||||
case unified.ActionStateRejected, unified.ActionStateExpired:
|
||||
return AttentionVerificationNotAvailable
|
||||
}
|
||||
if action.Result != nil && action.Result.ActionResultV2 != nil {
|
||||
switch action.Result.ActionResultV2.Verification.Status {
|
||||
case unified.ActionVerificationConfirmed:
|
||||
return AttentionVerificationSucceeded
|
||||
case unified.ActionVerificationContradicted:
|
||||
return AttentionVerificationFailed
|
||||
case unified.ActionVerificationInconclusive,
|
||||
unified.ActionVerificationNotAttempted:
|
||||
return AttentionVerificationUnknown
|
||||
}
|
||||
}
|
||||
if action.State == unified.ActionStateFailed {
|
||||
return AttentionVerificationFailed
|
||||
}
|
||||
return AttentionVerificationUnknown
|
||||
}
|
||||
224
internal/ai/attention_actions_test.go
Normal file
224
internal/ai/attention_actions_test.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestProjectAttentionActionRequiresFreshConfirmedCanonicalEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
||||
detail := attentionDockerHealthDetail(now)
|
||||
resource := attentionDockerResource(detail.Item.SubjectResourceID)
|
||||
eligible := AttentionActionCandidate{
|
||||
Resource: &resource,
|
||||
Readiness: unified.ResourceActionReadiness{Name: "restart", Available: true},
|
||||
Authorized: true,
|
||||
}
|
||||
|
||||
offer, reason := ProjectAttentionAction(&detail, eligible, now)
|
||||
if reason != AttentionActionEligible {
|
||||
t.Fatalf("reason = %q, want eligible", reason)
|
||||
}
|
||||
if offer.Capability != "restart" ||
|
||||
offer.TargetResourceID != detail.Item.SubjectResourceID ||
|
||||
offer.Approval != "required" ||
|
||||
offer.Eligibility != "eligible" ||
|
||||
len(offer.EvidenceIDs) != 1 {
|
||||
t.Fatalf("offer = %+v", offer)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*AttentionItemDetail, *AttentionActionCandidate)
|
||||
want AttentionActionEligibilityReason
|
||||
}{
|
||||
{
|
||||
name: "stale evidence",
|
||||
mutate: func(detail *AttentionItemDetail, _ *AttentionActionCandidate) {
|
||||
expired := now.Add(-time.Second)
|
||||
detail.Evidence[0].ValidUntil = &expired
|
||||
},
|
||||
want: AttentionActionEvidenceStale,
|
||||
},
|
||||
{
|
||||
name: "partial evidence",
|
||||
mutate: func(detail *AttentionItemDetail, _ *AttentionActionCandidate) {
|
||||
detail.Evidence[0].Completeness = operationaltrust.EvidencePartial
|
||||
},
|
||||
want: AttentionActionEvidenceIncomplete,
|
||||
},
|
||||
{
|
||||
name: "permission limited",
|
||||
mutate: func(detail *AttentionItemDetail, _ *AttentionActionCandidate) {
|
||||
detail.Evidence[0].Permissions = operationaltrust.EvidencePermissionsPartial
|
||||
},
|
||||
want: AttentionActionEvidencePermissionLimited,
|
||||
},
|
||||
{
|
||||
name: "ambiguous subject",
|
||||
mutate: func(detail *AttentionItemDetail, _ *AttentionActionCandidate) {
|
||||
detail.Evidence[0].Correlation = &operationaltrust.IdentityCorrelation{
|
||||
Rule: "hostname",
|
||||
MatchedFields: map[string]string{
|
||||
"hostname": "api",
|
||||
},
|
||||
CandidateCount: 2,
|
||||
}
|
||||
},
|
||||
want: AttentionActionEvidenceSubjectMismatch,
|
||||
},
|
||||
{
|
||||
name: "capability missing",
|
||||
mutate: func(_ *AttentionItemDetail, candidate *AttentionActionCandidate) {
|
||||
candidate.Resource.Capabilities = nil
|
||||
},
|
||||
want: AttentionActionCapabilityUnavailable,
|
||||
},
|
||||
{
|
||||
name: "executor refused",
|
||||
mutate: func(_ *AttentionItemDetail, candidate *AttentionActionCandidate) {
|
||||
candidate.Readiness.Available = false
|
||||
candidate.Readiness.ReasonCode = "agent_disconnected"
|
||||
},
|
||||
want: AttentionActionExecutionUnavailable,
|
||||
},
|
||||
{
|
||||
name: "executor readiness missing",
|
||||
mutate: func(_ *AttentionItemDetail, candidate *AttentionActionCandidate) {
|
||||
candidate.Readiness = unified.ResourceActionReadiness{}
|
||||
},
|
||||
want: AttentionActionExecutionUnavailable,
|
||||
},
|
||||
{
|
||||
name: "operator unauthorized",
|
||||
mutate: func(_ *AttentionItemDetail, candidate *AttentionActionCandidate) {
|
||||
candidate.Authorized = false
|
||||
},
|
||||
want: AttentionActionOperatorUnauthorized,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
currentDetail := attentionDockerHealthDetail(now)
|
||||
currentResource := attentionDockerResource(currentDetail.Item.SubjectResourceID)
|
||||
currentCandidate := AttentionActionCandidate{
|
||||
Resource: ¤tResource,
|
||||
Readiness: unified.ResourceActionReadiness{Name: "restart", Available: true},
|
||||
Authorized: true,
|
||||
}
|
||||
test.mutate(¤tDetail, ¤tCandidate)
|
||||
if offer, got := ProjectAttentionAction(¤tDetail, currentCandidate, now); got != test.want || offer.Capability != "" {
|
||||
t.Fatalf("offer=%+v reason=%q, want empty/%q", offer, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAttentionActionUsesOnlyTheExistingRecordBoundAction(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
|
||||
detail := attentionDockerHealthDetail(now)
|
||||
detail.Item.State = operationaltrust.OperationalResolved
|
||||
action := unified.ActionAuditRecord{
|
||||
ID: "action-1",
|
||||
State: unified.ActionStateCompleted,
|
||||
Request: unified.ActionRequest{
|
||||
ResourceID: detail.Item.SubjectResourceID,
|
||||
CapabilityName: AttentionDockerRestartCapability,
|
||||
},
|
||||
Origin: &unified.ActionOrigin{
|
||||
OperationalRecordID: detail.Item.OperationalRecordID,
|
||||
EvidenceIDs: []string{"planned-evidence", "planned-evidence"},
|
||||
},
|
||||
}
|
||||
offer, reason := ProjectAttentionAction(
|
||||
&detail,
|
||||
AttentionActionCandidate{Action: &action, Authorized: true},
|
||||
now,
|
||||
)
|
||||
if reason != AttentionActionEligible ||
|
||||
offer.ActionID != action.ID ||
|
||||
len(offer.EvidenceIDs) != 1 ||
|
||||
offer.EvidenceIDs[0] != "planned-evidence" {
|
||||
t.Fatalf("offer=%+v reason=%q", offer, reason)
|
||||
}
|
||||
|
||||
action.Request.ResourceID = "docker:other/container"
|
||||
detail.Item.State = operationaltrust.OperationalOpen
|
||||
resource := attentionDockerResource(detail.Item.SubjectResourceID)
|
||||
offer, reason = ProjectAttentionAction(
|
||||
&detail,
|
||||
AttentionActionCandidate{
|
||||
Resource: &resource,
|
||||
Readiness: unified.ResourceActionReadiness{Name: "restart", Available: true},
|
||||
Authorized: true,
|
||||
Action: &action,
|
||||
},
|
||||
now,
|
||||
)
|
||||
if reason != AttentionActionEligible || offer.ActionID != "" {
|
||||
t.Fatalf("mismatched action offer=%+v reason=%q", offer, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionActionVerificationStatePreservesExecutionAndPostconditionTruth(t *testing.T) {
|
||||
record := unified.ActionAuditRecord{State: unified.ActionStateExecuting}
|
||||
if got := AttentionActionVerificationState(&record); got != AttentionVerificationPending {
|
||||
t.Fatalf("executing = %q", got)
|
||||
}
|
||||
record.State = unified.ActionStateCompleted
|
||||
record.Result = &unified.ExecutionResult{ActionResultV2: &unified.ActionResultV2{
|
||||
Version: unified.ActionResultV2Version,
|
||||
Verification: unified.ActionVerificationTruth{
|
||||
Status: unified.ActionVerificationConfirmed,
|
||||
EvidenceClass: unified.ActionEvidenceAgentAttested,
|
||||
},
|
||||
}}
|
||||
if got := AttentionActionVerificationState(&record); got != AttentionVerificationSucceeded {
|
||||
t.Fatalf("confirmed = %q", got)
|
||||
}
|
||||
record.Result.ActionResultV2.Verification.Status = unified.ActionVerificationContradicted
|
||||
if got := AttentionActionVerificationState(&record); got != AttentionVerificationFailed {
|
||||
t.Fatalf("contradicted = %q", got)
|
||||
}
|
||||
record.Result.ActionResultV2.Verification.Status = unified.ActionVerificationInconclusive
|
||||
if got := AttentionActionVerificationState(&record); got != AttentionVerificationUnknown {
|
||||
t.Fatalf("inconclusive = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func attentionDockerHealthDetail(now time.Time) AttentionItemDetail {
|
||||
resourceID := "docker:host-1/container-1"
|
||||
evidence := attentionTestEvidence(resourceID, now)
|
||||
return AttentionItemDetail{
|
||||
Item: AttentionItem{
|
||||
ID: "operational-docker-health",
|
||||
OperationalRecordID: "operational-docker-health",
|
||||
SubjectResourceID: resourceID,
|
||||
SubjectResourceName: "api",
|
||||
SubjectResourceType: string(unified.ResourceTypeAppContainer),
|
||||
Kind: AttentionDockerHealthKind,
|
||||
State: operationaltrust.OperationalOpen,
|
||||
},
|
||||
OperationalRecord: operationaltrust.OperationalRecord{
|
||||
ID: "operational-docker-health",
|
||||
SubjectResourceID: resourceID,
|
||||
},
|
||||
Evidence: []operationaltrust.EvidenceEnvelope{evidence},
|
||||
}
|
||||
}
|
||||
|
||||
func attentionDockerResource(resourceID string) unified.Resource {
|
||||
return unified.Resource{
|
||||
ID: resourceID,
|
||||
Type: unified.ResourceTypeAppContainer,
|
||||
Capabilities: []unified.ResourceCapability{{
|
||||
Name: AttentionDockerRestartCapability,
|
||||
Type: unified.CapabilityTypeCommon,
|
||||
MinimumApprovalLevel: unified.ApprovalAdmin,
|
||||
InternalHandler: AttentionDockerLifecycleHandler,
|
||||
}},
|
||||
}
|
||||
}
|
||||
240
internal/api/attention_actions.go
Normal file
240
internal/api/attention_actions.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
const operationalTrustActionOriginSurface = "operational_trust_attention"
|
||||
|
||||
func isAttentionActionPlanPath(path string) bool {
|
||||
_, capability, ok := parseAttentionActionPlanPath(path)
|
||||
return ok && capability != ""
|
||||
}
|
||||
|
||||
func parseAttentionActionPlanPath(path string) (string, string, bool) {
|
||||
path = strings.Trim(path, "/")
|
||||
actionBoundary := strings.LastIndex(path, "/actions/")
|
||||
if actionBoundary <= 0 {
|
||||
return "", "", false
|
||||
}
|
||||
itemPart := path[:actionBoundary]
|
||||
actionPart := path[actionBoundary+len("/actions/"):]
|
||||
actionSegments := strings.Split(actionPart, "/")
|
||||
if len(actionSegments) != 2 || actionSegments[1] != "plan" {
|
||||
return "", "", false
|
||||
}
|
||||
itemID, err := url.PathUnescape(itemPart)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
capability, err := url.PathUnescape(actionSegments[0])
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
itemID = strings.TrimSpace(itemID)
|
||||
capability = strings.TrimSpace(capability)
|
||||
return itemID, capability, itemID != "" && capability != ""
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) projectAttentionActions(
|
||||
r *http.Request,
|
||||
details []ai.AttentionItemDetail,
|
||||
) {
|
||||
if h == nil || h.resources == nil || len(details) == 0 {
|
||||
return
|
||||
}
|
||||
orgID := GetOrgID(r.Context())
|
||||
registry, registryErr := h.resources.buildRegistry(orgID)
|
||||
store, storeErr := h.resources.getStore(orgID)
|
||||
actor, actorErr := actionActorForRequest(h.resources.cfg, r, orgID)
|
||||
authorized := actorErr == nil &&
|
||||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionPlan) == nil &&
|
||||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionApprove) == nil &&
|
||||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionExecute) == nil
|
||||
|
||||
actions := make(map[string]unified.ActionAuditRecord, len(details))
|
||||
if storeErr == nil {
|
||||
if reader, ok := store.(unified.OperationalActionAuditOriginBatchReader); ok {
|
||||
recordIDs := make([]string, 0, len(details))
|
||||
for index := range details {
|
||||
recordIDs = append(recordIDs, details[index].Item.OperationalRecordID)
|
||||
}
|
||||
if records, err := reader.GetLatestActionAuditsByOperationalRecords(
|
||||
operationalTrustActionOriginSurface,
|
||||
recordIDs,
|
||||
); err == nil {
|
||||
actions = records
|
||||
}
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for index := range details {
|
||||
detail := &details[index]
|
||||
candidate := ai.AttentionActionCandidate{Authorized: authorized}
|
||||
if record, found := actions[detail.Item.OperationalRecordID]; found &&
|
||||
ai.AttentionActionMatchesItem(detail.Item, &record) {
|
||||
candidate.Action = &record
|
||||
detail.Item.VerificationState = ai.AttentionActionVerificationState(&record)
|
||||
}
|
||||
if registryErr == nil {
|
||||
if resource, found := registry.Get(detail.Item.SubjectResourceID); found {
|
||||
candidate.Resource = resource
|
||||
candidate.Readiness = unified.ResourceActionReadiness{
|
||||
Name: ai.AttentionDockerRestartCapability,
|
||||
Available: false,
|
||||
ReasonCode: "executor_readiness_unavailable",
|
||||
Reason: "Current action readiness could not be verified.",
|
||||
}
|
||||
if checker, ok := h.resources.actionExecutor.(ActionAvailabilityChecker); ok {
|
||||
candidate.Readiness = checker.CheckActionAvailable(
|
||||
r.Context(),
|
||||
unified.ActionRequest{
|
||||
RequestID: attentionActionRequestID(detail.Item.OperationalRecordID),
|
||||
ResourceID: detail.Item.SubjectResourceID,
|
||||
CapabilityName: ai.AttentionDockerRestartCapability,
|
||||
Params: map[string]any{},
|
||||
Reason: attentionActionReason(detail.Item),
|
||||
},
|
||||
*resource,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
offer, reason := ai.ProjectAttentionAction(detail, candidate, now)
|
||||
if reason == ai.AttentionActionEligible {
|
||||
detail.Item.AvailableActions = []ai.AttentionActionOffer{offer}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionActionPlan(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
path string,
|
||||
) {
|
||||
if mock.IsMockEnabled() {
|
||||
writeJSONError(w, http.StatusForbidden, "attention_action_mock_mode", "Cannot plan actions in mock mode")
|
||||
return
|
||||
}
|
||||
itemID, capability, ok := parseAttentionActionPlanPath(path)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusNotFound, "attention_action_not_found", "Attention action was not found")
|
||||
return
|
||||
}
|
||||
if capability != ai.AttentionDockerRestartCapability {
|
||||
writeJSONError(w, http.StatusConflict, "attention_action_ineligible", "This action is not eligible for the selected attention item")
|
||||
return
|
||||
}
|
||||
if h == nil || h.resources == nil {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "attention_actions_unavailable", "Governed actions are unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
projection, err := h.project(r, true)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
}
|
||||
var detail *ai.AttentionItemDetail
|
||||
for index := range projection.Details {
|
||||
if projection.Details[index].Item.ID == itemID {
|
||||
detail = &projection.Details[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if detail == nil {
|
||||
writeJSONError(w, http.StatusNotFound, "attention_item_not_found", "Attention item was not found")
|
||||
return
|
||||
}
|
||||
enriched := []ai.AttentionItemDetail{*detail}
|
||||
h.projectAttentionActions(r, enriched)
|
||||
detail = &enriched[0]
|
||||
var offer *ai.AttentionActionOffer
|
||||
for index := range detail.Item.AvailableActions {
|
||||
if detail.Item.AvailableActions[index].Capability == capability {
|
||||
offer = &detail.Item.AvailableActions[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if offer == nil {
|
||||
writeJSONError(w, http.StatusConflict, "attention_action_ineligible", "Current evidence, capability state, or operator authority does not permit this action")
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
actor, err := actionActorForRequest(h.resources.cfg, r, orgID)
|
||||
if err != nil ||
|
||||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionPlan) != nil ||
|
||||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionApprove) != nil ||
|
||||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionExecute) != nil {
|
||||
writeJSONError(w, http.StatusForbidden, "attention_action_denied", "You do not have permission to review and run this action")
|
||||
return
|
||||
}
|
||||
if offer.ActionID != "" {
|
||||
record, found, getErr := h.resources.ActionLifecycle().Get(orgID, offer.ActionID)
|
||||
if getErr != nil || !found {
|
||||
writeJSONError(w, http.StatusServiceUnavailable, "attention_action_query_failed", "The governed action record is unavailable")
|
||||
return
|
||||
}
|
||||
writeAttentionActionPlan(w, record.Plan)
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := h.resources.ActionLifecycle().PlanWithOptions(
|
||||
r.Context(),
|
||||
orgID,
|
||||
unified.ActionRequest{
|
||||
RequestID: attentionActionRequestID(detail.Item.OperationalRecordID),
|
||||
ResourceID: detail.Item.SubjectResourceID,
|
||||
CapabilityName: capability,
|
||||
Params: map[string]any{},
|
||||
Reason: attentionActionReason(detail.Item),
|
||||
},
|
||||
actionlifecycle.PlanOptions{
|
||||
Actor: actor,
|
||||
Origin: &unified.ActionOrigin{
|
||||
Surface: operationalTrustActionOriginSurface,
|
||||
OperationalRecordID: detail.Item.OperationalRecordID,
|
||||
EvidenceIDs: append([]string(nil), offer.EvidenceIDs...),
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeActionPlanError(w, err)
|
||||
return
|
||||
}
|
||||
writeAttentionActionPlan(w, plan)
|
||||
}
|
||||
|
||||
func writeAttentionActionPlan(w http.ResponseWriter, plan unified.ActionPlan) {
|
||||
if err := utils.WriteJSONResponse(w, plan); err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "attention_action_encode_failed", "Failed to encode action plan")
|
||||
}
|
||||
}
|
||||
|
||||
func attentionActionRequestID(operationalRecordID string) string {
|
||||
return "operational-trust:" + strings.TrimSpace(operationalRecordID) + ":" +
|
||||
ai.AttentionDockerRestartCapability
|
||||
}
|
||||
|
||||
func attentionActionReason(item ai.AttentionItem) string {
|
||||
name := strings.TrimSpace(item.SubjectResourceName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(item.SubjectResourceID)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"Restart %s after fresh confirmed evidence reported an unhealthy container.",
|
||||
name,
|
||||
)
|
||||
}
|
||||
289
internal/api/attention_actions_test.go
Normal file
289
internal/api/attention_actions_test.go
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
type attentionActionTestExecutor struct {
|
||||
result *unified.ExecutionResult
|
||||
calls int
|
||||
}
|
||||
|
||||
func (e *attentionActionTestExecutor) ExecuteAction(
|
||||
context.Context,
|
||||
unified.ActionAuditRecord,
|
||||
) (*unified.ExecutionResult, error) {
|
||||
e.calls++
|
||||
return e.result, nil
|
||||
}
|
||||
|
||||
func (*attentionActionTestExecutor) CheckActionAvailable(
|
||||
_ context.Context,
|
||||
request unified.ActionRequest,
|
||||
_ unified.Resource,
|
||||
) unified.ResourceActionReadiness {
|
||||
return unified.ResourceActionReadiness{Name: request.CapabilityName, Available: true}
|
||||
}
|
||||
|
||||
func TestAttentionActionPlanBindsCanonicalRecordEvidenceAndReplaysIdempotently(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
alert := attentionHandlerAlert("docker-health-record", operationaltrust.OperationalOpen, now)
|
||||
resourceID := "docker:host-1/container-1"
|
||||
alert.Type = ai.AttentionDockerHealthKind
|
||||
alert.ResourceID = resourceID
|
||||
alert.ResourceName = "API container"
|
||||
alert.Metadata = map[string]interface{}{"resourceType": string(unified.ResourceTypeAppContainer)}
|
||||
alert.OperationalRecord.SubjectResourceID = resourceID
|
||||
alert.Evidence[0].Subject.ResourceID = resourceID
|
||||
|
||||
resources := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
|
||||
resources.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{{
|
||||
ID: resourceID,
|
||||
Type: unified.ResourceTypeAppContainer,
|
||||
Name: "API container",
|
||||
Status: unified.StatusWarning,
|
||||
LastSeen: now,
|
||||
UpdatedAt: now,
|
||||
Capabilities: []unified.ResourceCapability{{
|
||||
Name: ai.AttentionDockerRestartCapability,
|
||||
Type: unified.CapabilityTypeCommon,
|
||||
Description: "Restart this Docker container.",
|
||||
MinimumApprovalLevel: unified.ApprovalAdmin,
|
||||
AutoAuthorization: unified.AutoAuthorizeLowRisk,
|
||||
InternalHandler: ai.AttentionDockerLifecycleHandler,
|
||||
}},
|
||||
}},
|
||||
})
|
||||
executor := &attentionActionTestExecutor{result: &unified.ExecutionResult{
|
||||
Success: true,
|
||||
Output: "Container restart completed.",
|
||||
Verification: &unified.ActionVerificationResult{
|
||||
Ran: true,
|
||||
Success: true,
|
||||
Note: "Fresh container readback observed the same container running.",
|
||||
},
|
||||
}}
|
||||
resources.SetActionExecutor(executor)
|
||||
authority := testActionAuthority()
|
||||
resolved := false
|
||||
handler := &AttentionHandlers{
|
||||
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
|
||||
if resolved {
|
||||
return nil, []alerts.Alert{alert}, nil
|
||||
}
|
||||
return []alerts.Alert{alert}, nil, nil
|
||||
},
|
||||
}
|
||||
handler.SetActionDependencies(resources, authority)
|
||||
|
||||
detailRequest := actionHandlerTestRequest(
|
||||
httptest.NewRequest(
|
||||
http.MethodGet,
|
||||
"/api/ai/patrol/attention/docker-health-record",
|
||||
nil,
|
||||
),
|
||||
"operator@example.com",
|
||||
)
|
||||
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 len(detail.Item.AvailableActions) != 1 ||
|
||||
detail.Item.AvailableActions[0].Capability != ai.AttentionDockerRestartCapability ||
|
||||
detail.Item.AvailableActions[0].ActionID != "" {
|
||||
t.Fatalf("initial offers = %+v", detail.Item.AvailableActions)
|
||||
}
|
||||
|
||||
var first unified.ActionPlan
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
request := actionHandlerTestRequest(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/ai/patrol/attention/docker-health-record/actions/restart/plan",
|
||||
nil,
|
||||
),
|
||||
"operator@example.com",
|
||||
)
|
||||
response := httptest.NewRecorder()
|
||||
handler.HandleAttention(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("attempt %d status=%d body=%s", attempt, response.Code, response.Body.String())
|
||||
}
|
||||
var plan unified.ActionPlan
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &plan); err != nil {
|
||||
t.Fatalf("decode plan: %v", err)
|
||||
}
|
||||
if attempt == 0 {
|
||||
first = plan
|
||||
} else if plan.ActionID != first.ActionID || plan.PlanHash != first.PlanHash {
|
||||
t.Fatalf("replay plan=%+v first=%+v", plan, first)
|
||||
}
|
||||
}
|
||||
if !first.RequiresApproval {
|
||||
t.Fatalf("plan = %+v, want explicit approval", first)
|
||||
}
|
||||
|
||||
store, err := resources.getStore("default")
|
||||
if err != nil {
|
||||
t.Fatalf("get store: %v", err)
|
||||
}
|
||||
reader, ok := store.(unified.OperationalActionAuditOriginReader)
|
||||
if !ok {
|
||||
t.Fatalf("%T lacks operational action lookup", store)
|
||||
}
|
||||
audit, found, err := reader.GetLatestActionAuditByOperationalRecord(
|
||||
operationalTrustActionOriginSurface,
|
||||
"docker-health-record",
|
||||
)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("origin audit found=%t err=%v", found, err)
|
||||
}
|
||||
if audit.ID != first.ActionID ||
|
||||
audit.Origin == nil ||
|
||||
audit.Origin.OperationalRecordID != "docker-health-record" ||
|
||||
len(audit.Origin.EvidenceIDs) != 1 ||
|
||||
audit.Origin.EvidenceIDs[0] != alert.Evidence[0].ID {
|
||||
t.Fatalf("audit origin = %+v", audit.Origin)
|
||||
}
|
||||
|
||||
decisionRequest := actionHandlerTestRequest(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/actions/"+first.ActionID+"/decision",
|
||||
bytes.NewBufferString(`{"outcome":"approved","planHash":"`+first.PlanHash+`"}`),
|
||||
),
|
||||
"operator@example.com",
|
||||
)
|
||||
decisionRequest.SetPathValue("id", first.ActionID)
|
||||
decisionResponse := httptest.NewRecorder()
|
||||
resources.HandleDecideAction(decisionResponse, decisionRequest)
|
||||
if decisionResponse.Code != http.StatusOK {
|
||||
t.Fatalf("decision status=%d body=%s", decisionResponse.Code, decisionResponse.Body.String())
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
executeRequest := actionHandlerTestRequest(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/actions/"+first.ActionID+"/execute",
|
||||
bytes.NewBufferString(`{"planHash":"`+first.PlanHash+`"}`),
|
||||
),
|
||||
"operator@example.com",
|
||||
)
|
||||
executeRequest.SetPathValue("id", first.ActionID)
|
||||
executeResponse := httptest.NewRecorder()
|
||||
resources.HandleExecuteAction(executeResponse, executeRequest)
|
||||
if executeResponse.Code != http.StatusOK {
|
||||
t.Fatalf("execute attempt %d status=%d body=%s", attempt, executeResponse.Code, executeResponse.Body.String())
|
||||
}
|
||||
}
|
||||
if executor.calls != 1 {
|
||||
t.Fatalf("executor calls=%d, want exactly one", executor.calls)
|
||||
}
|
||||
|
||||
detailResponse = httptest.NewRecorder()
|
||||
handler.HandleAttention(detailResponse, detailRequest)
|
||||
if detailResponse.Code != http.StatusOK {
|
||||
t.Fatalf("reloaded detail status=%d body=%s", detailResponse.Code, detailResponse.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode reloaded detail: %v", err)
|
||||
}
|
||||
if len(detail.Item.AvailableActions) != 1 ||
|
||||
detail.Item.AvailableActions[0].ActionID != first.ActionID ||
|
||||
detail.Item.VerificationState != ai.AttentionVerificationSucceeded ||
|
||||
detail.Item.State != operationaltrust.OperationalOpen {
|
||||
t.Fatalf("reloaded item = %+v", detail.Item)
|
||||
}
|
||||
|
||||
// A successful command/readback never closes the operational record by
|
||||
// itself. Only a fresh detector-owned recovery observation moves the item
|
||||
// to resolved.
|
||||
recoveredAt := time.Now().UTC().Add(time.Second)
|
||||
alert.OperationalRecord.State = operationaltrust.OperationalResolved
|
||||
alert.OperationalRecord.StateChangedAt = recoveredAt
|
||||
alert.OperationalRecord.LastObservedAt = recoveredAt
|
||||
alert.OperationalRecord.ResolvedAt = &recoveredAt
|
||||
alert.Evidence[0].ObservedAt = recoveredAt
|
||||
alert.Evidence[0].IngestedAt = recoveredAt
|
||||
validUntil := recoveredAt.Add(time.Hour)
|
||||
alert.Evidence[0].ValidUntil = &validUntil
|
||||
resolved = true
|
||||
detailResponse = httptest.NewRecorder()
|
||||
handler.HandleAttention(detailResponse, detailRequest)
|
||||
if detailResponse.Code != http.StatusOK {
|
||||
t.Fatalf("resolved detail status=%d body=%s", detailResponse.Code, detailResponse.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
|
||||
t.Fatalf("decode resolved detail: %v", err)
|
||||
}
|
||||
if detail.Item.State != operationaltrust.OperationalResolved ||
|
||||
detail.Item.VerificationState != ai.AttentionVerificationSucceeded {
|
||||
t.Fatalf("resolved item = %+v", detail.Item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionActionOfferFailsClosedWithoutFreshEvidenceOrExecutorReadiness(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
detail := ai.AttentionItemDetail{
|
||||
Item: ai.AttentionItem{
|
||||
ID: "record",
|
||||
OperationalRecordID: "record",
|
||||
SubjectResourceID: "docker:host/container",
|
||||
SubjectResourceType: string(unified.ResourceTypeAppContainer),
|
||||
Kind: ai.AttentionDockerHealthKind,
|
||||
State: operationaltrust.OperationalOpen,
|
||||
},
|
||||
Evidence: []operationaltrust.EvidenceEnvelope{{
|
||||
ID: "evidence",
|
||||
Subject: operationaltrust.EvidenceSubject{ResourceID: "docker:host/container"},
|
||||
ObservedAt: now.Add(-time.Hour),
|
||||
IngestedAt: now.Add(-time.Hour),
|
||||
Completeness: operationaltrust.EvidenceComplete,
|
||||
Confidence: operationaltrust.EvidenceConfirmed,
|
||||
Permissions: operationaltrust.EvidencePermissionsSufficient,
|
||||
}},
|
||||
}
|
||||
resource := unified.Resource{
|
||||
ID: detail.Item.SubjectResourceID,
|
||||
Type: unified.ResourceTypeAppContainer,
|
||||
Capabilities: []unified.ResourceCapability{{
|
||||
Name: ai.AttentionDockerRestartCapability,
|
||||
MinimumApprovalLevel: unified.ApprovalAdmin,
|
||||
InternalHandler: ai.AttentionDockerLifecycleHandler,
|
||||
}},
|
||||
}
|
||||
if offer, reason := ai.ProjectAttentionAction(
|
||||
&detail,
|
||||
ai.AttentionActionCandidate{
|
||||
Resource: &resource,
|
||||
Readiness: unified.ResourceActionReadiness{
|
||||
Name: ai.AttentionDockerRestartCapability,
|
||||
Available: true,
|
||||
},
|
||||
Authorized: true,
|
||||
},
|
||||
now,
|
||||
); reason != ai.AttentionActionEvidenceStale || offer.Capability != "" {
|
||||
t.Fatalf("stale offer=%+v reason=%q", offer, reason)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ type attentionAlertSnapshot func(context.Context) ([]alerts.Alert, []alerts.Aler
|
|||
type AttentionHandlers struct {
|
||||
readAlerts attentionAlertSnapshot
|
||||
recoveryManager *recoverymanager.Manager
|
||||
resources *ResourceHandlers
|
||||
actionAuthority actionAuthority
|
||||
}
|
||||
|
||||
func NewAttentionHandlers(
|
||||
|
|
@ -53,6 +55,17 @@ func NewAttentionHandlers(
|
|||
}
|
||||
}
|
||||
|
||||
func (h *AttentionHandlers) SetActionDependencies(
|
||||
resources *ResourceHandlers,
|
||||
authority actionAuthority,
|
||||
) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.resources = resources
|
||||
h.actionAuthority = authority
|
||||
}
|
||||
|
||||
type attentionListResponse struct {
|
||||
Data []ai.AttentionItem `json:"data"`
|
||||
Summary ai.AttentionSummary `json:"summary"`
|
||||
|
|
@ -65,12 +78,15 @@ type attentionListResponse struct {
|
|||
}
|
||||
|
||||
func (h *AttentionHandlers) HandleAttention(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/attention")
|
||||
if r.Method == http.MethodPost && isAttentionActionPlanPath(path) {
|
||||
h.handleAttentionActionPlan(w, r, path)
|
||||
return
|
||||
}
|
||||
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)
|
||||
|
|
@ -88,7 +104,7 @@ func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.R
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
projection, err := h.project(r.Context(), true)
|
||||
projection, err := h.project(r, true)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
|
|
@ -115,6 +131,7 @@ func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.R
|
|||
)
|
||||
return
|
||||
}
|
||||
h.projectAttentionActions(r, paged)
|
||||
|
||||
response := attentionListResponse{
|
||||
Data: make([]ai.AttentionItem, 0, len(paged)),
|
||||
|
|
@ -135,7 +152,7 @@ func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.R
|
|||
}
|
||||
|
||||
func (h *AttentionHandlers) handleAttentionSummary(w http.ResponseWriter, r *http.Request) {
|
||||
projection, err := h.project(r.Context(), false)
|
||||
projection, err := h.project(r, false)
|
||||
if err != nil {
|
||||
writeAttentionUnavailable(w, err)
|
||||
return
|
||||
|
|
@ -161,14 +178,16 @@ func (h *AttentionHandlers) handleAttentionDetail(
|
|||
)
|
||||
return
|
||||
}
|
||||
projection, err := h.project(r.Context(), true)
|
||||
projection, err := h.project(r, 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 {
|
||||
details := []ai.AttentionItemDetail{detail}
|
||||
h.projectAttentionActions(r, details)
|
||||
if err := utils.WriteJSONResponse(w, details[0]); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to serialize Patrol attention detail")
|
||||
}
|
||||
return
|
||||
|
|
@ -184,12 +203,13 @@ func (h *AttentionHandlers) handleAttentionDetail(
|
|||
}
|
||||
|
||||
func (h *AttentionHandlers) project(
|
||||
ctx context.Context,
|
||||
r *http.Request,
|
||||
includeProtectionPosture bool,
|
||||
) (ai.AttentionProjection, error) {
|
||||
if h == nil || h.readAlerts == nil {
|
||||
return ai.AttentionProjection{}, fmt.Errorf("attention lifecycle source is not configured")
|
||||
}
|
||||
ctx := r.Context()
|
||||
active, history, err := h.readAlerts(ctx)
|
||||
if err != nil {
|
||||
return ai.AttentionProjection{}, err
|
||||
|
|
|
|||
|
|
@ -175,6 +175,17 @@ func TestAttentionHandlersRejectInvalidOrUnboundedQueries(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseAttentionActionPlanPathPreservesOperationalIDsContainingSlashes(t *testing.T) {
|
||||
itemID, capability, ok := parseAttentionActionPlanPath(
|
||||
"/agent:node-1/disk:mnt-disk2::metric-threshold:disk/actions/restart/plan",
|
||||
)
|
||||
if !ok ||
|
||||
itemID != "agent:node-1/disk:mnt-disk2::metric-threshold:disk" ||
|
||||
capability != "restart" {
|
||||
t.Fatalf("item=%q capability=%q ok=%t", itemID, capability, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func attentionHandlerAlert(
|
||||
id string,
|
||||
state operationaltrust.OperationalState,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,52 @@ type basicActionContractAuthorizer struct {
|
|||
wantUser string
|
||||
}
|
||||
|
||||
func TestContract_OperationalTrustAttentionActionOfferJSONIsAdditiveAndTyped(t *testing.T) {
|
||||
payload, err := json.Marshal(ai.AttentionActionOffer{
|
||||
ActionID: "action-1",
|
||||
TargetResourceID: "docker:host/container",
|
||||
Capability: ai.AttentionDockerRestartCapability,
|
||||
Kind: "container_restart",
|
||||
Label: "Restart this container",
|
||||
Mode: "execute",
|
||||
Risk: "low",
|
||||
Approval: "granted",
|
||||
Eligibility: "eligible",
|
||||
Reasons: []string{"fresh_confirmed_unhealthy_container"},
|
||||
EvidenceIDs: []string{"evidence-1"},
|
||||
ExpectedPostcondition: "The same container is observed running after the restart.",
|
||||
VerificationPolicy: "Pulse requires a fresh container readback.",
|
||||
RequiresApproval: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal attention action offer: %v", err)
|
||||
}
|
||||
var contract map[string]any
|
||||
if err := json.Unmarshal(payload, &contract); err != nil {
|
||||
t.Fatalf("decode attention action offer: %v", err)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"actionId",
|
||||
"targetResourceId",
|
||||
"capability",
|
||||
"kind",
|
||||
"label",
|
||||
"mode",
|
||||
"risk",
|
||||
"approval",
|
||||
"eligibility",
|
||||
"reasons",
|
||||
"evidenceIds",
|
||||
"expectedPostcondition",
|
||||
"verificationPolicy",
|
||||
"requiresApproval",
|
||||
} {
|
||||
if _, found := contract[field]; !found {
|
||||
t.Fatalf("attention action offer missing %q: %s", field, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a basicActionContractAuthorizer) Authorize(ctx context.Context, action, resource string) (bool, error) {
|
||||
return authpkg.GetUser(ctx) == a.wantUser && action == authpkg.ActionApprove && resource == authpkg.ResourceActions, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -494,6 +494,7 @@ func (r *Router) setupRoutes() {
|
|||
actionOrgChecker := NewAuthorizationChecker(NewMultiTenantOrganizationLoader(r.multiTenant))
|
||||
actionAuth := actionAuthority{authorizer: r.authorizer, orgChecker: actionOrgChecker}
|
||||
r.resourceHandlers.SetActionAuthorizers(actionAuth, actionAuth)
|
||||
r.attentionHandlers.SetActionDependencies(r.resourceHandlers, actionAuth)
|
||||
r.maintenanceSentinel = r.buildMaintenanceVerificationSentinel()
|
||||
r.maintenanceVerificationHandlers = NewMaintenanceVerificationHandlers(r.resourceHandlers, r.maintenanceSentinel)
|
||||
if r.maintenanceSentinel != nil {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -484,10 +485,12 @@ type ActionAuditRecord struct {
|
|||
// Origin is broker-owned metadata: it is set by in-process planning callers
|
||||
// only and is never accepted from the public plan request body.
|
||||
type ActionOrigin struct {
|
||||
Surface string `json:"surface"`
|
||||
FindingID string `json:"findingId,omitempty"`
|
||||
InvestigationID string `json:"investigationId,omitempty"`
|
||||
ProposalID string `json:"proposalId,omitempty"`
|
||||
Surface string `json:"surface"`
|
||||
FindingID string `json:"findingId,omitempty"`
|
||||
InvestigationID string `json:"investigationId,omitempty"`
|
||||
ProposalID string `json:"proposalId,omitempty"`
|
||||
OperationalRecordID string `json:"operationalRecordId,omitempty"`
|
||||
EvidenceIDs []string `json:"evidenceIds,omitempty"`
|
||||
}
|
||||
|
||||
// NormalizeActionOrigin trims origin fields and collapses an all-empty
|
||||
|
|
@ -497,12 +500,20 @@ func NormalizeActionOrigin(origin *ActionOrigin) *ActionOrigin {
|
|||
return nil
|
||||
}
|
||||
normalized := ActionOrigin{
|
||||
Surface: strings.TrimSpace(origin.Surface),
|
||||
FindingID: strings.TrimSpace(origin.FindingID),
|
||||
InvestigationID: strings.TrimSpace(origin.InvestigationID),
|
||||
ProposalID: strings.TrimSpace(origin.ProposalID),
|
||||
Surface: strings.TrimSpace(origin.Surface),
|
||||
FindingID: strings.TrimSpace(origin.FindingID),
|
||||
InvestigationID: strings.TrimSpace(origin.InvestigationID),
|
||||
ProposalID: strings.TrimSpace(origin.ProposalID),
|
||||
OperationalRecordID: strings.TrimSpace(origin.OperationalRecordID),
|
||||
EvidenceIDs: uniqueStrings(origin.EvidenceIDs),
|
||||
}
|
||||
if normalized == (ActionOrigin{}) {
|
||||
sort.Strings(normalized.EvidenceIDs)
|
||||
if normalized.Surface == "" &&
|
||||
normalized.FindingID == "" &&
|
||||
normalized.InvestigationID == "" &&
|
||||
normalized.ProposalID == "" &&
|
||||
normalized.OperationalRecordID == "" &&
|
||||
len(normalized.EvidenceIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &normalized
|
||||
|
|
|
|||
|
|
@ -98,6 +98,20 @@ type ActionAuditOriginReader interface {
|
|||
GetLatestActionAuditByOrigin(surface, investigationID string) (ActionAuditRecord, bool, error)
|
||||
}
|
||||
|
||||
// OperationalActionAuditOriginReader is the bounded origin lookup used by the
|
||||
// Operational Trust attention read model. The operational record identifier is
|
||||
// stable across resource observations, action retries, and process restarts.
|
||||
type OperationalActionAuditOriginReader interface {
|
||||
GetLatestActionAuditByOperationalRecord(surface, operationalRecordID string) (ActionAuditRecord, bool, error)
|
||||
}
|
||||
|
||||
// OperationalActionAuditOriginBatchReader resolves a bounded attention page
|
||||
// in one indexed store read. Attention list rendering must not issue one
|
||||
// action-audit query per item.
|
||||
type OperationalActionAuditOriginBatchReader interface {
|
||||
GetLatestActionAuditsByOperationalRecords(surface string, operationalRecordIDs []string) (map[string]ActionAuditRecord, error)
|
||||
}
|
||||
|
||||
// PendingActionAuditReader owns the indexed operator queue for canonical
|
||||
// actions awaiting a decision. Mobile and desktop clients must not reconstruct
|
||||
// this queue from the retired command-approval store.
|
||||
|
|
@ -689,6 +703,13 @@ func (s *SQLiteResourceStore) migrateActionAuditsSchema() error {
|
|||
`); err != nil {
|
||||
return fmt.Errorf("create action audit origin investigation index: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_action_audits_origin_operational_record_updated
|
||||
ON action_audits(json_extract(origin_json, '$.surface'), json_extract(origin_json, '$.operationalRecordId'), updated_at DESC)
|
||||
WHERE json_valid(origin_json)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create action audit origin operational record index: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_action_audits_state_updated
|
||||
ON action_audits(state, updated_at ASC, created_at ASC)
|
||||
|
|
@ -2263,6 +2284,85 @@ func (s *SQLiteResourceStore) GetLatestActionAuditByOrigin(surface, investigatio
|
|||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) GetLatestActionAuditByOperationalRecord(surface, operationalRecordID string) (ActionAuditRecord, bool, error) {
|
||||
surface = strings.TrimSpace(surface)
|
||||
operationalRecordID = strings.TrimSpace(operationalRecordID)
|
||||
if surface == "" || operationalRecordID == "" {
|
||||
return ActionAuditRecord{}, false, nil
|
||||
}
|
||||
row := s.db.QueryRow(`
|
||||
SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json
|
||||
FROM action_audits
|
||||
WHERE json_valid(origin_json)
|
||||
AND json_extract(origin_json, '$.surface') = ?
|
||||
AND json_extract(origin_json, '$.operationalRecordId') = ?
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
`, surface, operationalRecordID)
|
||||
record, err := scanActionAuditRecord(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ActionAuditRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ActionAuditRecord{}, false, fmt.Errorf("query action audit by operational record: %w", err)
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) GetLatestActionAuditsByOperationalRecords(
|
||||
surface string,
|
||||
operationalRecordIDs []string,
|
||||
) (map[string]ActionAuditRecord, error) {
|
||||
surface = strings.TrimSpace(surface)
|
||||
ids := uniqueStrings(operationalRecordIDs)
|
||||
if surface == "" || len(ids) == 0 {
|
||||
return map[string]ActionAuditRecord{}, nil
|
||||
}
|
||||
if len(ids) > 200 {
|
||||
return nil, fmt.Errorf("operational action audit batch exceeds 200 records")
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
args = append(args, surface)
|
||||
for index, id := range ids {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json
|
||||
FROM action_audits
|
||||
WHERE json_valid(origin_json)
|
||||
AND json_extract(origin_json, '$.surface') = ?
|
||||
AND json_extract(origin_json, '$.operationalRecordId') IN (`+strings.Join(placeholders, ",")+`)
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query action audits by operational records: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make(map[string]ActionAuditRecord, len(ids))
|
||||
for rows.Next() {
|
||||
record, scanErr := scanActionAuditRecord(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan action audit by operational record: %w", scanErr)
|
||||
}
|
||||
if record.Origin == nil {
|
||||
continue
|
||||
}
|
||||
recordID := strings.TrimSpace(record.Origin.OperationalRecordID)
|
||||
if recordID == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := result[recordID]; !found {
|
||||
result[recordID] = record
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate action audits by operational records: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteResourceStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
|
|
@ -3501,6 +3601,74 @@ func (m *MemoryStore) GetLatestActionAuditByOrigin(surface, investigationID stri
|
|||
return cloneActionAuditRecordForRead(latest), true, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetLatestActionAuditByOperationalRecord(surface, operationalRecordID string) (ActionAuditRecord, bool, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
surface = strings.TrimSpace(surface)
|
||||
operationalRecordID = strings.TrimSpace(operationalRecordID)
|
||||
if surface == "" || operationalRecordID == "" {
|
||||
return ActionAuditRecord{}, false, nil
|
||||
}
|
||||
var latest ActionAuditRecord
|
||||
found := false
|
||||
for _, record := range m.actionAudits {
|
||||
if record.Origin == nil ||
|
||||
strings.TrimSpace(record.Origin.Surface) != surface ||
|
||||
strings.TrimSpace(record.Origin.OperationalRecordID) != operationalRecordID {
|
||||
continue
|
||||
}
|
||||
if !found ||
|
||||
record.UpdatedAt.After(latest.UpdatedAt) ||
|
||||
(record.UpdatedAt.Equal(latest.UpdatedAt) && record.CreatedAt.After(latest.CreatedAt)) {
|
||||
latest = record
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return ActionAuditRecord{}, false, nil
|
||||
}
|
||||
return cloneActionAuditRecordForRead(latest), true, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetLatestActionAuditsByOperationalRecords(
|
||||
surface string,
|
||||
operationalRecordIDs []string,
|
||||
) (map[string]ActionAuditRecord, error) {
|
||||
surface = strings.TrimSpace(surface)
|
||||
ids := uniqueStrings(operationalRecordIDs)
|
||||
if surface == "" || len(ids) == 0 {
|
||||
return map[string]ActionAuditRecord{}, nil
|
||||
}
|
||||
if len(ids) > 200 {
|
||||
return nil, fmt.Errorf("operational action audit batch exceeds 200 records")
|
||||
}
|
||||
wanted := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make(map[string]ActionAuditRecord, len(ids))
|
||||
for _, record := range m.actionAudits {
|
||||
if record.Origin == nil ||
|
||||
strings.TrimSpace(record.Origin.Surface) != surface {
|
||||
continue
|
||||
}
|
||||
recordID := strings.TrimSpace(record.Origin.OperationalRecordID)
|
||||
if _, found := wanted[recordID]; !found {
|
||||
continue
|
||||
}
|
||||
latest, found := result[recordID]
|
||||
if !found ||
|
||||
record.UpdatedAt.After(latest.UpdatedAt) ||
|
||||
(record.UpdatedAt.Equal(latest.UpdatedAt) &&
|
||||
record.CreatedAt.After(latest.CreatedAt)) {
|
||||
result[recordID] = cloneActionAuditRecordForRead(record)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MemoryStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
|
|
|||
|
|
@ -3134,19 +3134,35 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) {
|
|||
if !ok {
|
||||
t.Fatalf("%T does not implement ActionAuditOriginReader", store)
|
||||
}
|
||||
operationalReader, ok := store.(OperationalActionAuditOriginReader)
|
||||
if !ok {
|
||||
t.Fatalf("%T does not implement OperationalActionAuditOriginReader", store)
|
||||
}
|
||||
operationalBatchReader, ok := store.(OperationalActionAuditOriginBatchReader)
|
||||
if !ok {
|
||||
t.Fatalf("%T does not implement OperationalActionAuditOriginBatchReader", store)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, record := range []ActionAuditRecord{
|
||||
{
|
||||
ID: "act-old", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute), State: ActionStatePending,
|
||||
Request: ActionRequest{RequestID: "prop-old", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"},
|
||||
Plan: ActionPlan{ActionID: "act-old", RequestID: "prop-old", Allowed: true},
|
||||
Origin: &ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "prop-old"},
|
||||
Origin: &ActionOrigin{
|
||||
Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1",
|
||||
ProposalID: "prop-old", OperationalRecordID: "operational-1",
|
||||
EvidenceIDs: []string{" evidence-b ", "evidence-a", "evidence-a"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "act-new", CreatedAt: now, UpdatedAt: now, State: ActionStateCompleted,
|
||||
Request: ActionRequest{RequestID: "prop-new", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"},
|
||||
Plan: ActionPlan{ActionID: "act-new", RequestID: "prop-new", Allowed: true},
|
||||
Origin: &ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "prop-new"},
|
||||
Request: ActionRequest{RequestID: "prop-new", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"},
|
||||
Plan: ActionPlan{ActionID: "act-new", RequestID: "prop-new", Allowed: true},
|
||||
Origin: &ActionOrigin{
|
||||
Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1",
|
||||
ProposalID: "prop-new", OperationalRecordID: "operational-1",
|
||||
EvidenceIDs: []string{"evidence-c"},
|
||||
},
|
||||
VerificationOutcome: VerificationOutcome{Status: VerificationVerified},
|
||||
},
|
||||
} {
|
||||
|
|
@ -3161,6 +3177,20 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) {
|
|||
if got.ID != "act-new" || got.State != ActionStateCompleted {
|
||||
t.Fatalf("latest audit = %#v, want act-new completed", got)
|
||||
}
|
||||
got, found, err = operationalReader.GetLatestActionAuditByOperationalRecord(
|
||||
"patrol",
|
||||
"operational-1",
|
||||
)
|
||||
if err != nil || !found || got.ID != "act-new" {
|
||||
t.Fatalf("GetLatestActionAuditByOperationalRecord: got=%#v found=%v err=%v", got, found, err)
|
||||
}
|
||||
batch, err := operationalBatchReader.GetLatestActionAuditsByOperationalRecords(
|
||||
"patrol",
|
||||
[]string{"operational-1", "missing"},
|
||||
)
|
||||
if err != nil || len(batch) != 1 || batch["operational-1"].ID != "act-new" {
|
||||
t.Fatalf("GetLatestActionAuditsByOperationalRecords: batch=%#v err=%v", batch, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,8 +105,15 @@ test.beforeEach(async ({ page }) => {
|
|||
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();
|
||||
expect(
|
||||
shellText,
|
||||
`The authenticated shell was blank at ${page.url()}`,
|
||||
).not.toBe("");
|
||||
await expect(
|
||||
page
|
||||
.getByRole("tab", { name: /Patrol/ })
|
||||
.or(page.getByRole("button", { name: "Patrol", exact: true })),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
|
@ -529,13 +536,13 @@ async function mockAttention(
|
|||
? [uncertainAttentionItem]
|
||||
: filter === "open"
|
||||
? [openAttentionItem]
|
||||
: filter === "acknowledged"
|
||||
? [acknowledgedAttentionItem]
|
||||
: filter === "suppressed"
|
||||
? [suppressedAttentionItem]
|
||||
: filter === "resolved"
|
||||
? [resolvedAttentionItem]
|
||||
: [openAttentionItem, uncertainAttentionItem];
|
||||
: filter === "acknowledged"
|
||||
? [acknowledgedAttentionItem]
|
||||
: filter === "suppressed"
|
||||
? [suppressedAttentionItem]
|
||||
: filter === "resolved"
|
||||
? [resolvedAttentionItem]
|
||||
: [openAttentionItem, uncertainAttentionItem];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
|
|
@ -578,7 +585,11 @@ test("makes active operational work primary and preserves the evidence boundary"
|
|||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(
|
||||
page.getByRole("tab", { name: "Patrol: 2 active attention items" }),
|
||||
page.getByRole("tab", { name: "Patrol: 2 active attention items" }).or(
|
||||
page.getByRole("button", {
|
||||
name: "Patrol: 2 active attention items",
|
||||
}),
|
||||
),
|
||||
).toBeVisible();
|
||||
const queue = page.getByRole("region", { name: "Needs attention" });
|
||||
await expect(queue.getByLabel("2 active attention items")).toBeVisible();
|
||||
|
|
@ -717,3 +728,491 @@ test("shows calm only with current coverage and never converts failure into heal
|
|||
).toBeVisible();
|
||||
await expect(page.getByText("Nothing needs your attention")).toHaveCount(0);
|
||||
});
|
||||
|
||||
type GovernedActionVerification = "confirmed" | "contradicted";
|
||||
|
||||
const governedActionAttentionID = "docker-health-operational-record";
|
||||
const governedActionID = "act-operational-trust-restart";
|
||||
const governedResourceID = "docker:host-1/container-api";
|
||||
|
||||
const governedActionOffer = (actionId?: string) => ({
|
||||
...(actionId ? { actionId } : {}),
|
||||
targetResourceId: governedResourceID,
|
||||
capability: "restart",
|
||||
kind: "container_restart",
|
||||
label: "Restart this container",
|
||||
mode: actionId ? "execute" : "plan",
|
||||
risk: "low",
|
||||
approval: actionId ? "granted" : "required",
|
||||
eligibility: "eligible",
|
||||
reasons: ["fresh_confirmed_unhealthy_container", "declared_live_capability"],
|
||||
evidenceIds: ["docker-health-evidence"],
|
||||
expectedPostcondition:
|
||||
"The same container is observed running after the restart.",
|
||||
verificationPolicy:
|
||||
"Pulse requires a fresh container readback and records whether it is agent-attested or independently observed.",
|
||||
requiresApproval: true,
|
||||
});
|
||||
|
||||
const governedActionAttentionItem = (
|
||||
actionState: "unplanned" | "pending_approval" | "approved" | "completed",
|
||||
verification: GovernedActionVerification,
|
||||
) => ({
|
||||
id: governedActionAttentionID,
|
||||
operationalRecordId: governedActionAttentionID,
|
||||
subjectResourceId: governedResourceID,
|
||||
subjectResourceName: "API container",
|
||||
subjectResourceType: "app-container",
|
||||
kind: "docker-container-health",
|
||||
title: "API container is unhealthy",
|
||||
plainLanguageSummary:
|
||||
"Docker reported that the API container is unhealthy from a current health check.",
|
||||
severity: "critical",
|
||||
state: "open",
|
||||
firstObservedAt,
|
||||
lastObservedAt,
|
||||
evidenceFreshness: "fresh",
|
||||
evidenceCompleteness: "complete",
|
||||
impact: "Requests handled by this container may fail.",
|
||||
relatedResources: [],
|
||||
recommendedNextStep:
|
||||
"Review the bounded restart and its current policy before approving it.",
|
||||
availableActions: [
|
||||
governedActionOffer(
|
||||
actionState === "unplanned" ? undefined : governedActionID,
|
||||
),
|
||||
],
|
||||
verificationState:
|
||||
actionState === "unplanned"
|
||||
? "not_available"
|
||||
: actionState === "completed"
|
||||
? verification === "confirmed"
|
||||
? "succeeded"
|
||||
: "failed"
|
||||
: "pending",
|
||||
});
|
||||
|
||||
const governedActionAudit = (
|
||||
actionState: "pending_approval" | "approved" | "completed",
|
||||
verification: GovernedActionVerification,
|
||||
) => {
|
||||
const scope = {
|
||||
orgId: "default",
|
||||
resourceId: governedResourceID,
|
||||
capabilityName: "restart",
|
||||
};
|
||||
const evidence = {
|
||||
version: 1,
|
||||
id: "restart-readback",
|
||||
observerId: "docker-agent-1",
|
||||
observerKind: "unified_agent",
|
||||
observerTrustDomain: "agent:docker-agent-1",
|
||||
executorTrustDomain: "agent:docker-agent-1",
|
||||
method: "typed_container_read_after_write",
|
||||
subjectId: governedResourceID,
|
||||
observedAt: lastObservedAt,
|
||||
receivedAt: evaluatedAt,
|
||||
summary:
|
||||
verification === "confirmed"
|
||||
? "The same container was observed running."
|
||||
: "The container was still not running after the provider returned success.",
|
||||
digest: `sha256:${"a".repeat(64)}`,
|
||||
};
|
||||
return {
|
||||
id: governedActionID,
|
||||
createdAt: firstObservedAt,
|
||||
updatedAt: evaluatedAt,
|
||||
state: actionState,
|
||||
decisionRevision: actionState === "pending_approval" ? 0 : 1,
|
||||
request: {
|
||||
requestId: `operational-trust:${governedActionAttentionID}:restart`,
|
||||
resourceId: governedResourceID,
|
||||
capabilityName: "restart",
|
||||
params: {},
|
||||
reason:
|
||||
"Restart API container after fresh confirmed evidence reported an unhealthy container.",
|
||||
requestedBy: "operator",
|
||||
},
|
||||
resource: {
|
||||
id: governedResourceID,
|
||||
name: "API container",
|
||||
type: "app-container",
|
||||
},
|
||||
plan: {
|
||||
actionId: governedActionID,
|
||||
requestId: `operational-trust:${governedActionAttentionID}:restart`,
|
||||
allowed: true,
|
||||
requiresApproval: true,
|
||||
approvalPolicy: "admin",
|
||||
approvalRequirement: {
|
||||
version: 1,
|
||||
floor: "admin",
|
||||
quorum: 1,
|
||||
disallowRequester: false,
|
||||
},
|
||||
predictedBlastRadius: [governedResourceID],
|
||||
rollbackAvailable: false,
|
||||
plannedAt: firstObservedAt,
|
||||
expiresAt: "2099-07-19T12:00:00Z",
|
||||
resourceVersion: "resource:sha256:docker-health",
|
||||
policyVersion: "policy:sha256:docker-restart",
|
||||
planHash: `sha256:${"b".repeat(64)}`,
|
||||
policyDecision: {
|
||||
version: 1,
|
||||
status: "resolved",
|
||||
decisionId: "policy-decision:docker-restart",
|
||||
actionId: governedActionID,
|
||||
scope,
|
||||
approvalRequirement: {
|
||||
version: 1,
|
||||
floor: "admin",
|
||||
quorum: 1,
|
||||
disallowRequester: false,
|
||||
},
|
||||
planningAllowed: true,
|
||||
requiresApproval: true,
|
||||
authorities: [
|
||||
{
|
||||
kind: "capability_registry",
|
||||
sourceId: "capability-registry:restart",
|
||||
revision: "policy:sha256:docker-restart",
|
||||
status: "consulted",
|
||||
scope,
|
||||
approvalFloor: "admin",
|
||||
reasonCodes: [
|
||||
"capability_approval_admin",
|
||||
"capability_auto_low_risk",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
preflight: {
|
||||
target: governedResourceID,
|
||||
currentState: "warning",
|
||||
intendedChange: "Restart this Docker container.",
|
||||
dryRunAvailable: false,
|
||||
safetyChecks: [
|
||||
"The container still declares restart.",
|
||||
"The reporting agent remains connected.",
|
||||
],
|
||||
verificationSteps: [
|
||||
"Read the same container after the restart and compare its running state.",
|
||||
],
|
||||
generatedAt: firstObservedAt,
|
||||
},
|
||||
},
|
||||
origin: {
|
||||
surface: "operational_trust_attention",
|
||||
operationalRecordId: governedActionAttentionID,
|
||||
evidenceIds: ["docker-health-evidence"],
|
||||
},
|
||||
approvals:
|
||||
actionState === "pending_approval"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
actor: "operator",
|
||||
method: "session",
|
||||
timestamp: evaluatedAt,
|
||||
outcome: "approved",
|
||||
},
|
||||
],
|
||||
...(actionState === "completed"
|
||||
? {
|
||||
result: {
|
||||
success: true,
|
||||
actionResultV2: {
|
||||
version: 2,
|
||||
execution: {
|
||||
status: "succeeded",
|
||||
summary: "The provider accepted and completed the restart.",
|
||||
},
|
||||
verification: {
|
||||
status: verification,
|
||||
evidenceClass: "agent_attested",
|
||||
...(verification === "contradicted"
|
||||
? { reasonCode: "postcondition_contradicted" }
|
||||
: {}),
|
||||
summary: evidence.summary,
|
||||
evidence: [evidence],
|
||||
},
|
||||
compensation: {
|
||||
support: "unavailable",
|
||||
status: "not_available",
|
||||
summary: "Container restart is not rollbackable.",
|
||||
},
|
||||
},
|
||||
},
|
||||
verificationOutcome: {
|
||||
status: verification === "confirmed" ? "verified" : "failed",
|
||||
evidenceSummary: evidence.summary,
|
||||
},
|
||||
}
|
||||
: { verificationOutcome: { status: "unknown" } }),
|
||||
};
|
||||
};
|
||||
|
||||
async function mockGovernedAttentionAction(
|
||||
page: Page,
|
||||
verification: GovernedActionVerification,
|
||||
) {
|
||||
await mockAttention(page, "active");
|
||||
let actionState: "unplanned" | "pending_approval" | "approved" | "completed" =
|
||||
"unplanned";
|
||||
let executeCalls = 0;
|
||||
|
||||
await page.route("**/api/ai/patrol/attention**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (route.request().method() === "POST" && url.pathname.endsWith("/plan")) {
|
||||
actionState = "pending_approval";
|
||||
const audit = governedActionAudit(actionState, verification);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(audit.plan),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const item = governedActionAttentionItem(actionState, verification);
|
||||
if (url.pathname.endsWith("/summary")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
activeCount: 1,
|
||||
openCount: 1,
|
||||
acknowledgedCount: 0,
|
||||
suppressedCount: 0,
|
||||
uncertainCount: 0,
|
||||
resolvedCount: 0,
|
||||
calm: false,
|
||||
coverageState: "current",
|
||||
evaluatedAt,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== "/api/ai/patrol/attention") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
item,
|
||||
operationalRecord: {
|
||||
id: governedActionAttentionID,
|
||||
canonicalSpecId: "docker-container-health",
|
||||
subjectResourceId: governedResourceID,
|
||||
state: "open",
|
||||
severity: "critical",
|
||||
firstObservedAt,
|
||||
lastObservedAt,
|
||||
stateChangedAt: firstObservedAt,
|
||||
evidenceIds: ["docker-health-evidence"],
|
||||
causeKey: "docker-container-health",
|
||||
relatedResourceIds: [],
|
||||
impactSummary: item.impact,
|
||||
recommendedNextStep: item.recommendedNextStep,
|
||||
},
|
||||
timeline: [],
|
||||
evidence: [
|
||||
{
|
||||
id: "docker-health-evidence",
|
||||
source: {
|
||||
provider: "docker",
|
||||
collector: "docker-container-health",
|
||||
},
|
||||
subject: { resourceId: governedResourceID },
|
||||
observedAt: lastObservedAt,
|
||||
ingestedAt: lastObservedAt,
|
||||
validUntil: new Date(Date.now() + 5 * 60_000).toISOString(),
|
||||
completeness: "complete",
|
||||
confidence: "confirmed",
|
||||
permissions: "sufficient",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: [item],
|
||||
summary: {
|
||||
activeCount: 1,
|
||||
openCount: 1,
|
||||
acknowledgedCount: 0,
|
||||
suppressedCount: 0,
|
||||
uncertainCount: 0,
|
||||
resolvedCount: 0,
|
||||
calm: false,
|
||||
coverageState: "current",
|
||||
evaluatedAt,
|
||||
},
|
||||
meta: { page: 1, limit: 50, total: 1, totalPages: 1 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/actions/**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.endsWith("/decision")) {
|
||||
actionState = "approved";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
actionId: governedActionID,
|
||||
state: actionState,
|
||||
approval: { outcome: "approved" },
|
||||
audit: governedActionAudit(actionState, verification),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname.endsWith("/execute")) {
|
||||
executeCalls += 1;
|
||||
actionState = "completed";
|
||||
const audit = governedActionAudit(actionState, verification);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
actionId: governedActionID,
|
||||
state: actionState,
|
||||
result: audit.result,
|
||||
audit,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const audit = governedActionAudit(
|
||||
actionState === "unplanned" ? "pending_approval" : actionState,
|
||||
verification,
|
||||
);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
audit,
|
||||
events: [],
|
||||
...(actionState === "completed"
|
||||
? {
|
||||
attempt: {
|
||||
id: `${governedActionID}.dispatch.1`,
|
||||
actionId: governedActionID,
|
||||
state: "receipt_recorded",
|
||||
createdAt: firstObservedAt,
|
||||
updatedAt: evaluatedAt,
|
||||
dispatchCount: 1,
|
||||
},
|
||||
receipt: {
|
||||
attemptId: `${governedActionID}.dispatch.1`,
|
||||
actionId: governedActionID,
|
||||
transportRequestId: `${governedActionID}.dispatch.1`,
|
||||
receivedAt: evaluatedAt,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
return {
|
||||
executeCalls: () => executeCalls,
|
||||
};
|
||||
}
|
||||
|
||||
for (const verification of [
|
||||
"confirmed",
|
||||
"contradicted",
|
||||
] as GovernedActionVerification[]) {
|
||||
test(`runs the governed restart and represents ${verification} verification honestly`, async ({
|
||||
page,
|
||||
}) => {
|
||||
if (verification === "contradicted") {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
}
|
||||
const fixture = await mockGovernedAttentionAction(page, verification);
|
||||
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const itemButton = page.getByRole("button", {
|
||||
name: "Open API container is unhealthy",
|
||||
});
|
||||
await itemButton.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const detailPanel = page.getByRole("complementary", {
|
||||
name: "API container is unhealthy",
|
||||
});
|
||||
await expect(detailPanel).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText(
|
||||
"The same container is observed running after the restart.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText(
|
||||
/explicit review and approval before Pulse sends anything/i,
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const reviewTrigger = detailPanel.getByRole("button", {
|
||||
name: "Review and approve",
|
||||
});
|
||||
await reviewTrigger.click();
|
||||
const dialog = page.getByRole("dialog", { name: "Restart" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Restart API container after fresh confirmed evidence reported an unhealthy container.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await dialog.getByText("Policy evidence", { exact: true }).click();
|
||||
await expect(
|
||||
dialog.getByText("Eligible for low-risk automation"),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: "Approve" }).click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Run action" }),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Run action" }).click();
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Run action" }),
|
||||
).toHaveCount(0);
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(
|
||||
detailPanel.getByRole("button", { name: "Review action" }),
|
||||
).toBeFocused();
|
||||
expect(fixture.executeCalls()).toBe(1);
|
||||
await expect(
|
||||
detailPanel.getByText(/recorded the action result below/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText(
|
||||
/explicit review and approval before Pulse sends anything/i,
|
||||
),
|
||||
).toHaveCount(0);
|
||||
|
||||
if (verification === "confirmed") {
|
||||
await expect(
|
||||
detailPanel.getByText(/restart postcondition was confirmed/i),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
detailPanel.getByText(/issue stays open until fresh health evidence/i),
|
||||
).toBeVisible();
|
||||
} else {
|
||||
await expect(
|
||||
detailPanel.getByText(/restart did not satisfy its postcondition/i),
|
||||
).toBeVisible();
|
||||
await expect(detailPanel.getByText(/issue remains open/i)).toBeVisible();
|
||||
const overflows = await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth >
|
||||
document.documentElement.clientWidth,
|
||||
);
|
||||
expect(overflows).toBeFalsy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue