From 68ffc8317436b996e9138f67a4d6a45796f5ea05 Mon Sep 17 00:00:00 2001 From: "Grok Bot (for Josh)" <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:10:31 +0800 Subject: [PATCH 1/8] Budget gate: stop the 1.5 KB tolerance stacking past a fixed ceiling WF-03: judgeAgainstMain compared each PR with a main that already held the previous PR's tolerance, so 20 PRs could add 30 KB with every build green. Growth within the tolerance now passes only while the route stays within 3 KB of its fixed budget; a PR measuring no bigger than main still passes. Tightening only; no limit changes. --- docs/ward-flow/PUBLIC-CI.md | 7 +++++-- scripts/check-script-budget.mjs | 14 +++++++++++++- tests/ward-ci-budget-heal.test.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/ward-flow/PUBLIC-CI.md b/docs/ward-flow/PUBLIC-CI.md index c470abf13a..cc7542279f 100644 --- a/docs/ward-flow/PUBLIC-CI.md +++ b/docs/ward-flow/PUBLIC-CI.md @@ -1,6 +1,6 @@ # Public Ward Flow PR checks -_Updated 2026-11-09 - no merge queue is in use; the merge_group triggers are dormant (D-107); Documentation owns._ +_Updated 2026-11-11 - the inherited-overage ceiling (performance audit WF-03); 2026-11-09 - no merge queue is in use; the merge_group triggers are dormant (D-107); Documentation owns._ The `Ward Flow CI` workflow in `.github/workflows/ward-flow.yml` checks the public `BigSimmo/Ward-Flow` repository with pinned actions, locked dependencies, a read-only token and @@ -206,7 +206,10 @@ required production build red until someone raised the limit by hand. Three part establish a match, and the existing ten-minute wait bound remains. - **Fair blame.** The budget step is `budget-gate.mjs`. When a PR's merged result is over budget, it builds and measures main alone in a separate worktree and fails only routes the PR pushed over, - or grew more than 1.5 KB (build noise) past an already-over main. Inherited overage is a warning. It first reuses the + or grew more than 1.5 KB (build noise) past an already-over main. Growth within that 1.5 KB passes only + while the route stays within 3 KB of its fixed budget, so the allowance cannot stack PR after PR; a PR + that measures no bigger than main always passes (performance audit WF-03, 11 November 2026). + Inherited overage is a warning. It first reuses the measurements main's own push run saved for that commit (`main-measured.mjs`), so main is built a second time only when none exist. That build runs only when the merged result is over. - **Trim issue, never an automatic raise.** Main's build saves its measurements as the diff --git a/scripts/check-script-budget.mjs b/scripts/check-script-budget.mjs index 669522c132..a24acdeeb3 100644 --- a/scripts/check-script-budget.mjs +++ b/scripts/check-script-budget.mjs @@ -113,11 +113,21 @@ export function overBudget(measured, budgets) { // Two builds of the same main measured 572.7 and 573.7 KB in CI on 4 November 2026. export const MAIN_TOLERANCE_KB = 1.5; +// The most a route may stand over its own budget and still pass as inherited, measured against the +// fixed budget rather than against main. Without it the tolerance compounded: each PR was compared +// with a main that already held the previous PR's 1.5 KB, so 20 PRs in a row could add 30 KB with +// every build green (performance audit WF-03, 11 November 2026). Twice the tolerance leaves room for +// main's own build noise plus this PR's. A PR that measures no bigger than main still passes, so a +// main that drifted further is never pinned on a PR that did not grow it. +export const MAX_INHERITED_OVERAGE_KB = 2 * MAIN_TOLERANCE_KB; + /** * Decides a pull request whose merged result is over budget, given what main alone measured. * A route fails when the PR pushed it over (main was within budget) or grew it further than * MAIN_TOLERANCE_KB past an already-over main. A route that was over only because main already was * is reported and passes: main's own breakage is tracked by the budget trim issue, not pinned on every other PR. + * Growth within the tolerance passes only while the route stays within MAX_INHERITED_OVERAGE_KB of its + * budget, so the tolerance cannot stack PR after PR; a PR measuring no bigger than main always passes. * No main measurement (main failed to build or measure) fails every over route, as before. */ export function judgeAgainstMain(prMeasured, mainMeasured, budgets) { @@ -127,7 +137,9 @@ export function judgeAgainstMain(prMeasured, mainMeasured, budgets) { for (const entry of over) { const main = mainMeasured?.[entry.route]; const mainKnown = typeof main === "number" && Number.isFinite(main) && main > 0; - if (mainKnown && main > entry.limit && entry.kb <= main + MAIN_TOLERANCE_KB) inherited.push({ ...entry, main }); + const withinNoise = + entry.kb <= main || (entry.kb <= main + MAIN_TOLERANCE_KB && entry.kb <= entry.limit + MAX_INHERITED_OVERAGE_KB); + if (mainKnown && main > entry.limit && withinNoise) inherited.push({ ...entry, main }); else failing.push({ ...entry, main: mainKnown ? main : undefined }); } return { failing, inherited }; diff --git a/tests/ward-ci-budget-heal.test.ts b/tests/ward-ci-budget-heal.test.ts index b867cf1001..7e475f23ca 100644 --- a/tests/ward-ci-budget-heal.test.ts +++ b/tests/ward-ci-budget-heal.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { removePathSync } from "../scripts/retryable-fs.mjs"; -import { judgeAgainstMain, MAIN_TOLERANCE_KB } from "../scripts/check-script-budget.mjs"; +import { judgeAgainstMain, MAIN_TOLERANCE_KB, MAX_INHERITED_OVERAGE_KB } from "../scripts/check-script-budget.mjs"; import { ISSUE_LABEL, ISSUE_TITLE, @@ -113,6 +113,31 @@ describe("budget gate: a PR is failed only for growth it caused", () => { expect(failing.map(({ route }) => route)).toEqual(["/a"]); }); + it("does not let the tolerance stack PR after PR (performance audit WF-03)", () => { + // Each PR grows /b by the full tolerance and then becomes main for the next one. + let main = 572.7; + let passed = 0; + for (let pr = 0; pr < 20; pr += 1) { + const kb = Math.round((main + MAIN_TOLERANCE_KB) * 10) / 10; + if (judgeAgainstMain({ "/a": 490, "/b": kb }, { "/a": 490, "/b": main }, budgets).failing.length) break; + passed += 1; + main = kb; + } + expect(main).toBeLessThanOrEqual(budgets["/b"] + MAX_INHERITED_OVERAGE_KB); + expect(passed).toBeLessThan(3); + }); + + it("still passes a PR that measures no bigger than a main already far over", () => { + const { failing, inherited } = judgeAgainstMain({ "/a": 490, "/b": 580 }, { "/a": 490, "/b": 580 }, budgets); + expect(failing).toEqual([]); + expect(inherited.map(({ route }) => route)).toEqual(["/b"]); + }); + + it("fails growth within the tolerance once the route is past the overage ceiling", () => { + const { failing } = judgeAgainstMain({ "/a": 490, "/b": 580.5 }, { "/a": 490, "/b": 580 }, budgets); + expect(failing.map(({ route }) => route)).toEqual(["/b"]); + }); + it("fails every over route when main could not be measured", () => { const { failing } = judgeAgainstMain({ "/a": 490, "/b": 572.7 }, undefined, budgets); expect(failing.map(({ route }) => route)).toEqual(["/b"]); From 5dac82d9a3845c65bd5761e342529a25c177bd87 Mon Sep 17 00:00:00 2001 From: "Grok Bot (for Josh)" <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:24:28 +0800 Subject: [PATCH 2/8] Budget check: report stylesheet, preload, shared-shell and unbudgeted route sizes Report only, never judged (performance audit WF-01, WF-02, WF-06, WF-18). After the budget verdict the script prints, and saves under "report" in measured.json (and the CI step summary), each budgeted route's stylesheet size, the preloaded script the budget does not count, the script every budgeted route shares, and four heavy routes with no budget line, triage first (719.3 KB of script on 8d948c74). A failure there is only printed. No new limit: setting one needs Josh's decision from a measured baseline. --- docs/ward-flow/PUBLIC-CI.md | 7 +- scripts/check-script-budget.mjs | 138 ++++++++++++++++++++++++++++++- tests/ward-script-budget.test.ts | 60 ++++++++++++++ 3 files changed, 200 insertions(+), 5 deletions(-) diff --git a/docs/ward-flow/PUBLIC-CI.md b/docs/ward-flow/PUBLIC-CI.md index cc7542279f..3d006c08bd 100644 --- a/docs/ward-flow/PUBLIC-CI.md +++ b/docs/ward-flow/PUBLIC-CI.md @@ -1,6 +1,6 @@ # Public Ward Flow PR checks -_Updated 2026-11-11 - the inherited-overage ceiling (performance audit WF-03); 2026-11-09 - no merge queue is in use; the merge_group triggers are dormant (D-107); Documentation owns._ +_Updated 2026-11-11 - the inherited-overage ceiling and report-only sizes (performance audit); 2026-11-09 - no merge queue is in use; the merge_group triggers are dormant (D-107); Documentation owns._ The `Ward Flow CI` workflow in `.github/workflows/ward-flow.yml` checks the public `BigSimmo/Ward-Flow` repository with pinned actions, locked dependencies, a read-only token and @@ -212,6 +212,11 @@ required production build red until someone raised the limit by hand. Three part Inherited overage is a warning. It first reuses the measurements main's own push run saved for that commit (`main-measured.mjs`), so main is built a second time only when none exist. That build runs only when the merged result is over. +- **Report-only sizes.** The same step prints, and saves in `measured.json` under `report`, each budgeted + route's stylesheet size, the preloaded script the budget does not count, the script every budgeted route + shares, and four heavy routes with no budget (`REPORT_ROUTES`, triage first). Nothing there can fail a + build; a limit on any of it is Josh's decision from a measured baseline (performance audit WF-01, WF-02, + WF-06 and WF-18, 11 November 2026). - **Trim issue, never an automatic raise.** Main's build saves its measurements as the `script-budget-measured` artifact. When a main run fails and those show a route over budget, `ward-flow-budget-heal.yml` opens one issue, "Phone script budget: main is over, trim needed", diff --git a/scripts/check-script-budget.mjs b/scripts/check-script-budget.mjs index a24acdeeb3..9c4c981dd1 100644 --- a/scripts/check-script-budget.mjs +++ b/scripts/check-script-budget.mjs @@ -10,7 +10,7 @@ // pull request from two saved measurements (see judgeAgainstMain), so a PR is only failed for a // route it grew itself, never for a main that was already over (4 November 2026). import { spawn } from "node:child_process"; -import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -168,6 +168,121 @@ function judge(prFile, mainFile) { if (failing.length) process.exitCode = 2; } +// ---- Report-only sizes (performance audit WF-01, WF-02, WF-06 and WF-18, 11 November 2026) ---- +// Printed and saved beside the budget measurements, never judged: no limit below can fail a build. +// Setting a limit on any of them is Josh's decision, from a measured baseline (never a raise). + +// Heavy routes with no budget line, reported so their size is at least visible. The triage page was +// 719 KB of script on 11 November 2026, more than the largest budget. +export const REPORT_ROUTES = Object.freeze([ + "/ward-flow/people/PT-028/triage", + "/ward-flow/help", + "/ward-flow/activity", + "/ward-flow/governance", +]); + +/** + * The other static assets a page names: render-blocking stylesheets, and JavaScript the page asks the + * browser to preload (`` or `modulepreload`), which `scriptSources` + * does not count. Same-origin Next static files only, deduplicated, in document order. + */ +export function pageAssets(html) { + const stylesheets = []; + const preloads = []; + const pending = [parse(html)]; + while (pending.length) { + const node = pending.pop(); + const children = node.childNodes ?? []; + for (let index = children.length - 1; index >= 0; index -= 1) pending.push(children[index]); + if (node.tagName !== "link" || node.namespaceURI !== "http://www.w3.org/1999/xhtml") continue; + const attributes = new Map(node.attrs.map(({ name, value }) => [name, value])); + const rel = (attributes.get("rel") ?? "").toLowerCase().split(/\s+/u); + const href = attributes.get("href") ?? ""; + if (!href.startsWith("/_next/static/")) continue; + const url = new URL(href, "http://local.invalid"); + const path = url.pathname + url.search; + if (rel.includes("stylesheet") && url.pathname.endsWith(".css")) stylesheets.push(path); + else if ( + url.pathname.endsWith(".js") && + (rel.includes("modulepreload") || (rel.includes("preload") && attributes.get("as") === "script")) + ) + preloads.push(path); + } + return { stylesheets: [...new Set(stylesheets)], preloads: [...new Set(preloads)] }; +} + +/** + * @typedef {{ budgeted: boolean, scriptKb?: number, cssKb?: number, cssFiles?: number, preloadKb?: number, + * preloadFiles?: number, error?: string }} RouteReport + */ + +/** + * Script, stylesheet and preload sizes per route, plus the script every budgeted route shares. + * @param {string} base + * @param {readonly string[]} budgetedRoutes + * @param {readonly string[]} [extraRoutes] + * @returns {Promise<{ routes: Record, sharedScriptKb: number | undefined }>} + */ +export async function reportSizes(base, budgetedRoutes, extraRoutes = REPORT_ROUTES) { + const gzipped = new Map(); + const sizeOf = async (src) => { + if (!gzipped.has(src)) { + const asset = await fetchWithDeadline(base + src, { redirect: "manual" }); + if (!asset.ok) throw new Error(`${src} answered ${asset.status}`); + gzipped.set(src, gzipSync(Buffer.from(await asset.arrayBuffer()), { level: 9 }).length); + } + return gzipped.get(src); + }; + const total = async (list) => { + let bytes = 0; + for (const src of list) bytes += await sizeOf(src); + return Math.round((bytes / 1024) * 10) / 10; + }; + /** @type {Record} */ + const routes = {}; + /** @type {Set | undefined} */ + let shared; + for (const route of [...budgetedRoutes, ...extraRoutes]) { + try { + const html = await fetchPageHtml(base, route); + const scripts = scriptSources(html); + const { stylesheets, preloads } = pageAssets(html); + const extraPreloads = preloads.filter((src) => !scripts.includes(src)); + routes[route] = { + budgeted: budgetedRoutes.includes(route), + scriptKb: await total(scripts), + cssKb: await total(stylesheets), + cssFiles: stylesheets.length, + preloadKb: await total(extraPreloads), + preloadFiles: extraPreloads.length, + }; + if (budgetedRoutes.includes(route)) + shared = shared ? new Set(scripts.filter((src) => shared.has(src))) : new Set(scripts); + } catch (error) { + routes[route] = { budgeted: budgetedRoutes.includes(route), error: error.message }; + } + } + return { routes, sharedScriptKb: shared ? await total([...shared]) : undefined }; +} + +export function formatReport(report) { + const lines = [ + "Report only, not judged (WF-01, WF-02, WF-06, WF-18): KB gzipped.", + "| Route | Budgeted | Script | Stylesheets | Preloaded script not counted |", + "| --- | --- | --- | --- | --- |", + ]; + for (const [route, size] of Object.entries(report.routes)) { + if (size.error) lines.push(`| ${route} | ${size.budgeted ? "yes" : "no"} | not measured: ${size.error} | | |`); + else + lines.push( + `| ${route} | ${size.budgeted ? "yes" : "no"} | ${size.scriptKb.toFixed(1)} | ${size.cssKb.toFixed(1)} (${size.cssFiles} files) | ${size.preloadKb.toFixed(1)} (${size.preloadFiles} files) |`, + ); + } + if (report.sharedScriptKb !== undefined) + lines.push(`Script every budgeted route loads (shared shell): ${report.sharedScriptKb.toFixed(1)} KB.`); + return lines.join("\n"); +} + // Asks the operating system for a free port, so a busy local port cannot make the check fail. function freePort() { return new Promise((resolve, reject) => { @@ -202,7 +317,8 @@ export const LEGACY_MOVEMENT_REDIRECT = Object.freeze({ destination: "/ward-flow/movements/1nfzcp26wc0g", }); -export async function routeKb(base, route) { +/** Fetches a route's HTML as a phone would, following only the one configured legacy redirect. */ +async function fetchPageHtml(base, route) { const options = { headers: { "user-agent": PHONE_UA }, redirect: "manual" }; let response = await fetchWithDeadline(base + route, options); if ((response.status === 307 || response.status === 308) && route === LEGACY_MOVEMENT_REDIRECT.route) { @@ -217,7 +333,11 @@ export async function routeKb(base, route) { if (!/^text\/html(?:\s*;|$)/i.test(response.headers.get("content-type") ?? "")) { throw new Error(`${route}: expected an HTML page`); } - const sources = scriptSources(await response.text()); + return response.text(); +} + +export async function routeKb(base, route) { + const sources = scriptSources(await fetchPageHtml(base, route)); if (sources.length === 0) throw new Error(`${route}: no Next static scripts found`); let bytes = 0; for (const src of sources) { @@ -253,7 +373,17 @@ async function main(writeTo) { measured[route] = await routeKb(base, route); console.log(`${route}: ${measured[route].toFixed(1)} KB gzipped (budget ${budgets[route]} KB)`); } - if (writeTo) writeFileSync(writeTo, `${JSON.stringify({ measured, budgets }, null, 2)}\n`); + // Report-only sizes never change the verdict below, so any failure here is only printed. + let report; + try { + report = await reportSizes(base, Object.keys(budgets)); + const text = formatReport(report); + console.log(text); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${text}\n`); + } catch (error) { + console.log(`Report-only sizes were not measured: ${error.message}`); + } + if (writeTo) writeFileSync(writeTo, `${JSON.stringify({ measured, budgets, report }, null, 2)}\n`); const over = overBudget(measured, budgets); for (const { route, kb, limit } of over) console.error(`Over budget: ${route} is ${kb.toFixed(1)} KB, budget ${limit} KB`); diff --git a/tests/ward-script-budget.test.ts b/tests/ward-script-budget.test.ts index 9f5b0ada6b..51da291b6c 100644 --- a/tests/ward-script-budget.test.ts +++ b/tests/ward-script-budget.test.ts @@ -7,8 +7,12 @@ import { describe, expect, it, vi } from "vitest"; import { removePathSync } from "../scripts/retryable-fs.mjs"; import { + formatReport, LEGACY_MOVEMENT_REDIRECT, overBudget, + pageAssets, + REPORT_ROUTES, + reportSizes, routeKb, scriptSources, validateBudgets, @@ -310,3 +314,59 @@ describe("phone script budget", () => { }); }); }); + +// Performance audit WF-01, WF-02, WF-06 and WF-18 (11 November 2026): sizes the budget did not see, +// reported beside it and never judged. +describe("report-only sizes", () => { + it("lists stylesheets and preloaded script the script count leaves out", () => { + const html = + '' + + '' + + '' + + '' + + '' + + ''; + expect(pageAssets(html)).toEqual({ + stylesheets: ["/_next/static/css/a.css"], + preloads: ["/_next/static/chunks/p.js", "/_next/static/chunks/m.js"], + }); + }); + + it("measures script, stylesheets, uncounted preloads and the shared shell, and records a failed page", async () => { + const css = "body{color:red}"; + const shell = ''; + await withPages( + { + "/one": { body: `${shell}` }, + "/two": { + body: `${shell}`, + }, + "/_next/static/chunks/a.js": asset, + "/_next/static/chunks/b.js": asset, + "/_next/static/chunks/p.js": asset, + "/_next/static/css/a.css": { type: "text/css", body: css }, + }, + async (base) => { + const report = await reportSizes(base, ["/one", "/two"], ["/missing"]); + const kb = (body: string) => Math.round((gzipSync(Buffer.from(body), { level: 9 }).length / 1024) * 10) / 10; + expect(report.routes["/one"]).toEqual({ + budgeted: true, + scriptKb: kb(chunk), + cssKb: kb(css), + cssFiles: 1, + preloadKb: 0, + preloadFiles: 0, + }); + expect(report.routes["/two"]).toMatchObject({ preloadFiles: 1, cssFiles: 0 }); + expect(report.routes["/missing"]).toEqual({ budgeted: false, error: "/missing answered 404" }); + expect(report.sharedScriptKb).toBe(kb(chunk)); + expect(formatReport(report)).toContain("| /missing | no | not measured: /missing answered 404 | | |"); + }, + ); + }); + + it("reports the heavy unbudgeted triage page", () => { + expect(REPORT_ROUTES).toContain("/ward-flow/people/PT-028/triage"); + for (const route of REPORT_ROUTES) expect(Object.keys(scriptBudget.budgets)).not.toContain(route); + }); +}); From ad63f647d0729c73913fdcfa1c68912d73d88f3a Mon Sep 17 00:00:00 2001 From: "Grok Bot (for Josh)" <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:26:33 +0800 Subject: [PATCH 3/8] Movements: add the Back listener once instead of on every movements change Performance audit WF-27. The popstate listener depended on the movements array, so every change removed and re-added it. It now reads the latest movements through useEffectEvent and is added once for the page's life. The new test failed before (4 listeners added after two escalations instead of 2) and passes after; Back to a missing movement still closes. --- .../movements/movements-screen.tsx | 8 ++- ...d-movements-popstate-listener.dom.test.tsx | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/ward-movements-popstate-listener.dom.test.tsx diff --git a/src/components/ward-management/movements/movements-screen.tsx b/src/components/ward-management/movements/movements-screen.tsx index 9c93f31f19..3686efecb0 100644 --- a/src/components/ward-management/movements/movements-screen.tsx +++ b/src/components/ward-management/movements/movements-screen.tsx @@ -17,6 +17,7 @@ import { useCallback, useDeferredValue, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -200,15 +201,18 @@ export function MovementsScreen() { setDetailId(null); }, []); + // Reads the latest movements when Back fires, so the listener is added once rather than again on + // every movements change (performance audit WF-27, 11 November 2026). + const movementExists = useEffectEvent((id: string) => movements.some((movement) => movement.id === id)); useEffect(() => { function handlePopState(event: PopStateEvent) { const state = event.state; const id = state && Object.hasOwn(state, "wardMovementDetail") ? state.wardMovementDetail : null; - setDetailId(typeof id === "string" && movements.some((movement) => movement.id === id) ? id : null); + setDetailId(typeof id === "string" && movementExists(id) ? id : null); } window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); - }, [movements]); + }, []); // Usability review, 24 Oct 2026: the tab lives in the address, so Back from a record returns to it. const [boardTab, setBoardTab] = useAddressTab("tab", MOVEMENT_BOARD_TABS, "every"); diff --git a/tests/ward-movements-popstate-listener.dom.test.tsx b/tests/ward-movements-popstate-listener.dom.test.tsx new file mode 100644 index 0000000000..043c6bc1e6 --- /dev/null +++ b/tests/ward-movements-popstate-listener.dom.test.tsx @@ -0,0 +1,66 @@ +import { act, render, screen } from "@testing-library/react"; +import { useEffect } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MovementsScreen } from "@/components/ward-management/movements/movements-screen"; +import { useWardFlow, WardFlowProvider } from "@/components/ward-management/ward-flow-provider"; +import { seedWardFlowState } from "@/components/ward-management/ward-flow-reducer"; +import { isOpen } from "@/components/ward-management/ward-derivations"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; + +// Performance audit WF-27 (11 November 2026): the Movements page added a new Back (popstate) +// listener on every movements change. It now adds one for the page's life and still reads the +// latest movements when Back fires. Synthetic seed data only. + +const openMovement = seedWardFlowState().movements.find(isOpen)!; + +let dispatchRef: ReturnType["dispatch"] | undefined; +function DispatchProbe() { + const { dispatch } = useWardFlow(); + useEffect(() => { + dispatchRef = dispatch; + }, [dispatch]); + return null; +} + +function popstateAdds(spy: { mock: { calls: unknown[][] } }) { + return spy.mock.calls.filter(([type]) => type === "popstate").length; +} + +afterEach(() => { + vi.restoreAllMocks(); + dispatchRef = undefined; +}); + +describe("Movements page Back listener (WF-27)", () => { + it("is added once, not again when the movements change, and still opens the latest movement", () => { + const add = vi.spyOn(window, "addEventListener"); + render( + + + + , + ); + const before = popstateAdds(add); + expect(before).toBeGreaterThan(0); + + for (const contact of ["Synthetic desk one", "Synthetic desk two"]) { + act(() => + dispatchRef!({ + type: "RECORD_ESCALATION", + role: "coordinator", + now: NOW_ANCHOR, + movementId: openMovement.id, + triedUnitIds: [], + contact, + }), + ); + } + expect(popstateAdds(add)).toBe(before); + + act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { wardMovementDetail: openMovement.id } }))); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { wardMovementDetail: "missing" } }))); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); From d3e0481b3256aa40116222b4bea3fdf01c5e42ee Mon Sep 17 00:00:00 2001 From: "Grok Bot (for Josh)" <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:29:25 +0800 Subject: [PATCH 4/8] Governance register: look up each event's reviews from one pass Performance audit WF-25 (governance part). historyFor scanned every review for each event, several times per row, so the register's work grew with events times reviews as the audit trail grew. reviewHistoryLookup builds the lookup once per render and returns the same reviews in the same order (same event, same world generation). The new test pins equal answers to the old filter and a single read of the reviews. No visible change. --- .../ward-management/governance-registers.tsx | 6 +- .../governance-review-history.ts | 21 +++++++ tests/ward-governance-review-history.test.ts | 56 +++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 src/components/ward-management/governance-review-history.ts create mode 100644 tests/ward-governance-review-history.test.ts diff --git a/src/components/ward-management/governance-registers.tsx b/src/components/ward-management/governance-registers.tsx index 11288c17b5..31d7bf4d95 100644 --- a/src/components/ward-management/governance-registers.tsx +++ b/src/components/ward-management/governance-registers.tsx @@ -18,6 +18,7 @@ import { useEffectiveWardAccess } from "./shell/ward-access-store"; import eventList from "./governance-event-list.module.css"; import type { WardCommandOutcome } from "./ward-shared-client"; import type { AuditCategory, AuditEvent, AuditReview } from "./ward-audit"; +import { reviewHistoryLookup } from "./governance-review-history"; import { UNDO_TOAST_MS } from "./ward-operational-defaults"; import { DOWNTIME_PACK_HREF, PATIENT_CHRONOLOGY_HREF, WEEKLY_REPORT_HREF } from "./reports/report-routes"; import { @@ -305,8 +306,7 @@ function GovernanceSession({ ...coverEvents(accessReview.covers, api?.worldGeneration ?? 0), ].sort((a, b) => (a.at ?? Number.MAX_SAFE_INTEGER) - (b.at ?? Number.MAX_SAFE_INTEGER)) : events; - const historyFor = (event: AuditEvent) => - reviews.filter((review) => review.eventId === event.id && review.generation === event.generation); + const historyFor = reviewHistoryLookup(reviews); const reviewState = (event: AuditEvent) => historyFor(event).at(-1)?.decision ?? "unreviewed"; const reviewable = rows.filter((event) => event.category !== "review"); const toReview = reviewable.filter((event) => reviewState(event) !== "reviewed").reverse(); @@ -342,7 +342,7 @@ function GovernanceSession({ setAutoOpened(false); setSelection(undefined); } - const history = selected ? historyFor(selected) : []; + const history = selected ? [...historyFor(selected)] : []; const reviewAttempt = pending ? [...events] .reverse() diff --git a/src/components/ward-management/governance-review-history.ts b/src/components/ward-management/governance-review-history.ts new file mode 100644 index 0000000000..b5d88e36c8 --- /dev/null +++ b/src/components/ward-management/governance-review-history.ts @@ -0,0 +1,21 @@ +import type { AuditEvent, AuditReview } from "./ward-audit"; + +/** + * Each audit event's reviews, oldest first, found by one pass over the reviews. The register asked + * for an event's history several times per row, and each ask scanned every review, so the work grew + * with events times reviews as the audit trail grew (performance audit WF-25, 11 November 2026). + * A review belongs to an event only in the same world generation, as before. + */ +export function reviewHistoryLookup( + reviews: readonly AuditReview[], +): (event: Pick) => readonly AuditReview[] { + const byGeneration = new Map>(); + for (const review of reviews) { + let byEvent = byGeneration.get(review.generation); + if (!byEvent) byGeneration.set(review.generation, (byEvent = new Map())); + const list = byEvent.get(review.eventId); + if (list) list.push(review); + else byEvent.set(review.eventId, [review]); + } + return (event) => byGeneration.get(event.generation)?.get(event.id) ?? []; +} diff --git a/tests/ward-governance-review-history.test.ts b/tests/ward-governance-review-history.test.ts new file mode 100644 index 0000000000..080fc30786 --- /dev/null +++ b/tests/ward-governance-review-history.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { reviewHistoryLookup } from "@/components/ward-management/governance-review-history"; +import type { AuditReview } from "@/components/ward-management/ward-audit"; + +// Performance audit WF-25 (11 November 2026): the governance register looked up each event's reviews +// by scanning every review, several times per row. The lookup is now built in one pass and keeps the +// same answer: the event's own reviews in that world generation, oldest first. Synthetic ids only. + +const review = (id: string, eventId: string, generation: number, decision: AuditReview["decision"]): AuditReview => ({ + id, + eventId, + generation, + at: 0 as AuditReview["at"], + byRole: "coordinator", + decision, +}); + +describe("governance review history lookup", () => { + const reviews = [ + review("r1", "E-1", 1, "follow-up-required"), + review("r2", "E-2", 1, "reviewed"), + review("r3", "E-1", 2, "reviewed"), + review("r4", "E-1", 1, "reviewed"), + ]; + + it("gives the same reviews, in the same order, as filtering every review", () => { + const historyFor = reviewHistoryLookup(reviews); + for (const event of [ + { id: "E-1", generation: 1 }, + { id: "E-1", generation: 2 }, + { id: "E-2", generation: 1 }, + { id: "E-3", generation: 1 }, + ]) { + expect(historyFor(event)).toEqual( + reviews.filter((entry) => entry.eventId === event.id && entry.generation === event.generation), + ); + } + expect(historyFor({ id: "E-1", generation: 1 }).at(-1)?.decision).toBe("reviewed"); + }); + + it("reads the reviews once, however many events ask", () => { + let reads = 0; + const counted = { + *[Symbol.iterator]() { + for (const entry of reviews) { + reads += 1; + yield entry; + } + }, + } as unknown as readonly AuditReview[]; + const historyFor = reviewHistoryLookup(counted); + for (let index = 0; index < 50; index += 1) historyFor({ id: `E-${index}`, generation: 1 }); + expect(reads).toBe(reviews.length); + }); +}); From 46f62635e74fd0f34e0e5167e67cd55498c64a8c Mon Sep 17 00:00:00 2001 From: "Grok Bot (for Josh)" <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:11:49 +0800 Subject: [PATCH 5/8] Inbox events: refuse a missing row id instead of throwing Performance audit WF-50 (inbox half). An ACKNOWLEDGE_INBOX_ITEM, and every other inbox row event, with no text row id threw a TypeError out of the audit capture and the reducer instead of being refused. A missing id is now treated as blank, so the existing refusals apply; no wording change. The new tests failed before (9 of 9 threw) and pass after. --- src/components/ward-management/ward-audit.ts | 11 ++++- .../ward-management/ward-inbox-reducer.ts | 15 ++++--- .../ward-inbox-acknowledge-malformed.test.ts | 43 +++++++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 tests/ward-inbox-acknowledge-malformed.test.ts diff --git a/src/components/ward-management/ward-audit.ts b/src/components/ward-management/ward-audit.ts index 7ac12e4614..93cda036bc 100644 --- a/src/components/ward-management/ward-audit.ts +++ b/src/components/ward-management/ward-audit.ts @@ -582,7 +582,14 @@ export function auditSubject(state: WardFlowState, event: WardFlowEvent): AuditS event.type === "SNOOZE_INBOX_ITEM" || event.type === "UNSNOOZE_INBOX_ITEM" ) { - const movement = uniqueRecord(state.movements, inboxRowMovementId(state.movements, event.inboxItemId.trim())); + const movement = uniqueRecord( + state.movements, + inboxRowMovementId( + state.movements, + // A malformed event with no text id is refused by the reducer; never throw here (WF-50). + typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : "", + ), + ); if (movement) return { kind: "movement", @@ -821,7 +828,7 @@ export function captureAuditEvent( category: "inbox", action: event.type, details: { - inboxItemId: event.inboxItemId.trim(), + inboxItemId: typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : "", reason: event.type === "SNOOZE_INBOX_ITEM" && isSnoozeReason(event.reason) ? event.reason : null, until: event.type === "SNOOZE_INBOX_ITEM" && Number.isFinite(event.until) ? event.until : null, }, diff --git a/src/components/ward-management/ward-inbox-reducer.ts b/src/components/ward-management/ward-inbox-reducer.ts index 91684767ea..cda1ece26c 100644 --- a/src/components/ward-management/ward-inbox-reducer.ts +++ b/src/components/ward-management/ward-inbox-reducer.ts @@ -310,7 +310,8 @@ export function reduceInboxEvent( } case "ACKNOWLEDGE_INBOX_ITEM": { - const inboxItemId = event.inboxItemId.trim(); + // A missing or non-text id is refused like a blank one, never thrown (performance audit WF-50). + const inboxItemId = typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : ""; if (inboxItemId.length === 0) { return reject(state, event, "ACKNOWLEDGE_INBOX_ITEM inboxItemId must name a row — a blank names nothing"); } @@ -347,7 +348,7 @@ export function reduceInboxEvent( } case "COMPLETE_INBOX_ITEM": { - const inboxItemId = event.inboxItemId.trim(); + const inboxItemId = typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : ""; if (inboxItemId.length === 0) { return reject(state, event, "COMPLETE_INBOX_ITEM inboxItemId must name a row — a blank names nothing"); } @@ -382,7 +383,7 @@ export function reduceInboxEvent( } case "REOPEN_INBOX_ITEM": { - const inboxItemId = event.inboxItemId.trim(); + const inboxItemId = typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : ""; if (inboxItemId.length === 0) { return reject(state, event, "REOPEN_INBOX_ITEM inboxItemId must name a row — a blank names nothing"); } @@ -400,7 +401,7 @@ export function reduceInboxEvent( } case "TAKE_INBOX_ITEM_OWNERSHIP": { - const inboxItemId = event.inboxItemId.trim(); + const inboxItemId = typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : ""; if (!inboxRowExists(state, inboxItemId)) { return reject( state, @@ -420,7 +421,7 @@ export function reduceInboxEvent( } case "RELEASE_INBOX_ITEM_OWNERSHIP": { - const inboxItemId = event.inboxItemId.trim(); + const inboxItemId = typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : ""; const by = WARD_FLOW_ROLE_LABELS[event.role]; const history = state.inboxOwnership[inboxItemId] ?? []; if (currentInboxOwner(history, inboxRowSince(state, inboxItemId))?.by !== by) { @@ -433,7 +434,7 @@ export function reduceInboxEvent( } case "SNOOZE_INBOX_ITEM": { - const inboxItemId = event.inboxItemId.trim(); + const inboxItemId = typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : ""; if (!inboxRowExists(state, inboxItemId)) { return reject(state, event, `SNOOZE_INBOX_ITEM inboxItemId ${inboxItemId} does not name a real inbox row`); } @@ -467,7 +468,7 @@ export function reduceInboxEvent( } case "UNSNOOZE_INBOX_ITEM": { - const inboxItemId = event.inboxItemId.trim(); + const inboxItemId = typeof event.inboxItemId === "string" ? event.inboxItemId.trim() : ""; const history = state.inboxSnoozes[inboxItemId]; if (!history || !activeSnooze(history, event.now, inboxRowSince(state, inboxItemId))) { return reject(state, event, `inbox row ${inboxItemId} is not snoozed, so there is nothing to return`); diff --git a/tests/ward-inbox-acknowledge-malformed.test.ts b/tests/ward-inbox-acknowledge-malformed.test.ts new file mode 100644 index 0000000000..c782143592 --- /dev/null +++ b/tests/ward-inbox-acknowledge-malformed.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import "@/components/ward-management/ward-flow-reducer-slices"; +import { seedWardFlowState, wardFlowReducer } from "@/components/ward-management/ward-flow-reducer"; +import type { WardFlowEvent } from "@/components/ward-management/ward-flow-events"; +import { NOW_ANCHOR } from "@/components/ward-management/ward-sites"; + +// Performance audit WF-50 (11 November 2026): an ACKNOWLEDGE_INBOX_ITEM with no inbox row id threw a +// TypeError out of the reducer instead of being refused like any other bad event. It is now refused +// with the existing blank-row reason. Synthetic seed data only. + +describe("ACKNOWLEDGE_INBOX_ITEM with a malformed row id", () => { + it.each([undefined, null, 7])("is refused, not thrown, when inboxItemId is %j", (inboxItemId) => { + const state = seedWardFlowState(); + const event = { type: "ACKNOWLEDGE_INBOX_ITEM", role: "coordinator", now: NOW_ANCHOR, inboxItemId }; + let next: ReturnType | undefined; + expect(() => { + next = wardFlowReducer(state, event as unknown as WardFlowEvent); + }).not.toThrow(); + expect(next!.inboxAcknowledgements).toEqual(state.inboxAcknowledgements); + expect(next!.rejections.length).toBe(state.rejections.length + 1); + expect(JSON.stringify(next!.rejections.at(-1))).toContain("a blank names nothing"); + }); +}); + +describe("every inbox row event with no row id", () => { + it.each([ + "COMPLETE_INBOX_ITEM", + "REOPEN_INBOX_ITEM", + "TAKE_INBOX_ITEM_OWNERSHIP", + "RELEASE_INBOX_ITEM_OWNERSHIP", + "SNOOZE_INBOX_ITEM", + "UNSNOOZE_INBOX_ITEM", + ])("refuses %s instead of throwing", (type) => { + const state = seedWardFlowState(); + const event = { type, role: "coordinator", now: NOW_ANCHOR, reason: "other", until: NOW_ANCHOR + 60 }; + let next: ReturnType | undefined; + expect(() => { + next = wardFlowReducer(state, event as unknown as WardFlowEvent); + }).not.toThrow(); + expect(next!.rejections.length).toBe(state.rejections.length + 1); + }); +}); From b1109fb6fdbf297452614ee70b98ad1c6efac86a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 05:50:48 +0000 Subject: [PATCH 6/8] Refresh journey source digests for the inbox and governance edits The perf-audit edits to ward-inbox-reducer.ts and governance-registers.tsx left the journey source reviews stale, so the static checks refused to build the explorer. Behaviour is unchanged (a non-text inbox id is now refused like a blank one; review history lookup is memoised), so only the digests and the shifted line references move. Regenerated outputs with verify.mjs --write. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J352nhYDmTvC457X3TjY2j --- docs/ward-flow/journey/MAP-INVENTORY.md | 22 +++---- docs/ward-flow/journey/actions.json | 60 +++++++++---------- .../ward-flow/journey/event-dispositions.json | 16 ++--- docs/ward-flow/journey/machines.json | 2 +- .../journey/ward-journey-explorer.html | 2 +- 5 files changed, 51 insertions(+), 51 deletions(-) diff --git a/docs/ward-flow/journey/MAP-INVENTORY.md b/docs/ward-flow/journey/MAP-INVENTORY.md index 5bf83b42ed..338d981a71 100644 --- a/docs/ward-flow/journey/MAP-INVENTORY.md +++ b/docs/ward-flow/journey/MAP-INVENTORY.md @@ -3,7 +3,7 @@ The route is selective. Its action notes and the additional command models have source review evidence; source digests detect later edits. This does not establish runtime or clinical acceptance. ## 193 CURRENT ENGINE EVENTS -Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e18616914ab6 +Source-contract SHA256: 5bd57121e382fdecf78bc1d3ccda230eb8dec719bdb35cb821b519346f28aebe - RECORD_ADMISSION_CARE — diagram-reviewed; event roles: ward, coordinator, community Diagram action notes reviewed against the recorded source revision. Source digests detect subsequent edits; this is not runtime acceptance. Permission source: src/components/ward-management/ward-flow-event-roles.ts:20 @@ -1081,19 +1081,19 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 Permission source: src/components/ward-management/ward-flow-event-roles.ts:527 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2191 Event/dispatch reference: src/components/ward-management/ward-inbox-events.ts:71 - Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:349 + Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:350 - REOPEN_INBOX_ITEM — diagram-reviewed; event roles: coordinator Diagram action notes reviewed against the recorded source revision. Source digests detect subsequent edits; this is not runtime acceptance. Permission source: src/components/ward-management/ward-flow-event-roles.ts:528 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2192 Event/dispatch reference: src/components/ward-management/ward-inbox-events.ts:99 - Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:384 + Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:385 - TAKE_INBOX_ITEM_OWNERSHIP — modelled; event roles: coordinator Source-reviewed command model: Ownership history appended; current owner is acting role’s label. Permission source: src/components/ward-management/ward-flow-event-roles.ts:530 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2193 Event/dispatch reference: src/components/ward-management/ward-inbox-events.ts:111 - Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:402 + Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:403 Subject: A coordinator inbox row and its occurrence-specific history Before: Real current inbox row not already owned by this desk After: Ownership history appended; current owner is acting role’s label @@ -1103,7 +1103,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 effects: Appends {at,by} to inboxOwnership; underlying row/fact and its visibility unchanged tests: tests/ward-inbox-snooze.test.ts tests: tests/ward-consolidated-census-20261104.test.ts - Reviewed source: src/components/ward-management/ward-inbox-reducer.ts:402; SHA256 4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d + Reviewed source: src/components/ward-management/ward-inbox-reducer.ts:403; SHA256 423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05 Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:530; SHA256 01913e33459ff2e0c7dfce7de7e0e731cc155400a42b2537e1e6ad7a6c3c83eb Reviewed source: src/components/ward-management/ward-inbox-events.ts:111; SHA256 c3c63fd47887f9bbabdefc8aa6b6742f72848bc9f744afa55b2aa7efd80f037c Reviewed source: src/components/ward-management/ward-access-state.ts:659; SHA256 924e9e6ab48fddbf423e5cf988cc09b75df3ace0ce4f4465f379b143957e3d07 @@ -1113,7 +1113,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 Permission source: src/components/ward-management/ward-flow-event-roles.ts:531 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2194 Event/dispatch reference: src/components/ward-management/ward-inbox-events.ts:121 - Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:422 + Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:423 Subject: A coordinator inbox row and its occurrence-specific history Before: Ownership history’s current entry belongs to acting role, applying a current-row start when available After: Ownership history appended with kind=released; ownership relinquished @@ -1124,7 +1124,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 tests: tests/ward-inbox-snooze.test.ts tests: tests/ward-alerts-release-ownership.dom.test.tsx tests: tests/ward-consolidated-census-20261104.test.ts - Reviewed source: src/components/ward-management/ward-inbox-reducer.ts:422; SHA256 4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d + Reviewed source: src/components/ward-management/ward-inbox-reducer.ts:423; SHA256 423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05 Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:531; SHA256 01913e33459ff2e0c7dfce7de7e0e731cc155400a42b2537e1e6ad7a6c3c83eb Reviewed source: src/components/ward-management/ward-inbox-events.ts:121; SHA256 c3c63fd47887f9bbabdefc8aa6b6742f72848bc9f744afa55b2aa7efd80f037c Reviewed source: src/components/ward-management/ward-access-state.ts:659; SHA256 924e9e6ab48fddbf423e5cf988cc09b75df3ace0ce4f4465f379b143957e3d07 @@ -1134,7 +1134,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 Permission source: src/components/ward-management/ward-flow-event-roles.ts:532 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2195 Event/dispatch reference: src/components/ward-management/ward-inbox-events.ts:133 - Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:435 + Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:436 Subject: A coordinator inbox row and its occurrence-specific history Before: Real current inbox row visible or previously snoozed After: Latest snoozed entry hides row until chosen return time while fact remains @@ -1146,7 +1146,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 effects: Current-occurrence selectors return row when until passes or a newer occurrence starts tests: tests/ward-inbox-snooze.test.ts tests: tests/ward-consolidated-census-20261104.test.ts - Reviewed source: src/components/ward-management/ward-inbox-reducer.ts:435; SHA256 4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d + Reviewed source: src/components/ward-management/ward-inbox-reducer.ts:436; SHA256 423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05 Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:532; SHA256 01913e33459ff2e0c7dfce7de7e0e731cc155400a42b2537e1e6ad7a6c3c83eb Reviewed source: src/components/ward-management/ward-inbox-events.ts:133; SHA256 c3c63fd47887f9bbabdefc8aa6b6742f72848bc9f744afa55b2aa7efd80f037c Reviewed source: src/components/ward-management/ward-access-state.ts:659; SHA256 924e9e6ab48fddbf423e5cf988cc09b75df3ace0ce4f4465f379b143957e3d07 @@ -1156,7 +1156,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 Permission source: src/components/ward-management/ward-flow-event-roles.ts:533 Event/dispatch reference: src/components/ward-management/ward-flow-reducer.ts:2196 Event/dispatch reference: src/components/ward-management/ward-inbox-events.ts:142 - Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:469 + Event/dispatch reference: src/components/ward-management/ward-inbox-reducer.ts:470 Subject: A coordinator inbox row and its occurrence-specific history Before: History has an active snooze, applying a current-row start when available After: Snooze history appended with kind=returned; active snooze ends now @@ -1166,7 +1166,7 @@ Source-contract SHA256: e73d1cd81542d3f2030a3f69d3b7b5dc096d8695d24f6ec6a605e186 effects: Appends returned entry, retaining earlier history; an underlying row, if still present, returns to the active list without resolving its fact tests: tests/ward-inbox-snooze.test.ts tests: tests/ward-consolidated-census-20261104.test.ts - Reviewed source: src/components/ward-management/ward-inbox-reducer.ts:469; SHA256 4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d + Reviewed source: src/components/ward-management/ward-inbox-reducer.ts:470; SHA256 423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05 Reviewed source: src/components/ward-management/ward-flow-event-roles.ts:533; SHA256 01913e33459ff2e0c7dfce7de7e0e731cc155400a42b2537e1e6ad7a6c3c83eb Reviewed source: src/components/ward-management/ward-inbox-events.ts:142; SHA256 c3c63fd47887f9bbabdefc8aa6b6742f72848bc9f744afa55b2aa7efd80f037c Reviewed source: src/components/ward-management/ward-access-state.ts:659; SHA256 924e9e6ab48fddbf423e5cf988cc09b75df3ace0ce4f4465f379b143957e3d07 diff --git a/docs/ward-flow/journey/actions.json b/docs/ward-flow/journey/actions.json index f0f5ca1e11..5287ece3a2 100644 --- a/docs/ward-flow/journey/actions.json +++ b/docs/ward-flow/journey/actions.json @@ -4685,7 +4685,7 @@ { "path": "src/components/ward-management/ward-inbox-reducer.ts", "line": 290, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/shell/ward-bar-state.ts", @@ -4705,12 +4705,12 @@ { "path": "src/components/ward-management/ward-inbox-reducer.ts", "line": 297, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", "line": 301, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" } ] } @@ -4732,7 +4732,7 @@ "message": "ACKNOWLEDGE_INBOX_ITEM inboxItemId must name a row — a blank names nothing", "source": { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 315 + "line": 316 } }, { @@ -4740,7 +4740,7 @@ "message": "ACKNOWLEDGE_INBOX_ITEM inboxItemId ${inboxItemId} does not name a real inbox row — its prefix must be one of INBOX_CATEGORIES and its remainder an existing record for that category", "source": { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 336 + "line": 337 } } ], @@ -4759,7 +4759,7 @@ { "path": "src/components/ward-management/ward-inbox-reducer.ts", "line": 312, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-tasks-drawer.tsx", @@ -4773,13 +4773,13 @@ }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 315, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 316, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 336, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 337, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" } ] } @@ -4801,7 +4801,7 @@ "message": "COMPLETE_INBOX_ITEM inboxItemId must name a row — a blank names nothing", "source": { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 352 + "line": 353 } }, { @@ -4809,7 +4809,7 @@ "message": "inbox row ${inboxItemId} does not name a day for the daily check", "source": { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 369 + "line": 370 } }, { @@ -4817,7 +4817,7 @@ "message": "inbox row ${inboxItemId} is already complete", "source": { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 373 + "line": 374 } } ], @@ -4835,8 +4835,8 @@ }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 349, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 350, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-tasks-drawer.tsx", @@ -4850,18 +4850,18 @@ }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 352, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 353, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 369, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 370, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 373, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 374, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" } ] } @@ -4883,7 +4883,7 @@ "message": "REOPEN_INBOX_ITEM inboxItemId must name a row — a blank names nothing", "source": { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 387 + "line": 388 } }, { @@ -4891,7 +4891,7 @@ "message": "inbox row ${inboxItemId} is not complete, so there is nothing to reopen", "source": { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 391 + "line": 392 } } ], @@ -4909,8 +4909,8 @@ }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 384, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 385, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-tasks-drawer.tsx", @@ -4919,13 +4919,13 @@ }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 387, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 388, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 391, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 392, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" } ] } @@ -5260,7 +5260,7 @@ { "path": "src/components/ward-management/governance-registers.tsx", "line": 546, - "sha256": "c68c89bd3aa60a17ca378d9e3f369d3ee42ffa57ac035d1ca8ebd4e29c8b634d" + "sha256": "198ab9034a632ffca21ae9f719bed97ce6b53f40f7148085fb9ddd034da46a2c" } ] } diff --git a/docs/ward-flow/journey/event-dispositions.json b/docs/ward-flow/journey/event-dispositions.json index 4c76c503ae..d922d1918b 100644 --- a/docs/ward-flow/journey/event-dispositions.json +++ b/docs/ward-flow/journey/event-dispositions.json @@ -1552,8 +1552,8 @@ "sources": [ { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 402, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 403, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-flow-event-roles.ts", @@ -1597,8 +1597,8 @@ "sources": [ { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 422, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 423, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-flow-event-roles.ts", @@ -1650,8 +1650,8 @@ "sources": [ { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 435, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 436, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-flow-event-roles.ts", @@ -1697,8 +1697,8 @@ "sources": [ { "path": "src/components/ward-management/ward-inbox-reducer.ts", - "line": 469, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "line": 470, + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-flow-event-roles.ts", diff --git a/docs/ward-flow/journey/machines.json b/docs/ward-flow/journey/machines.json index 2fcdf924c5..7934a4b41f 100644 --- a/docs/ward-flow/journey/machines.json +++ b/docs/ward-flow/journey/machines.json @@ -1879,7 +1879,7 @@ { "path": "src/components/ward-management/ward-inbox-reducer.ts", "line": 58, - "sha256": "4a7dde821d15d7032a38cb18333ed34872d78fb28e4be6efb382ad5e9095c81d" + "sha256": "423499562da6da82c4e887f5ab5aade3c572049781b80eb62070bddbff3d4d05" }, { "path": "src/components/ward-management/ward-model-leave.ts", diff --git a/docs/ward-flow/journey/ward-journey-explorer.html b/docs/ward-flow/journey/ward-journey-explorer.html index 70659ed84a..f9a777ddb7 100644 --- a/docs/ward-flow/journey/ward-journey-explorer.html +++ b/docs/ward-flow/journey/ward-journey-explorer.html @@ -1284,7 +1284,7 @@

What to do here

', + }, + "/two": { + body: '', + }, + "/_next/static/chunks/a.js": asset, + "/_next/static/chunks/b.js": asset, + }; + await withPages(pages, async (base) => { + const kb = (body: string) => Math.round((gzipSync(Buffer.from(body), { level: 9 }).length / 1024) * 10) / 10; + const report = await reportSizes(base, ["/one", "/two"], []); + expect(report.routes["/two"]).toMatchObject({ budgeted: true, error: expect.stringContaining("gone.css") }); + // Only a.js is on both budgeted pages; b.js must not count as shared. + expect(report.sharedScriptKb).toBe(kb(chunk)); + + const unreadable = await reportSizes(base, ["/one", "/missing"], []); + expect(unreadable.sharedScriptKb).toBeUndefined(); + expect(formatReport(unreadable)).not.toContain("shared shell"); + }); + }); + it("reports the heavy unbudgeted triage page", () => { expect(REPORT_ROUTES).toContain("/ward-flow/people/PT-028/triage"); for (const route of REPORT_ROUTES) expect(Object.keys(scriptBudget.budgets)).not.toContain(route); From 908f3fcae865707f96898053a0d3ae504629c72d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 17:50:56 +0000 Subject: [PATCH 8/8] Carry #1631's mirror-free browser setup so CI can run during the Ubuntu mirror outage Brings in the workflow, install-chromium script and test from #1631 (a5dd0ff6b) unchanged, so browser system packages install from the cache instead of the Ubuntu mirror. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GxPPxn8xZAWHw5dJPGdUjY --- .github/workflows/ward-flow.yml | 17 ++++-- scripts/ward-ci-public/install-chromium.mjs | 59 ++++++++++++++++++++- tests/ward-browser-install.test.ts | 48 +++++++++++++++++ 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ward-flow.yml b/.github/workflows/ward-flow.yml index e788f42626..c7ce0b48c1 100644 --- a/.github/workflows/ward-flow.yml +++ b/.github/workflows/ward-flow.yml @@ -377,15 +377,26 @@ jobs: - name: Seed release system packages if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" + # WebKit's own packages too, so the offline install covers both engines when the mirror is down. + # Same path as the save step, because the path is part of the cache version. + - name: Restore release WebKit system packages + if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ~/.cache/ward-release-apt + key: ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}-${{ steps.shared-version.outputs.image }} + restore-keys: | + ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}- + - name: Seed release WebKit system packages + if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} + run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" - name: Install Chromium and WebKit for the shared-mode journeys id: shared-chromium timeout-minutes: 10 if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} env: PLAYWRIGHT_CACHE_HIT: ${{ steps.shared-cache.outputs.cache-hit }} - run: | - node scripts/ward-ci-public/install-chromium.mjs - node node_modules/playwright/cli.js install --with-deps webkit + run: node scripts/ward-ci-public/install-chromium.mjs chromium webkit - name: Run the shared-mode browser journey timeout-minutes: 20 if: ${{ !cancelled() && steps.shared-chromium.outcome == 'success' }} diff --git a/scripts/ward-ci-public/install-chromium.mjs b/scripts/ward-ci-public/install-chromium.mjs index cdaa7da064..e88a3d9af5 100644 --- a/scripts/ward-ci-public/install-chromium.mjs +++ b/scripts/ward-ci-public/install-chromium.mjs @@ -1,6 +1,8 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; +import { APT_ARCHIVES } from "./apt-archive-cache.mjs"; const root = fileURLToPath(new URL("../../", import.meta.url)); const cli = resolve(root, "node_modules/playwright/cli.js"); @@ -21,7 +23,10 @@ export function waitForPackageManager(deadline, now = Date.now) { /** * Six-minute shared budget, two attempts per operation; the workflow adds an eight-minute outer bound. - * @param {{ browsers?: string[], cacheHit?: boolean, execute?: (command: string, args: string[], timeoutMs: number) => { status: number | null, error?: { code?: string }, signal?: string | null }, now?: () => number, log?: (message: string) => void, waitForIdle?: (deadline: number) => void }} options + * When the seed step has left cached system packages in apt's archive folder, they are installed + * first with no network at all, then each browser must launch; only if that fails does the setup + * fall back to Playwright's own apt-get, which needs the runner's Ubuntu mirror. + * @param {{ browsers?: string[], cacheHit?: boolean, cachedDebs?: string[], execute?: (command: string, args: string[], timeoutMs: number) => { status: number | null, error?: { code?: string }, signal?: string | null }, now?: () => number, log?: (message: string) => void, waitForIdle?: (deadline: number) => void }} options */ export function installBrowsers({ browsers = ["chromium"], @@ -30,6 +35,7 @@ export function installBrowsers({ now = Date.now, log = console.log, waitForIdle = execute === runBounded ? (deadline) => waitForPackageManager(deadline, now) : () => {}, + cachedDebs = execute === runBounded ? archiveDebs() : [], } = {}) { if ( !browsers.length || @@ -39,6 +45,7 @@ export function installBrowsers({ throw new Error("Invalid browser selection; name unique supported engines"); const label = browsers.length === 1 && browsers[0] === "chromium" ? "Chromium" : "Playwright browser"; const deadline = now() + 360_000; + if (cachedDebs.length && installOffline({ browsers, cachedDebs, execute, now, deadline, label, log }) === 0) return 0; const operations = cacheHit ? [ ["install-deps", ...browsers], @@ -67,6 +74,54 @@ export function installBrowsers({ return 0; } +/** The cached .deb files the seed step copied in; none when the cache missed. */ +export function archiveDebs(dir = APT_ARCHIVES) { + return existsSync(dir) + ? readdirSync(dir) + .filter((name) => name.endsWith(".deb")) + .sort() + .map((name) => join(dir, name)) + : []; +} + +/** A fixed script; the Playwright path and the engine names arrive as arguments, never as code. */ +const LAUNCH_PROBE = `const [playwright, ...names] = process.argv.slice(1); +const pw = require(playwright); +(async () => { + for (const name of names) { + const browser = await pw[name].launch(); + await browser.close(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +});`; + +/** + * The runner's Ubuntu mirror can be unreachable for hours (11 Nov 2026: "Temporary failure + * resolving 'azure.archive.ubuntu.com'"), and Playwright's install-deps always runs apt-get update + * first. Install the cached packages as local files with downloads switched off, fetch the browsers + * alone, and pass only if every browser launches. Any failure hands back to the online path. + */ +function installOffline({ browsers, cachedDebs, execute, now, deadline, label, log }) { + log(`${label} setup: installing ${cachedDebs.length} cached system packages without the network.`); + const steps = [ + ["sudo", ["apt-get", "install", "-y", "--no-download", "--no-install-recommends", ...cachedDebs], 120_000], + [process.execPath, [cli, "install", ...browsers], 120_000], + [process.execPath, ["-e", LAUNCH_PROBE, resolve(root, "node_modules/playwright"), ...browsers], 60_000], + ]; + for (const [command, args, limit] of steps) { + const remaining = deadline - now(); + const result = remaining > 0 ? execute(command, args, Math.min(limit, remaining)) : undefined; + if (!result || result.error || result.status !== 0) { + log(`${label} setup: the offline install did not finish; trying the runner's apt mirror.`); + return 1; + } + } + log(`${label} setup: installed from cached system packages; every browser launches.`); + return 0; +} + // Existing consumers retain their default Chromium-only command and retry budget. /** Compatibility entry point for callers requesting Chromium only. @param {Parameters[0]} [options] */ export function installChromium(options) { diff --git a/tests/ward-browser-install.test.ts b/tests/ward-browser-install.test.ts index 7d5bbcb3b9..58159bff75 100644 --- a/tests/ward-browser-install.test.ts +++ b/tests/ward-browser-install.test.ts @@ -34,6 +34,54 @@ describe("bounded release browser setup", () => { ]); }); + it("installs cached system packages without the network and stops once every browser launches", () => { + const commands: string[][] = []; + expect( + installChromium({ + browsers: ["chromium", "webkit"], + cacheHit: true, + cachedDebs: ["/var/cache/apt/archives/a.deb", "/var/cache/apt/archives/b.deb"], + log: () => {}, + execute(command, args) { + commands.push([command, ...args]); + return { status: 0 }; + }, + }), + ).toBe(0); + expect(commands).toHaveLength(3); + expect(commands[0]).toEqual([ + "sudo", + "apt-get", + "install", + "-y", + "--no-download", + "--no-install-recommends", + "/var/cache/apt/archives/a.deb", + "/var/cache/apt/archives/b.deb", + ]); + expect(commands[1].slice(2)).toEqual(["install", "chromium", "webkit"]); + expect(commands[2][1]).toBe("-e"); + expect(commands[2].slice(-2)).toEqual(["chromium", "webkit"]); + expect(commands.flat()).not.toContain("install-deps"); + expect(commands.flat()).not.toContain("--with-deps"); + }); + + it("falls back to the apt mirror when a browser does not launch after the offline install", () => { + const commands: string[][] = []; + expect( + installChromium({ + cachedDebs: ["/var/cache/apt/archives/a.deb"], + log: () => {}, + execute(command, args) { + commands.push([command, ...args]); + return { status: args[0] === "-e" ? 1 : 0 }; + }, + }), + ).toBe(0); + expect(commands).toHaveLength(4); + expect(commands[3].slice(2)).toEqual(["install", "--with-deps", "chromium"]); + }); + it("refuses an unsupported or empty engine selection without invoking a command", () => { const execute = () => { throw new Error("must not start an installation");