From a1b7e9d5d8adb70725f504dddcd5f3de2e927df8 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:10:56 +0100 Subject: [PATCH] Make pending action receipts safely refreshable Show an unknown-outcome warning for every receipt-pending review and offer a same-action status re-read without another dispatch. Keep aged audit closure behind its existing admin and server gates, and cover desktop and phone interactions. Change-source: pulse-maintainer --- .../subsystems/frontend-primitives.md | 5 + .../internal/subsystems/unified-resources.md | 5 +- .../browser-tests/action-receipt-wait.cjs | 142 ++++++++++++++++++ .../browser-tests/action-receipt-wait.html | 11 ++ .../browser-tests/action-receipt-wait.tsx | 73 +++++++++ frontend-modern/browser-verification.json | 92 +++++++++--- .../features/actions/ActionReviewDialog.tsx | 59 +++++++- .../__tests__/ActionReviewDialog.test.tsx | 41 +++++ 8 files changed, 402 insertions(+), 26 deletions(-) create mode 100644 frontend-modern/browser-tests/action-receipt-wait.cjs create mode 100644 frontend-modern/browser-tests/action-receipt-wait.html create mode 100644 frontend-modern/browser-tests/action-receipt-wait.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a98df276fd..48bc844e21 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 8e70cef3a7..4701e6cbba 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/browser-tests/action-receipt-wait.cjs b/frontend-modern/browser-tests/action-receipt-wait.cjs new file mode 100644 index 0000000000..c0ec48ae07 --- /dev/null +++ b/frontend-modern/browser-tests/action-receipt-wait.cjs @@ -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; +}); diff --git a/frontend-modern/browser-tests/action-receipt-wait.html b/frontend-modern/browser-tests/action-receipt-wait.html new file mode 100644 index 0000000000..87ed8c4775 --- /dev/null +++ b/frontend-modern/browser-tests/action-receipt-wait.html @@ -0,0 +1,11 @@ + + +
+ + + + + + + + diff --git a/frontend-modern/browser-tests/action-receipt-wait.tsx b/frontend-modern/browser-tests/action-receipt-wait.tsx new file mode 100644 index 0000000000..f5bf6d820b --- /dev/null +++ b/frontend-modern/browser-tests/action-receipt-wait.tsx @@ -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( + () =>@@ -401,6 +426,36 @@ export const ActionReviewDialog: Component<{
+ 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. +
+ ++ {receiptRefreshError()} +
+