fix(alerts): distinguish dispatch from notification receipt

LastNotified is recorded before delivery callbacks, so the active card cannot use it as evidence of destination success. Name dispatch and cooldown eligibility explicitly while preserving policy and timestamp fallbacks.

Pin the evidence boundary in presentation and Overview regressions, subsystem contracts and an isolated real-browser qualification. Scripted diagnoses verify labels and wrapping, not installed delivery or recipient receipt.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-06 15:12:07 +01:00
parent 4fd68c72b2
commit 9ddee2f8f9
8 changed files with 252 additions and 19 deletions
@@ -2647,3 +2647,20 @@ The hook and destinations caller regressions in
`useAlertDestinationsTabState.test.tsx` pin ordering and loading ownership.
`scripts/check-delivery-health-ordering.mjs` exercises the real caller and card
in Chromium with scripted API completions; it is not installed delivery proof.
### Alert status distinguishes dispatch from destination evidence
The active alert card renders a valid diagnosis `lastNotified` timestamp as
“Dispatch requested”, never “Notified”: the alert manager records this field
before invoking delivery callbacks. A cooldown's `nextEligibleAt` is labelled
“next eligible”, not a promised send time. Neither field proves destination
acceptance or recipient receipt; that evidence must not be inferred from the
muted presentation tone. Missing or invalid timestamps retain the existing
pending/cooldown fallback; acknowledged alerts retain their badge without a
second status line. No API field, notification policy or shared primitive changes.
The existing wrapping status text must remain readable at desktop and phone
widths despite the longer labels. The presentation and Overview delivery-status
tests cover the evidence boundary; `scripts/check-alert-dispatch-copy.mjs`
qualifies the real Overview with scripted API data in Chromium, not installed
notification delivery.
@@ -7154,3 +7154,20 @@ confirmation and wrapping controls remain unchanged; no new primitive is added.
The focused hook/caller tests and `scripts/check-delivery-health-ordering.mjs`
cover this dependency at desktop and narrow widths using scripted health and
queue-action responses, without claiming backend notification delivery.
### Alert status distinguishes dispatch from destination evidence
The active alert card renders a valid diagnosis `lastNotified` timestamp as
“Dispatch requested”, never “Notified”: the alert manager records this field
before invoking delivery callbacks. A cooldown's `nextEligibleAt` is labelled
“next eligible”, not a promised send time. Neither field proves destination
acceptance or recipient receipt; that evidence must not be inferred from the
muted presentation tone. Missing or invalid timestamps retain the existing
pending/cooldown fallback; acknowledged alerts retain their badge without a
second status line. No API field, notification policy or shared primitive changes.
The existing wrapping status text must remain readable at desktop and phone
widths despite the longer labels. The presentation and Overview delivery-status
tests cover the evidence boundary; `scripts/check-alert-dispatch-copy.mjs`
qualifies the real Overview with scripted API data in Chromium, not installed
notification delivery.
+7 -12
View File
@@ -1,18 +1,16 @@
{
"version": 1,
"base_sha": "9b4fa52d84e6196108e961ccba2b317df497268b",
"verified_at": "2026-09-06T10:15:18.709765Z",
"base_sha": "4fd68c72b26f033f5b1b0fcb7d7b2b0d67e85ec8",
"verified_at": "2026-09-06T14:12:06.315693Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx",
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts"
"frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/components/AI/Chat/ChatMessages.tsx": "9672f7608d1e3a531c73cba20fd4a78752316783212afd0c292ddfd11d2bf371",
"frontend-modern/src/components/AI/Chat/hooks/useChat.ts": "0b56b7a56e35d51ca96f0e126dd493b3164aa9e0ad4d8ae24bcf3af7a574b97c"
"frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts": "bc3eadf8f790517b377430a42eb67e8fdfcadb53bb13b87cd971d6a9ab91d607"
},
"routes": [
"/patrol"
"/qualification (isolated real OverviewTab, not installed /alerts)"
],
"viewports": [
{
@@ -29,12 +27,9 @@
}
],
"states": [
"Captured healthy and dependency Assistant responses replayed without provider calls. All 7 and 16 terminal tool records retain exact inputs, outputs and distinct rows. Success, failed NO_AGENT and unsupported config results inspected.",
"Controlled stream: concurrent same-name calls, repeated starts, progress, two separate approval cards, sibling completion, canceled call and failed completion. No real action was attempted.",
"Restored persisted healthy and dependency sessions retain original prompts and evidence. Artificial route warning reflects blocked non-GET readiness checks, not route qualification."
"Scripted ready and cooldown diagnoses with lastNotified render Dispatch requested; cooldown says next eligible. Missing timestamp remains Notification pending. No Notified label."
],
"interactions": [
"Hover, focus, Enter/Space expand and collapse, scroll expanded output to its end, Escape, reload and select the original persisted session. Tool details and wrapping inspected at desktop, intermediate and narrow widths.",
"Private evidence: /Volumes/Development/pulse/tmp/patrol-assistant-lab-readiness/identity-verified-healthy, identity-verified-fault and identity-states. Baseline reproductions retained in identity-before-fault and failed focused regressions."
"Loaded real Overview in Chromium via scripts/check-alert-dispatch-copy.mjs; asserted status text ranges fit each viewport and no page errors. Inspected desktop and phone screenshots. No delivery actions invoked; no backend receipt claimed. Screenshots retained in lane outcome evidence."
]
}
@@ -111,6 +111,19 @@ describe('OverviewTab delivery status line', () => {
expect(getDeliveryDiagnoses).toHaveBeenCalled();
});
it.each([
{ status: 'would_send', reason: 'ready' },
{ status: 'suppressed', reason: 'cooldown', nextEligibleAt: '2026-08-26T10:20:00Z' },
] as const)('does not turn dispatch evidence into receipt for $reason', async (state) => {
getDeliveryDiagnoses.mockResolvedValue([
makeDiagnosis('a1', { ...state, lastNotified: '2026-08-26T10:15:00Z' }),
]);
render(() => <OverviewTab {...defaultProps({ activeAlerts: { a1: makeAlert('a1') } })} />);
await waitFor(() => expect(screen.getByText(/^Dispatch requested /)).toBeTruthy());
expect(screen.queryByText(/^Notified /)).toBeNull();
if (state.reason === 'cooldown') expect(screen.getByText(/next eligible/)).toBeTruthy();
});
it('renders no delivery line when the diagnosis fetch fails', async () => {
const activeAlerts: Record<string, Alert> = { a1: makeAlert('a1') };
getDeliveryDiagnoses.mockRejectedValue(new Error('boom'));
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@solidjs/testing-library';
import { cleanup, render, screen, waitFor } from '@solidjs/testing-library';
import { DEFAULT_LOCALE, setActiveLocale } from '@/i18n';
import type { Alert, AlertDeliveryDiagnosis } from '@/types/api';
@@ -75,6 +75,37 @@ describe('OverviewTab Last 24 Hours stat', () => {
setActiveLocale(DEFAULT_LOCALE);
});
it('keeps dispatch evidence separate from the triggered count', async () => {
vi.useRealTimers();
const now = Date.now();
getDeliveryDiagnoses.mockResolvedValue([
{
alertIdentifier: 'old',
alertId: 'old',
status: 'would_send',
reason: 'ready',
lastNotified: new Date(now).toISOString(),
} as AlertDeliveryDiagnosis,
]);
render(() => (
<OverviewTab
{...defaultProps({
activeAlerts: {
old: makeAlert('old', new Date(now - 3 * 86_400_000).toISOString()),
},
})}
/>
));
await waitFor(() => expect(screen.getByText(/^Dispatch requested /)).toBeTruthy());
expect(screen.queryByText(/^Notified /)).toBeNull();
expect(
screen
.getByText('Triggered (24h)')
.closest('tr')
?.querySelector('[data-testid="alert-overview-stat-value"]')?.textContent,
).toBe('0');
});
it('counts only alerts with startTime within the last 24 hours', () => {
const now = Date.now();
const oneHourAgo = new Date(now - 3_600_000).toISOString();
@@ -35,13 +35,45 @@ describe('describeAlertDeliveryStatus', () => {
expect(describeAlertDeliveryStatus(diagnosis, true)).toBeNull();
});
it('shows the notified time when eligible and already notified', () => {
it('shows dispatch evidence without claiming destination success', () => {
const diagnosis = baseDiagnosis({ lastNotified: '2026-08-26T10:15:00Z' });
const line = describeAlertDeliveryStatus(diagnosis, false);
expect(line?.tone).toBe('muted');
expect(line?.label).toMatch(/^Notified /);
expect(line?.label).toMatch(/^Dispatch requested /);
});
it('shows dispatch without promising another send when cooldown has no next time', () => {
const line = describeAlertDeliveryStatus(
baseDiagnosis({
status: 'suppressed',
reason: 'cooldown',
lastNotified: '2026-08-26T10:15:00Z',
}),
false,
);
expect(line?.label).toMatch(/^Dispatch requested /);
expect(line?.label).not.toContain('next');
});
it.each([undefined, '', 'invalid'])(
'does not invent dispatch for timestamp %s',
(lastNotified) => {
expect(describeAlertDeliveryStatus(baseDiagnosis({ lastNotified }), false)?.label).toBe(
'Notification pending',
);
expect(
describeAlertDeliveryStatus(
baseDiagnosis({
status: 'suppressed',
reason: 'cooldown',
lastNotified,
}),
false,
)?.label,
).toBe('Waiting for cooldown');
},
);
it('shows pending when eligible but never notified', () => {
const line = describeAlertDeliveryStatus(baseDiagnosis({}), false);
expect(line).toEqual({ label: 'Notification pending', tone: 'muted' });
@@ -67,7 +99,7 @@ describe('describeAlertDeliveryStatus', () => {
});
const line = describeAlertDeliveryStatus(diagnosis, false);
expect(line?.tone).toBe('muted');
expect(line?.label).toMatch(/^Notified .* — next /);
expect(line?.label).toMatch(/^Dispatch requested .* — next eligible /);
});
it.each([
@@ -54,9 +54,11 @@ export const describeAlertDeliveryStatus = (
const reason = (diagnosis.reason || '').split(':')[0];
// lastNotified is recorded before delivery callbacks; it is dispatch evidence,
// not confirmation that a destination accepted or a person received anything.
if (diagnosis.status === 'would_send') {
const notifiedAt = formatShortTime(diagnosis.lastNotified);
if (notifiedAt) return { label: `Notified ${notifiedAt}`, tone: 'muted' };
if (notifiedAt) return { label: `Dispatch requested ${notifiedAt}`, tone: 'muted' };
return { label: 'Notification pending', tone: 'muted' };
}
@@ -75,9 +77,12 @@ export const describeAlertDeliveryStatus = (
const notifiedAt = formatShortTime(diagnosis.lastNotified);
const nextAt = formatShortTime(diagnosis.nextEligibleAt);
if (notifiedAt && nextAt) {
return { label: `Notified ${notifiedAt} — next ${nextAt}`, tone: 'muted' };
return {
label: `Dispatch requested ${notifiedAt} — next eligible ${nextAt}`,
tone: 'muted',
};
}
if (notifiedAt) return { label: `Notified ${notifiedAt}`, tone: 'muted' };
if (notifiedAt) return { label: `Dispatch requested ${notifiedAt}`, tone: 'muted' };
return { label: 'Waiting for cooldown', tone: 'muted' };
}
case 'rate_limited':
+123
View File
@@ -0,0 +1,123 @@
// Isolated real-browser component qualification; no installed backend or delivery claim.
import { createServer } from "../frontend-modern/node_modules/vite/dist/node/index.js";
import solid from "../frontend-modern/node_modules/vite-plugin-solid/dist/esm/index.mjs";
import { chromium } from "@playwright/test";
import { resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";
const root = resolve("frontend-modern");
process.chdir(root);
const fixture = `
import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router';
import { AlertsAPI } from '/src/api/alerts';
import { NotificationsAPI } from '/src/api/notifications';
import { OverviewTab } from '/src/features/alerts/OverviewTab';
import '/src/index.css';
const ids = ['ready','cooldown','pending'];
AlertsAPI.getDeliveryDiagnoses = async () => ids.map(id => ({
alertIdentifier:id, alertId:id, trackingKey:id, status:id==='cooldown'?'suppressed':'would_send',
reason:id==='cooldown'?'cooldown':'ready', lastNotified:id==='pending'?undefined:'2026-08-26T10:15:00Z',
nextEligibleAt:id==='cooldown'?'2026-08-26T10:20:00Z':undefined
}));
AlertsAPI.getEvents = async () => [];
NotificationsAPI.getHealth = async () => ({queue:{status:'healthy'}});
const alerts = Object.fromEntries(ids.map(id => [id, {id,resourceId:id,resourceName:'VM '+id,
type:'cpu',level:'warning',message:'High CPU on '+id,startTime:'2026-08-26T10:00:00Z',acknowledged:false,node:'node1'}]));
function Fixture() { return <main class="p-4"><OverviewTab overrides={[]} activeAlerts={alerts}
updateAlert={()=>{}} showQuickTip={()=>false} dismissQuickTip={()=>{}} showAcknowledged={()=>true}
setShowAcknowledged={()=>{}} alertsDisabled={()=>false}/></main>; }
render(()=><Router><Route path="/qualification" component={Fixture}/></Router>,document.getElementById('root'));
`;
const server = await createServer({
root,
configFile: false,
optimizeDeps: {
noDiscovery: true,
entries: [],
esbuildOptions: { target: "esnext" },
},
esbuild: { target: "esnext" },
plugins: [
solid(),
{
name: "dispatch-fixture",
configureServer(s) {
s.middlewares.use((req, res, next) => {
if (req.url === "/qualification") {
res.setHeader("Content-Type", "text/html");
res.end(
'<div id="root"></div><script type="module" src="/dispatch-fixture.tsx"></script>',
);
} else next();
});
},
resolveId(id) {
if (id === "/dispatch-fixture.tsx") return id;
},
load(id) {
if (id === "/dispatch-fixture.tsx") return fixture;
},
},
],
resolve: { alias: { "@": resolve(root, "src") } },
server: { host: "127.0.0.1", port: 5198, strictPort: true },
});
let browser;
try {
await server.listen();
browser = await chromium.launch({ headless: true });
mkdirSync("/tmp/pulse-alert-dispatch", { recursive: true });
for (const width of [1440, 900, 390]) {
const page = await browser.newPage({ viewport: { width, height: 1000 } });
const errors = [];
page.on("pageerror", (e) => {
errors.push(e.message);
console.error(e.message);
});
page.on("console", (m) => {
if (m.type() === "error") console.error(m.text());
});
await page.route("http://127.0.0.1:5198/api/**", (route) =>
route.fulfill({ json: [] }),
);
await page.goto("http://127.0.0.1:5198/qualification");
await page.getByText(/^Dispatch requested .*next eligible/).waitFor();
assert.equal(await page.getByText(/^Dispatch requested /).count(), 2);
assert.equal(await page.getByText(/^Notified /).count(), 0);
assert.equal(
await page.getByText("Notification pending", { exact: true }).count(),
1,
);
for (const label of await page.getByText(/^Dispatch requested /).all()) {
assert.equal(
await label.evaluate((el) => {
const r = document.createRange();
r.selectNodeContents(el);
return [...r.getClientRects()].every(
(b) => b.left >= 0 && b.right <= innerWidth,
);
}),
true,
"status text must fit viewport",
);
}
assert.deepEqual(errors, []);
await page.screenshot({
path: "/tmp/pulse-alert-dispatch/" + width + ".png",
fullPage: true,
});
await page.close();
}
console.log(
JSON.stringify({
result: "passed",
viewports: [1440, 900, 390],
scope:
"Real Overview and Chromium; scripted diagnoses, not installed delivery or receipt",
}),
);
} finally {
await browser?.close();
await server.close();
}