From 9ddee2f8f91987fb9cbc0bc01a6e21ba7e041a13 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:12:07 +0100 Subject: [PATCH] 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 --- .../v6/internal/subsystems/alerts.md | 17 +++ .../subsystems/frontend-primitives.md | 17 +++ frontend-modern/browser-verification.json | 19 +-- .../OverviewTab.deliverystatus.test.tsx | 13 ++ .../__tests__/OverviewTab.total24h.test.tsx | 33 ++++- .../deliveryDiagnosisPresentation.test.ts | 38 +++++- .../alerts/deliveryDiagnosisPresentation.ts | 11 +- scripts/check-alert-dispatch-copy.mjs | 123 ++++++++++++++++++ 8 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 scripts/check-alert-dispatch-copy.mjs diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index ed5a3b0e3..a47e21132 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 47f916c66..19d0e4991 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index a942fbaa7..5e8b35ad1 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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." ] } diff --git a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx index 995a149a4..631ae1590 100644 --- a/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/OverviewTab.deliverystatus.test.tsx @@ -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(() => ); + 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 = { a1: makeAlert('a1') }; getDeliveryDiagnoses.mockRejectedValue(new Error('boom')); diff --git a/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx b/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx index dedd034d3..1309b153d 100644 --- a/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/OverviewTab.total24h.test.tsx @@ -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(() => ( + + )); + 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(); diff --git a/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts b/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts index ecc7be09c..c5c4f53fd 100644 --- a/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts +++ b/frontend-modern/src/features/alerts/__tests__/deliveryDiagnosisPresentation.test.ts @@ -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([ diff --git a/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts b/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts index b5ca69e95..33db25b55 100644 --- a/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts +++ b/frontend-modern/src/features/alerts/deliveryDiagnosisPresentation.ts @@ -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': diff --git a/scripts/check-alert-dispatch-copy.mjs b/scripts/check-alert-dispatch-copy.mjs new file mode 100644 index 000000000..c3daecccb --- /dev/null +++ b/scripts/check-alert-dispatch-copy.mjs @@ -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
{}} showQuickTip={()=>false} dismissQuickTip={()=>{}} showAcknowledged={()=>true} +setShowAcknowledged={()=>{}} alertsDisabled={()=>false}/>
; } +render(()=>,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( + '
', + ); + } 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(); +}