Polish Actions layout and review details

This commit is contained in:
rcourtman
2026-07-13 17:00:36 +01:00
parent c471d8d201
commit 66dc5fd7df
20 changed files with 1509 additions and 252 deletions
@@ -414,6 +414,12 @@ same read routes, the response is explicitly `readOnly` and the rows remain
inspection fixtures only. Agent lifecycle surfaces must remove decision and
execution affordances, must not treat a mock approval as an agent command
grant, and must not infer lifecycle reachability from fixture state.
Those list/detail reads may also attach the canonical unified-resource name and
contract type as a sibling `resource` presentation object. Agent lifecycle
consumers must treat that object as display metadata only: the exact
`request.resourceId` remains the command target, and resource display changes
must not alter the action ID, plan hash, approval binding, dispatch identity, or
durable receipt reconciliation.
The investigation
continuity reconciler in `internal/api/patrol_action_reconciliation.go` is
also API-owned: callbacks only wake an authoritative action-audit re-read,
@@ -7355,7 +7355,13 @@ dispatch attempt, and correlated receipt. Decisions and execution remain
inventory and router allowlist, and queue failures use the shared
`agentcapabilities` vocabulary. Pending rows are oldest-first and expose the
same action audit shape, including requester and origin, used by desktop and
mobile. The mobile client must approve by recording an approved decision and
mobile. Action list and detail audits may add a `resource` read projection with
the canonical unified-resource `id`, `name`, and contract `type`. That object is
resolved at read time and is presentation metadata only: it must stay outside
`ActionRequest`, persistence, action ID, plan hash, decision binding, and
execution authority. An unavailable or superseded resource therefore omits the
projection without making the durable audit unreadable. The mobile client must
approve by recording an approved decision and
must not treat that decision as execution. A later explicit Run action gesture,
with its own local authentication gate, calls execute. Interactive clients bind
both requests to the exact reviewed plan by sending `planHash`; the REST fields
@@ -635,9 +635,15 @@ Global Actions review uses the canonical shared `Dialog`, `Button`, `Subtabs`,
selector is an in-page sub-navigation surface and must compose `Subtabs`
instead of recreating a segmented tablist in `Actions.tsx`; queue rows may own
action state and resource semantics, but their frame and badge chrome stay on
the shared primitives. The responsive route must preserve named tabs, keyboard
focus, dialog focus containment, and phone-width overflow checks; journey 83
is the desktop/browser accessibility proof and is not mobile-device proof.
the shared primitives. Actions uses the full content width supplied by the app
shell, matching Patrol instead of adding a page-local maximum-width container.
The review's policy provenance uses a native keyboard-operable disclosure so
the initial dialog layer stays calm without removing audit detail; intent,
exact target identity, safety/authority posture, and fail-closed provenance
warnings remain visible before expansion. The responsive route must preserve
named tabs, keyboard focus, dialog focus containment, and phone-width overflow
checks; journey 83 is the desktop/browser accessibility proof and is not
mobile-device proof.
Assistant shell entry changes must keep Assistant contextual rather than
generic: `AppLayout.tsx` and the command palette may expose a compact launcher,
@@ -406,6 +406,13 @@ scope overlap as storage or recovery ownership. A Docker workload that also
belongs to an owning platform remains governed by the resource, policy, and
backup capability contracts exposed by unified resources and the shared API
boundary.
Action list/detail reads may attach the current canonical resource name and
contract type as a sibling `resource` presentation object. Storage and recovery
consumers may show that metadata, but restore or remediation authority remains
bound to `request.resourceId`, the reviewed plan hash, and the durable action
record. A resource rename or an unavailable presentation projection must never
retarget execution, invalidate recovery evidence, or make an existing audit
unreadable.
Successful action plans also remain API-owned audit facts before
storage/recovery surfaces consume them: approval-required plans must persist
as `pending_approval` with initial lifecycle evidence, and retry/idempotency
@@ -1743,11 +1743,21 @@ The Actions inbox presents that durable record as a compact operator queue,
not a stack of equally weighted audit cards. Open work orders approval-required
decisions before runnable and executing actions; each collapsed row exposes
only state, action, bounded resource identity, recency, and reason before the
operator opens the governed review. Opaque canonical resource IDs remain in
the row's accessible name and title but are visually demoted to a type plus
short suffix. Read-only demo posture is quiet supporting context rather than a
page-level callout. The governed review dialog continues to own the full exact
resource ID, plan, policy evidence, lifecycle, authority, and outcome truth.
operator opens the governed review. Action list and detail reads enrich each
audit with the current canonical resource name and contract type when that
resource can be resolved. This metadata is a read-time presentation projection,
not part of `ActionRequest`, durable plan identity, or `planHash`; a resource
rename therefore cannot change action authority. Opaque canonical resource IDs
remain in the row's accessible name and title, while the visual row prefers the
API-supplied name and type and falls back to the bounded ID-derived type plus
short suffix only when the resource projection is unavailable. Read-only demo
posture is quiet supporting context rather than a page-level callout. The
governed review dialog continues to own the full exact resource ID, plan,
policy evidence, lifecycle, authority, and outcome truth. Its first layer keeps
intent plus safety/authority facts visible while the immutable planning-time
policy sources, revisions, and reason codes sit behind an explicit disclosure;
missing provenance remains an immediate fail-closed warning rather than hidden
detail.
APT review presents server-recorded policy provenance and distinguishes the
elevated update posture from low-risk-eligible cache cleanup. Both typed actions
@@ -190,6 +190,11 @@ describe('ActionAuditAPI', () => {
reason: 'Recover the edge proxy',
requestedBy: 'pulse_patrol',
},
resource: {
id: scope.resourceId,
name: 'Edge proxy',
type: 'app-container',
},
plan: {
actionId: 'action/one',
requestId: 'request-1',
@@ -242,6 +247,12 @@ describe('ActionAuditAPI', () => {
expect(apiFetchJSONMock).toHaveBeenNthCalledWith(2, '/api/actions?view=settled&limit=25');
expect(apiFetchJSONMock).toHaveBeenNthCalledWith(3, '/api/actions/action%2Fone');
expect(detail.audit.plan.policyDecision).toEqual(policyDecision);
expect(detail.audit.resource).toEqual({
id: 'docker:container:web',
name: 'Edge proxy',
type: 'app-container',
});
expect(detail.audit.request).not.toHaveProperty('resourceName');
expect(detail.readOnly).toBe(true);
expect(detail.audit.result?.actionResultV2).toMatchObject({
execution: { status: 'succeeded' },
@@ -1166,6 +1166,7 @@ describe('shared primitive guardrails', () => {
it('keeps the Actions inbox on the canonical in-page subtab primitive', () => {
expect(actionsSource).toContain('<Subtabs');
expect(actionsSource).not.toContain('role="tablist"');
expect(actionsSource).not.toContain('max-w-6xl');
});
it('keeps contextual row focus on one shared helper across summary consumers', () => {
@@ -1,19 +1,27 @@
import { For, Show, createMemo, type Component } from 'solid-js';
import ChevronDownIcon from 'lucide-solid/icons/chevron-down';
import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit';
import {
formatActionName,
formatEvidenceClass,
formatPolicyAuthority,
formatPolicyReason,
getActionResourcePresentation,
verificationTruthLabel,
} from './actionPresentation';
import { getAPTActionPresentation } from './aptActionPresentation';
export const ActionDecisionPacket: Component<{ audit: ActionAuditRecord; detail?: ActionDetailResponse }> = (props) => {
export const ActionDecisionPacket: Component<{
audit: ActionAuditRecord;
detail?: ActionDetailResponse;
}> = (props) => {
const policy = () => props.audit.plan.policyDecision;
const result = () => props.audit.result?.actionResultV2;
const apt = () => getAPTActionPresentation(props.audit);
const firstEvidence = () => result()?.verification.evidence?.[0];
const resource = createMemo(() =>
getActionResourcePresentation(props.audit.request.resourceId, props.audit.resource),
);
const expiry = createMemo(() => {
const value = new Date(props.audit.plan.expiresAt ?? '');
return Number.isNaN(value.valueOf()) ? 'Not recorded' : value.toLocaleString();
@@ -21,108 +29,342 @@ export const ActionDecisionPacket: Component<{ audit: ActionAuditRecord; detail?
return (
<div class="space-y-4" data-testid="action-decision-packet">
<section aria-labelledby="action-intent-heading" class="rounded-lg border border-border bg-surface p-4">
<h3 id="action-intent-heading" class="text-sm font-semibold text-base-content">What will happen</h3>
<section
aria-labelledby="action-intent-heading"
class="rounded-lg border border-border bg-surface p-4"
>
<h3 id="action-intent-heading" class="text-sm font-semibold text-base-content">
What will happen
</h3>
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<div><dt class="text-muted">Action</dt><dd class="font-medium">{formatActionName(props.audit.request.capabilityName)}</dd></div>
<div><dt class="text-muted">Resource</dt><dd class="break-all font-medium">{props.audit.request.resourceId}</dd></div>
<div class="sm:col-span-2"><dt class="text-muted">Reason</dt><dd>{props.audit.request.reason}</dd></div>
<Show when={props.audit.plan.preflight?.currentState}><div><dt class="text-muted">Current state</dt><dd>{props.audit.plan.preflight?.currentState}</dd></div></Show>
<Show when={props.audit.plan.preflight?.intendedChange}><div><dt class="text-muted">Intended change</dt><dd>{props.audit.plan.preflight?.intendedChange}</dd></div></Show>
<div><dt class="text-muted">Approval expires</dt><dd>{expiry()}</dd></div>
<div><dt class="text-muted">Rollback declared</dt><dd>{props.audit.plan.rollbackAvailable ? 'Yes' : 'No'}</dd></div>
<div>
<dt class="text-muted">Action</dt>
<dd class="font-medium">{formatActionName(props.audit.request.capabilityName)}</dd>
</div>
<div>
<dt class="text-muted">Resource</dt>
<dd class="font-medium">{resource().label}</dd>
<dd class="break-all text-xs text-muted">{props.audit.request.resourceId}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-muted">Reason</dt>
<dd>{props.audit.request.reason}</dd>
</div>
<Show when={props.audit.plan.preflight?.currentState}>
<div>
<dt class="text-muted">Current state</dt>
<dd>{props.audit.plan.preflight?.currentState}</dd>
</div>
</Show>
<Show when={props.audit.plan.preflight?.intendedChange}>
<div>
<dt class="text-muted">Intended change</dt>
<dd>{props.audit.plan.preflight?.intendedChange}</dd>
</div>
</Show>
<div>
<dt class="text-muted">Approval expires</dt>
<dd>{expiry()}</dd>
</div>
<div>
<dt class="text-muted">Rollback declared</dt>
<dd>{props.audit.plan.rollbackAvailable ? 'Yes' : 'No'}</dd>
</div>
</dl>
<Show when={(props.audit.plan.predictedBlastRadius ?? []).length > 0}>
<div class="mt-3"><div class="text-sm text-muted">Also affected</div><ul class="mt-1 list-disc pl-5 text-sm"><For each={props.audit.plan.predictedBlastRadius}>{(resource) => <li>{resource}</li>}</For></ul></div>
<div class="mt-3">
<div class="text-sm text-muted">Also affected</div>
<ul class="mt-1 list-disc pl-5 text-sm">
<For each={props.audit.plan.predictedBlastRadius}>
{(resource) => <li>{resource}</li>}
</For>
</ul>
</div>
</Show>
</section>
<Show when={apt()}>
{(presentation) => (
<section aria-labelledby="action-safety-heading" data-testid="apt-action-safety" class="rounded-lg border border-border bg-surface p-4">
<h3 id="action-safety-heading" class="text-sm font-semibold text-base-content">Safety and authority</h3>
<section
aria-labelledby="action-safety-heading"
data-testid="apt-action-safety"
class="rounded-lg border border-border bg-surface p-4"
>
<h3 id="action-safety-heading" class="text-sm font-semibold text-base-content">
Safety and authority
</h3>
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<div><dt class="text-muted">Risk posture</dt><dd class="font-medium">{presentation().safetyPosture}</dd></div>
<div><dt class="text-muted">Decision required</dt><dd class="font-medium">{presentation().approvalPosture}</dd></div>
<div class="sm:col-span-2"><dt class="text-muted">Operator-selected parameters</dt><dd>{presentation().parameterAuthority}</dd></div>
<div class="sm:col-span-2"><dt class="text-muted">What this action can do</dt><dd>{presentation().authorityBoundary}</dd></div>
<div>
<dt class="text-muted">Risk posture</dt>
<dd class="font-medium">{presentation().safetyPosture}</dd>
</div>
<div>
<dt class="text-muted">Decision required</dt>
<dd class="font-medium">{presentation().approvalPosture}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-muted">Operator-selected parameters</dt>
<dd>{presentation().parameterAuthority}</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-muted">What this action can do</dt>
<dd>{presentation().authorityBoundary}</dd>
</div>
</dl>
</section>
)}
</Show>
<section aria-labelledby="action-policy-heading" class="rounded-lg border border-border bg-surface p-4">
<h3 id="action-policy-heading" class="text-sm font-semibold text-base-content">Why Pulse allows this review</h3>
<Show when={policy()?.status === 'resolved'} fallback={<p class="mt-2 text-sm text-amber-700">This older action has no server-recorded policy provenance. Re-plan it before acting.</p>}>
<p class="mt-1 text-xs text-muted">Server decision {policy()?.decisionId}</p>
<div class="mt-3 space-y-2">
<For each={policy()?.authorities ?? []}>
{(authority) => (
<div class="rounded border border-border-subtle bg-surface-hover p-3">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<span class="font-medium">{formatPolicyAuthority(authority)}</span>
<span class="text-muted">{authority.status === 'consulted' ? 'Consulted' : formatActionName(authority.status)}</span>
<Show
when={policy()?.status === 'resolved'}
fallback={
<section
aria-labelledby="action-policy-heading"
class="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:bg-amber-950/40"
>
<h3 id="action-policy-heading" class="text-sm font-semibold text-base-content">
Policy evidence unavailable
</h3>
<p class="mt-2 text-sm text-amber-700 dark:text-amber-200">
This older action has no server-recorded policy provenance. Re-plan it before acting.
</p>
</section>
}
>
<details
data-testid="action-policy-provenance"
class="group rounded-lg border border-border bg-surface"
>
<summary class="flex cursor-pointer list-none items-center justify-between gap-4 p-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500">
<span>
<span
id="action-policy-heading"
class="block text-sm font-semibold text-base-content"
>
Policy evidence
</span>
<span class="mt-1 block text-xs text-muted">
{policy()?.authorities.length ?? 0}{' '}
{(policy()?.authorities.length ?? 0) === 1 ? 'authority' : 'authorities'} checked at
planning. Pulse checks current authority again before execution.
</span>
</span>
<ChevronDownIcon
class="h-4 w-4 shrink-0 text-muted transition-transform group-open:rotate-180"
aria-hidden="true"
/>
</summary>
<div class="border-t border-border-subtle px-4 pb-4 pt-3">
<p class="text-xs text-muted">Server decision {policy()?.decisionId}</p>
<div class="mt-3 space-y-2">
<For each={policy()?.authorities ?? []}>
{(authority) => (
<div class="rounded border border-border-subtle bg-surface-hover p-3">
<div class="flex flex-wrap items-center justify-between gap-2 text-sm">
<span class="font-medium">{formatPolicyAuthority(authority)}</span>
<span class="text-muted">
{authority.status === 'consulted'
? 'Consulted'
: formatActionName(authority.status)}
</span>
</div>
<div class="mt-1 text-xs text-muted">
{authority.sourceId}
<Show when={authority.revision}> · {authority.revision}</Show>
</div>
<ul class="mt-2 list-disc pl-5 text-xs text-base-content">
<For each={authority.reasonCodes}>
{(reason) => <li>{formatPolicyReason(reason)}</li>}
</For>
</ul>
</div>
<div class="mt-1 text-xs text-muted">{authority.sourceId}<Show when={authority.revision}> · {authority.revision}</Show></div>
<ul class="mt-2 list-disc pl-5 text-xs text-base-content"><For each={authority.reasonCodes}>{(reason) => <li>{formatPolicyReason(reason)}</li>}</For></ul>
</div>
)}
</For>
)}
</For>
</div>
<p class="mt-3 text-xs text-muted">
This is the immutable planning-time policy record. It does not authorize execution by
itself.
</p>
</div>
<p class="mt-3 text-xs text-muted">This records planning-time policy evidence. Pulse checks current authority again before execution.</p>
</Show>
</section>
</details>
</Show>
<Show when={result()}>
{(truth) => (
<section aria-labelledby="action-result-heading" class="space-y-3">
<h3 id="action-result-heading" class="text-sm font-semibold text-base-content">Recorded outcome</h3>
<h3 id="action-result-heading" class="text-sm font-semibold text-base-content">
Recorded outcome
</h3>
<Show when={apt()?.facts.length}>
<div data-testid="apt-action-facts" class="rounded-lg border border-border bg-surface p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Agent-reported facts</div>
<div
data-testid="apt-action-facts"
class="rounded-lg border border-border bg-surface p-4"
>
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
Agent-reported facts
</div>
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<For each={apt()?.facts ?? []}>{(fact) => <div><dt class="text-muted">{fact.label}</dt><dd class="font-medium">{fact.value}</dd></div>}</For>
<For each={apt()?.facts ?? []}>
{(fact) => (
<div>
<dt class="text-muted">{fact.label}</dt>
<dd class="font-medium">{fact.value}</dd>
</div>
)}
</For>
</dl>
</div>
</Show>
<div data-testid="action-execution-truth" class="rounded-lg border border-border bg-surface p-4">
<div
data-testid="action-execution-truth"
class="rounded-lg border border-border bg-surface p-4"
>
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Execution</div>
<div class="mt-1 font-semibold">{truth().execution.status === 'not_run' ? 'Did not run' : formatActionName(truth().execution.status)}</div>
<Show when={truth().execution.reasonCode}><div class="mt-1 text-xs text-muted">Reason: {formatActionName(truth().execution.reasonCode!)}</div></Show>
<Show when={truth().execution.summary && !apt()}><p class="mt-2 text-sm">{truth().execution.summary}</p></Show>
</div>
<div data-testid="action-verification-truth" class="rounded-lg border border-border bg-surface p-4">
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Verification</div>
<div class="mt-1 font-semibold">{verificationTruthLabel(truth().verification.status, truth().verification.evidenceClass)}</div>
<div class="mt-1 text-sm text-muted">Source: {formatEvidenceClass(truth().verification.evidenceClass)}</div>
<Show when={truth().verification.reasonCode}><div class="mt-1 text-xs text-muted">Reason: {formatActionName(truth().verification.reasonCode!)}</div></Show>
<Show when={truth().verification.summary && (!apt() || truth().verification.summary !== truth().execution.summary)}><p class="mt-2 text-sm">{truth().verification.summary}</p></Show>
<Show when={(truth().verification.evidence ?? []).length > 0}>
<details class="mt-3 rounded border border-border-subtle p-3 text-xs"><summary class="cursor-pointer font-medium">Evidence details</summary><ul class="mt-2 space-y-2"><For each={truth().verification.evidence}>{(evidence) => <li><div>{apt() ? 'Typed read-after-write observation' : evidence.summary || evidence.method}</div><div class="text-muted">Observed by {evidence.observerId} · {evidence.observerTrustDomain}</div><div class="text-muted">Agent observed {new Date(evidence.observedAt).toLocaleString()} · Pulse received {new Date(evidence.receivedAt).toLocaleString()}</div></li>}</For></ul></details>
<div class="mt-1 font-semibold">
{truth().execution.status === 'not_run'
? 'Did not run'
: formatActionName(truth().execution.status)}
</div>
<Show when={truth().execution.reasonCode}>
<div class="mt-1 text-xs text-muted">
Reason: {formatActionName(truth().execution.reasonCode!)}
</div>
</Show>
<Show when={truth().execution.summary && !apt()}>
<p class="mt-2 text-sm">{truth().execution.summary}</p>
</Show>
</div>
<div data-testid="action-compensation-truth" class="rounded-lg border border-border bg-surface p-4">
<div
data-testid="action-verification-truth"
class="rounded-lg border border-border bg-surface p-4"
>
<div class="text-xs font-semibold uppercase tracking-wide text-muted">
Verification
</div>
<div class="mt-1 font-semibold">
{verificationTruthLabel(
truth().verification.status,
truth().verification.evidenceClass,
)}
</div>
<div class="mt-1 text-sm text-muted">
Source: {formatEvidenceClass(truth().verification.evidenceClass)}
</div>
<Show when={truth().verification.reasonCode}>
<div class="mt-1 text-xs text-muted">
Reason: {formatActionName(truth().verification.reasonCode!)}
</div>
</Show>
<Show
when={
truth().verification.summary &&
(!apt() || truth().verification.summary !== truth().execution.summary)
}
>
<p class="mt-2 text-sm">{truth().verification.summary}</p>
</Show>
<Show when={(truth().verification.evidence ?? []).length > 0}>
<details class="mt-3 rounded border border-border-subtle p-3 text-xs">
<summary class="cursor-pointer font-medium">Evidence details</summary>
<ul class="mt-2 space-y-2">
<For each={truth().verification.evidence}>
{(evidence) => (
<li>
<div>
{apt()
? 'Typed read-after-write observation'
: evidence.summary || evidence.method}
</div>
<div class="text-muted">
Observed by {evidence.observerId} · {evidence.observerTrustDomain}
</div>
<div class="text-muted">
Agent observed {new Date(evidence.observedAt).toLocaleString()} · Pulse
received {new Date(evidence.receivedAt).toLocaleString()}
</div>
</li>
)}
</For>
</ul>
</details>
</Show>
</div>
<div
data-testid="action-compensation-truth"
class="rounded-lg border border-border bg-surface p-4"
>
<div class="text-xs font-semibold uppercase tracking-wide text-muted">Recovery</div>
<div class="mt-1 font-semibold">{formatActionName(truth().compensation.status)}</div>
<div class="mt-1 text-sm text-muted">Support: {formatActionName(truth().compensation.support)}</div>
<Show when={truth().compensation.strategy}><div class="mt-1 text-sm">Strategy: {truth().compensation.strategy}</div></Show>
<Show when={truth().compensation.summary}><p class="mt-2 text-sm">{truth().compensation.summary}</p></Show>
<div class="mt-1 text-sm text-muted">
Support: {formatActionName(truth().compensation.support)}
</div>
<Show when={truth().compensation.strategy}>
<div class="mt-1 text-sm">Strategy: {truth().compensation.strategy}</div>
</Show>
<Show when={truth().compensation.summary}>
<p class="mt-2 text-sm">{truth().compensation.summary}</p>
</Show>
</div>
<Show when={apt()}>{(presentation) => <div data-testid="apt-action-next-step" class="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200"><div class="font-semibold">What to do next</div><p class="mt-1">{presentation().nextStep}</p></div>}</Show>
<Show when={apt()}>
{(presentation) => (
<div
data-testid="apt-action-next-step"
class="rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200"
>
<div class="font-semibold">What to do next</div>
<p class="mt-1">{presentation().nextStep}</p>
</div>
)}
</Show>
</section>
)}
</Show>
<Show when={props.detail?.attempt || props.detail?.receipt}>
<section aria-labelledby="action-delivery-heading" data-testid="action-delivery-truth" class="rounded-lg border border-border bg-surface p-4">
<h3 id="action-delivery-heading" class="text-sm font-semibold text-base-content">Durable delivery record</h3>
<p class="mt-2 text-sm font-medium">{props.detail?.receipt ? 'One agent receipt is recorded for this action.' : props.detail?.attempt?.state === 'receipt_pending' ? 'The action was sent once and Pulse is waiting for the durable agent receipt.' : 'Pulse recorded the delivery attempt before sending it.'}</p>
<p class="mt-1 text-sm text-muted">Refreshing or reconnecting re-reads this action record; it does not create another action.</p>
<section
aria-labelledby="action-delivery-heading"
data-testid="action-delivery-truth"
class="rounded-lg border border-border bg-surface p-4"
>
<h3 id="action-delivery-heading" class="text-sm font-semibold text-base-content">
Durable delivery record
</h3>
<p class="mt-2 text-sm font-medium">
{props.detail?.receipt
? 'One agent receipt is recorded for this action.'
: props.detail?.attempt?.state === 'receipt_pending'
? 'The action was sent once and Pulse is waiting for the durable agent receipt.'
: 'Pulse recorded the delivery attempt before sending it.'}
</p>
<p class="mt-1 text-sm text-muted">
Refreshing or reconnecting re-reads this action record; it does not create another
action.
</p>
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
<Show when={firstEvidence()?.observedAt}><div><dt class="text-muted">Agent observation</dt><dd>{new Date(firstEvidence()!.observedAt).toLocaleString()}</dd></div></Show>
<Show when={props.detail?.receipt?.receivedAt}><div><dt class="text-muted">Receipt recorded by Pulse</dt><dd>{new Date(props.detail!.receipt!.receivedAt).toLocaleString()}</dd></div></Show>
<Show when={firstEvidence()?.observedAt}>
<div>
<dt class="text-muted">Agent observation</dt>
<dd>{new Date(firstEvidence()!.observedAt).toLocaleString()}</dd>
</div>
</Show>
<Show when={props.detail?.receipt?.receivedAt}>
<div>
<dt class="text-muted">Receipt recorded by Pulse</dt>
<dd>{new Date(props.detail!.receipt!.receivedAt).toLocaleString()}</dd>
</div>
</Show>
</dl>
<details class="mt-3 rounded border border-border-subtle p-3 text-xs"><summary class="cursor-pointer font-medium">Delivery identifiers</summary><div class="mt-2 break-all text-muted">Action {props.audit.id}<Show when={props.detail?.attempt?.id}> · Attempt {props.detail?.attempt?.id}</Show><Show when={props.detail?.receipt?.transportRequestId}> · Transport {props.detail?.receipt?.transportRequestId}</Show></div></details>
<details class="mt-3 rounded border border-border-subtle p-3 text-xs">
<summary class="cursor-pointer font-medium">Delivery identifiers</summary>
<div class="mt-2 break-all text-muted">
Action {props.audit.id}
<Show when={props.detail?.attempt?.id}> · Attempt {props.detail?.attempt?.id}</Show>
<Show when={props.detail?.receipt?.transportRequestId}>
{' '}
· Transport {props.detail?.receipt?.transportRequestId}
</Show>
</div>
</details>
</section>
</Show>
</div>
@@ -7,7 +7,7 @@ import { notificationStore } from '@/stores/notifications';
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
import type { ActionDetailResponse } from '@/types/actionAudit';
import { ActionDecisionPacket } from './ActionDecisionPacket';
import { formatActionName } from './actionPresentation';
import { formatActionName, getActionResourcePresentation } from './actionPresentation';
import { getAPTActionPresentation } from './aptActionPresentation';
export const ActionReviewDialog: Component<{
@@ -19,6 +19,12 @@ export const ActionReviewDialog: Component<{
const [error, setError] = createSignal('');
const [clock, setClock] = createSignal(Date.now());
const audit = () => props.detail?.audit;
const resource = createMemo(() => {
const record = audit();
return record
? getActionResourcePresentation(record.request.resourceId, record.resource)
: { label: '', detail: '' };
});
const readOnly = createMemo(
() => props.detail?.readOnly === true || presentationPolicyIsReadOnly(),
);
@@ -155,7 +161,10 @@ export const ActionReviewDialog: Component<{
<h2 id="action-review-title" class="mt-1 text-xl font-semibold">
{formatActionName(record().request.capabilityName)}
</h2>
<p class="mt-1 break-all text-sm text-muted">{record().request.resourceId}</p>
<p class="mt-1 text-sm text-muted">
{resource().label}
<Show when={resource().detail}> · {resource().detail}</Show>
</p>
</div>
<Button
variant="ghost"
@@ -1,4 +1,4 @@
import { cleanup, render, screen, within } from '@solidjs/testing-library';
import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library';
import { afterEach, describe, expect, it } from 'vitest';
import type { ActionAuditRecord } from '@/types/actionAudit';
import { ActionDecisionPacket } from '../ActionDecisionPacket';
@@ -6,83 +6,310 @@ import { ActionDecisionPacket } from '../ActionDecisionPacket';
afterEach(cleanup);
const audit: ActionAuditRecord = {
id: 'action-1', createdAt: '2026-07-12T00:00:00Z', updatedAt: '2026-07-12T00:01:00Z', state: 'completed', decisionRevision: 1,
request: { requestId: 'request-1', resourceId: 'docker:container:edge', capabilityName: 'restart', reason: 'Recover the edge proxy', requestedBy: 'ui:docker-page' },
plan: {
actionId: 'action-1', requestId: 'request-1', allowed: true, requiresApproval: true, approvalPolicy: 'admin', approvalRequirement: { version: 1, floor: 'admin', quorum: 1, disallowRequester: false }, rollbackAvailable: false,
plannedAt: '2026-07-12T00:00:00Z', expiresAt: '2026-07-12T00:10:00Z', resourceVersion: 'resource:sha256:one', policyVersion: 'policy:sha256:one', planHash: 'sha256:plan',
policyDecision: { version: 1, status: 'resolved', decisionId: 'policy-decision:sha256:one', actionId: 'action-1', scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' }, approvalRequirement: { version: 1, floor: 'admin', quorum: 1, disallowRequester: false }, planningAllowed: true, requiresApproval: true, authorities: [
{ kind: 'capability_registry', sourceId: 'capability-registry:restart', revision: 'policy:sha256:one', status: 'consulted', scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' }, approvalFloor: 'admin', reasonCodes: ['capability_approval_admin', 'capability_auto_low_risk'] },
{ kind: 'resource_operator_policy', sourceId: 'resource-operator-policy:docker:container:edge', revision: 'resource-policy:sha256:one', status: 'consulted', scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' }, approvalFloor: 'admin', reasonCodes: ['resource_capability_allowed', 'resource_window_open'] },
] },
id: 'action-1',
createdAt: '2026-07-12T00:00:00Z',
updatedAt: '2026-07-12T00:01:00Z',
state: 'completed',
decisionRevision: 1,
request: {
requestId: 'request-1',
resourceId: 'docker:container:edge',
capabilityName: 'restart',
reason: 'Recover the edge proxy',
requestedBy: 'ui:docker-page',
},
resource: { id: 'docker:container:edge', name: 'Edge proxy', type: 'app-container' },
plan: {
actionId: 'action-1',
requestId: 'request-1',
allowed: true,
requiresApproval: true,
approvalPolicy: 'admin',
approvalRequirement: { version: 1, floor: 'admin', quorum: 1, disallowRequester: false },
rollbackAvailable: false,
plannedAt: '2026-07-12T00:00:00Z',
expiresAt: '2026-07-12T00:10:00Z',
resourceVersion: 'resource:sha256:one',
policyVersion: 'policy:sha256:one',
planHash: 'sha256:plan',
policyDecision: {
version: 1,
status: 'resolved',
decisionId: 'policy-decision:sha256:one',
actionId: 'action-1',
scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' },
approvalRequirement: { version: 1, floor: 'admin', quorum: 1, disallowRequester: false },
planningAllowed: true,
requiresApproval: true,
authorities: [
{
kind: 'capability_registry',
sourceId: 'capability-registry:restart',
revision: 'policy:sha256:one',
status: 'consulted',
scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' },
approvalFloor: 'admin',
reasonCodes: ['capability_approval_admin', 'capability_auto_low_risk'],
},
{
kind: 'resource_operator_policy',
sourceId: 'resource-operator-policy:docker:container:edge',
revision: 'resource-policy:sha256:one',
status: 'consulted',
scope: { orgId: 'org-1', resourceId: 'docker:container:edge', capabilityName: 'restart' },
approvalFloor: 'admin',
reasonCodes: ['resource_capability_allowed', 'resource_window_open'],
},
],
},
},
result: {
success: true,
actionResultV2: {
version: 2,
execution: { status: 'succeeded', summary: 'Dispatch completed.' },
verification: {
status: 'confirmed',
evidenceClass: 'independent',
summary: 'A separate observer saw the target state.',
evidence: [],
},
compensation: { support: 'unavailable', status: 'not_available' },
},
},
result: { success: true, actionResultV2: { version: 2, execution: { status: 'succeeded', summary: 'Dispatch completed.' }, verification: { status: 'confirmed', evidenceClass: 'independent', summary: 'A separate observer saw the target state.', evidence: [] }, compensation: { support: 'unavailable', status: 'not_available' } } },
verificationOutcome: { status: 'verified' },
};
describe('ActionDecisionPacket', () => {
it('shows server policy provenance, expiry, and independent result evidence as separate truth', () => {
render(() => <ActionDecisionPacket audit={audit} />);
expect(screen.getByText('Why Pulse allows this review')).toBeInTheDocument();
expect(screen.getByText('Edge proxy')).toBeInTheDocument();
expect(screen.getByText('docker:container:edge')).toBeInTheDocument();
expect(screen.getByText('Policy evidence')).toBeInTheDocument();
expect(screen.getByText(/2 authorities checked at planning/)).toBeInTheDocument();
expect(screen.getByText('Capability safety policy')).not.toBeVisible();
fireEvent.click(screen.getByText('Policy evidence'));
expect(screen.getByText('Capability safety policy')).toBeInTheDocument();
expect(screen.getByText('Policy for this resource')).toBeInTheDocument();
expect(within(screen.getByTestId('action-execution-truth')).getByText('Succeeded')).toBeInTheDocument();
expect(
within(screen.getByTestId('action-execution-truth')).getByText('Succeeded'),
).toBeInTheDocument();
expect(screen.getByText('Confirmed by independent observer')).toBeInTheDocument();
expect(screen.getByText('Source: Independent observer')).toBeInTheDocument();
});
it.each([
['agent-attested confirmed', 'succeeded', 'confirmed', 'agent_attested', 'Succeeded', 'Confirmed by executing agent', 'Source: Executing agent'],
['independent confirmed', 'succeeded', 'confirmed', 'independent', 'Succeeded', 'Confirmed by independent observer', 'Source: Independent observer'],
['succeeded plus contradicted', 'succeeded', 'contradicted', 'independent', 'Succeeded', 'Outcome contradicted', 'Source: Independent observer'],
['failed plus confirmed', 'failed', 'confirmed', 'agent_attested', 'Failed', 'Confirmed by executing agent', 'Source: Executing agent'],
['not run plus not attempted', 'not_run', 'not_attempted', 'none', 'Did not run', 'Outcome not verified', 'Source: No evidence source'],
['inconclusive plus confirmed', 'inconclusive', 'confirmed', 'independent', 'Inconclusive', 'Confirmed by independent observer', 'Source: Independent observer'],
['confirmed without evidence source', 'succeeded', 'confirmed', 'none', 'Succeeded', 'Confirmation lacks an evidence source', 'Source: No evidence source'],
] as const)('keeps execution, verification source, and recovery separate for %s', (_name, execution, verification, evidenceClass, executionLabel, verificationLabel, sourceLabel) => {
const variant: ActionAuditRecord = {
...audit,
result: {
success: execution === 'succeeded',
actionResultV2: {
version: 2,
execution: { status: execution },
verification: { status: verification, evidenceClass },
compensation: { support: 'declared', status: 'not_attempted', strategy: 'restart previous container' },
[
'agent-attested confirmed',
'succeeded',
'confirmed',
'agent_attested',
'Succeeded',
'Confirmed by executing agent',
'Source: Executing agent',
],
[
'independent confirmed',
'succeeded',
'confirmed',
'independent',
'Succeeded',
'Confirmed by independent observer',
'Source: Independent observer',
],
[
'succeeded plus contradicted',
'succeeded',
'contradicted',
'independent',
'Succeeded',
'Outcome contradicted',
'Source: Independent observer',
],
[
'failed plus confirmed',
'failed',
'confirmed',
'agent_attested',
'Failed',
'Confirmed by executing agent',
'Source: Executing agent',
],
[
'not run plus not attempted',
'not_run',
'not_attempted',
'none',
'Did not run',
'Outcome not verified',
'Source: No evidence source',
],
[
'inconclusive plus confirmed',
'inconclusive',
'confirmed',
'independent',
'Inconclusive',
'Confirmed by independent observer',
'Source: Independent observer',
],
[
'confirmed without evidence source',
'succeeded',
'confirmed',
'none',
'Succeeded',
'Confirmation lacks an evidence source',
'Source: No evidence source',
],
] as const)(
'keeps execution, verification source, and recovery separate for %s',
(
_name,
execution,
verification,
evidenceClass,
executionLabel,
verificationLabel,
sourceLabel,
) => {
const variant: ActionAuditRecord = {
...audit,
result: {
success: execution === 'succeeded',
actionResultV2: {
version: 2,
execution: { status: execution },
verification: { status: verification, evidenceClass },
compensation: {
support: 'declared',
status: 'not_attempted',
strategy: 'restart previous container',
},
},
},
},
};
render(() => <ActionDecisionPacket audit={variant} />);
expect(within(screen.getByTestId('action-execution-truth')).getByText(executionLabel)).toBeInTheDocument();
expect(within(screen.getByTestId('action-verification-truth')).getByText(verificationLabel)).toBeInTheDocument();
expect(within(screen.getByTestId('action-verification-truth')).getByText(sourceLabel)).toBeInTheDocument();
expect(within(screen.getByTestId('action-compensation-truth')).getByText('Not Attempted')).toBeInTheDocument();
cleanup();
});
};
render(() => <ActionDecisionPacket audit={variant} />);
expect(
within(screen.getByTestId('action-execution-truth')).getByText(executionLabel),
).toBeInTheDocument();
expect(
within(screen.getByTestId('action-verification-truth')).getByText(verificationLabel),
).toBeInTheDocument();
expect(
within(screen.getByTestId('action-verification-truth')).getByText(sourceLabel),
).toBeInTheDocument();
expect(
within(screen.getByTestId('action-compensation-truth')).getByText('Not Attempted'),
).toBeInTheDocument();
cleanup();
},
);
it('shows bounded APT facts, agent attestation, recovery, and one durable receipt without a reboot control', () => {
const aptAudit: ActionAuditRecord = {
...audit,
request: { ...audit.request, resourceId: 'proxmox:node:pve-1', capabilityName: 'install_os_updates', params: {} },
request: {
...audit.request,
resourceId: 'proxmox:node:pve-1',
capabilityName: 'install_os_updates',
params: {},
},
plan: {
...audit.plan,
policyDecision: {
...audit.plan.policyDecision!,
scope: { ...audit.plan.policyDecision!.scope, resourceId: 'proxmox:node:pve-1', capabilityName: 'install_os_updates' },
authorities: audit.plan.policyDecision!.authorities.map((authority) => ({ ...authority, scope: { ...authority.scope, resourceId: 'proxmox:node:pve-1', capabilityName: 'install_os_updates' }, reasonCodes: ['capability_approval_admin', 'capability_auto_elevated'] })),
scope: {
...audit.plan.policyDecision!.scope,
resourceId: 'proxmox:node:pve-1',
capabilityName: 'install_os_updates',
},
authorities: audit.plan.policyDecision!.authorities.map((authority) => ({
...authority,
scope: {
...authority.scope,
resourceId: 'proxmox:node:pve-1',
capabilityName: 'install_os_updates',
},
reasonCodes: ['capability_approval_admin', 'capability_auto_elevated'],
})),
},
},
result: {
success: true,
actionResultV2: {
version: 2,
execution: {
status: 'succeeded',
summary:
'APT package updates: phase=complete; 6 pending before, 0 pending after; package manager health: healthy; recovery required: false; reboot required: true',
},
verification: {
status: 'confirmed',
evidenceClass: 'agent_attested',
summary: 'The executing agent observed the canonical postcondition.',
evidence: [
{
version: 1,
id: 'evidence-1',
observerId: 'agent:pve-1',
observerKind: 'agent',
observerTrustDomain: 'host:pve-1',
executorTrustDomain: 'host:pve-1',
method: 'typed_read_after_write',
subjectId: 'proxmox:node:pve-1',
observedAt: '2026-07-12T00:01:00Z',
receivedAt: '2026-07-12T00:05:00Z',
digest: 'sha256:evidence',
},
],
},
compensation: {
support: 'unavailable',
status: 'not_available',
summary: 'No rollback is available.',
},
},
},
result: { success: true, actionResultV2: {
version: 2,
execution: { status: 'succeeded', summary: 'APT package updates: phase=complete; 6 pending before, 0 pending after; package manager health: healthy; recovery required: false; reboot required: true' },
verification: { status: 'confirmed', evidenceClass: 'agent_attested', summary: 'The executing agent observed the canonical postcondition.', evidence: [{ version: 1, id: 'evidence-1', observerId: 'agent:pve-1', observerKind: 'agent', observerTrustDomain: 'host:pve-1', executorTrustDomain: 'host:pve-1', method: 'typed_read_after_write', subjectId: 'proxmox:node:pve-1', observedAt: '2026-07-12T00:01:00Z', receivedAt: '2026-07-12T00:05:00Z', digest: 'sha256:evidence' }] },
compensation: { support: 'unavailable', status: 'not_available', summary: 'No rollback is available.' },
} },
};
render(() => <ActionDecisionPacket audit={aptAudit} detail={{ audit: aptAudit, events: [], attempt: { id: 'attempt-1', actionId: aptAudit.id, state: 'receipt_recorded', createdAt: aptAudit.createdAt, updatedAt: aptAudit.updatedAt, dispatchCount: 1 }, receipt: { attemptId: 'attempt-1', actionId: aptAudit.id, transportRequestId: 'transport-1', receivedAt: '2026-07-12T00:05:00Z' } }} />);
expect(within(screen.getByTestId('apt-action-facts')).getByText('Yes — fact only; no reboot was authorized')).toBeInTheDocument();
expect(within(screen.getByTestId('action-verification-truth')).getByText('Confirmed by executing agent')).toBeInTheDocument();
expect(within(screen.getByTestId('action-compensation-truth')).getByText('No rollback is available.')).toBeInTheDocument();
render(() => (
<ActionDecisionPacket
audit={aptAudit}
detail={{
audit: aptAudit,
events: [],
attempt: {
id: 'attempt-1',
actionId: aptAudit.id,
state: 'receipt_recorded',
createdAt: aptAudit.createdAt,
updatedAt: aptAudit.updatedAt,
dispatchCount: 1,
},
receipt: {
attemptId: 'attempt-1',
actionId: aptAudit.id,
transportRequestId: 'transport-1',
receivedAt: '2026-07-12T00:05:00Z',
},
}}
/>
));
expect(
within(screen.getByTestId('apt-action-facts')).getByText(
'Yes — fact only; no reboot was authorized',
),
).toBeInTheDocument();
expect(
within(screen.getByTestId('action-verification-truth')).getByText(
'Confirmed by executing agent',
),
).toBeInTheDocument();
expect(
within(screen.getByTestId('action-compensation-truth')).getByText(
'No rollback is available.',
),
).toBeInTheDocument();
expect(screen.getByText('One agent receipt is recorded for this action.')).toBeInTheDocument();
expect(screen.getAllByTestId('action-delivery-truth')).toHaveLength(1);
expect(screen.queryByRole('button', { name: /reboot/i })).toBeNull();
@@ -124,6 +124,16 @@ describe('Actions inbox presentation', () => {
});
});
it('prefers canonical display metadata supplied by the action API', () => {
expect(
getActionResourcePresentation('docker:container:7d3a91bd1a70', {
id: 'docker:container:7d3a91bd1a70',
name: 'Checkout API',
type: 'app-container',
}),
).toEqual({ label: 'Checkout API', detail: 'App container' });
});
it('sorts decisions before runnable and executing work without mutating the API response', () => {
const actions = [
{ id: 'running', state: 'executing', updatedAt: '2026-07-13T12:03:00Z' },
@@ -1,5 +1,6 @@
import type {
ActionAuditRecord,
ActionResourceReference,
ActionAuditState,
ActionEvidenceClass,
ActionPolicyAuthorityFactor,
@@ -85,10 +86,33 @@ const CANONICAL_RESOURCE_KINDS: Record<string, string> = {
'proxmox:lxc': 'Proxmox container',
};
export const getActionResourcePresentation = (resourceId: string): ActionResourcePresentation => {
const RESOURCE_TYPE_LABELS: Record<string, string> = {
'app-container': 'App container',
agent: 'Host agent',
container: 'Container',
lxc: 'Proxmox container',
node: 'Node',
vm: 'Virtual machine',
};
const resourceTypeLabel = (resourceType: string): string =>
RESOURCE_TYPE_LABELS[resourceType.trim().toLowerCase()] || formatActionName(resourceType);
export const getActionResourcePresentation = (
resourceId: string,
resource?: ActionResourceReference,
): ActionResourcePresentation => {
const normalized = resourceId.trim();
if (!normalized) return { label: 'Unknown resource', detail: '' };
const authoritativeName = resource?.name?.trim();
if (authoritativeName) {
return {
label: authoritativeName,
detail: resourceTypeLabel(resource?.type ?? ''),
};
}
const canonicalParts = normalized.split(':').filter(Boolean);
if (canonicalParts.length > 1) {
const name = canonicalParts.at(-1) || normalized;
@@ -795,7 +795,7 @@ describe('Docker native tables', () => {
),
);
expect(await screen.findByRole('dialog', { name: 'Restart' })).toBeInTheDocument();
expect(screen.getByText('Why Pulse allows this review')).toBeInTheDocument();
expect(screen.getByText('Policy evidence unavailable')).toBeInTheDocument();
expect(ResourceActionsAPI.decideAction).not.toHaveBeenCalled();
expect(ResourceActionsAPI.executeAction).not.toHaveBeenCalled();
expect(onLifecycleActionSettled).not.toHaveBeenCalled();
+3 -2
View File
@@ -74,7 +74,7 @@ export function Actions() {
};
return (
<div class="mx-auto w-full max-w-6xl space-y-4 px-3 py-4 sm:px-5">
<div class="w-full space-y-4 px-3 py-4 sm:px-5">
<PageHeader
title="Actions"
description="Review proposed infrastructure changes and track their outcomes."
@@ -160,7 +160,8 @@ export function Actions() {
<For each={displayedActions()}>
{(action) => {
const state = () => getActionInboxStatePresentation(action.state);
const resource = () => getActionResourcePresentation(action.request.resourceId);
const resource = () =>
getActionResourcePresentation(action.request.resourceId, action.resource);
const title = () => formatActionName(action.request.capabilityName);
return (
<li>
+8
View File
@@ -29,6 +29,12 @@ export interface ActionAuditRequest {
actor?: ActionActor;
}
export interface ActionResourceReference {
id: string;
name: string;
type: string;
}
export type ResourceActionRequest = Omit<ActionAuditRequest, 'actor'>;
export interface ActionApprovalRequirement {
@@ -257,6 +263,8 @@ export interface ActionAuditRecord {
state: ActionAuditState;
decisionRevision?: number;
request: ActionAuditRequest;
/** Read-time display metadata from the canonical resource API; never part of plan identity. */
resource?: ActionResourceReference;
plan: ActionAuditPlan;
origin?: ActionAuditOrigin;
approvals?: ActionAuditApprovalRecord[];
+85 -13
View File
@@ -69,22 +69,39 @@ type actionExecutionResponse struct {
Audit unified.ActionAuditRecord `json:"audit"`
}
// actionResourcePresentation is read-time metadata from the canonical unified
// resource registry. It is deliberately kept outside ActionRequest so a name
// change cannot alter a persisted plan identity or plan hash.
type actionResourcePresentation struct {
ID string `json:"id"`
Name string `json:"name"`
Type unified.ResourceType `json:"type"`
}
type actionAuditProjection struct {
unified.ActionAuditRecord
Resource *actionResourcePresentation `json:"resource,omitempty"`
}
type pendingActionsResponse struct {
Actions []unified.ActionAuditRecord `json:"actions"`
Count int `json:"count"`
ReadOnly bool `json:"readOnly"`
Actions []actionAuditProjection `json:"actions"`
Count int `json:"count"`
ReadOnly bool `json:"readOnly"`
}
type actionInboxResponse struct {
View actionlifecycle.ActionListView `json:"view"`
Actions []unified.ActionAuditRecord `json:"actions"`
Actions []actionAuditProjection `json:"actions"`
Count int `json:"count"`
ReadOnly bool `json:"readOnly"`
}
type actionDetailResponse struct {
actionlifecycle.ActionDetail
ReadOnly bool `json:"readOnly"`
Audit actionAuditProjection `json:"audit"`
Events []unified.ActionLifecycleEvent `json:"events"`
Attempt *unified.ActionDispatchAttempt `json:"attempt,omitempty"`
Receipt *unified.ActionDispatchReceipt `json:"receipt,omitempty"`
ReadOnly bool `json:"readOnly"`
}
// ActionLifecycle returns the shared transport-independent action lifecycle
@@ -169,8 +186,9 @@ func (h *ResourceHandlers) HandleListPendingActions(w http.ResponseWriter, r *ht
}
if mock.IsMockEnabled() {
actions := mockActionAuditsByState(maxPendingActionAudits, unified.ActionStatePending)
projected := projectActionAudits(actions, mockActionResourceRegistry())
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(pendingActionsResponse{Actions: actions, Count: len(actions), ReadOnly: true}); err != nil {
if err := json.NewEncoder(w).Encode(pendingActionsResponse{Actions: projected, Count: len(projected), ReadOnly: true}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueEncodeFailed, "Failed to encode pending actions")
}
return
@@ -183,8 +201,9 @@ func (h *ResourceHandlers) HandleListPendingActions(w http.ResponseWriter, r *ht
if actions == nil {
actions = []unified.ActionAuditRecord{}
}
projected := h.projectActionAudits(GetOrgID(r.Context()), actions)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(pendingActionsResponse{Actions: actions, Count: len(actions)}); err != nil {
if err := json.NewEncoder(w).Encode(pendingActionsResponse{Actions: projected, Count: len(projected)}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionQueueEncodeFailed, "Failed to encode pending actions")
}
}
@@ -213,8 +232,9 @@ func (h *ResourceHandlers) HandleListActions(w http.ResponseWriter, r *http.Requ
return
}
actions := mockActionAuditsByState(limit, states...)
projected := projectActionAudits(actions, mockActionResourceRegistry())
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(actionInboxResponse{View: view, Actions: actions, Count: len(actions), ReadOnly: true}); err != nil {
if err := json.NewEncoder(w).Encode(actionInboxResponse{View: view, Actions: projected, Count: len(projected), ReadOnly: true}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionListEncodeFailed, "Failed to encode actions")
}
return
@@ -233,8 +253,9 @@ func (h *ResourceHandlers) HandleListActions(w http.ResponseWriter, r *http.Requ
if actions == nil {
actions = []unified.ActionAuditRecord{}
}
projected := h.projectActionAudits(GetOrgID(r.Context()), actions)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(actionInboxResponse{View: view, Actions: actions, Count: len(actions)}); err != nil {
if err := json.NewEncoder(w).Encode(actionInboxResponse{View: view, Actions: projected, Count: len(projected)}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionListEncodeFailed, "Failed to encode actions")
}
}
@@ -253,8 +274,9 @@ func (h *ResourceHandlers) HandleGetAction(w http.ResponseWriter, r *http.Reques
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(actionDetailResponse{
ActionDetail: actionlifecycle.ActionDetail{Audit: fixture.Audit, Events: fixture.Events},
ReadOnly: true,
Audit: projectActionAudit(fixture.Audit, mockActionResourceRegistry()),
Events: fixture.Events,
ReadOnly: true,
}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode action detail")
}
@@ -275,7 +297,12 @@ func (h *ResourceHandlers) HandleGetAction(w http.ResponseWriter, r *http.Reques
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(detail); err != nil {
if err := json.NewEncoder(w).Encode(actionDetailResponse{
Audit: h.projectActionAudit(GetOrgID(r.Context()), detail.Audit),
Events: detail.Events,
Attempt: detail.Attempt,
Receipt: detail.Receipt,
}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode action detail")
}
}
@@ -522,6 +549,51 @@ func mockActionAuditsByState(limit int, states ...unified.ActionState) []unified
return actions
}
func mockActionResourceRegistry() *unified.ResourceRegistry {
resources, _ := mock.UnifiedResourceSnapshot()
registry := unified.NewRegistry(nil)
registry.IngestResources(resources)
return registry
}
func (h *ResourceHandlers) projectActionAudit(orgID string, record unified.ActionAuditRecord) actionAuditProjection {
registry, err := h.buildRegistry(orgID)
if err != nil {
return actionAuditProjection{ActionAuditRecord: record}
}
return projectActionAudit(record, registry)
}
func (h *ResourceHandlers) projectActionAudits(orgID string, records []unified.ActionAuditRecord) []actionAuditProjection {
registry, err := h.buildRegistry(orgID)
if err != nil {
registry = nil
}
return projectActionAudits(records, registry)
}
func projectActionAudits(records []unified.ActionAuditRecord, registry *unified.ResourceRegistry) []actionAuditProjection {
projected := make([]actionAuditProjection, 0, len(records))
for _, record := range records {
projected = append(projected, projectActionAudit(record, registry))
}
return projected
}
func projectActionAudit(record unified.ActionAuditRecord, registry *unified.ResourceRegistry) actionAuditProjection {
projection := actionAuditProjection{ActionAuditRecord: record}
resource, ok := presentationResourceByID(registry, record.Request.ResourceID)
if !ok || resource == nil || strings.TrimSpace(resource.Name) == "" {
return projection
}
projection.Resource = &actionResourcePresentation{
ID: unified.CanonicalResourceID(resource.ID),
Name: strings.TrimSpace(resource.Name),
Type: resourceContractType(*resource),
}
return projection
}
// writeActionLifecycleReadError handles the store/query/not-found failures
// shared by the decision and execution endpoints, delegating anything else
// to the endpoint-specific fallback.
+6
View File
@@ -45,6 +45,9 @@ func TestMockActionInboxAndDetailUseCanonicalResponses(t *testing.T) {
if action.Plan.PolicyDecision.DecisionID == "" || action.Plan.PlanHash == "" {
t.Fatalf("list %s returned incomplete review identity: %#v", test.view, action.Plan)
}
if action.Resource == nil || action.Resource.Name == "" || action.Resource.Type == "" {
t.Fatalf("list %s returned no canonical resource presentation: %#v", test.view, action.Resource)
}
}
}
@@ -63,6 +66,9 @@ func TestMockActionInboxAndDetailUseCanonicalResponses(t *testing.T) {
if detail.Audit.ID != fixtures[0].Audit.ID || len(detail.Events) == 0 {
t.Fatalf("detail = %#v", detail)
}
if detail.Audit.Resource == nil || detail.Audit.Resource.Name == "" {
t.Fatalf("detail returned no canonical resource presentation: %#v", detail.Audit.Resource)
}
if !detail.ReadOnly {
t.Fatal("mock detail must declare itself read-only")
}
+54
View File
@@ -366,6 +366,60 @@ func TestHandleListPendingActionsReturnsOnlyCanonicalDecisionQueue(t *testing.T)
}
}
func TestHandleListAndDetailActionsProjectCanonicalResourcePresentation(t *testing.T) {
now := time.Date(2026, 7, 13, 15, 0, 0, 0, time.UTC)
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Name: "Checkout API",
Status: unified.StatusWarning,
LastSeen: now,
Sources: []unified.DataSource{unified.SourceProxmox},
}},
})
store, err := h.getStore("default")
if err != nil {
t.Fatal(err)
}
record := unified.ActionAuditRecord{
ID: "act-resource-presentation", CreatedAt: now, UpdatedAt: now, State: unified.ActionStatePending,
Request: unified.ActionRequest{RequestID: "req-resource-presentation", ResourceID: "vm:42", CapabilityName: "restart", Reason: "Recover checkout", RequestedBy: "pulse_patrol"},
Plan: unified.ActionPlan{
ActionID: "act-resource-presentation", RequestID: "req-resource-presentation",
Allowed: true, RequiresApproval: true, ApprovalPolicy: unified.ApprovalAdmin,
PlannedAt: now, ExpiresAt: now.Add(4 * time.Hour), PlanHash: "sha256:resource-presentation",
},
}
if err := store.RecordActionAudit(record); err != nil {
t.Fatal(err)
}
listRec := httptest.NewRecorder()
h.HandleListActions(listRec, httptest.NewRequest(http.MethodGet, "/api/actions?view=pending", nil))
var inbox actionInboxResponse
if err := json.Unmarshal(listRec.Body.Bytes(), &inbox); listRec.Code != http.StatusOK || err != nil {
t.Fatalf("list status=%d body=%s err=%v", listRec.Code, listRec.Body.String(), err)
}
if len(inbox.Actions) != 1 || inbox.Actions[0].Resource == nil || inbox.Actions[0].Resource.Name != "Checkout API" || inbox.Actions[0].Resource.ID != "vm:42" || inbox.Actions[0].Resource.Type != unified.ResourceTypeVM {
t.Fatalf("list resource projection=%#v", inbox.Actions)
}
detailRec := httptest.NewRecorder()
detailReq := httptest.NewRequest(http.MethodGet, "/api/actions/act-resource-presentation", nil)
detailReq.SetPathValue("id", record.ID)
h.HandleGetAction(detailRec, detailReq)
var detail actionDetailResponse
if err := json.Unmarshal(detailRec.Body.Bytes(), &detail); detailRec.Code != http.StatusOK || err != nil {
t.Fatalf("detail status=%d body=%s err=%v", detailRec.Code, detailRec.Body.String(), err)
}
if detail.Audit.Resource == nil || detail.Audit.Resource.Name != "Checkout API" || detail.Audit.Request.ResourceID != "vm:42" {
t.Fatalf("detail resource projection=%#v", detail.Audit)
}
}
func TestHandleGetActionAndInboxAreTenantScoped(t *testing.T) {
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
now := time.Now().UTC()
+49
View File
@@ -14795,6 +14795,55 @@ func TestContract_UnifiedActionAuditsJSONSnapshot(t *testing.T) {
assertJSONSnapshot(t, got, want)
}
func TestContract_ActionReadProjectionKeepsResourcePresentationOutsidePlanIdentity(t *testing.T) {
projection := actionAuditProjection{
ActionAuditRecord: unifiedresources.ActionAuditRecord{
ID: "action-resource-contract",
State: unifiedresources.ActionStatePending,
Request: unifiedresources.ActionRequest{
RequestID: "request-resource-contract",
ResourceID: "vm:42",
CapabilityName: "restart",
Reason: "Recover checkout",
RequestedBy: "pulse_patrol",
},
Plan: unifiedresources.ActionPlan{
ActionID: "action-resource-contract",
PlanHash: "sha256:stable-plan-identity",
},
},
Resource: &actionResourcePresentation{
ID: "vm:42",
Name: "Checkout API",
Type: unifiedresources.ResourceTypeVM,
},
}
encoded, err := json.Marshal(projection)
if err != nil {
t.Fatalf("marshal action read projection: %v", err)
}
var wire map[string]any
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatalf("decode action read projection: %v", err)
}
resource, ok := wire["resource"].(map[string]any)
if !ok || resource["id"] != "vm:42" || resource["name"] != "Checkout API" || resource["type"] != "vm" {
t.Fatalf("resource presentation = %#v", wire["resource"])
}
request, ok := wire["request"].(map[string]any)
if !ok {
t.Fatalf("request = %#v", wire["request"])
}
if _, exists := request["resourceName"]; exists {
t.Fatalf("resource presentation leaked into action request: %#v", request)
}
plan, ok := wire["plan"].(map[string]any)
if !ok || plan["planHash"] != "sha256:stable-plan-identity" {
t.Fatalf("plan identity changed in read projection: %#v", wire["plan"])
}
}
func TestContract_UnifiedActionLifecycleEventsJSONSnapshot(t *testing.T) {
now := time.Date(2026, 3, 18, 16, 0, 0, 0, time.UTC)
payload := struct {
+615 -107
View File
@@ -8,44 +8,173 @@ import { createAuthenticatedStorageState } from "./helpers";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = { authStorageStatePath: string };
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => use(authStorageStatePath),
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(__dirname, "..", "..", "tmp", "playwright-auth", `actions-inbox-${workerInfo.project.name}.json`);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try { await use(storageStatePath); } finally { fs.rmSync(storageStatePath, { force: true }); }
}, { scope: "worker" }],
storageState: async ({ authStorageStatePath }, use) =>
use(authStorageStatePath),
authStorageStatePath: [
async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
"..",
"..",
"tmp",
"playwright-auth",
`actions-inbox-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
},
{ scope: "worker" },
],
});
const scope = { orgId: "org-1", resourceId: "docker:container:edge", capabilityName: "restart" };
const requirement = { version: 1, floor: "admin", quorum: 1, disallowRequester: false };
const scope = {
orgId: "org-1",
resourceId: "docker:container:edge",
capabilityName: "restart",
};
const requirement = {
version: 1,
floor: "admin",
quorum: 1,
disallowRequester: false,
};
const action = {
id: "action-1", createdAt: "2026-07-12T00:00:00Z", updatedAt: "2026-07-12T00:01:00Z", state: "pending_approval", decisionRevision: 0,
request: { requestId: "request-1", resourceId: scope.resourceId, capabilityName: scope.capabilityName, reason: "Recover the edge proxy", requestedBy: "pulse_patrol", actor: { subjectId: "patrol", kind: "service", credentialId: "patrol", orgId: "org-1" } },
plan: { actionId: "action-1", requestId: "request-1", allowed: true, requiresApproval: true, approvalPolicy: "admin", approvalRequirement: requirement, rollbackAvailable: false, plannedAt: "2026-07-12T00:00:00Z", expiresAt: "2099-07-12T00:10:00Z", resourceVersion: "resource:sha256:one", policyVersion: "policy:sha256:one", planHash: "sha256:plan", policyDecision: { version: 1, status: "resolved", decisionId: "policy-decision:sha256:one", actionId: "action-1", scope, approvalRequirement: requirement, planningAllowed: true, requiresApproval: true, authorities: [{ kind: "capability_registry", sourceId: "capability-registry:restart", revision: "policy:sha256:one", status: "consulted", scope, approvalFloor: "admin", reasonCodes: ["capability_approval_admin", "capability_auto_low_risk"] }, { kind: "resource_operator_policy", sourceId: "resource-operator-policy:docker:container:edge", revision: "resource-policy:sha256:one", status: "consulted", scope, approvalFloor: "admin", reasonCodes: ["resource_capability_allowed", "resource_window_open"] }] } },
id: "action-1",
createdAt: "2026-07-12T00:00:00Z",
updatedAt: "2026-07-12T00:01:00Z",
state: "pending_approval",
decisionRevision: 0,
request: {
requestId: "request-1",
resourceId: scope.resourceId,
capabilityName: scope.capabilityName,
reason: "Recover the edge proxy",
requestedBy: "pulse_patrol",
actor: {
subjectId: "patrol",
kind: "service",
credentialId: "patrol",
orgId: "org-1",
},
},
resource: { id: scope.resourceId, name: "Edge proxy", type: "app-container" },
plan: {
actionId: "action-1",
requestId: "request-1",
allowed: true,
requiresApproval: true,
approvalPolicy: "admin",
approvalRequirement: requirement,
rollbackAvailable: false,
plannedAt: "2026-07-12T00:00:00Z",
expiresAt: "2099-07-12T00:10:00Z",
resourceVersion: "resource:sha256:one",
policyVersion: "policy:sha256:one",
planHash: "sha256:plan",
policyDecision: {
version: 1,
status: "resolved",
decisionId: "policy-decision:sha256:one",
actionId: "action-1",
scope,
approvalRequirement: requirement,
planningAllowed: true,
requiresApproval: true,
authorities: [
{
kind: "capability_registry",
sourceId: "capability-registry:restart",
revision: "policy:sha256:one",
status: "consulted",
scope,
approvalFloor: "admin",
reasonCodes: [
"capability_approval_admin",
"capability_auto_low_risk",
],
},
{
kind: "resource_operator_policy",
sourceId: "resource-operator-policy:docker:container:edge",
revision: "resource-policy:sha256:one",
status: "consulted",
scope,
approvalFloor: "admin",
reasonCodes: ["resource_capability_allowed", "resource_window_open"],
},
],
},
},
verificationOutcome: { status: "unknown" },
};
type CanonicalResultFixture = {
execution: { status: string; reasonCode?: string };
verification: { status: string; evidenceClass: string; reasonCode?: string; evidence?: unknown[] };
verification: {
status: string;
evidenceClass: string;
reasonCode?: string;
evidence?: unknown[];
};
compensation: { support: string; status: string };
};
const assertCanonicalResultFixture = (truth: CanonicalResultFixture): void => {
if (truth.execution.status !== "succeeded" && !truth.execution.reasonCode) throw new Error("non-success execution fixture requires reasonCode");
if (truth.verification.status === "inconclusive" && !truth.verification.reasonCode) throw new Error("inconclusive verification fixture requires reasonCode");
if (truth.execution.status !== "succeeded" && !truth.execution.reasonCode)
throw new Error("non-success execution fixture requires reasonCode");
if (
truth.verification.status === "inconclusive" &&
!truth.verification.reasonCode
)
throw new Error("inconclusive verification fixture requires reasonCode");
const evidenceCount = truth.verification.evidence?.length ?? 0;
const conclusive = truth.verification.status === "confirmed" || truth.verification.status === "contradicted";
if (conclusive && (truth.verification.evidenceClass === "none" || evidenceCount === 0)) throw new Error("conclusive verification fixture requires sourced evidence");
if (truth.verification.evidenceClass === "none" && evidenceCount !== 0) throw new Error("no-source verification fixture cannot carry evidence");
if (truth.verification.evidenceClass !== "none" && evidenceCount === 0) throw new Error("declared evidence source fixture requires evidence");
if (truth.verification.evidenceClass === "independent") throw new Error("APT browser fixtures cannot claim tier-6 independent evidence");
if (truth.verification.status === "not_attempted" && (truth.verification.evidenceClass !== "none" || evidenceCount !== 0)) throw new Error("not-attempted verification fixture cannot carry evidence");
if (truth.compensation.support !== "unavailable" || truth.compensation.status !== "not_available") throw new Error("APT fixture cannot claim rollback or compensation");
const conclusive =
truth.verification.status === "confirmed" ||
truth.verification.status === "contradicted";
if (
conclusive &&
(truth.verification.evidenceClass === "none" || evidenceCount === 0)
)
throw new Error(
"conclusive verification fixture requires sourced evidence",
);
if (truth.verification.evidenceClass === "none" && evidenceCount !== 0)
throw new Error("no-source verification fixture cannot carry evidence");
if (truth.verification.evidenceClass !== "none" && evidenceCount === 0)
throw new Error("declared evidence source fixture requires evidence");
if (truth.verification.evidenceClass === "independent")
throw new Error(
"APT browser fixtures cannot claim tier-6 independent evidence",
);
if (
truth.verification.status === "not_attempted" &&
(truth.verification.evidenceClass !== "none" || evidenceCount !== 0)
)
throw new Error("not-attempted verification fixture cannot carry evidence");
if (
truth.compensation.support !== "unavailable" ||
truth.compensation.status !== "not_available"
)
throw new Error("APT fixture cannot claim rollback or compensation");
};
const aptAction = ({ id, capabilityName, state = "completed", summary, execution = "succeeded", verification = "confirmed", evidenceClass, verificationReasonCode, elevated = true, params = {} }: {
const aptAction = ({
id,
capabilityName,
state = "completed",
summary,
execution = "succeeded",
verification = "confirmed",
evidenceClass,
verificationReasonCode,
elevated = true,
params = {},
}: {
id: string;
capabilityName: "install_os_updates" | "clean_package_cache";
state?: string;
@@ -57,173 +186,552 @@ const aptAction = ({ id, capabilityName, state = "completed", summary, execution
elevated?: boolean;
params?: Record<string, unknown>;
}) => {
const aptScope = { orgId: "org-1", resourceId: "proxmox:node:pve-1", capabilityName };
const aptScope = {
orgId: "org-1",
resourceId: "proxmox:node:pve-1",
capabilityName,
};
const requiresApproval = state === "pending_approval";
const executionReasonCode = execution === "inconclusive" ? "possible_partial_effect" : execution === "failed" ? "execution_failed" : execution === "not_run" ? "preflight_refused" : undefined;
const resolvedEvidenceClass = evidenceClass ?? (verification === "confirmed" || verification === "contradicted" ? "agent_attested" : "none");
const resolvedVerificationReason = verificationReasonCode ?? (verification === "inconclusive" ? "agent_readback_inconclusive" : undefined);
const evidenceEnvelope = { version: 1, id: `${id}-evidence`, observerId: "agent:pve-1", observerKind: "unified_agent", observerTrustDomain: "agent:pve-1", executorTrustDomain: "agent:pve-1", method: "typed_read_after_write", subjectId: aptScope.resourceId, observedAt: "2026-07-12T10:01:00Z", receivedAt: "2026-07-12T10:05:00Z", digest: "" };
const evidence = resolvedEvidenceClass === "none" ? undefined : [{ ...evidenceEnvelope, digest: `sha256:${createHash("sha256").update(JSON.stringify(evidenceEnvelope)).digest("hex")}` }];
const actionResultV2 = summary ? {
version: 2 as const,
execution: { status: execution, ...(executionReasonCode ? { reasonCode: executionReasonCode } : {}), summary },
verification: { status: verification, evidenceClass: resolvedEvidenceClass, ...(resolvedVerificationReason ? { reasonCode: resolvedVerificationReason } : {}), summary: verification === "confirmed" ? "The executing agent observed the canonical postcondition." : "The canonical postcondition was not confirmed.", ...(evidence ? { evidence } : {}) },
compensation: { support: "unavailable", status: "not_available", summary: "No rollback is available for this typed workflow." },
} : undefined;
const executionReasonCode =
execution === "inconclusive"
? "possible_partial_effect"
: execution === "failed"
? "execution_failed"
: execution === "not_run"
? "preflight_refused"
: undefined;
const resolvedEvidenceClass =
evidenceClass ??
(verification === "confirmed" || verification === "contradicted"
? "agent_attested"
: "none");
const resolvedVerificationReason =
verificationReasonCode ??
(verification === "inconclusive"
? "agent_readback_inconclusive"
: undefined);
const evidenceEnvelope = {
version: 1,
id: `${id}-evidence`,
observerId: "agent:pve-1",
observerKind: "unified_agent",
observerTrustDomain: "agent:pve-1",
executorTrustDomain: "agent:pve-1",
method: "typed_read_after_write",
subjectId: aptScope.resourceId,
observedAt: "2026-07-12T10:01:00Z",
receivedAt: "2026-07-12T10:05:00Z",
digest: "",
};
const evidence =
resolvedEvidenceClass === "none"
? undefined
: [
{
...evidenceEnvelope,
digest: `sha256:${createHash("sha256").update(JSON.stringify(evidenceEnvelope)).digest("hex")}`,
},
];
const actionResultV2 = summary
? {
version: 2 as const,
execution: {
status: execution,
...(executionReasonCode ? { reasonCode: executionReasonCode } : {}),
summary,
},
verification: {
status: verification,
evidenceClass: resolvedEvidenceClass,
...(resolvedVerificationReason
? { reasonCode: resolvedVerificationReason }
: {}),
summary:
verification === "confirmed"
? "The executing agent observed the canonical postcondition."
: "The canonical postcondition was not confirmed.",
...(evidence ? { evidence } : {}),
},
compensation: {
support: "unavailable",
status: "not_available",
summary: "No rollback is available for this typed workflow.",
},
}
: undefined;
if (actionResultV2) assertCanonicalResultFixture(actionResultV2);
return {
id, createdAt: "2026-07-12T10:00:00Z", updatedAt: "2026-07-12T10:05:00Z", state, decisionRevision: 0,
request: { requestId: `${id}-request`, resourceId: aptScope.resourceId, capabilityName, params, reason: capabilityName === "install_os_updates" ? "Resolve the current operating system update finding" : "Relieve package data pressure reported by Patrol", requestedBy: "pulse_patrol" },
plan: { actionId: id, requestId: `${id}-request`, allowed: true, requiresApproval, approvalPolicy: requiresApproval ? "admin" : "none", approvalRequirement: { ...requirement, floor: requiresApproval ? "admin" : "none" }, rollbackAvailable: false, plannedAt: "2026-07-12T10:00:00Z", expiresAt: "2099-07-12T10:10:00Z", resourceVersion: "resource:sha256:apt", policyVersion: "policy:sha256:apt", planHash: `sha256:${id}`, policyDecision: { version: 1, status: "resolved", decisionId: `policy-decision:${id}`, actionId: id, scope: aptScope, approvalRequirement: { ...requirement, floor: requiresApproval ? "admin" : "none" }, planningAllowed: true, requiresApproval, authorities: [{ kind: "capability_registry", sourceId: `capability-registry:${capabilityName}`, revision: "policy:sha256:apt", status: "consulted", scope: aptScope, approvalFloor: requiresApproval ? "admin" : "none", reasonCodes: [requiresApproval ? "capability_approval_admin" : "capability_approval_none", elevated ? "capability_auto_elevated" : "capability_auto_low_risk"] }] } },
...(actionResultV2 ? { result: { success: execution === "succeeded", actionResultV2 } } : {}),
verificationOutcome: { status: verification === "confirmed" ? "verified" : verification === "contradicted" ? "failed" : verification === "inconclusive" ? "unverified" : "unknown" },
id,
createdAt: "2026-07-12T10:00:00Z",
updatedAt: "2026-07-12T10:05:00Z",
state,
decisionRevision: 0,
request: {
requestId: `${id}-request`,
resourceId: aptScope.resourceId,
capabilityName,
params,
reason:
capabilityName === "install_os_updates"
? "Resolve the current operating system update finding"
: "Relieve package data pressure reported by Patrol",
requestedBy: "pulse_patrol",
},
plan: {
actionId: id,
requestId: `${id}-request`,
allowed: true,
requiresApproval,
approvalPolicy: requiresApproval ? "admin" : "none",
approvalRequirement: {
...requirement,
floor: requiresApproval ? "admin" : "none",
},
rollbackAvailable: false,
plannedAt: "2026-07-12T10:00:00Z",
expiresAt: "2099-07-12T10:10:00Z",
resourceVersion: "resource:sha256:apt",
policyVersion: "policy:sha256:apt",
planHash: `sha256:${id}`,
policyDecision: {
version: 1,
status: "resolved",
decisionId: `policy-decision:${id}`,
actionId: id,
scope: aptScope,
approvalRequirement: {
...requirement,
floor: requiresApproval ? "admin" : "none",
},
planningAllowed: true,
requiresApproval,
authorities: [
{
kind: "capability_registry",
sourceId: `capability-registry:${capabilityName}`,
revision: "policy:sha256:apt",
status: "consulted",
scope: aptScope,
approvalFloor: requiresApproval ? "admin" : "none",
reasonCodes: [
requiresApproval
? "capability_approval_admin"
: "capability_approval_none",
elevated
? "capability_auto_elevated"
: "capability_auto_low_risk",
],
},
],
},
},
...(actionResultV2
? { result: { success: execution === "succeeded", actionResultV2 } }
: {}),
verificationOutcome: {
status:
verification === "confirmed"
? "verified"
: verification === "contradicted"
? "failed"
: verification === "inconclusive"
? "unverified"
: "unknown",
},
};
};
const routeActionFixtures = async (page: Page, pending: ReturnType<typeof aptAction>[], settled: ReturnType<typeof aptAction>[]) => {
const routeActionFixtures = async (
page: Page,
pending: ReturnType<typeof aptAction>[],
settled: ReturnType<typeof aptAction>[],
) => {
const all = [...pending, ...settled];
await page.route("**/api/actions?*", async (route) => {
const view = new URL(route.request().url()).searchParams.get("view");
const actions = view === "pending" ? pending : settled;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ view, actions, count: actions.length }) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ view, actions, count: actions.length }),
});
});
await page.route("**/api/actions/*", async (route) => {
const id = decodeURIComponent(new URL(route.request().url()).pathname.split("/").pop() || "");
const id = decodeURIComponent(
new URL(route.request().url()).pathname.split("/").pop() || "",
);
const audit = all.find((candidate) => candidate.id === id);
await route.fulfill({ status: audit ? 200 : 404, contentType: "application/json", body: JSON.stringify(audit ? { audit, events: [], attempt: { id: `${id}-attempt`, actionId: id, state: "receipt_recorded", createdAt: audit.createdAt, updatedAt: audit.updatedAt, dispatchCount: 1 }, receipt: { attemptId: `${id}-attempt`, actionId: id, transportRequestId: `${id}-transport`, receivedAt: "2026-07-12T10:05:00Z" } } : { error: "not found" }) });
await route.fulfill({
status: audit ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(
audit
? {
audit,
events: [],
attempt: {
id: `${id}-attempt`,
actionId: id,
state: "receipt_recorded",
createdAt: audit.createdAt,
updatedAt: audit.updatedAt,
dispatchCount: 1,
},
receipt: {
attemptId: `${id}-attempt`,
actionId: id,
transportRequestId: `${id}-transport`,
receivedAt: "2026-07-12T10:05:00Z",
},
}
: { error: "not found" },
),
});
});
};
test("Actions inbox exposes the canonical decision packet and durable calm history", async ({ page }, testInfo) => {
test("Actions inbox exposes the canonical decision packet and durable calm history", async ({
page,
}, testInfo) => {
await page.route("**/api/actions?*", async (route) => {
const view = new URL(route.request().url()).searchParams.get("view");
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(view === "pending" ? { view, actions: [action], count: 1 } : { view, actions: [], count: 0 }) });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
view === "pending"
? { view, actions: [action], count: 1 }
: { view, actions: [], count: 0 },
),
});
});
await page.route("**/api/actions/action-1", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ audit: action, events: [] }) }));
await page.route("**/api/actions/action-1", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ audit: action, events: [] }),
}),
);
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Actions" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Open", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(
page.getByRole("tab", { name: "Open", exact: true }),
).toHaveAttribute("aria-selected", "true");
const openActions = page.getByRole("list", { name: "Open actions" });
await expect(openActions).toBeVisible();
await expect(openActions.getByText("Approval required")).toBeVisible();
await expect(openActions.getByText("edge", { exact: true })).toBeVisible();
await expect(openActions.getByText("Docker container", { exact: true })).toBeVisible();
await page.getByRole("button", { name: /Restart.*docker:container:edge/ }).click();
await expect(
openActions.getByText("Edge proxy", { exact: true }),
).toBeVisible();
await expect(
openActions.getByText("App container", { exact: true }),
).toBeVisible();
await page
.getByRole("button", { name: /Restart.*docker:container:edge/ })
.click();
await expect(page.getByRole("dialog", { name: "Restart" })).toBeVisible();
await expect(
page.getByText(
"2 authorities checked at planning. Pulse checks current authority again before execution.",
),
).toBeVisible();
await expect(page.getByText("Capability safety policy")).not.toBeVisible();
await page.getByText("Policy evidence", { exact: true }).click();
await expect(page.getByText("Capability safety policy")).toBeVisible();
await expect(page.getByText("Policy for this resource")).toBeVisible();
await expect(page.getByText("This records planning-time policy evidence. Pulse checks current authority again before execution.")).toBeVisible();
await testInfo.attach("canonical-action-decision-packet", { body: await page.screenshot(), contentType: "image/png" });
await expect(
page.getByText(
"This is the immutable planning-time policy record. It does not authorize execution by itself.",
),
).toBeVisible();
await testInfo.attach("canonical-action-decision-packet", {
body: await page.screenshot(),
contentType: "image/png",
});
await page.getByRole("button", { name: "Close action review" }).click();
await page.getByRole("tab", { name: "History" }).click();
await expect(page.getByTestId("actions-calm-state")).toContainText("No action history yet");
await expect(page.getByTestId("actions-calm-state")).toContainText(
"No action history yet",
);
});
test("Actions inbox gives a recoverable error without presenting stale authority", async ({ page }, testInfo) => {
await page.route("**/api/actions?*", (route) => route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "action store unavailable" }) }));
test("Actions inbox gives a recoverable error without presenting stale authority", async ({
page,
}, testInfo) => {
await page.route("**/api/actions?*", (route) =>
route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: "action store unavailable" }),
}),
);
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("alert")).toContainText("Actions could not be loaded");
await expect(page.getByRole("alert")).toContainText(
"Actions could not be loaded",
);
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
await expect(page.getByTestId("actions-calm-state")).toHaveCount(0);
await testInfo.attach("actions-recoverable-error", { body: await page.screenshot(), contentType: "image/png" });
await testInfo.attach("actions-recoverable-error", {
body: await page.screenshot(),
contentType: "image/png",
});
});
test("APT plans expose exact empty authority and elevated versus low-risk posture", async ({ page }, testInfo) => {
const update = aptAction({ id: "apt-update-pending", capabilityName: "install_os_updates", state: "pending_approval", elevated: true });
const cleanup = aptAction({ id: "apt-cleanup-planned", capabilityName: "clean_package_cache", state: "planned", elevated: false });
test("APT plans expose exact empty authority and elevated versus low-risk posture", async ({
page,
}, testInfo) => {
const update = aptAction({
id: "apt-update-pending",
capabilityName: "install_os_updates",
state: "pending_approval",
elevated: true,
});
const cleanup = aptAction({
id: "apt-cleanup-planned",
capabilityName: "clean_package_cache",
state: "planned",
elevated: false,
});
await routeActionFixtures(page, [update, cleanup], []);
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /Install operating system updates.*proxmox:node:pve-1/ }).click();
const updateDialog = page.getByRole("dialog", { name: "Install operating system updates" });
await page
.getByRole("button", {
name: /Install operating system updates.*proxmox:node:pve-1/,
})
.click();
const updateDialog = page.getByRole("dialog", {
name: "Install operating system updates",
});
await expect(updateDialog.getByText("Elevated change")).toBeVisible();
await expect(updateDialog.getByText(/accepts no command, path, package selection, removal choice, or reboot request/)).toBeVisible();
await expect(updateDialog.getByText(/cannot remove packages or reboot the host/)).toBeVisible();
await expect(updateDialog.getByRole("button", { name: "Approve" })).toBeVisible();
await expect(updateDialog.getByRole("button", { name: /reboot/i })).toHaveCount(0);
await expect(
updateDialog.getByText(
/accepts no command, path, package selection, removal choice, or reboot request/,
),
).toBeVisible();
await expect(
updateDialog.getByText(/cannot remove packages or reboot the host/),
).toBeVisible();
await expect(
updateDialog.getByRole("button", { name: "Approve" }),
).toBeVisible();
await expect(
updateDialog.getByRole("button", { name: /reboot/i }),
).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: /Clear downloaded package data.*proxmox:node:pve-1/ }).click();
const cleanupDialog = page.getByRole("dialog", { name: "Clear downloaded package data" });
await expect(cleanupDialog.getByText("Low-risk automation eligible")).toBeVisible();
await expect(cleanupDialog.getByText(/clear only downloaded package data/)).toBeVisible();
await expect(cleanupDialog.getByRole("button", { name: "Run action" })).toBeVisible();
await testInfo.attach("apt-empty-authority-and-policy-posture", { body: await page.screenshot(), contentType: "image/png" });
await page
.getByRole("button", {
name: /Clear downloaded package data.*proxmox:node:pve-1/,
})
.click();
const cleanupDialog = page.getByRole("dialog", {
name: "Clear downloaded package data",
});
await expect(
cleanupDialog.getByText("Low-risk automation eligible"),
).toBeVisible();
await expect(
cleanupDialog.getByText(/clear only downloaded package data/),
).toBeVisible();
await expect(
cleanupDialog.getByRole("button", { name: "Run action" }),
).toBeVisible();
await testInfo.attach("apt-empty-authority-and-policy-posture", {
body: await page.screenshot(),
contentType: "image/png",
});
});
test("APT history keeps update execution verification recovery and delayed receipt truth separate", async ({ page }, testInfo) => {
const confirmed = aptAction({ id: "apt-update-confirmed", capabilityName: "install_os_updates", summary: "APT package updates: phase=complete; 6 pending before, 0 pending after; package manager health: healthy; recovery required: false; reboot required: true" });
const partial = aptAction({ id: "apt-update-partial", capabilityName: "install_os_updates", summary: "APT package updates: phase=install; 6 pending before, 3 pending after; package manager health: unhealthy; recovery required: true; reboot required: false", execution: "inconclusive", verification: "contradicted" });
const delayed = aptAction({ id: "apt-update-delayed", capabilityName: "install_os_updates", summary: "APT package updates: phase=verify; 4 pending before, 4 pending after; package manager health: unknown; recovery required: false; reboot required: false", execution: "inconclusive", verification: "inconclusive", evidenceClass: "none", verificationReasonCode: "package_manager_health_unknown" });
test("APT history keeps update execution verification recovery and delayed receipt truth separate", async ({
page,
}, testInfo) => {
const confirmed = aptAction({
id: "apt-update-confirmed",
capabilityName: "install_os_updates",
summary:
"APT package updates: phase=complete; 6 pending before, 0 pending after; package manager health: healthy; recovery required: false; reboot required: true",
});
const partial = aptAction({
id: "apt-update-partial",
capabilityName: "install_os_updates",
summary:
"APT package updates: phase=install; 6 pending before, 3 pending after; package manager health: unhealthy; recovery required: true; reboot required: false",
execution: "inconclusive",
verification: "contradicted",
});
const delayed = aptAction({
id: "apt-update-delayed",
capabilityName: "install_os_updates",
summary:
"APT package updates: phase=verify; 4 pending before, 4 pending after; package manager health: unknown; recovery required: false; reboot required: false",
execution: "inconclusive",
verification: "inconclusive",
evidenceClass: "none",
verificationReasonCode: "package_manager_health_unknown",
});
await routeActionFixtures(page, [], [confirmed, partial, delayed]);
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await page.getByRole("tab", { name: "History" }).click();
await page.getByRole("button", { name: /Install operating system updates.*proxmox:node:pve-1/ }).first().click();
let dialog = page.getByRole("dialog", { name: "Install operating system updates" });
await page
.getByRole("button", {
name: /Install operating system updates.*proxmox:node:pve-1/,
})
.first()
.click();
let dialog = page.getByRole("dialog", {
name: "Install operating system updates",
});
await expect(dialog.getByText("Confirmed by executing agent")).toBeVisible();
await expect(dialog.getByText("Source: Executing agent")).toBeVisible();
await expect(dialog.getByText("Yes — fact only; no reboot was authorized")).toBeVisible();
await expect(
dialog.getByText("Yes — fact only; no reboot was authorized"),
).toBeVisible();
await expect(dialog.getByTestId("action-execution-truth")).toBeVisible();
await expect(dialog.getByTestId("action-verification-truth")).toBeVisible();
await expect(dialog.getByTestId("action-compensation-truth")).toBeVisible();
await expect(dialog.getByTestId("action-delivery-truth")).toHaveCount(1);
await expect(dialog.getByText("Agent observation")).toBeVisible();
await expect(dialog.getByText("Receipt recorded by Pulse")).toBeVisible();
await expect(dialog.getByText("Legacy check passed (source unclassified)")).toHaveCount(0);
await expect(
dialog.getByText("Legacy check passed (source unclassified)"),
).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: /Install operating system updates.*proxmox:node:pve-1/ }).nth(1).click();
dialog = page.getByRole("dialog", { name: "Install operating system updates" });
await expect(dialog.getByTestId("action-execution-truth")).toContainText("Inconclusive");
await expect(dialog.getByTestId("action-verification-truth")).toContainText("Outcome contradicted");
await page
.getByRole("button", {
name: /Install operating system updates.*proxmox:node:pve-1/,
})
.nth(1)
.click();
dialog = page.getByRole("dialog", {
name: "Install operating system updates",
});
await expect(dialog.getByTestId("action-execution-truth")).toContainText(
"Inconclusive",
);
await expect(dialog.getByTestId("action-verification-truth")).toContainText(
"Outcome contradicted",
);
await expect(dialog.getByText("Install updates")).toBeVisible();
await expect(dialog.getByText("Known unhealthy")).toBeVisible();
await expect(dialog.getByText("Do not retry. Repair the host update system")).toBeVisible();
await expect(
dialog.getByText("Do not retry. Repair the host update system"),
).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: /Install operating system updates.*proxmox:node:pve-1/ }).nth(2).click();
dialog = page.getByRole("dialog", { name: "Install operating system updates" });
await page
.getByRole("button", {
name: /Install operating system updates.*proxmox:node:pve-1/,
})
.nth(2)
.click();
dialog = page.getByRole("dialog", {
name: "Install operating system updates",
});
await expect(dialog.getByText("Unknown", { exact: true })).toBeVisible();
await expect(dialog.getByText("Do not retry automatically. Run a fresh host scan")).toBeVisible();
await testInfo.attach("apt-update-truth-and-receipt-recovery", { body: await page.screenshot(), contentType: "image/png" });
await expect(
dialog.getByText("Do not retry automatically. Run a fresh host scan"),
).toBeVisible();
await testInfo.attach("apt-update-truth-and-receipt-recovery", {
body: await page.screenshot(),
contentType: "image/png",
});
});
test("APT cleanup history shows measured bytes and irreversible rescan recovery", async ({ page }, testInfo) => {
const confirmed = aptAction({ id: "apt-cleanup-confirmed", capabilityName: "clean_package_cache", elevated: false, summary: "APT package cache: phase=complete; 104857600 bytes before, 52428800 bytes after, 52428800 bytes reclaimed; rollback available: false; rescan required: false" });
const failed = aptAction({ id: "apt-cleanup-failed", capabilityName: "clean_package_cache", elevated: false, summary: "APT package cache: phase=clean; 104857600 bytes before, 52428800 bytes after, 52428800 bytes reclaimed; rollback available: false; rescan required: true", execution: "failed", verification: "inconclusive" });
test("APT cleanup history shows measured bytes and irreversible rescan recovery", async ({
page,
}, testInfo) => {
const confirmed = aptAction({
id: "apt-cleanup-confirmed",
capabilityName: "clean_package_cache",
elevated: false,
summary:
"APT package cache: phase=complete; 104857600 bytes before, 52428800 bytes after, 52428800 bytes reclaimed; rollback available: false; rescan required: false",
});
const failed = aptAction({
id: "apt-cleanup-failed",
capabilityName: "clean_package_cache",
elevated: false,
summary:
"APT package cache: phase=clean; 104857600 bytes before, 52428800 bytes after, 52428800 bytes reclaimed; rollback available: false; rescan required: true",
execution: "failed",
verification: "inconclusive",
});
await routeActionFixtures(page, [], [confirmed, failed]);
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await page.getByRole("tab", { name: "History" }).click();
await page.getByRole("button", { name: /Clear downloaded package data.*proxmox:node:pve-1/ }).first().click();
let dialog = page.getByRole("dialog", { name: "Clear downloaded package data" });
await page
.getByRole("button", {
name: /Clear downloaded package data.*proxmox:node:pve-1/,
})
.first()
.click();
let dialog = page.getByRole("dialog", {
name: "Clear downloaded package data",
});
await expect(dialog.getByText("100 MB")).toBeVisible();
await expect(dialog.getByText("50.0 MB")).toHaveCount(2);
await expect(dialog.getByText("Unavailable — cleanup is irreversible")).toBeVisible();
await expect(
dialog.getByText("Unavailable — cleanup is irreversible"),
).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: /Clear downloaded package data.*proxmox:node:pve-1/ }).nth(1).click();
await page
.getByRole("button", {
name: /Clear downloaded package data.*proxmox:node:pve-1/,
})
.nth(1)
.click();
dialog = page.getByRole("dialog", { name: "Clear downloaded package data" });
await expect(dialog.getByTestId("action-execution-truth")).toContainText("Failed");
await expect(dialog.getByTestId("action-verification-truth")).toContainText("Outcome inconclusive");
await expect(dialog.getByTestId("action-execution-truth")).toContainText(
"Failed",
);
await expect(dialog.getByTestId("action-verification-truth")).toContainText(
"Outcome inconclusive",
);
await expect(dialog.getByText("Fresh rescan required")).toBeVisible();
await expect(dialog.getByText("Do not retry automatically. Run a fresh scan")).toBeVisible();
await testInfo.attach("apt-cleanup-measurement-and-recovery", { body: await page.screenshot(), contentType: "image/png" });
await expect(
dialog.getByText("Do not retry automatically. Run a fresh scan"),
).toBeVisible();
await testInfo.attach("apt-cleanup-measurement-and-recovery", {
body: await page.screenshot(),
contentType: "image/png",
});
});
test("APT action review remains keyboard reachable and actionable at a phone viewport", async ({ page }, testInfo) => {
test("APT action review remains keyboard reachable and actionable at a phone viewport", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
const partial = aptAction({ id: "apt-phone-partial", capabilityName: "install_os_updates", summary: "APT package updates: phase=install; 6 pending before, 3 pending after; package manager health: unhealthy; recovery required: true; reboot required: false", execution: "inconclusive", verification: "contradicted" });
const partial = aptAction({
id: "apt-phone-partial",
capabilityName: "install_os_updates",
summary:
"APT package updates: phase=install; 6 pending before, 3 pending after; package manager health: unhealthy; recovery required: true; reboot required: false",
execution: "inconclusive",
verification: "contradicted",
});
await routeActionFixtures(page, [], [partial]);
await page.goto("/actions", { waitUntil: "domcontentloaded" });
await page.getByRole("tab", { name: "History" }).click();
await page.getByRole("button", { name: /Install operating system updates/ }).focus();
await page
.getByRole("button", { name: /Install operating system updates/ })
.focus();
await page.keyboard.press("Enter");
const dialog = page.getByRole("dialog", { name: "Install operating system updates" });
const dialog = page.getByRole("dialog", {
name: "Install operating system updates",
});
await expect(dialog).toBeVisible();
await expect(dialog.getByText("What to do next")).toBeVisible();
await expect(dialog.getByRole("button", { name: "Close action review" })).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Close action review" }),
).toBeVisible();
const box = await dialog.boundingBox();
expect(box?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((box?.x ?? 0) + (box?.width ?? 999)).toBeLessThanOrEqual(390);
await testInfo.attach("apt-phone-action-review", { body: await page.screenshot(), contentType: "image/png" });
await testInfo.attach("apt-phone-action-review", {
body: await page.screenshot(),
contentType: "image/png",
});
});