mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Polish Actions layout and review details
This commit is contained in:
@@ -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();
|
||||
|
||||
+10
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user