From 9341d03f207973ecf86762d3ab74a5efba5abc04 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Thu, 15 Oct 2026 06:56:15 -0400 Subject: [PATCH] =?UTF-8?q?feat(ops):=20the=20panel=20render=20gate=20?= =?UTF-8?q?=E2=80=94=20dead-widgets-zero=20wired=20into=20deploy-gate.sh?= =?UTF-8?q?=20(#1804)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After any dist swap, the headless probe boots the REAL panel through the frontdoor and asserts the composer renders, zero stuck loading states past the settle window, no per-URL poll loop above 1 req/s, and every 4xx/5xx carries a documented honest-absence reason. The module is repo-owned and shipped to the harness at gate time — no machine-owned gate code. Acceptance is what the panel renders, not what the tree contains. The standing constraint (no surface ships without live data) becomes mechanical: dead-widgets-zero or the dist does not ship. Detectors exercised on constructed bad signatures (spinner, poll loop, unexplained 404 — all three fail closed) and passing against the live lane build (99 requests, 0 4xx/5xx). --- ops/hub/deploy-gate.sh | 30 ++++++++- ops/hub/panel-gate.mjs | 142 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 ops/hub/panel-gate.mjs diff --git a/ops/hub/deploy-gate.sh b/ops/hub/deploy-gate.sh index 5a006c717..3efbb91e9 100755 --- a/ops/hub/deploy-gate.sh +++ b/ops/hub/deploy-gate.sh @@ -12,6 +12,14 @@ # ops/hub/deploy-gate.sh # frontdoor + jev suites, syntax, configs # ops/hub/deploy-gate.sh --scope engine # + materialize drift + engine typecheck/test lanes # ops/hub/deploy-gate.sh --staged-binary B # + boot B isolated: routes, SSE ids, lists +# ops/hub/deploy-gate.sh --panel-gate # + the panel render gate (#1804): dead-widgets-zero +# +# THE PANEL GATE (#1804, 2026-10-15): after any dist swap, the headless probe +# boots the REAL panel through the frontdoor and asserts the composer renders, +# zero stuck loading states past the settle window, no per-URL poll loop above +# 1 req/s, and every 4xx/5xx carries a documented honest-absence reason. The +# standing constraint is an ACCEPTANCE CRITERION: no surface ships in the +# user's panel without live data. Dead-widgets-zero or the dist does not ship. # AMICODE_GATE_QUICK=1 skips the engine test lane (typecheck still runs). # # Exit 0 = deployable. Anything else = STOP — no stage, no swap, no restart. @@ -20,11 +28,15 @@ HERE="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$HERE/../.." && pwd)" SCOPE="all" STAGED_BIN="" +PANEL_GATE="${AMICODE_PANEL_GATE:-0}" +PANEL_HOST="${AMICODE_PANEL_HOST:-macbook}" +PANEL_HARNESS="${AMICODE_PANEL_HARNESS:-/tmp/opencode/panel-probe}" QUICK="${AMICODE_GATE_QUICK:-0}" while [ $# -gt 0 ]; do case "$1" in --scope) SCOPE="${2:?}"; shift 2 ;; --staged-binary) STAGED_BIN="${2:?}"; shift 2 ;; + --panel-gate) PANEL_GATE=1; shift ;; *) echo "usage: deploy-gate.sh [--scope frontdoor|engine|all] [--staged-binary ]"; exit 2 ;; esac done @@ -130,6 +142,22 @@ if [ -n "$STAGED_BIN" ]; then echo "gate-approved sha recorded: $(sha256sum "$STAGED_BIN" | cut -c1-16)…" fi +# ── the panel render gate (#1804) ──────────────────────────────────────────── +# Acceptance is what the panel RENDERS, not what the tree contains. The module +# is repo-owned; each run ships the current copy to the harness so no machine- +# owned gate code can drift from the discipline. +if [ "$PANEL_GATE" = "1" ]; then + panel_probe() { + local mod="$HERE/panel-gate.mjs" + [ -f "$mod" ] || { echo "PANEL-GATE: module missing ($mod)"; return 1; } + scp -q -o BatchMode=yes -o ConnectTimeout=8 "$mod" "$PANEL_HOST:$PANEL_HARNESS/panel-gate.mjs" || { + echo "PANEL-GATE: cannot ship the module to $PANEL_HOST:$PANEL_HARNESS"; return 1; } + ssh -o BatchMode=yes -o ConnectTimeout=8 "$PANEL_HOST" \ + "cd '$PANEL_HARNESS' && /opt/homebrew/bin/node panel-gate.mjs" + } + run_check "panel render gate (composer + zero dead widgets + poll caps + documented absences)" panel_probe +fi + # ── verdict ───────────────────────────────────────────────────────────────── if [ "$FAILED" != "0" ]; then echo "===============================================================" @@ -138,6 +166,6 @@ if [ "$FAILED" != "0" ]; then exit 1 fi echo "===============================================================" -echo "GATE GREEN — deployable (scope: $SCOPE${STAGED_BIN:+, staged smoke ok})" +echo "GATE GREEN — deployable (scope: $SCOPE${STAGED_BIN:+, staged smoke ok}${PANEL_GATE:+, panel gate ok})" echo "===============================================================" exit 0 diff --git a/ops/hub/panel-gate.mjs b/ops/hub/panel-gate.mjs new file mode 100644 index 000000000..146508c16 --- /dev/null +++ b/ops/hub/panel-gate.mjs @@ -0,0 +1,142 @@ +// panel-gate.mjs — the panel render gate (#1804). Dead-widgets-zero or the +// dist does not ship. +// +// 2026-10-15, Aaron's directive: three times the user rejected a dist whose +// surfaces rendered without live data, and the deploy gates never caught it +// because they measured what the TREE contains, never what the PANEL renders. +// This module is the binding: after any dist swap, boot the real panel through +// the frontdoor, let it settle, and assert — +// (1) the composer renders (the panel is usable at all); +// (2) zero elements stuck in a loading/spinner state past the settle window +// (a dead widget is a spinner that never resolves: "renders data or +// renders nothing" — never "renders waiting forever"); +// (3) no per-URL request rate above 1/s in any sliding second (the poll-loop +// signature of a widget hammering a route that never answers); +// (4) every 4xx/5xx response belongs to the documented honest-absence +// allowlist, reason-checked ({"ok":false,"reason":"no-fleet-peers"} and +// the extension's own no-route 404 for routes the app never calls) — +// an UNEXPLAINED 4xx/5xx from a shipped surface fails the gate. +// +// Exit 0 = the panel gate passes (the dist may ship). Anything else = STOP. +// +// Runs headless via the panel-probe harness (playwright-core) on the client +// machine, driven by ops/hub/deploy-gate.sh over ssh — this file is +// repo-owned; the gate script ships it to the harness before each run. + +import { chromium } from "playwright-core" +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +// The harness's known headless shell path (see sibling probes). +const shell = + process.env.PANEL_GATE_SHELL ?? + homedir() + + "/Library/Caches/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-mac-arm64/chrome-headless-shell" + +const origin = process.env.PANEL_GATE_ORIGIN ?? "http://127.0.0.1:4097/" +const settleMs = Number(process.env.PANEL_GATE_SETTLE_MS ?? 30_000) +const RATE_CAP_PER_S = 1 +const RATE_WINDOW_MS = 1_000 + +if (!existsSync(shell)) { + console.error("PANEL-GATE: headless shell not found at " + shell) + process.exit(2) +} + +// ── the honest-absence allowlist ───────────────────────────────────────────── +// A 4xx/5xx passes the gate ONLY if its body carries a documented reason. +// The no-fleet-peers absence (#1768) is the hub's designed silent fallback; a +// bare 404 from a shipped surface is a dead widget and fails. +const OK_ABSENCE_REASONS = new Set(["no-fleet-peers"]) + +// ── run ───────────────────────────────────────────────────────────────────── +const browser = await chromium.launch({ executablePath: shell, headless: true }) +const page = await browser.newPage() + +const failures = [] +const fail = (why) => failures.push(why) + +const reqs = [] // { url, t } +const responses = [] // { url, status, body? } +page.on("request", (r) => reqs.push({ url: r.url(), t: Date.now() })) +page.on("response", async (r) => { + const entry = { url: r.url(), status: r.status() } + if (r.status() >= 400 && r.url().includes("/amicode/")) { + try { + entry.body = (await r.text()).slice(0, 200) + } catch {} + } + responses.push(entry) +}) +page.on("pageerror", (e) => fail(`page error: ${e.message.slice(0, 160)}`)) + +console.log(`PANEL-GATE: booting ${origin}`) +await page.goto(origin, { waitUntil: "domcontentloaded", timeout: 20_000 }) +await page.waitForTimeout(settleMs) + +// (1) the composer renders — the panel is usable. +const composer = await page.evaluate(() => { + const el = document.querySelector("textarea, [contenteditable=true], [data-composer]") + return el !== null && el.offsetParent !== null +}) +if (!composer) fail("composer did not render (no visible textarea/contenteditable after settle)") + +// (2) zero stuck spinners past the settle window — a surface renders data or +// renders nothing; never waiting forever. Spinner classes are drawn from the +// app's loading states (animate-spin / skeleton / loading / spinner families). +const stuck = await page.evaluate(() => { + const sel = [ + "[class*='animate-spin']", + "[class*='spinner']", + "[class*='skeleton']", + "[data-loading='true']", + "[aria-busy='true']", + ].join(",") + return [...document.querySelectorAll(sel)] + .filter((el) => el.offsetParent !== null) + .slice(0, 5) + .map((el) => el.className.toString().slice(0, 80)) +}) +if (stuck.length > 0) fail(`loading state(s) stuck past the settle window: ${JSON.stringify(stuck)}`) + +// (3) no poll loop: per-URL rate in any sliding 1s window <= RATE_CAP_PER_S. +const perUrl = new Map() +for (const { url, t } of reqs) { + if (!perUrl.has(url)) perUrl.set(url, []) + perUrl.get(url).push(t) +} +for (const [url, ts] of perUrl) { + ts.sort((a, b) => a - b) + for (let i = 0; i + RATE_CAP_PER_S + 1 < ts.length; i++) { + const j = i + RATE_CAP_PER_S + 1 + if (ts[j] - ts[i] < RATE_WINDOW_MS) { + fail(`poll loop: ${url} — ${ts.length} requests, >${RATE_CAP_PER_S}/s in a sliding second`) + break + } + } +} + +// (4) every 4xx/5xx is a documented honest absence, reason-checked. +for (const r of responses.filter((r) => r.status >= 400)) { + let reason = "" + try { + reason = JSON.parse(r.body ?? "{}").reason ?? "" + } catch {} + if (!OK_ABSENCE_REASONS.has(reason)) { + fail(`unexplained ${r.status} from a shipped surface: ${r.url.slice(0, 140)} body=${(r.body ?? "").slice(0, 80)}`) + } +} + +await browser.close() + +// ── verdict ───────────────────────────────────────────────────────────────── +const totalReqs = reqs.length +console.log(`PANEL-GATE: ${totalReqs} requests, ${responses.filter((r) => r.status >= 400).length} 4xx/5xx responses`) +if (failures.length === 0) { + console.log("PANEL-GATE: PASS — composer rendered, zero stuck spinners, no poll loops, all absences documented") + process.exit(0) +} +for (const f of failures) console.error("PANEL-GATE FAIL: " + f) +console.error("PANEL-GATE: FAIL — the dist does not ship. Acceptance is what the panel renders.") +process.exit(1)