mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Offer guarded recovery for aged action receipt waits
Expose the existing admin audit escape hatch in the shared Actions review with direct-state confirmation, fresh re-read, and inconclusive outcome presentation. Keep server authorization and transport truth unchanged. Record affected canonical contracts and fresh desktop/phone browser verification in the same candidate. Change-source: pulse-maintainer
This commit is contained in:
@@ -9538,6 +9538,16 @@ inconclusive shape as automatic terminalization under reason code
|
||||
`operator_force_failed`, attributed to the authenticated operator, with the
|
||||
operator's justification preserved verbatim in the terminal audit event.
|
||||
|
||||
The browser's `ResourceActionsAPI.forceFailAction` is a thin client for that
|
||||
existing route. It sends a non-empty operator reason and consumes the returned
|
||||
action detail; it carries no plan hash or new dispatch authority. The Actions
|
||||
review may offer it only after an aged `receipt_pending` presentation and a
|
||||
fresh detail read, but those are safeguards rather than authorization: the API
|
||||
must still enforce the local admin, `settings:write`, action-execute and
|
||||
`executing`-only checks, including the already-final conflict on a race. A
|
||||
successful response says that the audit wait was closed inconclusively, not
|
||||
that the operation failed, stopped or was cancelled.
|
||||
|
||||
Typed Docker / Podman container start, stop, and restart operations extend that
|
||||
same durable boundary rather than creating a provider-local action protocol.
|
||||
The agent wire request is closed and binds the exact action, dispatch attempt,
|
||||
|
||||
@@ -6770,6 +6770,14 @@ action path or verification card when `ActionResultV2` is present. Read-only
|
||||
sessions keep the review packet inspectable but must not render approve, reject,
|
||||
or run controls, while settled historical records must not be mislabeled as
|
||||
expired actionable reviews.
|
||||
For an aged, receipt-pending execution, the same responsive review dialog may
|
||||
expose an audit-recovery disclosure to an eligible local administrator. It must
|
||||
first instruct the operator to check the resource's actual state outside the
|
||||
action record, then require a written reason and an explicit acknowledgement
|
||||
before a fresh action read and guarded force-fail call. Desktop and phone
|
||||
layouts keep this confirmation visible without implying a retry, cancellation
|
||||
or failed mutation. Read-only and settled records show no recovery control;
|
||||
permission hints in the client never replace the server's authority check.
|
||||
Its action controls are also plan-identity-bound: a missing reviewed `planHash`
|
||||
renders explicit replan guidance and hides approve, reject, and run controls,
|
||||
while an actionable record sends the exact displayed hash on every mutation.
|
||||
|
||||
@@ -2957,6 +2957,16 @@ creating a duplicate. Package cleanup is explicitly irreversible; an
|
||||
inconclusive or failed cleanup requires a fresh scan and never presents fake
|
||||
rollback or automatic retry.
|
||||
|
||||
An executing action whose durable dispatch attempt remains `receipt_pending`
|
||||
for more than the server's one-hour reconciliation window is still an unknown
|
||||
operational outcome. The Actions review may offer the already-authorized local
|
||||
audit force-fail recourse only after direct resource-state checking, a reason,
|
||||
acknowledgement and a fresh detail read. This terminalizes the audit row
|
||||
inconclusively; it does not stop or retry the agent operation. The shared
|
||||
decision packet and Actions history therefore label an operator-force-failed
|
||||
record as outcome unknown, not as a proved failed mutation. Terminal records
|
||||
and read-only sessions retain their review evidence but no recovery control.
|
||||
|
||||
Task 09 owns shared APT telemetry freshness in
|
||||
`internal/unifiedresources/host_apt_telemetry.go`. Capability construction,
|
||||
finding admission, and execution readiness consume the same dual-timestamp and
|
||||
|
||||
@@ -1,73 +1,62 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "2606c9d36a0b91c40fbc234fecad2167b7979fec",
|
||||
"verified_at": "2026-09-28T22:20:33Z",
|
||||
"base_sha": "8619ab2ec8f5d239d045dec1f1f719ff3c5d687e",
|
||||
"verified_at": "2026-09-29T06:55:46Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/App.tsx",
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts",
|
||||
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
|
||||
"frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx",
|
||||
"frontend-modern/src/components/shared/commandPaletteModel.ts",
|
||||
"frontend-modern/src/components/shared/useCommandPaletteState.ts",
|
||||
"frontend-modern/src/api/resourceActions.ts",
|
||||
"frontend-modern/src/features/actions/ActionDecisionPacket.tsx",
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx",
|
||||
"frontend-modern/src/features/actions/actionPresentation.ts",
|
||||
"frontend-modern/src/hooks/useKeyboardShortcuts.ts",
|
||||
"frontend-modern/src/i18n/messages.de.ts",
|
||||
"frontend-modern/src/i18n/messages.es.ts",
|
||||
"frontend-modern/src/i18n/messages.ts",
|
||||
"frontend-modern/src/pages/Actions.tsx",
|
||||
"frontend-modern/src/types/config.ts",
|
||||
"frontend-modern/src/utils/updatesPresentation.ts"
|
||||
"frontend-modern/src/pages/Actions.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/App.tsx": "a6ca67087cce917dc28914701262e6f631eb03621c24c8a773d5d025beb41215",
|
||||
"frontend-modern/src/AppLayout.tsx": "d1d8bd884c43a20c1dbbde2ddb9f3278eb885b02c21e4cc3fa3fa1089cd1d320",
|
||||
"frontend-modern/src/components/Settings/settingsHeaderMeta.ts": "d024df468000f183b2452d6b3fcb60fdce1b050107cd2e9d4ac586c4f13f2ddd",
|
||||
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "8d3bff1c977b9db02c807d03fd80d40dabfc18c6a59809358c52a938a9c97c88",
|
||||
"frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx": "a27ccd1bdccf31f1890779fc661ad4e896786594b99ff419160ce6c556fc7c96",
|
||||
"frontend-modern/src/components/shared/commandPaletteModel.ts": "69a75c17579239c407ba18820e9f4d47d8b8b0e587cdcc3bb154a684b2a17047",
|
||||
"frontend-modern/src/components/shared/useCommandPaletteState.ts": "95ff00c6e790f17fdd849e845b34e9047f655ec89ca279032ab764da283b4cd4",
|
||||
"frontend-modern/src/features/actions/actionPresentation.ts": "8e67a99e744cf7dbbc1fc53da1d9697af78a90d95779df3553ee8867c3c51496",
|
||||
"frontend-modern/src/hooks/useKeyboardShortcuts.ts": "f55d67c136d5d8e21ed220b33eac0512d5fae5b5aa835f60fa2570159c140d30",
|
||||
"frontend-modern/src/i18n/messages.de.ts": "e55a115865e8b2c9cf4352eecac1a2f8996f4e0f51b9c14ddb7cfbe20e5b2f40",
|
||||
"frontend-modern/src/i18n/messages.es.ts": "a348d63dd816e848cd75a0b5114b0f77c719d16117d9c86db719036fbe106af0",
|
||||
"frontend-modern/src/i18n/messages.ts": "728aa1cf4b42698e14f16593825249c6a190c43d4ef28e2d4e28756c7c1990db",
|
||||
"frontend-modern/src/pages/Actions.tsx": "88ab28e9333652e81770e570c140aa19f0b6d833be3b65fd41afd2e9614f60c5",
|
||||
"frontend-modern/src/types/config.ts": "49ad539bcefc2f87380b744c1da387401151e3f4af9047db8a85b1ce0b70322e",
|
||||
"frontend-modern/src/utils/updatesPresentation.ts": "4696f8426385aaf287ab610746ca278acc7e23bd377c9739fcfedcf635910634"
|
||||
"frontend-modern/src/api/resourceActions.ts": "60e65faa74d6fffd43f97dfa6ab2eff946c666893b5201217bd3e886312ac47b",
|
||||
"frontend-modern/src/features/actions/ActionDecisionPacket.tsx": "2839a0be877b906bbc717bae7ef829aaffc2e46de8242413617d078087799cb1",
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "ff35ee1912d45b3a0546bd31ed498ecf94f01a493c0a86b93252b0e5d7a529d0",
|
||||
"frontend-modern/src/features/actions/actionPresentation.ts": "723a40b5b41f8645c76f54e2c0ffac3ae60757b885ee0bee6b299dc97146ebc1",
|
||||
"frontend-modern/src/pages/Actions.tsx": "c43ae74844fb8d31b904fa273d4a35c0344084ef7b6e227e55d8924dfa7b248c"
|
||||
},
|
||||
"routes": [
|
||||
"/actions",
|
||||
"/patrol",
|
||||
"/settings/system-updates",
|
||||
"/preview/setup-complete?scenario=empty",
|
||||
"/preview/setup-complete?scenario=vmware-api-backed"
|
||||
"/action-recovery-proof.html (mock-backed shared /actions review component)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 375,
|
||||
"height": 812
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"AI off and AI on with mocked authenticated API responses: Actions empty Open tab, app-shell Patrol navigation, command palette and shortcuts at desktop and phone widths",
|
||||
"AI off: plain Actions empty-state copy; no Patrol navigation, palette command, shortcut help row or g r navigation",
|
||||
"AI on: Watch only guidance and Patrol entry points; g r reaches /patrol",
|
||||
"Empty setup completion preview at desktop and VMware API-backed setup completion preview at phone width show first-source guidance and credential disclosure",
|
||||
"System Updates settings route at desktop and phone widths labels the item Updates and retains the agent-update handoff to Infrastructure"
|
||||
"aged admin",
|
||||
"terminal inconclusive",
|
||||
"recent",
|
||||
"read-only",
|
||||
"settled",
|
||||
"non-admin",
|
||||
"stale re-read"
|
||||
],
|
||||
"interactions": [
|
||||
"Loaded /actions, opened and closed command palette and keyboard shortcuts, then pressed g r in both AI states at both widths",
|
||||
"Loaded both setup completion previews and checked connection choices, next-step copy and horizontal overflow",
|
||||
"Loaded /settings/system-updates at both widths and checked heading and copy"
|
||||
"open disclosure",
|
||||
"reason and acknowledgement gates",
|
||||
"force-fail post",
|
||||
"refresh outcome",
|
||||
"stale skip",
|
||||
"phone layout"
|
||||
],
|
||||
"command": "pulse-worker-browser web-composed-proof.cjs from assigned workspace root; offline Vite preview and Chromium 141.0.7390.37",
|
||||
"artifacts": "/var/lib/pulse-maintainer/worker-outputs/web-product-mc08uh4u/browser-composed-2606c9d36a",
|
||||
"notes": "The browser used mocked API responses and no live backend, paired agent or installed release. Mocked WebSocket reconnect warnings do not establish runtime connectivity. Screenshots of Actions, command palette, shortcut help, setup and Updates at both widths were inspected.",
|
||||
"verified_source_sha": "2606c9d36a0b91c40fbc234fecad2167b7979fec"
|
||||
"command": "pulse-worker-browser web-1891-proof.cjs from assigned workspace root; offline Vite API preview and Chromium 141.0.7390.37 / Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-desktop-before.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-desktop-after.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-mobile.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/browser-1891-mobile-dialog.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/web-1891-proof.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/action-recovery-proof.html",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-paaa_868/action-recovery-proof.tsx"
|
||||
],
|
||||
"notes": "Shared ActionReviewDialog exercised with mock-backed API/status at exact staged runtime content; no live backend, container outcome, installed release or reporter acceptance. Component tests cover Actions history state. Screenshots visually inspected after transition."
|
||||
}
|
||||
|
||||
@@ -27,4 +27,20 @@ describe('ResourceActionsAPI durable inbox', () => {
|
||||
body: JSON.stringify({ planHash: 'sha256:reviewed' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('records a justified operator audit override without a plan hash or resend', async () => {
|
||||
fetchJSON.mockResolvedValue({ actionId: 'action/one', state: 'failed' });
|
||||
await ResourceActionsAPI.forceFailAction(
|
||||
'action/one',
|
||||
' Checked the running container image. ',
|
||||
);
|
||||
expect(fetchJSON).toHaveBeenCalledWith('/api/actions/action%2Fone/force-fail', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: 'Checked the running container image.' }),
|
||||
});
|
||||
await expect(ResourceActionsAPI.forceFailAction('action/one', ' ')).rejects.toThrow(
|
||||
'Record what you verified directly',
|
||||
);
|
||||
expect(fetchJSON).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,4 +88,18 @@ export class ResourceActionsAPI {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static async forceFailAction(actionId: string, reason: string): Promise<ActionExecutionResponse> {
|
||||
const justification = reason.trim();
|
||||
if (!justification) {
|
||||
throw new Error('Record what you verified directly before closing the action audit.');
|
||||
}
|
||||
return apiFetchJSON<ActionExecutionResponse>(
|
||||
`/api/actions/${encodeURIComponent(actionId)}/force-fail`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: justification }),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,9 +356,11 @@ export const ActionDecisionPacket: Component<{
|
||||
<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.'}
|
||||
: props.audit.result?.actionResultV2?.execution.reasonCode === 'operator_force_failed'
|
||||
? 'The audit was closed without an agent receipt. The operation outcome remains unknown.'
|
||||
: 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
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Component
|
||||
import XIcon from 'lucide-solid/icons/x';
|
||||
import ArrowUpRightIcon from 'lucide-solid/icons/arrow-up-right';
|
||||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import { SecurityAPI } from '@/api/security';
|
||||
import { Button, ButtonLink } from '@/components/shared/Button';
|
||||
import { Dialog } from '@/components/shared/Dialog';
|
||||
import { FormTextarea } from '@/components/shared/FormTextarea';
|
||||
import { MetadataBadge } from '@/components/shared/MetadataBadge';
|
||||
import { notificationStore } from '@/stores/notifications';
|
||||
import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy';
|
||||
@@ -11,12 +13,29 @@ import type { ActionDetailResponse } from '@/types/actionAudit';
|
||||
import { ActionDecisionPacket } from './ActionDecisionPacket';
|
||||
import {
|
||||
formatActionName,
|
||||
getActionInboxStatePresentation,
|
||||
getActionAuditStatePresentation,
|
||||
getActionOriginDestination,
|
||||
getActionResourcePresentation,
|
||||
} from './actionPresentation';
|
||||
import { getAPTActionPresentation } from './aptActionPresentation';
|
||||
|
||||
// The server's bounded reconciliation window is one hour. This is only a
|
||||
// presentation threshold: the server remains the authority for every write.
|
||||
const RECEIPT_WAIT_RECOVERY_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
const agedReceiptPending = (detail: ActionDetailResponse | null, now: number): boolean => {
|
||||
if (detail?.audit.state !== 'executing' || detail.attempt?.state !== 'receipt_pending') {
|
||||
return false;
|
||||
}
|
||||
const createdAt = Date.parse(detail.attempt.createdAt);
|
||||
const updatedAt = Date.parse(detail.attempt.updatedAt);
|
||||
return (
|
||||
Number.isFinite(createdAt) &&
|
||||
Number.isFinite(updatedAt) &&
|
||||
now >= Math.max(createdAt, updatedAt) + RECEIPT_WAIT_RECOVERY_AGE_MS
|
||||
);
|
||||
};
|
||||
|
||||
export const ActionReviewDialog: Component<{
|
||||
detail: ActionDetailResponse | null;
|
||||
onClose: () => void;
|
||||
@@ -25,6 +44,10 @@ export const ActionReviewDialog: Component<{
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
const [clock, setClock] = createSignal(Date.now());
|
||||
const [canForceFail, setCanForceFail] = createSignal(false);
|
||||
const [recoveryOpen, setRecoveryOpen] = createSignal(false);
|
||||
const [recoveryReason, setRecoveryReason] = createSignal('');
|
||||
const [recoveryConfirmed, setRecoveryConfirmed] = createSignal(false);
|
||||
const audit = () => props.detail?.audit;
|
||||
const originDestination = () => getActionOriginDestination(audit()?.origin);
|
||||
const resource = createMemo(() => {
|
||||
@@ -36,6 +59,36 @@ export const ActionReviewDialog: Component<{
|
||||
const readOnly = createMemo(
|
||||
() => props.detail?.readOnly === true || presentationPolicyIsReadOnly(),
|
||||
);
|
||||
const canOfferRecovery = createMemo(
|
||||
() => !readOnly() && canForceFail() && agedReceiptPending(props.detail, clock()),
|
||||
);
|
||||
createEffect(() => {
|
||||
const detail = props.detail;
|
||||
setRecoveryOpen(false);
|
||||
setRecoveryReason('');
|
||||
setRecoveryConfirmed(false);
|
||||
setCanForceFail(false);
|
||||
if (
|
||||
detail?.audit.state !== 'executing' ||
|
||||
detail.attempt?.state !== 'receipt_pending' ||
|
||||
readOnly()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let current = true;
|
||||
void SecurityAPI.getStatus()
|
||||
.then((status) => {
|
||||
// The route repeats this admin/settings-write gate and additionally
|
||||
// checks action-execute authority. Never infer permission from the UI.
|
||||
if (current) setCanForceFail(status.settingsCapabilities?.authenticationWrite === true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (current) setCanForceFail(false);
|
||||
});
|
||||
onCleanup(() => {
|
||||
current = false;
|
||||
});
|
||||
});
|
||||
createEffect(() => {
|
||||
if (!props.detail) return;
|
||||
setClock(Date.now());
|
||||
@@ -231,6 +284,72 @@ export const ActionReviewDialog: Component<{
|
||||
}
|
||||
};
|
||||
|
||||
const closeStuckAudit = async () => {
|
||||
const currentDetail = props.detail;
|
||||
const reason = recoveryReason().trim();
|
||||
if (
|
||||
!currentDetail ||
|
||||
!canOfferRecovery() ||
|
||||
!recoveryOpen() ||
|
||||
!recoveryConfirmed() ||
|
||||
reason.length < 15 ||
|
||||
busy()
|
||||
)
|
||||
return;
|
||||
|
||||
setBusy(true);
|
||||
setError('');
|
||||
let recorded = false;
|
||||
try {
|
||||
const latest = await ResourceActionsAPI.getAction(currentDetail.audit.id);
|
||||
if (
|
||||
!agedReceiptPending(latest, Date.now()) ||
|
||||
latest.attempt?.id !== currentDetail.attempt?.id ||
|
||||
latest.attempt?.updatedAt !== currentDetail.attempt?.updatedAt
|
||||
) {
|
||||
await props.onChanged?.(latest);
|
||||
setRecoveryOpen(false);
|
||||
setError(
|
||||
'The action changed while you were reviewing it. No audit override was sent. Review its latest outcome.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const outcome = await ResourceActionsAPI.forceFailAction(currentDetail.audit.id, reason);
|
||||
recorded = true;
|
||||
// Keep the server's terminal audit visible even if the follow-up read
|
||||
// fails; do not invite a second override after a successful mutation.
|
||||
let displayed: ActionDetailResponse = { ...latest, audit: outcome.audit };
|
||||
try {
|
||||
displayed = await ResourceActionsAPI.getAction(currentDetail.audit.id);
|
||||
} catch {
|
||||
setError(
|
||||
'The audit was closed, but its latest details could not be loaded. Refresh the action history.',
|
||||
);
|
||||
}
|
||||
await props.onChanged?.(displayed);
|
||||
setRecoveryOpen(false);
|
||||
notificationStore.success(
|
||||
'Audit closed with an unknown operation outcome. Check the resource before any retry.',
|
||||
);
|
||||
} catch (cause) {
|
||||
setError(
|
||||
recorded
|
||||
? 'The audit was closed, but the view could not refresh. Reload action history before doing anything else.'
|
||||
: actionableErrorMessage(cause, 'The audit could not be closed.'),
|
||||
);
|
||||
if (!recorded) {
|
||||
try {
|
||||
await refresh();
|
||||
} catch {
|
||||
/* preserve the override failure */
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={Boolean(props.detail)}
|
||||
@@ -250,8 +369,8 @@ export const ActionReviewDialog: Component<{
|
||||
<h2 id="action-review-title" class="text-xl font-semibold">
|
||||
{formatActionName(record().request.capabilityName)}
|
||||
</h2>
|
||||
<MetadataBadge tone={getActionInboxStatePresentation(record().state).tone}>
|
||||
{getActionInboxStatePresentation(record().state).label}
|
||||
<MetadataBadge tone={getActionAuditStatePresentation(record()).tone}>
|
||||
{getActionAuditStatePresentation(record()).label}
|
||||
</MetadataBadge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
@@ -283,6 +402,78 @@ export const ActionReviewDialog: Component<{
|
||||
</header>
|
||||
<div class="overflow-y-auto px-5 py-4">
|
||||
<ActionDecisionPacket audit={record()} detail={props.detail ?? undefined} />
|
||||
<Show when={canOfferRecovery()}>
|
||||
<section
|
||||
aria-labelledby="action-stuck-recovery-heading"
|
||||
data-testid="action-stuck-recovery"
|
||||
class="mt-4 rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm dark:bg-amber-950/40"
|
||||
>
|
||||
<h3 id="action-stuck-recovery-heading" class="font-semibold">
|
||||
Receipt still missing
|
||||
</h3>
|
||||
<p class="mt-2">
|
||||
Pulse sent this action but cannot confirm what the agent did. Check the actual
|
||||
resource directly before any retry. For a container update, check the running
|
||||
container and image, not just this audit record.
|
||||
</p>
|
||||
<Show
|
||||
when={recoveryOpen()}
|
||||
fallback={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mt-3"
|
||||
onClick={() => setRecoveryOpen(true)}
|
||||
>
|
||||
Close stuck audit record…
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div class="mt-4 space-y-3 border-t border-amber-300 pt-4 dark:border-amber-800">
|
||||
<p>
|
||||
This only closes the Pulse audit with an <strong>inconclusive</strong>{' '}
|
||||
outcome. It does not cancel the agent operation, roll back the change, or
|
||||
prove that the operation failed.
|
||||
</p>
|
||||
<FormTextarea
|
||||
label="What did you verify directly?"
|
||||
help="Record the resource state you checked and why the receipt cannot be recovered. This becomes part of the audit."
|
||||
value={recoveryReason()}
|
||||
onInput={(event) => setRecoveryReason(event.currentTarget.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
<label class="flex cursor-pointer items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4"
|
||||
checked={recoveryConfirmed()}
|
||||
onChange={(event) => setRecoveryConfirmed(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>
|
||||
I checked the actual resource and understand the operation outcome remains
|
||||
unknown.
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={
|
||||
busy() || !recoveryConfirmed() || recoveryReason().trim().length < 15
|
||||
}
|
||||
onClick={() => void closeStuckAudit()}
|
||||
>
|
||||
Close audit as inconclusive
|
||||
</Button>
|
||||
<Button size="sm" disabled={busy()} onClick={() => setRecoveryOpen(false)}>
|
||||
Keep waiting
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
<Show when={invalidActionMessage()}>
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
|
||||
import { Route, Router } from '@solidjs/router';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ResourceActionsAPI } from '@/api/resourceActions';
|
||||
import { SecurityAPI } from '@/api/security';
|
||||
import { syncSessionPresentationPolicy } from '@/stores/sessionPresentationPolicy';
|
||||
import type { SecurityStatus } from '@/types/config';
|
||||
import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit';
|
||||
import { ActionReviewDialog } from '../ActionReviewDialog';
|
||||
|
||||
@@ -12,16 +15,27 @@ vi.mock('@/api/resourceActions', () => ({
|
||||
refreshAction: vi.fn(),
|
||||
decideAction: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
forceFailAction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/api/security', () => ({ SecurityAPI: { getStatus: vi.fn() } }));
|
||||
vi.mock('@/stores/notifications', () => ({
|
||||
notificationStore: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(SecurityAPI.getStatus).mockResolvedValue({
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
settingsCapabilities: { authenticationWrite: true },
|
||||
} as SecurityStatus);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
syncSessionPresentationPolicy(null);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const makeAudit = (
|
||||
@@ -114,7 +128,133 @@ const detail = (audit: ActionAuditRecord): ActionDetailResponse => ({
|
||||
},
|
||||
});
|
||||
|
||||
const waitingDetail = (updatedAt = '2026-07-12T00:02:00Z'): ActionDetailResponse => {
|
||||
const current = detail(makeAudit('resolved', '2026-07-12T00:10:00Z'));
|
||||
current.audit.state = 'executing';
|
||||
current.attempt = {
|
||||
id: 'attempt-1',
|
||||
actionId: current.audit.id,
|
||||
state: 'receipt_pending',
|
||||
createdAt: '2026-07-12T00:01:00Z',
|
||||
updatedAt,
|
||||
dispatchCount: 1,
|
||||
};
|
||||
return current;
|
||||
};
|
||||
|
||||
describe('ActionReviewDialog trust gates', () => {
|
||||
it('lets an admin close an aged receipt wait only after a direct-check reason and acknowledgement', async () => {
|
||||
const current = waitingDetail();
|
||||
const terminalAudit: ActionAuditRecord = {
|
||||
...current.audit,
|
||||
state: 'failed',
|
||||
result: {
|
||||
success: false,
|
||||
actionResultV2: {
|
||||
version: 2,
|
||||
execution: { status: 'inconclusive', reasonCode: 'operator_force_failed' },
|
||||
verification: { status: 'inconclusive', evidenceClass: 'none' },
|
||||
compensation: { support: 'unavailable', status: 'not_attempted' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const terminal = { ...current, audit: terminalAudit };
|
||||
vi.mocked(ResourceActionsAPI.getAction)
|
||||
.mockResolvedValueOnce(current)
|
||||
.mockResolvedValueOnce(terminal);
|
||||
vi.mocked(ResourceActionsAPI.forceFailAction).mockResolvedValue({
|
||||
actionId: terminalAudit.id,
|
||||
state: 'failed',
|
||||
audit: terminalAudit,
|
||||
result: terminalAudit.result,
|
||||
});
|
||||
const [selected, setSelected] = createSignal(current);
|
||||
const onChanged = vi.fn((next: ActionDetailResponse) => {
|
||||
setSelected(next);
|
||||
});
|
||||
render(() => (
|
||||
<ActionReviewDialog detail={selected()} onClose={vi.fn()} onChanged={onChanged} />
|
||||
));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Close stuck audit record…' }));
|
||||
expect(screen.getByText(/does not cancel the agent operation/)).toBeVisible();
|
||||
const close = screen.getByRole('button', { name: 'Close audit as inconclusive' });
|
||||
expect(close).toBeDisabled();
|
||||
fireEvent.input(screen.getByLabelText('What did you verify directly?'), {
|
||||
target: { value: 'Checked running container edge; image is already updated.' },
|
||||
});
|
||||
expect(close).toBeDisabled();
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /I checked the actual resource and understand/,
|
||||
}),
|
||||
);
|
||||
expect(close).toBeEnabled();
|
||||
fireEvent.click(close);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2);
|
||||
expect(ResourceActionsAPI.forceFailAction).toHaveBeenCalledWith(
|
||||
'action-1',
|
||||
'Checked running container edge; image is already updated.',
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalledWith(terminal);
|
||||
});
|
||||
expect(screen.getByText('Outcome unknown')).toBeVisible();
|
||||
expect(screen.getByTestId('action-execution-truth')).toHaveTextContent('Inconclusive');
|
||||
expect(screen.getByText(/audit was closed without an agent receipt/)).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: 'Close audit as inconclusive' })).toBeNull();
|
||||
});
|
||||
|
||||
it('does not expose override for fresh, settled, read-only, or non-admin receipt records', async () => {
|
||||
const fresh = waitingDetail(new Date(Date.now() - 5 * 60 * 1000).toISOString());
|
||||
const freshView = render(() => <ActionReviewDialog detail={fresh} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(SecurityAPI.getStatus).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('action-stuck-recovery')).toBeNull();
|
||||
freshView.unmount();
|
||||
|
||||
const settled = waitingDetail();
|
||||
settled.audit.state = 'failed';
|
||||
const settledView = render(() => <ActionReviewDialog detail={settled} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('action-stuck-recovery')).toBeNull();
|
||||
settledView.unmount();
|
||||
|
||||
const readOnly = { ...waitingDetail(), readOnly: true };
|
||||
const readOnlyView = render(() => <ActionReviewDialog detail={readOnly} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId('action-stuck-recovery')).toBeNull();
|
||||
readOnlyView.unmount();
|
||||
|
||||
vi.mocked(SecurityAPI.getStatus).mockResolvedValue({
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
settingsCapabilities: { authenticationWrite: false },
|
||||
} as SecurityStatus);
|
||||
render(() => <ActionReviewDialog detail={waitingDetail()} onClose={vi.fn()} />);
|
||||
await waitFor(() => expect(SecurityAPI.getStatus).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('action-stuck-recovery')).toBeNull();
|
||||
expect(ResourceActionsAPI.forceFailAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-reads the receipt before mutation and refuses a stale override', async () => {
|
||||
const current = waitingDetail();
|
||||
const settled = { ...current, audit: { ...current.audit, state: 'completed' as const } };
|
||||
vi.mocked(ResourceActionsAPI.getAction).mockResolvedValueOnce(settled);
|
||||
const onChanged = vi.fn();
|
||||
render(() => <ActionReviewDialog detail={current} onClose={vi.fn()} onChanged={onChanged} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Close stuck audit record…' }));
|
||||
fireEvent.input(screen.getByLabelText('What did you verify directly?'), {
|
||||
target: { value: 'I checked the running container and its current image.' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /I checked the actual resource and understand/,
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close audit as inconclusive' }));
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledWith(settled));
|
||||
expect(ResourceActionsAPI.forceFailAction).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('No audit override was sent');
|
||||
});
|
||||
it('keeps a rejected action outcome visible without offering execution', () => {
|
||||
const audit = makeAudit('resolved', '2026-07-12T00:10:00Z');
|
||||
audit.state = 'rejected';
|
||||
|
||||
+16
@@ -9,6 +9,7 @@ import type {
|
||||
import {
|
||||
formatActionName,
|
||||
formatPolicyAuthority,
|
||||
getActionAuditStatePresentation,
|
||||
getActionInboxStatePresentation,
|
||||
getActionResourcePresentation,
|
||||
sortOpenActionsForReview,
|
||||
@@ -90,6 +91,21 @@ describe('formatActionName', () => {
|
||||
});
|
||||
|
||||
describe('Actions inbox presentation', () => {
|
||||
it('distinguishes an operator-closed unknown outcome from an observed failure', () => {
|
||||
const audit = {
|
||||
state: 'failed',
|
||||
result: {
|
||||
actionResultV2: { execution: { reasonCode: 'operator_force_failed' } },
|
||||
},
|
||||
} as ActionAuditRecord;
|
||||
expect(getActionAuditStatePresentation(audit)).toEqual({
|
||||
label: 'Outcome unknown',
|
||||
tone: 'warning',
|
||||
accentClass: 'border-l-amber-500',
|
||||
});
|
||||
expect(getActionAuditStatePresentation({ ...audit, result: undefined }).label).toBe('Failed');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['pending_approval', 'Approval required', 'warning', 'border-l-amber-500'],
|
||||
['planned', 'Ready to review', 'muted', 'border-l-slate-400'],
|
||||
|
||||
@@ -82,6 +82,14 @@ export const getActionInboxStatePresentation = (
|
||||
state: ActionAuditState,
|
||||
): ActionInboxStatePresentation => ACTION_INBOX_STATE_PRESENTATION[state];
|
||||
|
||||
export const getActionAuditStatePresentation = (
|
||||
audit: ActionAuditRecord,
|
||||
): ActionInboxStatePresentation =>
|
||||
audit.state === 'failed' &&
|
||||
audit.result?.actionResultV2?.execution.reasonCode === 'operator_force_failed'
|
||||
? { accentClass: 'border-l-amber-500', label: 'Outcome unknown', tone: 'warning' }
|
||||
: getActionInboxStatePresentation(audit.state);
|
||||
|
||||
const OPEN_ACTION_PRIORITY: Record<ActionAuditState, number> = {
|
||||
pending_approval: 0,
|
||||
planned: 1,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ACTION_REVIEW_QUERY_PARAM, parseActionReviewId } from '@/features/actio
|
||||
import {
|
||||
formatActionName,
|
||||
getActionOriginLabel,
|
||||
getActionInboxStatePresentation,
|
||||
getActionAuditStatePresentation,
|
||||
getActionResourcePresentation,
|
||||
getActionsWatchOnlyEmptyState,
|
||||
sortOpenActionsForReview,
|
||||
@@ -262,7 +262,7 @@ export function Actions() {
|
||||
windowSize={48}
|
||||
>
|
||||
{(action) => {
|
||||
const state = () => getActionInboxStatePresentation(action.state);
|
||||
const state = () => getActionAuditStatePresentation(action);
|
||||
const resource = () =>
|
||||
getActionResourcePresentation(action.request.resourceId, action.resource);
|
||||
const title = () => formatActionName(action.request.capabilityName);
|
||||
|
||||
Reference in New Issue
Block a user