From 0684be8d70a104232eda59f2bc5b6bf4258f4ffc Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:56:08 +0100 Subject: [PATCH] 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 --- .../v6/internal/subsystems/api-contracts.md | 10 + .../subsystems/frontend-primitives.md | 8 + .../internal/subsystems/unified-resources.md | 10 + frontend-modern/browser-verification.json | 91 ++++---- .../src/api/__tests__/resourceActions.test.ts | 16 ++ frontend-modern/src/api/resourceActions.ts | 14 ++ .../features/actions/ActionDecisionPacket.tsx | 8 +- .../features/actions/ActionReviewDialog.tsx | 197 +++++++++++++++++- .../__tests__/ActionReviewDialog.test.tsx | 142 ++++++++++++- .../actionPresentation.branchcov0713.test.ts | 16 ++ .../features/actions/actionPresentation.ts | 8 + frontend-modern/src/pages/Actions.tsx | 4 +- 12 files changed, 464 insertions(+), 60 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ee1a5418b5..e18bf4748d 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d43728358d..1bb13fb6c2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 954d9b0dc2..6305b11959 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index c94f0106ec..25a94eeed0 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." } diff --git a/frontend-modern/src/api/__tests__/resourceActions.test.ts b/frontend-modern/src/api/__tests__/resourceActions.test.ts index c1bbd0e069..c41d2f30d2 100644 --- a/frontend-modern/src/api/__tests__/resourceActions.test.ts +++ b/frontend-modern/src/api/__tests__/resourceActions.test.ts @@ -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); + }); }); diff --git a/frontend-modern/src/api/resourceActions.ts b/frontend-modern/src/api/resourceActions.ts index 024069fe8b..ad2d09f716 100644 --- a/frontend-modern/src/api/resourceActions.ts +++ b/frontend-modern/src/api/resourceActions.ts @@ -88,4 +88,18 @@ export class ResourceActionsAPI { }, ); } + + static async forceFailAction(actionId: string, reason: string): Promise { + const justification = reason.trim(); + if (!justification) { + throw new Error('Record what you verified directly before closing the action audit.'); + } + return apiFetchJSON( + `/api/actions/${encodeURIComponent(actionId)}/force-fail`, + { + method: 'POST', + body: JSON.stringify({ reason: justification }), + }, + ); + } } diff --git a/frontend-modern/src/features/actions/ActionDecisionPacket.tsx b/frontend-modern/src/features/actions/ActionDecisionPacket.tsx index a33e2f26e3..141a584e72 100644 --- a/frontend-modern/src/features/actions/ActionDecisionPacket.tsx +++ b/frontend-modern/src/features/actions/ActionDecisionPacket.tsx @@ -356,9 +356,11 @@ export const ActionDecisionPacket: Component<{

{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.'}

Refreshing or reconnecting re-reads this action record. It does not create another diff --git a/frontend-modern/src/features/actions/ActionReviewDialog.tsx b/frontend-modern/src/features/actions/ActionReviewDialog.tsx index 89bf30c112..c340468348 100644 --- a/frontend-modern/src/features/actions/ActionReviewDialog.tsx +++ b/frontend-modern/src/features/actions/ActionReviewDialog.tsx @@ -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 (

{formatActionName(record().request.capabilityName)} - - {getActionInboxStatePresentation(record().state).label} + + {getActionAuditStatePresentation(record()).label}

@@ -283,6 +402,78 @@ export const ActionReviewDialog: Component<{

+ +
+

+ Receipt still missing +

+

+ 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. +

+ setRecoveryOpen(true)} + > + Close stuck audit record… + + } + > +
+

+ This only closes the Pulse audit with an inconclusive{' '} + outcome. It does not cancel the agent operation, roll back the change, or + prove that the operation failed. +

+ setRecoveryReason(event.currentTarget.value)} + maxLength={500} + rows={3} + /> + +
+ + +
+
+
+
+
({ 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(() => ( + + )); + + 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(() => ); + 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(() => ); + expect(screen.queryByTestId('action-stuck-recovery')).toBeNull(); + settledView.unmount(); + + const readOnly = { ...waitingDetail(), readOnly: true }; + const readOnlyView = render(() => ); + expect(screen.queryByTestId('action-stuck-recovery')).toBeNull(); + readOnlyView.unmount(); + + vi.mocked(SecurityAPI.getStatus).mockResolvedValue({ + hasAuthentication: true, + requiresAuth: true, + settingsCapabilities: { authenticationWrite: false }, + } as SecurityStatus); + render(() => ); + 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(() => ); + 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'; diff --git a/frontend-modern/src/features/actions/__tests__/actionPresentation.branchcov0713.test.ts b/frontend-modern/src/features/actions/__tests__/actionPresentation.branchcov0713.test.ts index 5e6dd67caf..9a97daf96f 100644 --- a/frontend-modern/src/features/actions/__tests__/actionPresentation.branchcov0713.test.ts +++ b/frontend-modern/src/features/actions/__tests__/actionPresentation.branchcov0713.test.ts @@ -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'], diff --git a/frontend-modern/src/features/actions/actionPresentation.ts b/frontend-modern/src/features/actions/actionPresentation.ts index 20c60af45a..e3a567e7e5 100644 --- a/frontend-modern/src/features/actions/actionPresentation.ts +++ b/frontend-modern/src/features/actions/actionPresentation.ts @@ -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 = { pending_approval: 0, planned: 1, diff --git a/frontend-modern/src/pages/Actions.tsx b/frontend-modern/src/pages/Actions.tsx index e808e68e63..7a85e93f92 100644 --- a/frontend-modern/src/pages/Actions.tsx +++ b/frontend-modern/src/pages/Actions.tsx @@ -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);