mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate pending action receipt review improvement
Change-source: pulse-maintainer
This commit is contained in:
@@ -6794,6 +6794,11 @@ 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
|
||||
show the receipt-pending state to every viewer as an unknown outcome, with a
|
||||
non-mutating in-dialog re-read of the same action and a warning not to create a
|
||||
second plan while the first outcome is unknown. The re-read remains available
|
||||
before the recovery window and in read-only sessions; a failed read retains the
|
||||
unknown state and never implies that the action was sent again. Recovery 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
|
||||
|
||||
@@ -2964,7 +2964,10 @@ 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
|
||||
operational outcome. At any age, the Actions review labels that wait as receipt
|
||||
pending, warns against a new plan, and can re-read the same action by GET without
|
||||
dispatching or closing it; this is available even to read-only viewers. 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
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Browser proof for the production action dialog's non-mutating receipt re-read.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const artifacts = path.join(root, 'browser-tests', 'action-receipt-proof');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
process.chdir(root);
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
server: { host: '127.0.0.1', port: 5202, strictPort: true },
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
browser = await chromium.launch({ headless: true, channel: 'chromium', args: ['--no-sandbox'] });
|
||||
const results = [];
|
||||
for (const width of [1365, 390]) {
|
||||
let mode = 'pending';
|
||||
const page = await browser.newPage({ viewport: { width, height: width === 390 ? 844 : 900 } });
|
||||
const pageErrors = [];
|
||||
const actionRequests = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.message));
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== 'http://127.0.0.1:5202') return route.abort();
|
||||
if (url.pathname === '/api/security/status') {
|
||||
return route.fulfill({ json: {
|
||||
hasAuthentication: true,
|
||||
requiresAuth: true,
|
||||
settingsCapabilities: { authenticationWrite: true },
|
||||
} });
|
||||
}
|
||||
if (url.pathname === '/api/actions/action-fixture-1') {
|
||||
actionRequests.push({ method: route.request().method(), mode });
|
||||
if (mode === 'error') return route.fulfill({ status: 503, json: { error: 'fixture read failed' } });
|
||||
if (mode === 'complete') {
|
||||
return route.fulfill({ json: {
|
||||
audit: {
|
||||
id: 'action-fixture-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
state: 'completed',
|
||||
decisionRevision: 1,
|
||||
request: {
|
||||
requestId: 'request-fixture-1', resourceId: 'docker:container:edge',
|
||||
capabilityName: 'update', reason: 'Update edge to its latest image.', requestedBy: 'operator',
|
||||
},
|
||||
plan: {
|
||||
actionId: 'action-fixture-1', requestId: 'request-fixture-1', allowed: true,
|
||||
requiresApproval: false, rollbackAvailable: false,
|
||||
expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
|
||||
planHash: 'sha256:fixture-plan', policyDecision: { status: 'resolved', authorities: [] },
|
||||
},
|
||||
result: {
|
||||
success: true,
|
||||
actionResultV2: {
|
||||
execution: { status: 'succeeded' },
|
||||
verification: { status: 'verified', evidenceClass: 'agent' },
|
||||
compensation: { status: 'not_attempted', support: 'unavailable' },
|
||||
},
|
||||
},
|
||||
},
|
||||
events: [],
|
||||
attempt: { id: 'attempt-fixture-1', actionId: 'action-fixture-1', state: 'receipt_recorded',
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), dispatchCount: 1 },
|
||||
receipt: { receivedAt: new Date().toISOString() },
|
||||
} });
|
||||
}
|
||||
return route.fulfill({ json: {
|
||||
audit: {
|
||||
id: 'action-fixture-1', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
state: 'executing', decisionRevision: 1,
|
||||
request: { requestId: 'request-fixture-1', resourceId: 'docker:container:edge',
|
||||
capabilityName: 'update', reason: 'Update edge to its latest image.', requestedBy: 'operator' },
|
||||
plan: { actionId: 'action-fixture-1', requestId: 'request-fixture-1', allowed: true,
|
||||
requiresApproval: false, rollbackAvailable: false, expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
|
||||
planHash: 'sha256:fixture-plan', policyDecision: { status: 'resolved', authorities: [] } },
|
||||
},
|
||||
events: [],
|
||||
attempt: { id: 'attempt-fixture-1', actionId: 'action-fixture-1', state: 'receipt_pending',
|
||||
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 5 * 60_000).toISOString(), dispatchCount: 1 },
|
||||
} });
|
||||
}
|
||||
if (url.pathname.startsWith('/api/')) return route.abort();
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5202/browser-tests/action-receipt-wait.html', {
|
||||
waitUntil: 'domcontentloaded', timeout: 120_000,
|
||||
});
|
||||
const dialog = page.getByRole('dialog', { name: 'Update' });
|
||||
await dialog.waitFor();
|
||||
await page.getByText('Receipt pending', { exact: true }).waitFor();
|
||||
assert.match(await page.getByTestId('action-receipt-waiting').innerText(), /Do not start the action again/);
|
||||
assert.equal(await page.getByTestId('action-stuck-recovery').count(), 0);
|
||||
await page.waitForTimeout(400); // Let the dialog entrance transition finish before capture.
|
||||
await page.screenshot({ path: path.join(artifacts, `pending-${width}.png`) });
|
||||
|
||||
const refresh = page.getByRole('button', { name: 'Check for receipt' });
|
||||
if (width === 390) {
|
||||
await refresh.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
} else {
|
||||
await refresh.click();
|
||||
}
|
||||
await page.waitForFunction(() => document.querySelector('[data-testid="action-receipt-waiting"]') !== null);
|
||||
assert.equal(actionRequests.length, 1);
|
||||
mode = 'error';
|
||||
await refresh.click();
|
||||
await page.getByRole('alert').filter({ hasText: 'No new action was sent' }).waitFor();
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: path.join(artifacts, `read-error-${width}.png`) });
|
||||
|
||||
mode = 'complete';
|
||||
await refresh.click();
|
||||
await page.getByText('Completed', { exact: true }).waitFor();
|
||||
assert.equal(await page.getByTestId('action-receipt-waiting').count(), 0);
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: path.join(artifacts, `completed-${width}.png`) });
|
||||
assert.equal(actionRequests.length, 3);
|
||||
assert.ok(actionRequests.every((request) => request.method === 'GET'), JSON.stringify(actionRequests));
|
||||
assert.deepEqual(pageErrors, []);
|
||||
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, inner: innerWidth }));
|
||||
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
|
||||
results.push({ width, actionRequests, pageErrors, dimensions });
|
||||
await page.close();
|
||||
}
|
||||
console.log(JSON.stringify({ result: 'passed', browser: browser.version(), playwright: '1.56.1', results }));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/browser-tests/action-receipt-wait.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,73 @@
|
||||
// Mount the production action dialog with a synthetic, recent receipt wait.
|
||||
// The browser runner supplies only bounded read-only API responses.
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { ActionReviewDialog } from '../src/features/actions/ActionReviewDialog';
|
||||
import type { ActionDetailResponse } from '../src/types/actionAudit';
|
||||
import '../src/index.css';
|
||||
|
||||
const now = Date.now();
|
||||
const initial: ActionDetailResponse = {
|
||||
audit: {
|
||||
id: 'action-fixture-1',
|
||||
createdAt: new Date(now - 6 * 60_000).toISOString(),
|
||||
updatedAt: new Date(now - 5 * 60_000).toISOString(),
|
||||
state: 'executing',
|
||||
decisionRevision: 1,
|
||||
request: {
|
||||
requestId: 'request-fixture-1',
|
||||
resourceId: 'docker:container:edge',
|
||||
capabilityName: 'update',
|
||||
reason: 'Update edge to its latest image.',
|
||||
requestedBy: 'operator',
|
||||
},
|
||||
plan: {
|
||||
actionId: 'action-fixture-1',
|
||||
requestId: 'request-fixture-1',
|
||||
allowed: true,
|
||||
requiresApproval: false,
|
||||
approvalPolicy: 'none',
|
||||
approvalRequirement: {
|
||||
version: 1,
|
||||
floor: 'none',
|
||||
quorum: 0,
|
||||
disallowRequester: false,
|
||||
},
|
||||
rollbackAvailable: false,
|
||||
expiresAt: new Date(now + 30 * 60_000).toISOString(),
|
||||
planHash: 'sha256:fixture-plan',
|
||||
policyDecision: {
|
||||
version: 1,
|
||||
status: 'resolved',
|
||||
decisionId: 'fixture-policy',
|
||||
actionId: 'action-fixture-1',
|
||||
scope: { orgId: 'org-fixture', resourceId: 'docker:container:edge', capabilityName: 'update' },
|
||||
authorities: [],
|
||||
approvalRequirement: {
|
||||
version: 1,
|
||||
floor: 'none',
|
||||
quorum: 0,
|
||||
disallowRequester: false,
|
||||
},
|
||||
planningAllowed: true,
|
||||
requiresApproval: false,
|
||||
},
|
||||
},
|
||||
verificationOutcome: { status: 'unknown' },
|
||||
},
|
||||
events: [],
|
||||
attempt: {
|
||||
id: 'attempt-fixture-1',
|
||||
actionId: 'action-fixture-1',
|
||||
state: 'receipt_pending',
|
||||
createdAt: new Date(now - 5 * 60_000).toISOString(),
|
||||
updatedAt: new Date(now - 5 * 60_000).toISOString(),
|
||||
dispatchCount: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const [selected, setSelected] = createSignal(initial);
|
||||
render(
|
||||
() => <ActionReviewDialog detail={selected()} onClose={() => {}} onChanged={setSelected} />,
|
||||
document.getElementById('root')!,
|
||||
);
|
||||
@@ -1,18 +1,16 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "498e2b12a22510520f9df7a510105d6775ea0522",
|
||||
"verified_at": "2026-09-29T16:42:43Z",
|
||||
"base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
|
||||
"verified_at": "2026-09-29T19:09:52Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
|
||||
"frontend-modern/src/types/resource.ts"
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12",
|
||||
"frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56"
|
||||
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)"
|
||||
"/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
@@ -25,26 +23,75 @@
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"PBS API + corroborated Agent with token-auth nodeName absent",
|
||||
"same-host PVE API joined alongside a PVE-only name collision",
|
||||
"correlated host row temporarily omitted",
|
||||
"correlated host row restored"
|
||||
"fresh executing/receipt_pending, including after a same-action re-read",
|
||||
"failed status re-read with unknown outcome preserved",
|
||||
"completed action after a recorded receipt"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop disclosure-button keyboard focus and Enter; phone row click",
|
||||
"open History and select 24h, 6h, 1h and 12h across topology changes",
|
||||
"join PVE API, omit host row, restore host row"
|
||||
"desktop pointer and phone keyboard Check for receipt",
|
||||
"GET same action under pending, read error and completion; no POST",
|
||||
"desktop and phone layout and horizontal-overflow checks"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/proof-pbs-1723.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png"
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png"
|
||||
],
|
||||
"notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.",
|
||||
"notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest.",
|
||||
"prior_verifications": [
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "498e2b12a22510520f9df7a510105d6775ea0522",
|
||||
"verified_at": "2026-09-29T16:42:43Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx",
|
||||
"frontend-modern/src/types/resource.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/features/proxmox/ProxmoxBackupServersTable.tsx": "fc3c71f792c91abe62475be2a341fbedc729dd079b31e2245b5769f969fa6b12",
|
||||
"frontend-modern/src/types/resource.ts": "4e3b64a3799aad0d357b9d8898dd2daf9a557e05c529df3650d2f6d03eb8ca56"
|
||||
},
|
||||
"routes": [
|
||||
"/browser-tests/pbs-mixed-source-1723.html (mock-backed current ProxmoxBackupServersTable)"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1365,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"PBS API + corroborated Agent with token-auth nodeName absent",
|
||||
"same-host PVE API joined alongside a PVE-only name collision",
|
||||
"correlated host row temporarily omitted",
|
||||
"correlated host row restored"
|
||||
],
|
||||
"interactions": [
|
||||
"desktop disclosure-button keyboard focus and Enter; phone row click",
|
||||
"open History and select 24h, 6h, 1h and 12h across topology changes",
|
||||
"join PVE API, omit host row, restore host row"
|
||||
],
|
||||
"command": "pulse-worker-browser frontend-modern/node_modules/proof-pbs-1723.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
|
||||
"artifacts": [
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/proof-pbs-1723.cjs",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-run.log",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/result.json",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-1365.png",
|
||||
"/var/lib/pulse-maintainer/worker-outputs/core-runtime-8wlxxg93/browser-proof-1723/joined-390.png"
|
||||
],
|
||||
"notes": "Fresh offline browser run on supplied main 498e2b12a2 plus unchanged #1723 runtime repair. At desktop and phone widths, History stayed selected with eight plotted paths through initial PBS + Agent, same-host PVE join, transient host omission and restoration. All eight captured history requests targeted agent:agent-uuid, never the PBS service key; joined screenshots were visually inspected. Mock-backed proof is not installed, second-topology or reporter acceptance.",
|
||||
"preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d"
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "fcbb7e58ad514ac146f666f49075039d40d829e2",
|
||||
@@ -112,6 +159,5 @@
|
||||
"Loaded /alerts/notifications, /settings/system-general, /settings/system-relay and /settings/system/billing/plan on the Community stack and checked nav items, panels and links"
|
||||
]
|
||||
}
|
||||
],
|
||||
"preserved_candidate_source": "25d6ab2e7a23adf3c74e0c071dfd40e799d2d83d"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,12 +43,15 @@ export const ActionReviewDialog: Component<{
|
||||
}> = (props) => {
|
||||
const [busy, setBusy] = createSignal(false);
|
||||
const [error, setError] = createSignal('');
|
||||
const [receiptRefreshError, setReceiptRefreshError] = 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 receiptWaiting = () =>
|
||||
audit()?.state === 'executing' && props.detail?.attempt?.state === 'receipt_pending';
|
||||
const originDestination = () => getActionOriginDestination(audit()?.origin);
|
||||
const resource = createMemo(() => {
|
||||
const record = audit();
|
||||
@@ -64,6 +67,7 @@ export const ActionReviewDialog: Component<{
|
||||
);
|
||||
createEffect(() => {
|
||||
const detail = props.detail;
|
||||
setReceiptRefreshError('');
|
||||
setRecoveryOpen(false);
|
||||
setRecoveryReason('');
|
||||
setRecoveryConfirmed(false);
|
||||
@@ -177,6 +181,21 @@ export const ActionReviewDialog: Component<{
|
||||
await props.onChanged?.(detail);
|
||||
};
|
||||
|
||||
const refreshReceipt = async () => {
|
||||
const actionId = audit()?.id;
|
||||
if (!actionId || !receiptWaiting() || busy()) return;
|
||||
setBusy(true);
|
||||
setReceiptRefreshError('');
|
||||
try {
|
||||
const latest = await ResourceActionsAPI.getAction(actionId);
|
||||
await props.onChanged?.(latest);
|
||||
} catch {
|
||||
setReceiptRefreshError('Could not re-read this action. No new action was sent.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const decide = async (outcome: 'approved' | 'rejected') => {
|
||||
const action = audit();
|
||||
if (!action || busy()) return;
|
||||
@@ -369,8 +388,14 @@ export const ActionReviewDialog: Component<{
|
||||
<h2 id="action-review-title" class="text-xl font-semibold">
|
||||
{formatActionName(record().request.capabilityName)}
|
||||
</h2>
|
||||
<MetadataBadge tone={getActionAuditStatePresentation(record()).tone}>
|
||||
{getActionAuditStatePresentation(record()).label}
|
||||
<MetadataBadge
|
||||
tone={
|
||||
receiptWaiting() ? 'warning' : getActionAuditStatePresentation(record()).tone
|
||||
}
|
||||
>
|
||||
{receiptWaiting()
|
||||
? 'Receipt pending'
|
||||
: getActionAuditStatePresentation(record()).label}
|
||||
</MetadataBadge>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
@@ -401,6 +426,36 @@ export const ActionReviewDialog: Component<{
|
||||
</Button>
|
||||
</header>
|
||||
<div class="overflow-y-auto px-5 py-4">
|
||||
<Show when={receiptWaiting()}>
|
||||
<section
|
||||
aria-labelledby="action-receipt-waiting-heading"
|
||||
data-testid="action-receipt-waiting"
|
||||
class="mb-4 rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm dark:bg-amber-950/40"
|
||||
>
|
||||
<h3 id="action-receipt-waiting-heading" class="font-semibold">
|
||||
Waiting for the agent receipt
|
||||
</h3>
|
||||
<p class="mt-2">
|
||||
Pulse sent this action once, but its outcome is not known yet. The agent may
|
||||
still be working or may have finished. Do not start the action again from a new
|
||||
plan. Check the actual resource if the wait persists.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="mt-3"
|
||||
isLoading={busy()}
|
||||
onClick={() => void refreshReceipt()}
|
||||
>
|
||||
Check for receipt
|
||||
</Button>
|
||||
<Show when={receiptRefreshError()}>
|
||||
<p role="alert" class="mt-2 text-red-800 dark:text-red-200">
|
||||
{receiptRefreshError()}
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
<ActionDecisionPacket audit={record()} detail={props.detail ?? undefined} />
|
||||
<Show when={canOfferRecovery()}>
|
||||
<section
|
||||
|
||||
@@ -143,6 +143,45 @@ const waitingDetail = (updatedAt = '2026-07-12T00:02:00Z'): ActionDetailResponse
|
||||
};
|
||||
|
||||
describe('ActionReviewDialog trust gates', () => {
|
||||
it('re-reads a fresh receipt wait without dispatching again and shows a recorded completion', async () => {
|
||||
const current = waitingDetail(new Date(Date.now() - 5 * 60 * 1000).toISOString());
|
||||
const completed = {
|
||||
...current,
|
||||
audit: { ...current.audit, state: 'completed' as const },
|
||||
attempt: { ...current.attempt!, state: 'receipt_recorded' as const },
|
||||
};
|
||||
vi.mocked(ResourceActionsAPI.getAction).mockResolvedValueOnce(completed);
|
||||
const [selected, setSelected] = createSignal(current);
|
||||
render(() => (
|
||||
<ActionReviewDialog detail={selected()} onClose={vi.fn()} onChanged={setSelected} />
|
||||
));
|
||||
|
||||
expect(screen.getByText('Receipt pending')).toBeVisible();
|
||||
expect(screen.getByTestId('action-receipt-waiting')).toHaveTextContent(
|
||||
'Do not start the action again from a new plan.',
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check for receipt' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Completed', { exact: true })).toBeVisible());
|
||||
expect(screen.queryByTestId('action-receipt-waiting')).toBeNull();
|
||||
expect(ResourceActionsAPI.getAction).toHaveBeenCalledWith('action-1');
|
||||
expect(ResourceActionsAPI.executeAction).not.toHaveBeenCalled();
|
||||
expect(ResourceActionsAPI.forceFailAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an unknown outcome visible when receipt re-read fails', async () => {
|
||||
const current = waitingDetail(new Date(Date.now() - 5 * 60 * 1000).toISOString());
|
||||
vi.mocked(ResourceActionsAPI.getAction).mockRejectedValueOnce(new Error('offline'));
|
||||
render(() => <ActionReviewDialog detail={current} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check for receipt' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Could not re-read this action. No new action was sent.',
|
||||
);
|
||||
expect(screen.getByText('Receipt pending')).toBeVisible();
|
||||
expect(ResourceActionsAPI.executeAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets an admin close an aged receipt wait only after a direct-check reason and acknowledgement', async () => {
|
||||
const current = waitingDetail();
|
||||
const terminalAudit: ActionAuditRecord = {
|
||||
@@ -210,6 +249,7 @@ describe('ActionReviewDialog trust gates', () => {
|
||||
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.getByTestId('action-receipt-waiting')).toBeVisible();
|
||||
expect(screen.queryByTestId('action-stuck-recovery')).toBeNull();
|
||||
freshView.unmount();
|
||||
|
||||
@@ -221,6 +261,7 @@ describe('ActionReviewDialog trust gates', () => {
|
||||
|
||||
const readOnly = { ...waitingDetail(), readOnly: true };
|
||||
const readOnlyView = render(() => <ActionReviewDialog detail={readOnly} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: 'Check for receipt' })).toBeEnabled();
|
||||
expect(screen.queryByTestId('action-stuck-recovery')).toBeNull();
|
||||
readOnlyView.unmount();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user