Keep action reviews safe and notify when incidents continue (#2335)

## What changed and why
This batch makes Actions review ownership safer when responses arrive
out of order, restores continuing-symptom notification handling after an
incident's primary recovery, and keeps the provider control-plane and
integration mock on the same pinned Go 1.26.8 builder. The first two
address unsafe update-review state and missed alert follow-through; the
builder pin follows the reviewed #2275 dependency update without
changing the prebuilt release runtime.

## Included source
Pulse `main` after `30bb963b6cbd9f0575e1b9ac86c88c3cd45a512a` through
`91d8822d76c5a7214995c0e370be637b6e6336e3`: reviewed Actions commits
`ba298b29e264f671f917b49139cf9031678b87da` and
`e57bb692ae79c75c2df1fa4c91d90b84d5aa0fb4`, notification commit
`fd9e836ca458e34f5b6736b456375de51a625b18`, builder pin
`91d8822d76c5a7214995c0e370be637b6e6336e3`, and additive
upstream/integration merges `d50b902797f9989eb3d786b502adc385a9a8fe37`
and `75d72f586881ba2356263dd0e664735b6baae4e9`.

## Verification and limits
The reviewed Actions candidate included focused component/browser
coverage; the alert candidate added focused incident-synthesis tests;
the builder candidate's exact-source install-test proof passed
(`source-ow_zygny`). Publication provenance and range diff checks pass
at this tip. A new combined-tip source-proof request could not start
because all executor slots were occupied; it is not a passing result.
Exact-head PR checks and independent review remain required before
landing. The builder image still needs the required exact Docker
qualification in its disposable executor; this PR does not establish
that the digest can be pulled or built, release qualification, or
installed behaviour.


[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-09-29 22:28:25 +00:00
committed by GitHub
15 changed files with 665 additions and 38 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ COPY SECURITY.md TERMS.md /app/
RUN --mount=type=cache,id=pulse-control-plane-npm-cache,target=/root/.npm \
npm run build
FROM --platform=$BUILDPLATFORM golang:1.26.8-alpine@sha256:51a7c389a5ddaf82f527191a1e9bff9928655130a44e4975dd1d7e0acf59f1ae AS builder
FROM --platform=$BUILDPLATFORM golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c AS builder
ARG VERSION=dev
ARG BUILD_TIME=unknown
ARG GIT_COMMIT=unknown
@@ -2833,11 +2833,16 @@ notification delivery, with the typed `correlated_primary` reason and primary
alert ID in the event log. The primary remains the one delivery owner. Supporting
members also skip escalation levels and critical repeats while that supported
cause is current; an already queued escalation is revalidated before delivery.
If the supported primary recovers or the causal link is lost while its symptom
still fails, the symptom regains independent delivery without a new occurrence.
It receives its first notification only if it was never dispatched before
grouping; an already-notified symptom does not duplicate on group dissolution.
Acknowledgement, rate limits and other ordinary delivery gates still apply.
`observation-set` members continue to notify independently because Pulse has
not established causality. `incident_synthesis_test.go` pins classification,
contradiction downgrade, bounded evidence, duplicate-delivery suppression,
partial-recovery behavior, escalation suppression, and queued-callback
revalidation without muting observation sets.
partial-recovery delivery and no duplicate after prior dispatch, escalation
suppression, and queued-callback revalidation without muting observation sets.
### Alert hydration is not resource admission
@@ -3724,6 +3724,13 @@ vulnerabilities in the current patch level, the canonical fix is to advance the
governed release toolchain and immutable Go builder digest together, not to
suppress the scanner or produce release artifacts with an older patched-over
runtime.
The hosted control-plane source builder and integration mock builder are both
on the reviewed `golang:1.26.8-alpine` digest
`sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c`.
`TestProviderMSPGoBuilderMatchesIntegrationMock` pins their exact agreement;
it does not pull the image, prove its registry contents, or qualify a deployed
control plane. The release publisher's prebuilt control-plane target is a
separate path and does not rebuild its binary from this stage.
As of 2026-08-27, the governed release floor is Go `1.26.7`. It supersedes
`1.26.5`, whose standard library is reachable through seven vulnerable Pulse
call paths reported by `govulncheck`, including HTTP/TLS, URL parsing, SAML XML
@@ -6815,6 +6815,13 @@ the matching Open or History subtab from server-authored lifecycle state, and
removes the query when the dialog closes. Feature pages may summarize action
context, but they must not recreate approve, reject, run, progress, or outcome
controls outside the shared Actions review.
The Actions route owns overlapping reads by request generation. A slower detail
response or late dialog refresh must not replace a newer URL-selected action or
reopen a closed review; a mismatched server action id is rejected. An older Open
or History list response must not overwrite the newer tab's results or error.
`Actions.requestOwnership.test.tsx` covers both response orders and close while
a receipt re-read is pending; the browser navigation proof checks the rendered
dialog and URL at desktop and phone widths without sending an action mutation.
The Actions ledger is a peer top-level navigation destination. Patrol remains
the primary detection and investigation home and may expose a route-backed
Actions handoff, but Actions owns its pending-approval count and selected state.
@@ -2951,6 +2951,13 @@ missing provenance remains an immediate fail-closed warning rather than hidden
detail. Actions is the canonical browser hub for action review, execution
progress, and recorded outcomes; contextual sources such as Patrol link into an
exact action review instead of duplicating those mutations locally.
When a user selects a different action before a prior detail read completes,
only the detail matching the current `action` URL may open the review. Closing
the review invalidates pending detail and receipt reads, so a late response
cannot reopen it or substitute another resource's action. The page also keeps
Open and History list responses scoped to their latest request; an older tab
result or error cannot replace the current tab. This is a browser ownership
rule over server-authored action identity, not a new action or receipt state.
APT review presents server-recorded policy provenance and distinguishes the
elevated update posture from low-risk-eligible cache cleanup. Both typed actions
@@ -0,0 +1,182 @@
// Prove that late action reads cannot change the route-backed review or reopen it.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const action = (id) => ({
id,
createdAt: '2026-09-29T18:00:00Z',
updatedAt: '2026-09-29T18:01:00Z',
state: 'executing',
decisionRevision: 1,
request: {
requestId: `request-${id}`,
resourceId: `docker:container:${id}`,
capabilityName: 'update',
reason: `Update ${id}`,
requestedBy: 'operator',
},
plan: {
actionId: id,
requestId: `request-${id}`,
allowed: true,
requiresApproval: false,
approvalPolicy: 'none',
rollbackAvailable: false,
expiresAt: '2026-09-29T21:00:00Z',
planHash: `sha256:plan-${id}`,
},
verificationOutcome: { status: 'unknown' },
});
const detail = (id) => ({
audit: action(id),
events: [],
readiness: {
ready: false,
code: 'receipt_pending',
message: 'Awaiting receipt',
refreshable: false,
checkedAt: '2026-09-29T18:01:00Z',
},
attempt: {
id: `attempt-${id}`,
actionId: id,
state: 'receipt_pending',
createdAt: '2026-09-29T18:00:00Z',
updatedAt: '2026-09-29T18:01:00Z',
dispatchCount: 1,
},
});
(async () => {
const root = '/workspace/frontend-modern';
const artifacts = path.join(root, 'node_modules', 'action-review-navigation-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: 5204, 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 releaseA;
let releaseReceipt;
let bReads = 0;
const requests = [];
const errors = [];
const page = await browser.newPage({
viewport: { width, height: width === 390 ? 844 : 900 },
});
page.on('pageerror', (error) => errors.push(error.message));
await page.route('**/*', async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin !== 'http://127.0.0.1:5204') return route.abort();
if (url.pathname.startsWith('/api/')) requests.push(`${request.method()} ${url.pathname}`);
if (url.pathname === '/api/actions') {
const view = url.searchParams.get('view');
return route.fulfill({
json: {
view,
actions: view === 'pending' ? [action('edge-a'), action('edge-b')] : [],
count: view === 'pending' ? 2 : 0,
readOnly: false,
},
});
}
if (url.pathname === '/api/actions/edge-a') {
await new Promise((resolve) => {
releaseA = resolve;
});
return route.fulfill({ json: detail('edge-a') });
}
if (url.pathname === '/api/actions/edge-b') {
bReads += 1;
if (bReads === 2)
await new Promise((resolve) => {
releaseReceipt = resolve;
});
return route.fulfill({ json: detail('edge-b') });
}
if (url.pathname === '/api/security/status')
return route.fulfill({
json: {
hasAuthentication: true,
requiresAuth: true,
settingsCapabilities: { authenticationWrite: false },
},
});
if (url.pathname.startsWith('/api/'))
return route.fulfill({ status: 503, json: { error: 'fixture unavailable' } });
return route.continue();
});
await page.goto('http://127.0.0.1:5204/browser-tests/action-review-navigation.html', {
waitUntil: 'domcontentloaded',
timeout: 120_000,
});
await page.getByRole('button', { name: /Review Update on docker:container:edge-a/ }).click();
await page.waitForFunction(() => location.search === '?action=edge-a');
await page.getByRole('button', { name: /Review Update on docker:container:edge-b/ }).click();
await page.getByRole('dialog').getByText('docker:container:edge-b').first().waitFor();
releaseA();
await page.waitForTimeout(300);
assert.match(await page.getByRole('dialog').innerText(), /docker:container:edge-b/);
assert.doesNotMatch(await page.getByRole('dialog').innerText(), /docker:container:edge-a/);
assert.equal(new URL(page.url()).search, '?action=edge-b');
await page.screenshot({ path: path.join(artifacts, `newest-review-${width}.png`) });
const receiptRead = page.waitForRequest(
(request) =>
new URL(request.url()).pathname === '/api/actions/edge-b' && request.method() === 'GET',
);
await page.getByRole('button', { name: 'Check for receipt' }).click();
await receiptRead;
await page.waitForTimeout(50);
// The pending route is observable through the second GET, not a mutation.
assert.equal(bReads, 2);
await page.getByRole('button', { name: 'Close action review' }).click();
await page.waitForFunction(() => location.search === '');
releaseReceipt();
await page.waitForTimeout(300);
assert.equal(await page.getByRole('dialog').count(), 0);
assert.equal(new URL(page.url()).search, '');
assert.ok(
requests.every((item) => item.startsWith('GET ')),
JSON.stringify(requests),
);
assert.deepEqual(errors, []);
const dimensions = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
inner: innerWidth,
}));
assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
results.push({ width, requests, errors, 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-review-navigation.tsx"></script>
</body>
</html>
@@ -0,0 +1,14 @@
// Exercise the production Actions page with scripted, read-only action responses.
import { Route, Router } from '@solidjs/router';
import { render } from 'solid-js/web';
import Actions from '../src/pages/Actions';
import '../src/index.css';
render(
() => (
<Router>
<Route path="*" component={Actions} />
</Router>
),
document.getElementById('root')!,
);
+63 -20
View File
@@ -1,16 +1,16 @@
{
"version": 1,
"base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
"verified_at": "2026-09-29T19:09:52Z",
"base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484",
"verified_at": "2026-09-29T19:50:45Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/actions/ActionReviewDialog.tsx"
"frontend-modern/src/pages/Actions.tsx"
],
"content_sha256": {
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
"frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce"
},
"routes": [
"/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
"/browser-tests/action-review-navigation.html (production Actions page with scripted action reads)"
],
"viewports": [
{
@@ -23,27 +23,70 @@
}
],
"states": [
"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"
"detail for edge-a held while edge-b is selected and rendered",
"late edge-a response cannot replace edge-b review or URL",
"pending receipt re-read finishes after edge-b review closes"
],
"interactions": [
"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"
"select edge-a then edge-b before the first detail response arrives",
"release older response and inspect current review at desktop and phone widths",
"start same-action receipt GET, close review, release response; no dialog reopens",
"assert all scripted API requests are GET and no horizontal overflow or page errors"
],
"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",
"command": "pulse-worker-browser frontend-modern/browser-tests/action-review-navigation.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/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"
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-browser.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-390.png"
],
"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.",
"notes": "Mock-backed navigation proof of production Actions page; screenshots inspected. No update was executed, no installed or reporter result established.",
"prior_verifications": [
{
"version": 1,
"base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
"verified_at": "2026-09-29T19:09:52Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/actions/ActionReviewDialog.tsx"
],
"content_sha256": {
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
},
"routes": [
"/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"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 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/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/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": "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."
},
{
"version": 1,
"base_sha": "498e2b12a22510520f9df7a510105d6775ea0522",
@@ -0,0 +1,174 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { SecurityAPI } from '@/api/security';
import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit';
import Actions from '../../../pages/Actions';
vi.mock('@/api/resourceActions', () => ({
ResourceActionsAPI: { listActions: vi.fn(), getAction: vi.fn() },
}));
vi.mock('@/api/security', () => ({ SecurityAPI: { getStatus: vi.fn() } }));
vi.mock('@/api/patrol', () => ({ getPatrolAutonomySettings: vi.fn().mockResolvedValue({}) }));
const deferred = <T,>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
};
const audit = (id: string, state: ActionAuditRecord['state'] = 'executing'): ActionAuditRecord => ({
id,
createdAt: '2026-09-29T18:00:00Z',
updatedAt: '2026-09-29T18:01:00Z',
state,
decisionRevision: 1,
request: {
requestId: `request-${id}`,
resourceId: `docker:container:${id}`,
capabilityName: 'update',
reason: `Update ${id}`,
requestedBy: 'operator',
},
plan: {
actionId: id,
requestId: `request-${id}`,
allowed: true,
requiresApproval: false,
approvalPolicy: 'none',
rollbackAvailable: false,
expiresAt: '2026-09-29T21:00:00Z',
planHash: `sha256:plan-${id}`,
},
verificationOutcome: { status: 'unknown' },
});
const detail = (id: string): ActionDetailResponse => ({
audit: audit(id),
events: [],
readiness: {
ready: false,
code: 'receipt_pending',
message: 'Awaiting receipt',
refreshable: false,
checkedAt: '2026-09-29T18:01:00Z',
},
attempt: {
id: `attempt-${id}`,
actionId: id,
state: 'receipt_pending',
createdAt: '2026-09-29T18:00:00Z',
updatedAt: '2026-09-29T18:01:00Z',
dispatchCount: 1,
},
});
const renderActions = () =>
render(() => (
<Router>
<Route path="*" component={Actions} />
</Router>
));
beforeEach(() => {
window.history.replaceState(null, '', '/');
vi.mocked(ResourceActionsAPI.listActions).mockResolvedValue({
actions: [audit('edge-a'), audit('edge-b')],
count: 2,
view: 'pending',
readOnly: false,
});
vi.mocked(SecurityAPI.getStatus).mockResolvedValue({
hasAuthentication: true,
requiresAuth: true,
settingsCapabilities: { authenticationWrite: false },
} as Awaited<ReturnType<typeof SecurityAPI.getStatus>>);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
window.history.replaceState(null, '', '/');
});
describe('Actions request ownership', () => {
it('keeps the newest selected action when an older detail response arrives last', async () => {
const first = deferred<ActionDetailResponse>();
vi.mocked(ResourceActionsAPI.getAction).mockImplementation((id) =>
id === 'edge-a' ? first.promise : Promise.resolve(detail('edge-b')),
);
renderActions();
fireEvent.click(
await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
);
fireEvent.click(
screen.getByRole('button', { name: /Review Update on docker:container:edge-b/ }),
);
expect(await screen.findByText('docker:container:edge-b')).toBeVisible();
first.resolve(detail('edge-a'));
await waitFor(() => expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2));
expect(screen.getByRole('dialog')).toHaveTextContent('docker:container:edge-b');
expect(screen.getByRole('dialog')).not.toHaveTextContent('docker:container:edge-a');
expect(window.location.search).toBe('?action=edge-b');
});
it('does not reopen a closed review when a receipt re-read finishes late', async () => {
const read = deferred<ActionDetailResponse>();
vi.mocked(ResourceActionsAPI.getAction)
.mockResolvedValueOnce(detail('edge-a'))
.mockReturnValueOnce(read.promise);
renderActions();
fireEvent.click(
await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
);
fireEvent.click(await screen.findByRole('button', { name: 'Check for receipt' }));
fireEvent.click(screen.getByRole('button', { name: 'Close action review' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await waitFor(() => expect(window.location.search).toBe(''));
read.resolve({ ...detail('edge-a'), audit: audit('edge-a', 'completed') });
await waitFor(() => expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(window.location.search).toBe('');
});
it('refuses details returned for a different action id', async () => {
vi.mocked(ResourceActionsAPI.getAction).mockResolvedValue(detail('edge-b'));
renderActions();
fireEvent.click(
await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Action details did not match the selected action.',
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('does not show an old Open-list response under History', async () => {
const open = deferred<Awaited<ReturnType<typeof ResourceActionsAPI.listActions>>>();
vi.mocked(ResourceActionsAPI.listActions).mockImplementation((requestedView) =>
requestedView === 'pending'
? open.promise
: Promise.resolve({
actions: [audit('finished', 'completed')],
count: 1,
view: 'settled',
readOnly: false,
}),
);
renderActions();
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
expect(await screen.findByRole('button', { name: /docker:container:finished/ })).toBeVisible();
open.resolve({ actions: [audit('edge-a')], count: 1, view: 'pending', readOnly: false });
await waitFor(() => expect(ResourceActionsAPI.listActions).toHaveBeenCalledTimes(2));
expect(screen.getByRole('button', { name: /docker:container:finished/ })).toBeVisible();
expect(
screen.queryByRole('button', { name: /docker:container:edge-a/ }),
).not.toBeInTheDocument();
});
});
+38 -8
View File
@@ -55,6 +55,8 @@ export function Actions() {
const [loadError, setLoadError] = createSignal('');
const [readOnly, setReadOnly] = createSignal(false);
const [patrolWatchOnly, setPatrolWatchOnly] = createSignal(false);
let listRequestGeneration = 0;
let detailRequestGeneration = 0;
// The Open tab is where users land wondering why nothing is queued. When
// Patrol runs Watch only it never proposes fixes, so the calm state must say
@@ -77,18 +79,22 @@ export function Actions() {
);
const loadActions = async () => {
const generation = ++listRequestGeneration;
const requestedView = view();
setLoading(true);
setLoadError('');
try {
const response = await ResourceActionsAPI.listActions(view());
const response = await ResourceActionsAPI.listActions(requestedView);
if (generation !== listRequestGeneration) return;
setActions(response.actions);
setReadOnly(response.readOnly === true);
} catch (cause) {
if (generation !== listRequestGeneration) return;
setActions([]);
setReadOnly(false);
setLoadError(cause instanceof Error ? cause.message : 'The action store is unavailable.');
} finally {
setLoading(false);
if (generation === listRequestGeneration) setLoading(false);
}
};
@@ -108,14 +114,26 @@ export function Actions() {
view() === 'pending' ? sortOpenActionsForReview(actions()) : actions(),
);
const openActionById = async (actionId: string) => {
setDetailError('');
const openActionById = async (actionId: string, generation: number) => {
try {
const detail = await ResourceActionsAPI.getAction(actionId);
if (
generation !== detailRequestGeneration ||
parseActionReviewId(location.search) !== actionId
)
return;
if (detail.audit.id !== actionId) {
setDetailError('Action details did not match the selected action. Open it again.');
return;
}
setSelected(detail);
setView(getInboxViewForState(detail.audit.state));
} catch (cause) {
setSelected(null);
if (
generation !== detailRequestGeneration ||
parseActionReviewId(location.search) !== actionId
)
return;
setDetailError(cause instanceof Error ? cause.message : 'Action details are unavailable.');
}
};
@@ -125,6 +143,7 @@ export function Actions() {
};
const closeAction = () => {
++detailRequestGeneration;
setSelected(null);
setDetailError('');
setSearchParams({ [ACTION_REVIEW_QUERY_PARAM]: null }, { replace: true });
@@ -137,7 +156,10 @@ export function Actions() {
createEffect(() => {
const actionId = parseActionReviewId(location.search);
if (actionId) void openActionById(actionId);
const generation = ++detailRequestGeneration;
setSelected(null);
setDetailError('');
if (actionId) void openActionById(actionId, generation);
});
return (
@@ -330,9 +352,17 @@ export function Actions() {
detail={selected()}
onClose={closeAction}
onChanged={async (detail) => {
// A read or mutation started in an earlier review must not replace
// the action currently selected by the URL (or reopen a closed one).
if (
selected()?.audit.id !== detail.audit.id ||
parseActionReviewId(location.search) !== detail.audit.id
)
return;
setSelected(detail);
setView(getInboxViewForState(detail.audit.state));
await loadActions();
const nextView = getInboxViewForState(detail.audit.state);
if (view() === nextView) await loadActions();
else setView(nextView); // the view effect loads the new list once
}}
/>
</div>
+107
View File
@@ -235,6 +235,113 @@ func TestInfrastructureIncidentSynthesisShowsPartialRecovery(t *testing.T) {
}
}
func TestSupportedSymptomNotifiesWhenPrimaryRecoversButSymptomPersists(t *testing.T) {
manager := newTestManager(t)
configureUnifiedEvalManager(t, manager, unifiedEvalBaseConfig())
startedAt := time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC)
hostID := "agent:edge-1"
endpointID := "availability:checkout"
host := unifiedresources.Resource{
ID: hostID, Type: unifiedresources.ResourceTypeAgent, Name: "Edge host", Status: unifiedresources.StatusOffline,
Incidents: []unifiedresources.ResourceIncident{{
Provider: "agent", NativeID: "edge-1", Code: "agent_offline", Severity: storagehealth.RiskCritical,
Source: "agent.heartbeat", Summary: "Edge host is offline", StartedAt: startedAt,
ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1,
}},
}
endpoint := unifiedresources.Resource{
ID: endpointID, Type: unifiedresources.ResourceTypeNetworkEndpoint, Name: "Checkout", Status: unifiedresources.StatusOffline,
Availability: &unifiedresources.AvailabilityData{AggregateState: "unavailable", TransportOutcome: "unreachable"},
Relationships: []unifiedresources.ResourceRelationship{{
SourceID: endpointID, TargetID: hostID, Type: unifiedresources.RelChecks, Confidence: 1, Active: true,
}},
Incidents: []unifiedresources.ResourceIncident{{
Provider: "availability", NativeID: "checkout", Code: "availability_unreachable", Severity: storagehealth.RiskCritical,
Source: "availability.probe", Summary: "Checkout is unreachable", StartedAt: startedAt.Add(time.Second),
ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1,
}},
}
var delivered []string
manager.SetAlertCallback(func(alert *Alert) { delivered = append(delivered, alert.ResourceID) })
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
if len(delivered) != 1 || delivered[0] != hostID {
t.Fatalf("grouped delivery = %v, want only the primary host", delivered)
}
for _, alert := range manager.GetActiveAlerts() {
if alert.ResourceID == endpointID && (!isSupportedInfrastructureSymptom(&alert) || alert.LastNotified != nil) {
t.Fatalf("grouped symptom = %+v, want supported and not separately dispatched", alert)
}
}
// The host is healthy again, but the endpoint still fails. It must regain
// its own notification path without requiring a new alert occurrence.
host.Status = unifiedresources.StatusOnline
host.Incidents = nil
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
if len(delivered) != 2 || delivered[1] != endpointID {
t.Fatalf("after primary recovery delivery = %v, want continuing symptom", delivered)
}
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
if len(delivered) != 2 {
t.Fatalf("unchanged independent symptom sent again: %v", delivered)
}
}
func TestPreviouslyNotifiedSymptomDoesNotDuplicateWhenPrimaryRecovers(t *testing.T) {
manager := newTestManager(t)
configureUnifiedEvalManager(t, manager, unifiedEvalBaseConfig())
startedAt := time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC)
hostID := "agent:edge-1"
endpointID := "availability:checkout"
host := unifiedresources.Resource{
ID: hostID, Type: unifiedresources.ResourceTypeAgent, Status: unifiedresources.StatusOnline,
}
endpoint := unifiedresources.Resource{
ID: endpointID, Type: unifiedresources.ResourceTypeNetworkEndpoint, Status: unifiedresources.StatusOffline,
Availability: &unifiedresources.AvailabilityData{AggregateState: "unavailable", TransportOutcome: "unreachable"},
Relationships: []unifiedresources.ResourceRelationship{{
SourceID: endpointID, TargetID: hostID, Type: unifiedresources.RelChecks, Confidence: 1, Active: true,
}},
Incidents: []unifiedresources.ResourceIncident{{
Provider: "availability", NativeID: "checkout", Code: "availability_unreachable", Severity: storagehealth.RiskCritical,
Source: "availability.probe", StartedAt: startedAt.Add(time.Second), ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1,
}},
}
var delivered []string
manager.SetAlertCallback(func(alert *Alert) { delivered = append(delivered, alert.ResourceID) })
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
if len(delivered) != 1 || delivered[0] != endpointID {
t.Fatalf("standalone symptom delivery = %v, want endpoint", delivered)
}
host.Status = unifiedresources.StatusOffline
host.Incidents = []unifiedresources.ResourceIncident{{
Provider: "agent", NativeID: "edge-1", Code: "agent_offline", Severity: storagehealth.RiskCritical,
Source: "agent.heartbeat", StartedAt: startedAt, ConfirmationsRequired: 1, RecoveryConfirmationsRequired: 1,
}}
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
if len(delivered) != 2 || delivered[1] != hostID {
t.Fatalf("new primary delivery = %v, want endpoint then host", delivered)
}
var grouped bool
for _, alert := range manager.GetActiveAlerts() {
if alert.ResourceID == endpointID {
grouped = isSupportedInfrastructureSymptom(&alert)
}
}
if !grouped {
t.Fatal("endpoint did not gain a supported primary")
}
host.Status = unifiedresources.StatusOnline
host.Incidents = nil
manager.SyncUnifiedResourceIncidents([]unifiedresources.Resource{host, endpoint})
if len(delivered) != 2 {
t.Fatalf("already-notified endpoint duplicated after primary recovery: %v", delivered)
}
}
func TestClassifyIncidentFailureSeparatesApplicationCertificateAndCoverage(t *testing.T) {
applicationResource := unifiedresources.Resource{Availability: &unifiedresources.AvailabilityData{
AggregateState: "unavailable", TransportOutcome: "reachable", ApplicationOutcome: "failed",
+24 -6
View File
@@ -260,6 +260,17 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso
m.clearAlertNoLock(storageKey)
}
// Synthesis replaces its previous links in place. Remember which retained
// incidents had a primary delivery owner before that information is cleared.
// If the primary recovers while a symptom remains active, the symptom needs
// its own first notification even though its occurrence did not restart.
previouslySupported := make(map[string]bool)
for storageKey := range desired {
if existing, exists := m.getActiveAlertNoLock(storageKey); exists && isSupportedInfrastructureSymptom(existing) {
previouslySupported[storageKey] = true
}
}
// Synthesize over the post-recovery live set before updating or dispatching
// any detector. Supporting symptoms retain their independent lifecycle but
// can share one evidence-backed incident presentation and delivery decision.
@@ -297,12 +308,19 @@ func (m *Manager) SyncUnifiedResourceIncidents(resources []unifiedresources.Reso
if !m.setActiveAlertNoLock(storageKey, existing) {
continue
}
// Like metric alerts, an existing provider incident becoming critical
// must notify again. Keep its lifecycle and all delivery policy gates;
// unchanged severity and downgrades must not create notification noise.
if oldLevel != existing.Level && existing.Level == AlertLevelCritical &&
!existing.Acknowledged &&
m.allowNotificationByRateLimit(storageKey, existing, "critical-escalation") {
// A still-failing symptom which loses its supported primary needs its
// first notification. An alert already dispatched before grouping must
// not be duplicated when the group dissolves. As with metric alerts, a
// separate increase to critical may also notify through normal gates.
newlyIndependent := previouslySupported[storageKey] &&
!isSupportedInfrastructureSymptom(existing) && existing.LastNotified == nil
becameCritical := oldLevel != existing.Level && existing.Level == AlertLevelCritical
notificationReason := "critical-escalation"
if newlyIndependent {
notificationReason = "supported-primary-ended"
}
if (newlyIndependent || becameCritical) && !existing.Acknowledged &&
m.allowNotificationByRateLimit(storageKey, existing, notificationReason) {
m.dispatchAlert(existing, false)
}
continue
@@ -4,12 +4,34 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func TestProviderMSPGoBuilderMatchesIntegrationMock(t *testing.T) {
// These builders use the same Go patch level and immutable image. The
// mock's build must not exercise a different compiler from the hosted
// control plane merely because Dependabot advanced one Dockerfile.
const expected = "golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"
pattern := regexp.MustCompile(`(?m)^FROM (?:--platform=\$BUILDPLATFORM )?(golang:[^[:space:]]+) AS builder$`)
for _, path := range [][]string{
{"deploy", "provider-msp", "Dockerfile.control-plane"},
{"tests", "integration", "mock-github-server", "Dockerfile"},
} {
content, err := os.ReadFile(repoFile(path...))
if err != nil {
t.Fatalf("read %s: %v", filepath.Join(path...), err)
}
match := pattern.FindStringSubmatch(string(content))
if len(match) != 2 || match[1] != expected {
t.Errorf("%s Go builder = %q, want %q", filepath.Join(path...), match, expected)
}
}
}
func TestProviderMSPControlPlaneImageConsumesExactCandidate(t *testing.T) {
dockerfileBytes, err := os.ReadFile(repoFile("deploy", "provider-msp", "Dockerfile.control-plane"))
if err != nil {
@@ -1,4 +1,4 @@
FROM golang:1.26.8-alpine@sha256:51a7c389a5ddaf82f527191a1e9bff9928655130a44e4975dd1d7e0acf59f1ae AS builder
FROM golang:1.26.8-alpine@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c AS builder
WORKDIR /build
COPY go.mod ./