From 5453b177cbc24a39b4a04b026278ed52604ffb82 Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 30 Jul 2026 18:01:32 +0000 Subject: [PATCH 1/5] fix(board): live harnesses archive their own throwaway boards (ready-153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every run of live-write-roundtrip.mjs and live-roundtrip-both-ways.mjs provisions a fresh, unarchived kind-30301 board and left it behind forever — 54 measured on the relay before this change (44 at the item's original measurement, more added by the dispatch that filed it). Every one showed up in the owner's `rd board` portfolio, a view that only ever grew. Each harness now runs `rd board archive ` on its own throwaway board in its `finally` clause (not a happy-path line at the end), so a run that fails partway still does not leave a permanent stray. Proved by running both harnesses in full: the owner's unarchived-board count was 25 before either run and 25 after both (total board count on the relay rose by 2, both immediately archived). archive-stray-boards.mjs cleans up the pre-existing strays: a kind-only, `until`-paged relay walk (never `authors` — it silently under-returns on wss://relay.3dl.network) enumerates every board this key owns, matches the naming prefixes the harnesses (and their prior item-numbered names) have used, and archives whatever is still unarchived — the count is always derived from the live relay, never asserted. Ran once: 54/54 archived. live-portfolio.mjs was named in the item's context as a third offender but is read-only (an oracle walk + `rd list --offline`, no `rd init` anywhere) — it creates no throwaway boards and needed no change; noted as a finding rather than fixed on assumption. Co-Authored-By: Claude Opus 5 (1M context) --- web/board/scripts/archive-stray-boards.mjs | 216 ++++++++++++++++++ .../scripts/live-roundtrip-both-ways.mjs | 26 ++- web/board/scripts/live-write-roundtrip.mjs | 26 ++- 3 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 web/board/scripts/archive-stray-boards.mjs diff --git a/web/board/scripts/archive-stray-boards.mjs b/web/board/scripts/archive-stray-boards.mjs new file mode 100644 index 0000000..c526935 --- /dev/null +++ b/web/board/scripts/archive-stray-boards.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node +// archive-stray-boards.mjs — ready-153's one-shot cleanup for the live +// harnesses' own throwaway boards. +// +// THE PROBLEM: live-write-roundtrip.mjs and live-roundtrip-both-ways.mjs each +// provision a fresh, PUBLIC-visible board on every run and (as of ready-153) +// archive it themselves in a `finally` clause. Every run BEFORE that fix left +// its board behind, permanently, in the owner's `rd board` portfolio — 44 of +// them, measured 2026-07-30. +// +// WHAT THIS DOES: walks wss://relay.3dl.network for every kind-30301 board +// definition owned by the LOCAL machine's rd key, finds the ones whose "d" tag +// matches one of the naming prefixes those harnesses (and their earlier, +// renamed incarnations) have used, and archives every one that is not already +// archived — via the real `rd board archive`, so the marker is the exact +// signed event the CLI itself publishes; nothing is invented here. +// +// NO `authors` FILTER (relay measurement discipline: wss://relay.3dl.network +// silently under-returns on one — measured 42/56 vs 56/56 for a paged +// kind-only walk, same relay, same run, ready-5c5). The walk below is +// kind-only, paged backwards with `until` at limit 500, exactly as +// live-portfolio.mjs's own oracle does; ownership is applied CLIENT-SIDE on +// the events that came back. +// +// THE PREFIX LIST IS THE NAMING CONVENTION ITSELF, not a stray count: every +// throwaway board these harnesses (and their prior item-numbered names) have +// ever provisioned derives its "d" tag from one of these five prefixes. HOW +// MANY boards match is always counted live against the relay below — never +// asserted as a fixed number here or anywhere this script's output is read. +// +// Usage: node scripts/archive-stray-boards.mjs [--relay wss://…] [--dry-run] +// Exits non-zero if any matched board fails to archive. + +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +const argv = process.argv.slice(2); +const RELAY = argv.includes("--relay") ? argv[argv.indexOf("--relay") + 1] : "wss://relay.3dl.network"; +const DRY_RUN = argv.includes("--dry-run"); + +const KIND_BOARD = 30301; +// The relay caps a single REQ at 500 (measured); paging less would just page +// more often. +const PAGE_LIMIT = 500; + +// b2blive/c191live: live-write-roundtrip.mjs (public/confidential, ready-b2b +// then ready-191). b4359/c4359: live-roundtrip-both-ways.mjs (ready-4359, +// same public/confidential split). s48f: live-stranger-walk.mjs (ready-48f, +// not yet merged to main but already run against the live relay). +const STRAY_PREFIXES = ["b2blive", "b4359", "c191live", "c4359", "s48f"]; + +const log = (...a) => console.log(...a); + +function rdHome() { + if (process.env.RD_HOME) return process.env.RD_HOME; + const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(xdg, "rd"); +} + +function reqOnce(relay, filter) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relay); + const out = []; + const sub = `strays${Math.random().toString(36).slice(2, 10)}`; + const t = setTimeout(() => { + try { + ws.close(); + } catch { + /* closed */ + } + resolve(out); + }, 45000); + ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); + ws.onmessage = (m) => { + const f = JSON.parse(m.data); + if (f[0] === "EVENT" && f[1] === sub) out.push(f[2]); + else if (f[0] === "EOSE" && f[1] === sub) { + clearTimeout(t); + try { + ws.send(JSON.stringify(["CLOSE", sub])); + ws.close(); + } catch { + /* closed */ + } + resolve(out); + } + }; + ws.onerror = () => { + clearTimeout(t); + reject(new Error(`relay ${relay}: connection failed`)); + }; + }); +} + +/** + * discoverBoards walks the relay for EVERY kind-30301 event (no `authors` + * filter — see this file's header) and returns the latest-per-coordinate + * definition for each one authored by `ownerPubkey`. + */ +async function discoverBoards(relay, ownerPubkey) { + const seen = new Map(); + let until; + for (let page = 0; page < 40; page++) { + const filter = { kinds: [KIND_BOARD], limit: PAGE_LIMIT }; + if (until !== undefined) filter.until = until; + const got = await reqOnce(relay, filter); + let added = 0; + let oldest = until; + for (const e of got) { + if (!seen.has(e.id)) { + seen.set(e.id, e); + added++; + } + if (oldest === undefined || e.created_at < oldest) oldest = e.created_at; + } + log(` page ${page + 1}: ${got.length} events, ${added} new, ${seen.size} total`); + if (added === 0 || got.length < PAGE_LIMIT) break; + if (oldest === undefined) break; + until = oldest - 1; + } + const mine = []; + for (const e of seen.values()) { + if (e.pubkey !== ownerPubkey) continue; + const d = (e.tags ?? []).find((t) => t[0] === "d")?.[1]; + if (!d) continue; + const archived = ((e.tags ?? []).find((t) => t[0] === "archived")?.[1] ?? "") !== ""; + mine.push({ coord: `${KIND_BOARD}:${e.pubkey}:${d}`, boardD: d, archived, createdAt: e.created_at }); + } + // Latest-wins per coordinate — an addressable (30301) event means the relay + // itself only ever serves one per (kind, pubkey, d), but this walk pages + // backwards through history and can see a superseded copy too. + const byCoord = new Map(); + for (const b of mine) { + const prev = byCoord.get(b.coord); + if (!prev || b.createdAt > prev.createdAt) byCoord.set(b.coord, b); + } + return [...byCoord.values()]; +} + +async function main() { + const idPath = path.join(rdHome(), "nostr-identity.json"); + const identity = JSON.parse(readFileSync(idPath, "utf8")); + const owner = identity.pubkey_hex; + + log(`walking ${RELAY} for every kind-30301 board owned by ${owner}`); + const all = await discoverBoards(RELAY, owner); + log(`\n${all.length} board(s) total on the relay for this key`); + + const strays = all.filter((b) => !b.archived && STRAY_PREFIXES.some((p) => b.boardD.startsWith(p))); + log(`${strays.length} unarchived stray(s) matching [${STRAY_PREFIXES.join(", ")}]`); + for (const b of strays) log(` ${b.boardD}`); + + if (strays.length === 0) { + log("\nnothing to archive."); + return; + } + + if (DRY_RUN) { + log("\n--dry-run: not archiving."); + return; + } + + // A real nostr-native project directory is required to run `rd board + // archive` (it supplies the signing key, local durability log, and + // configured relays) but need NOT pin any of the stray boards' own + // coordinates — the command takes its target as an explicit argument, and + // the archive event for a foreign board-d lands harmlessly in this + // scratch project's own local log (see board_archive.go's header comment). + const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-archive-strays-")); + try { + const rdBin = path.join(tmp, "rd"); + execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); + + const home = path.join(tmp, "home"); + mkdirSync(home, { recursive: true }); + writeFileSync(path.join(home, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); + + const dir = path.join(tmp, "scratch"); + mkdirSync(path.join(dir, ".ready"), { recursive: true }); + writeFileSync( + path.join(dir, ".ready", "config.json"), + JSON.stringify({ project_name: "archive-strays-scratch", board: strays[0].coord, public: true }, null, 2), + ); + + let failures = 0; + for (const b of strays) { + try { + execFileSync(rdBin, ["board", "archive", b.coord], { + cwd: dir, + env: { ...process.env, RD_HOME: home, RD_NOSTR_RELAY_URL: RELAY }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + log(` archived ${b.coord}`); + } catch (err) { + failures++; + console.error(` FAILED to archive ${b.coord}: ${err.message}`); + } + } + if (failures > 0) { + console.error(`\n${failures}/${strays.length} archive(s) failed`); + process.exit(1); + } + log(`\narchived ${strays.length}/${strays.length} stray board(s)`); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error(err.stack ?? err); + process.exit(1); +}); diff --git a/web/board/scripts/live-roundtrip-both-ways.mjs b/web/board/scripts/live-roundtrip-both-ways.mjs index 9dbd23d..727ab9a 100644 --- a/web/board/scripts/live-roundtrip-both-ways.mjs +++ b/web/board/scripts/live-roundtrip-both-ways.mjs @@ -401,10 +401,14 @@ async function main() { const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-4359-")); const cleanup = []; + // Hoisted out of the try block so the `finally` clause below can archive the + // throwaway board EVEN WHEN THE SCRIPT FAILS PARTWAY — a red run must not + // leave a permanent stray in the owner's portfolio (ready-153). + let rdBin, projectDir, writerHome, coord; try { step("build rd from this tree"); - const rdBin = path.join(tmp, "rd"); + rdBin = path.join(tmp, "rd"); execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); step("prepare the injected signer (REAL secp256k1 — see this file's header)"); @@ -420,7 +424,7 @@ async function main() { const esbuild = (await import("esbuild")).default ?? (await import("esbuild")); step(`provision the throwaway board (owner key, ${CONFIDENTIAL ? "CONFIDENTIAL" : "PUBLIC"}, fresh board-d)`); - const writerHome = path.join(tmp, "writer-home"); + writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); const idPath = path.join( process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), @@ -429,7 +433,7 @@ async function main() { const identity = JSON.parse(readFileSync(idPath, "utf8")); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); - const projectDir = path.join(tmp, BOARD_D); + projectDir = path.join(tmp, BOARD_D); mkdirSync(projectDir, { recursive: true }); const initOut = JSON.parse( rd(rdBin, projectDir, writerHome, [ @@ -443,7 +447,7 @@ async function main() { "--json", ]), ); - const coord = initOut.board; + coord = initOut.board; const owner = initOut.owner; log(` board ${coord}`); @@ -729,6 +733,20 @@ async function main() { /* best effort */ } } + // ready-153: this board exists ONLY to be thrown away. Archived here, in + // `finally` rather than after a happy-path return, so a run that fails + // partway (or even before Chromium ever opens) still does not leave a + // permanent stray in the owner's portfolio. `rd board archive` only + // republishes the board's own kind-30301 definition — every card and + // status event already written is untouched. + if (coord && rdBin) { + try { + rd(rdBin, projectDir, writerHome, ["board", "archive", coord]); + log(` archived throwaway board ${coord}`); + } catch (err) { + console.error(`WARNING: could not archive throwaway board ${coord}: ${err.message}`); + } + } if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } diff --git a/web/board/scripts/live-write-roundtrip.mjs b/web/board/scripts/live-write-roundtrip.mjs index 107569d..d78cdc9 100644 --- a/web/board/scripts/live-write-roundtrip.mjs +++ b/web/board/scripts/live-write-roundtrip.mjs @@ -769,10 +769,14 @@ async function main() { const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-b2b-")); const cleanup = []; let failures = 0; + // Hoisted out of the try block so the `finally` clause below can archive the + // throwaway board EVEN WHEN THE SCRIPT FAILS PARTWAY — a red run must not + // leave a permanent stray in the owner's portfolio (ready-153). + let rdBin, projectDir, writerHome, coord; try { step("build rd from this tree"); - const rdBin = path.join(tmp, "rd"); + rdBin = path.join(tmp, "rd"); execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); // Stood up BEFORE the board is provisioned (ready-191) because mintKey draws @@ -791,7 +795,7 @@ async function main() { const esbuild = (await import("esbuild")).default ?? (await import("esbuild")); step(`provision the throwaway board (owner key, ${CONFIDENTIAL ? "CONFIDENTIAL" : "PUBLIC"}, fresh board-d)`); - const writerHome = path.join(tmp, "writer-home"); + writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); const idPath = path.join( process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), @@ -800,7 +804,7 @@ async function main() { const identity = JSON.parse(readFileSync(idPath, "utf8")); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); - const projectDir = path.join(tmp, BOARD_D); + projectDir = path.join(tmp, BOARD_D); mkdirSync(projectDir, { recursive: true }); const initOut = JSON.parse( rd(rdBin, projectDir, writerHome, [ @@ -817,7 +821,7 @@ async function main() { "--json", ]), ); - const coord = initOut.board; + coord = initOut.board; const owner = initOut.owner; log(` board ${coord}`); @@ -1954,6 +1958,20 @@ async function main() { /* best effort */ } } + // ready-153: this board exists ONLY to be thrown away. Archived here, in + // `finally` rather than after a happy-path return, so a run that fails + // partway (or even before Chromium ever opens) still does not leave a + // permanent stray in the owner's portfolio. `rd board archive` only + // republishes the board's own kind-30301 definition — every card and + // status event already written is untouched. + if (coord && rdBin) { + try { + rd(rdBin, projectDir, writerHome, ["board", "archive", coord]); + log(` archived throwaway board ${coord}`); + } catch (err) { + console.error(`WARNING: could not archive throwaway board ${coord}: ${err.message}`); + } + } if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } From c189cd4fd4522ebc3362dabe68b9f3ca76f3e5e0 Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 30 Jul 2026 19:17:10 +0000 Subject: [PATCH 2/5] fix(board): the cleanup a live harness relies on is now itself asserted (ready-153 rework) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An archive failure in the finally block was logged as a WARNING and did not count toward `failures`, so `process.exit(failures === 0 ? 0 : 1)` could still exit 0 with a stray board left behind — the exact outcome the block exists to prevent, reached silently. And nothing asserted the cleanup actually ran: the prior version reported 18/18 and 9/9 even with the whole finally block reverted. Both live-write-roundtrip.mjs and live-roundtrip-both-ways.mjs now: - count this key's unarchived boards on the relay before the run and again after the finally block completes, and fail the run if they differ (verified live: deleting the archive call turns a 9/9 run red — boards 39 -> 40 — while the fix keeps it 40 -> 40); - count an archive failure as a failure instead of swallowing it. Full live runs against wss://relay.3dl.network: 18/18 and 9/9 assertions held, both harnesses now including a passing ready-153 board-count check. archive-stray-boards.mjs (already on this branch) swept the remaining strays from testing; --dry-run now reports zero. Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/live-roundtrip-both-ways.mjs | 128 ++++++++++++++++- web/board/scripts/live-write-roundtrip.mjs | 129 +++++++++++++++++- 2 files changed, 245 insertions(+), 12 deletions(-) diff --git a/web/board/scripts/live-roundtrip-both-ways.mjs b/web/board/scripts/live-roundtrip-both-ways.mjs index 727ab9a..da6b72b 100644 --- a/web/board/scripts/live-roundtrip-both-ways.mjs +++ b/web/board/scripts/live-roundtrip-both-ways.mjs @@ -396,6 +396,91 @@ async function settle(cdp, ms = 9000) { return cdp.evaluate(`return document.querySelector(".transient-error")?.textContent ?? "";`); } +/** + * ownedBoardCount answers "how many unarchived boards does this key own, right + * now, on the relay" — a kind-only walk paged backwards with `until` at limit + * 500, exactly the shape archive-stray-boards.mjs and live-portfolio.mjs's own + * oracle use, and for the same reason (relay measurement discipline: an + * `authors` filter silently under-returns on wss://relay.3dl.network — measured + * 42/56 vs 56/56 for the same walk, ready-5c5). + * + * WHY THIS EXISTS (ready-153 rework): the finally block below archives this + * run's own throwaway board, but a harness that only trusts its own archive + * call proves nothing if that call is deleted or starts failing — the earlier + * version of this file did exactly that and still reported 9/9, green. This is + * the assertion that makes a stopped cleanup show up as a FAILED run: a count + * taken before this script does anything and a count taken after everything + * (including the finally block) has run must agree, because the only board + * this process is allowed to add to the relay is the one it also removes. + */ +async function ownedBoardCount(relay, ownerPubkey) { + const seen = new Map(); + let until; + for (let page = 0; page < 40; page++) { + const filter = { kinds: [30301], limit: 500 }; + if (until !== undefined) filter.until = until; + const got = await new Promise((resolve, reject) => { + const ws = new WebSocket(relay); + const out = []; + const sub = `cnt${Math.random().toString(36).slice(2, 10)}`; + const t = setTimeout(() => { + try { + ws.close(); + } catch { + /* closed */ + } + resolve(out); + }, 45000); + ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); + ws.onmessage = (m) => { + const f = JSON.parse(m.data); + if (f[0] === "EVENT" && f[1] === sub) out.push(f[2]); + else if (f[0] === "EOSE" && f[1] === sub) { + clearTimeout(t); + try { + ws.send(JSON.stringify(["CLOSE", sub])); + ws.close(); + } catch { + /* closed */ + } + resolve(out); + } + }; + ws.onerror = () => { + clearTimeout(t); + reject(new Error(`relay ${relay}: connection failed`)); + }; + }); + let added = 0; + let oldest = until; + for (const e of got) { + if (!seen.has(e.id)) { + seen.set(e.id, e); + added++; + } + if (oldest === undefined || e.created_at < oldest) oldest = e.created_at; + } + if (added === 0 || got.length < 500) break; + if (oldest === undefined) break; + until = oldest - 1; + } + const byCoord = new Map(); + for (const e of seen.values()) { + if (e.pubkey !== ownerPubkey) continue; + const d = (e.tags ?? []).find((t) => t[0] === "d")?.[1]; + if (!d) continue; + const coord = `30301:${e.pubkey}:${d}`; + const prev = byCoord.get(coord); + if (!prev || e.created_at > prev.created_at) byCoord.set(coord, e); + } + let count = 0; + for (const e of byCoord.values()) { + const archived = ((e.tags ?? []).find((t) => t[0] === "archived")?.[1] ?? "") !== ""; + if (!archived) count++; + } + return count; +} + async function main() { if (!existsSync(CHROME)) throw new Error(`no Chromium at ${CHROME} (set CHROME_PATH)`); @@ -406,6 +491,18 @@ async function main() { // leave a permanent stray in the owner's portfolio (ready-153). let rdBin, projectDir, writerHome, coord; + // Loaded BEFORE the try block, and used to bracket the ENTIRE run (ready-153): + // the owner's own board count, taken before this process creates anything and + // compared against the same count taken after the finally block has run. + const idPath = path.join( + process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), + "nostr-identity.json", + ); + const identity = JSON.parse(readFileSync(idPath, "utf8")); + step("ready-153: count this key's unarchived boards BEFORE the run"); + const boardsBefore = await ownedBoardCount(RELAY, identity.pubkey_hex); + log(` ${boardsBefore} unarchived board(s) owned by this key, before this run`); + try { step("build rd from this tree"); rdBin = path.join(tmp, "rd"); @@ -426,11 +523,6 @@ async function main() { step(`provision the throwaway board (owner key, ${CONFIDENTIAL ? "CONFIDENTIAL" : "PUBLIC"}, fresh board-d)`); writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); - const idPath = path.join( - process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), - "nostr-identity.json", - ); - const identity = JSON.parse(readFileSync(idPath, "utf8")); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); projectDir = path.join(tmp, BOARD_D); @@ -744,13 +836,37 @@ async function main() { rd(rdBin, projectDir, writerHome, ["board", "archive", coord]); log(` archived throwaway board ${coord}`); } catch (err) { - console.error(`WARNING: could not archive throwaway board ${coord}: ${err.message}`); + // An archive failure is a FAILURE, not a warning (ready-153 rework): the + // whole reason this finally block exists is to keep a stray out of the + // owner's permanent portfolio, and an error that is only logged still + // lets the process exit 0 while that stray persists. + failures++; + console.error(`FAILURE: could not archive throwaway board ${coord}: ${err.message}`); } } if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } + // ready-153: THE PROOF THAT CLEANUP ACTUALLY RAN, not merely that it was + // attempted. A count taken before this process touched the relay and a count + // taken after everything above (including the finally block) has finished + // must agree — the only board this run is entitled to add is the one it also + // archives. Delete the `rd board archive` call above and this is the + // assertion that goes red; nothing before it would notice. + step("ready-153: the owner's board count is unchanged after this run"); + const boardsAfter = await ownedBoardCount(RELAY, identity.pubkey_hex); + const cleanupHeld = boardsAfter === boardsBefore; + if (!cleanupHeld) failures++; + log(` boards before: ${boardsBefore} boards after: ${boardsAfter}`); + log( + ` ${cleanupHeld ? "PASS" : "FAIL"} ${ + cleanupHeld + ? "the run left the owner's board count exactly where it started" + : "the run LEFT A STRAY BEHIND — board count grew and was not cleaned up" + }`, + ); + process.exit(failures === 0 ? 0 : 1); } diff --git a/web/board/scripts/live-write-roundtrip.mjs b/web/board/scripts/live-write-roundtrip.mjs index d78cdc9..81dee61 100644 --- a/web/board/scripts/live-write-roundtrip.mjs +++ b/web/board/scripts/live-write-roundtrip.mjs @@ -763,6 +763,92 @@ async function settle(cdp, ms = 9000) { return cdp.evaluate(`return document.querySelector(".transient-error")?.textContent ?? "";`); } +/** + * ownedBoardCount answers "how many unarchived boards does this key own, right + * now, on the relay" — a kind-only walk paged backwards with `until` at limit + * 500, exactly the shape archive-stray-boards.mjs and live-portfolio.mjs's own + * oracle use, and for the same reason (relay measurement discipline: an + * `authors` filter silently under-returns on wss://relay.3dl.network — measured + * 42/56 vs 56/56 for the same walk, ready-5c5). + * + * WHY THIS EXISTS (ready-153 rework): the finally block below archives this + * run's own throwaway board, but a harness that only trusts its own archive + * call proves nothing if that call is deleted or starts failing — the earlier + * version of this file did exactly that and still reported 18/18, green. This + * is the assertion that makes a stopped cleanup show up as a FAILED run: a + * count taken before this script does anything and a count taken after + * everything (including the finally block) has run must agree, because the + * only board this process is allowed to add to the relay is the one it also + * removes. + */ +async function ownedBoardCount(relay, ownerPubkey) { + const seen = new Map(); + let until; + for (let page = 0; page < 40; page++) { + const filter = { kinds: [30301], limit: 500 }; + if (until !== undefined) filter.until = until; + const got = await new Promise((resolve, reject) => { + const ws = new WebSocket(relay); + const out = []; + const sub = `cnt${Math.random().toString(36).slice(2, 10)}`; + const t = setTimeout(() => { + try { + ws.close(); + } catch { + /* closed */ + } + resolve(out); + }, 45000); + ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); + ws.onmessage = (m) => { + const f = JSON.parse(m.data); + if (f[0] === "EVENT" && f[1] === sub) out.push(f[2]); + else if (f[0] === "EOSE" && f[1] === sub) { + clearTimeout(t); + try { + ws.send(JSON.stringify(["CLOSE", sub])); + ws.close(); + } catch { + /* closed */ + } + resolve(out); + } + }; + ws.onerror = () => { + clearTimeout(t); + reject(new Error(`relay ${relay}: connection failed`)); + }; + }); + let added = 0; + let oldest = until; + for (const e of got) { + if (!seen.has(e.id)) { + seen.set(e.id, e); + added++; + } + if (oldest === undefined || e.created_at < oldest) oldest = e.created_at; + } + if (added === 0 || got.length < 500) break; + if (oldest === undefined) break; + until = oldest - 1; + } + const byCoord = new Map(); + for (const e of seen.values()) { + if (e.pubkey !== ownerPubkey) continue; + const d = (e.tags ?? []).find((t) => t[0] === "d")?.[1]; + if (!d) continue; + const coord = `30301:${e.pubkey}:${d}`; + const prev = byCoord.get(coord); + if (!prev || e.created_at > prev.created_at) byCoord.set(coord, e); + } + let count = 0; + for (const e of byCoord.values()) { + const archived = ((e.tags ?? []).find((t) => t[0] === "archived")?.[1] ?? "") !== ""; + if (!archived) count++; + } + return count; +} + async function main() { if (!existsSync(CHROME)) throw new Error(`no Chromium at ${CHROME} (set CHROME_PATH)`); @@ -774,6 +860,18 @@ async function main() { // leave a permanent stray in the owner's portfolio (ready-153). let rdBin, projectDir, writerHome, coord; + // Loaded BEFORE the try block, and used to bracket the ENTIRE run (ready-153): + // the owner's own board count, taken before this process creates anything and + // compared against the same count taken after the finally block has run. + const idPath = path.join( + process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), + "nostr-identity.json", + ); + const identity = JSON.parse(readFileSync(idPath, "utf8")); + step("ready-153: count this key's unarchived boards BEFORE the run"); + const boardsBefore = await ownedBoardCount(RELAY, identity.pubkey_hex); + log(` ${boardsBefore} unarchived board(s) owned by this key, before this run`); + try { step("build rd from this tree"); rdBin = path.join(tmp, "rd"); @@ -797,11 +895,6 @@ async function main() { step(`provision the throwaway board (owner key, ${CONFIDENTIAL ? "CONFIDENTIAL" : "PUBLIC"}, fresh board-d)`); writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); - const idPath = path.join( - process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), - "nostr-identity.json", - ); - const identity = JSON.parse(readFileSync(idPath, "utf8")); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); projectDir = path.join(tmp, BOARD_D); @@ -1969,13 +2062,37 @@ async function main() { rd(rdBin, projectDir, writerHome, ["board", "archive", coord]); log(` archived throwaway board ${coord}`); } catch (err) { - console.error(`WARNING: could not archive throwaway board ${coord}: ${err.message}`); + // An archive failure is a FAILURE, not a warning (ready-153 rework): the + // whole reason this finally block exists is to keep a stray out of the + // owner's permanent portfolio, and an error that is only logged still + // lets the process exit 0 while that stray persists. + failures++; + console.error(`FAILURE: could not archive throwaway board ${coord}: ${err.message}`); } } if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } + // ready-153: THE PROOF THAT CLEANUP ACTUALLY RAN, not merely that it was + // attempted. A count taken before this process touched the relay and a count + // taken after everything above (including the finally block) has finished + // must agree — the only board this run is entitled to add is the one it also + // archives. Delete the `rd board archive` call above and this is the + // assertion that goes red; nothing before it would notice. + step("ready-153: the owner's board count is unchanged after this run"); + const boardsAfter = await ownedBoardCount(RELAY, identity.pubkey_hex); + const cleanupHeld = boardsAfter === boardsBefore; + if (!cleanupHeld) failures++; + log(` boards before: ${boardsBefore} boards after: ${boardsAfter}`); + log( + ` ${cleanupHeld ? "PASS" : "FAIL"} ${ + cleanupHeld + ? "the run left the owner's board count exactly where it started" + : "the run LEFT A STRAY BEHIND — board count grew and was not cleaned up" + }`, + ); + process.exit(failures === 0 ? 0 : 1); } From e03b13c37ead9bc1a3945941a263ef055cf86e1d Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 30 Jul 2026 20:10:54 +0000 Subject: [PATCH 3/5] test(board): the throwaway-board cleanup is a contract with a test, not a claim in a commit message (ready-153 round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds 1 and 2 argued this item's done condition — "a full run of every live harness leaves the owner's portfolio with the same board count it started with" — from a manual before/after count typed into a commit message. Nothing ran again, and nothing went red when the cleanup stopped running. Round 2 also missed live-stranger-walk.mjs, which merged as 2640e40 with `rd init` at line 590 and a `finally` clause that archived nothing. THE CONTRACT IS NOW ONE MODULE, scripts/throwaway-board.mjs, and it derives everything from the RELAY rather than from the harness's local variables: - what to archive is whatever the relay says this key owns under the board-ds the run registered. No local `coord` is consulted, so the `if (coord && rdBin)` leak is gone: a run that dies inside `rd init` — the exact case a finally block exists for — still cleans up. (Measured while building this: `rd init` appends the kind-30301 board to the LOCAL log only; the board first reaches the relay on the run's first write. So a run that died before writing has nothing in the portfolio to clean, and the guard correctly archives nothing.) - a failed `rd board archive` is a recorded failure that reaches the exit code, never a console.error the run exits 0 past. - the archived marker is READ BACK off the relay, polled to a deadline. An `rd board archive` that exits 0 without the event landing is not success. - the run is bracketed by the owner's unarchived board count, before and after, and a mismatch fails the run. That is the done condition as an assertion. EVERY HARNESS THAT RUNS `rd init` IS BOUND TO IT — live-write-roundtrip.mjs, live-roundtrip-both-ways.mjs and live-stranger-walk.mjs, the list derived from the tree, not from memory. live-parity.mjs and live-portfolio.mjs run no `rd init` and provision no board. THE TESTS, both in CI via board-ci.yml (vitest include now covers scripts/**/*.test.mjs): - scripts/throwaway-board.test.mjs, 19 tests, hermetic. The relay socket and the `rd` binary are the only fakes; the in-memory relay honours kinds/limit/until and stores an archive as a republish, so the paging walk, latest-wins, the marker read and the count bracket are all the real code. Covers: a cleanup that does not actually archive; an `rd board archive` that exits 0 without publishing; one that throws; a board published by an init that then failed; no rd binary with and without a published board; a relay that goes away mid-run; and that no filter this module sends ever carries `authors`. - scripts/harness-cleanup.test.mjs, 20 tests: for every live-*.mjs in the tree that runs `rd init`, it imports the guard, registers its board-d BEFORE init, closes the guard inside the finally clause, feeds the result into `failures`, and keeps no hand-rolled `rd board archive` that could drift. PROVED BY DELETION, as asked. Removing the `exec(...)` call from archiveBoard turns 8 of the 19 module tests red, including the happy path's board-count assertion. Removing the `guard.close(...)` line from live-stranger-walk.mjs turns harness-cleanup.test.mjs red on that file alone. Both restored, suite green. PROVED LIVE against wss://relay.3dl.network with the real rd, twice, by driving the guard through a real `rd init` + write + `rd board archive`: - working archive: 41 boards before, board provisioned and archived, 41 after, marker read back, 0 failures. - archive that exits 0 and publishes nothing: 41 before, 42 after, 2 failures reported ("the archived marker ... is NOT on wss://relay.3dl.network", "41 before, 42 after — it left a stray behind"). The deliberate stray was then swept by archive-stray-boards.mjs. Strays re-derived from the relay after all of it, not remembered: 0 unarchived boards matching [b2blive, b4359, c191live, c4359, s48f]. Full suites: web/board 927 tests green (54 files, now including the two new ones), tsc -b --noEmit clean, vite build clean, `go test ./...` green. Co-Authored-By: Claude Opus 5 (1M context) --- web/board/scripts/harness-cleanup.test.mjs | 95 +++++ .../scripts/live-roundtrip-both-ways.mjs | 151 ++----- web/board/scripts/live-stranger-walk.mjs | 51 ++- web/board/scripts/live-write-roundtrip.mjs | 152 ++----- web/board/scripts/throwaway-board.mjs | 344 +++++++++++++++ web/board/scripts/throwaway-board.test.mjs | 391 ++++++++++++++++++ web/board/vitest.config.ts | 10 +- 7 files changed, 932 insertions(+), 262 deletions(-) create mode 100644 web/board/scripts/harness-cleanup.test.mjs create mode 100644 web/board/scripts/throwaway-board.mjs create mode 100644 web/board/scripts/throwaway-board.test.mjs diff --git a/web/board/scripts/harness-cleanup.test.mjs b/web/board/scripts/harness-cleanup.test.mjs new file mode 100644 index 0000000..3ffe422 --- /dev/null +++ b/web/board/scripts/harness-cleanup.test.mjs @@ -0,0 +1,95 @@ +/** + * harness-cleanup.test.mjs — ready-153's "EVERY live harness" clause, as a + * check that runs on every PR. + * + * throwaway-board.test.mjs proves the cleanup contract WORKS. This file proves + * every harness is BOUND to it. Both are needed, and the second is the one the + * previous two attempts lacked: live-stranger-walk.mjs merged (2640e40) with a + * `rd init` at line 590 and a `finally` clause that archived nothing, three + * days after the cleanup was "added to every harness". Nothing anywhere + * noticed, because the invariant lived in a commit message. + * + * The harness list is DERIVED FROM THE TREE, never from a list maintained + * here — a new live-*.mjs that provisions a board is covered the moment it is + * written, which is the failure mode this file exists for. + * + * These are source-shape assertions, deliberately. A live harness needs + * Chromium, a Go toolchain, the owner's signing key and a real relay; it can + * never run in CI. What CAN run in CI is "this file creates a board and does + * not hand it to the guard", and that is exactly the regression that shipped. + */ + +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +const SCRIPTS = path.resolve(import.meta.dirname); + +const harnesses = readdirSync(SCRIPTS) + .filter((f) => f.startsWith("live-") && f.endsWith(".mjs")) + .sort() + .map((f) => ({ name: f, src: readFileSync(path.join(SCRIPTS, f), "utf8") })); + +/** A harness provisions a throwaway board iff it runs `rd init` — that is the + * only command in the CLI that publishes a new kind-30301 board definition. */ +const createsBoard = (h) => /(^|[[,]\s*)"init"/m.test(h.src); + +/** Everything after the LAST `finally {` in the file: a cleanup that is not in + * the finally clause does not run when the harness fails partway, which is + * precisely the run that leaks. */ +function finallyClause(src) { + const i = src.lastIndexOf("} finally {"); + return i === -1 ? "" : src.slice(i); +} + +test("the harness list is derived from the tree and is not empty", () => { + expect(harnesses.map((h) => h.name)).toContain("live-stranger-walk.mjs"); + expect(harnesses.length).toBeGreaterThanOrEqual(5); +}); + +test("at least one harness provisions a board (or this whole file is vacuous)", () => { + expect(harnesses.filter(createsBoard).map((h) => h.name)).toEqual([ + "live-roundtrip-both-ways.mjs", + "live-stranger-walk.mjs", + "live-write-roundtrip.mjs", + ]); +}); + +describe.each(harnesses.filter(createsBoard).map((h) => [h.name, h]))( + "%s runs `rd init`, so it is bound to the ready-153 cleanup contract", + (_name, h) => { + test("imports the shared guard rather than hand-rolling cleanup", () => { + expect(h.src).toMatch(/import\s*{[^}]*openThrowawayBoardGuard[^}]*}\s*from\s*"\.\/throwaway-board\.mjs"/); + }); + + test("opens the guard, which takes the BEFORE board count", () => { + expect(h.src).toMatch(/await openThrowawayBoardGuard\(/); + }); + + test("registers its board-d BEFORE `rd init` runs, so a crashed init still cleans up", () => { + const register = h.src.search(/guard\.expect\(/); + const init = h.src.search(/(^|[[,]\s*)"init"/m); + expect(register).toBeGreaterThan(-1); + expect(init).toBeGreaterThan(-1); + expect(register).toBeLessThan(init); + }); + + test("closes the guard inside the `finally` clause, not on the happy path", () => { + expect(finallyClause(h.src)).toMatch(/guard\.close\(/); + }); + + test("counts the guard's failures toward its own exit code", () => { + // reportCleanup returns the failure count; a harness that ignores it + // exits 0 with a stray on the relay, which is the whole bug. + expect(finallyClause(h.src)).toMatch(/failures \+= reportCleanup\(/); + expect(h.src).toMatch(/process\.exit\(failures === 0 \? 0 : 1\)/); + }); + + test("has no leftover hand-rolled archive call that could drift from the contract", () => { + // The single `rd board archive` invocation lives in throwaway-board.mjs + // (archiveBoard). A second copy here is how the three harnesses' + // cleanups diverged in the first place. + expect(h.src).not.toMatch(/"board",\s*"archive"/); + }); + }, +); diff --git a/web/board/scripts/live-roundtrip-both-ways.mjs b/web/board/scripts/live-roundtrip-both-ways.mjs index da6b72b..216933e 100644 --- a/web/board/scripts/live-roundtrip-both-ways.mjs +++ b/web/board/scripts/live-roundtrip-both-ways.mjs @@ -83,6 +83,11 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; import { createServer } from "vite"; +// ready-153: the throwaway board this run provisions must not survive it. The +// contract — archive, prove the marker landed on the relay, and bracket the +// run with the owner's unarchived board count — lives in one module that +// scripts/throwaway-board.test.mjs exercises hermetically in CI. +import { openThrowawayBoardGuard, reportCleanup } from "./throwaway-board.mjs"; const BOARD_DIR = path.resolve(import.meta.dirname, ".."); const REPO_ROOT = path.resolve(BOARD_DIR, "../.."); @@ -396,91 +401,6 @@ async function settle(cdp, ms = 9000) { return cdp.evaluate(`return document.querySelector(".transient-error")?.textContent ?? "";`); } -/** - * ownedBoardCount answers "how many unarchived boards does this key own, right - * now, on the relay" — a kind-only walk paged backwards with `until` at limit - * 500, exactly the shape archive-stray-boards.mjs and live-portfolio.mjs's own - * oracle use, and for the same reason (relay measurement discipline: an - * `authors` filter silently under-returns on wss://relay.3dl.network — measured - * 42/56 vs 56/56 for the same walk, ready-5c5). - * - * WHY THIS EXISTS (ready-153 rework): the finally block below archives this - * run's own throwaway board, but a harness that only trusts its own archive - * call proves nothing if that call is deleted or starts failing — the earlier - * version of this file did exactly that and still reported 9/9, green. This is - * the assertion that makes a stopped cleanup show up as a FAILED run: a count - * taken before this script does anything and a count taken after everything - * (including the finally block) has run must agree, because the only board - * this process is allowed to add to the relay is the one it also removes. - */ -async function ownedBoardCount(relay, ownerPubkey) { - const seen = new Map(); - let until; - for (let page = 0; page < 40; page++) { - const filter = { kinds: [30301], limit: 500 }; - if (until !== undefined) filter.until = until; - const got = await new Promise((resolve, reject) => { - const ws = new WebSocket(relay); - const out = []; - const sub = `cnt${Math.random().toString(36).slice(2, 10)}`; - const t = setTimeout(() => { - try { - ws.close(); - } catch { - /* closed */ - } - resolve(out); - }, 45000); - ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); - ws.onmessage = (m) => { - const f = JSON.parse(m.data); - if (f[0] === "EVENT" && f[1] === sub) out.push(f[2]); - else if (f[0] === "EOSE" && f[1] === sub) { - clearTimeout(t); - try { - ws.send(JSON.stringify(["CLOSE", sub])); - ws.close(); - } catch { - /* closed */ - } - resolve(out); - } - }; - ws.onerror = () => { - clearTimeout(t); - reject(new Error(`relay ${relay}: connection failed`)); - }; - }); - let added = 0; - let oldest = until; - for (const e of got) { - if (!seen.has(e.id)) { - seen.set(e.id, e); - added++; - } - if (oldest === undefined || e.created_at < oldest) oldest = e.created_at; - } - if (added === 0 || got.length < 500) break; - if (oldest === undefined) break; - until = oldest - 1; - } - const byCoord = new Map(); - for (const e of seen.values()) { - if (e.pubkey !== ownerPubkey) continue; - const d = (e.tags ?? []).find((t) => t[0] === "d")?.[1]; - if (!d) continue; - const coord = `30301:${e.pubkey}:${d}`; - const prev = byCoord.get(coord); - if (!prev || e.created_at > prev.created_at) byCoord.set(coord, e); - } - let count = 0; - for (const e of byCoord.values()) { - const archived = ((e.tags ?? []).find((t) => t[0] === "archived")?.[1] ?? "") !== ""; - if (!archived) count++; - } - return count; -} - async function main() { if (!existsSync(CHROME)) throw new Error(`no Chromium at ${CHROME} (set CHROME_PATH)`); @@ -491,17 +411,19 @@ async function main() { // leave a permanent stray in the owner's portfolio (ready-153). let rdBin, projectDir, writerHome, coord; - // Loaded BEFORE the try block, and used to bracket the ENTIRE run (ready-153): - // the owner's own board count, taken before this process creates anything and - // compared against the same count taken after the finally block has run. const idPath = path.join( process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), "nostr-identity.json", ); const identity = JSON.parse(readFileSync(idPath, "utf8")); + + // ready-153: opened BEFORE anything is created, so it holds the owner's + // unarchived board count as it was before this process existed, and BOARD_D + // is registered before `rd init` runs — a run that dies inside `rd init` has + // no coordinate to hand back but may already have published the board. step("ready-153: count this key's unarchived boards BEFORE the run"); - const boardsBefore = await ownedBoardCount(RELAY, identity.pubkey_hex); - log(` ${boardsBefore} unarchived board(s) owned by this key, before this run`); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: identity.pubkey_hex, log }); + guard.expect(BOARD_D); try { step("build rd from this tree"); @@ -825,48 +747,25 @@ async function main() { /* best effort */ } } - // ready-153: this board exists ONLY to be thrown away. Archived here, in + // ready-153: this board exists ONLY to be thrown away. Cleaned up here, in // `finally` rather than after a happy-path return, so a run that fails // partway (or even before Chromium ever opens) still does not leave a - // permanent stray in the owner's portfolio. `rd board archive` only - // republishes the board's own kind-30301 definition — every card and - // status event already written is untouched. - if (coord && rdBin) { - try { - rd(rdBin, projectDir, writerHome, ["board", "archive", coord]); - log(` archived throwaway board ${coord}`); - } catch (err) { - // An archive failure is a FAILURE, not a warning (ready-153 rework): the - // whole reason this finally block exists is to keep a stray out of the - // owner's permanent portfolio, and an error that is only logged still - // lets the process exit 0 while that stray persists. - failures++; - console.error(`FAILURE: could not archive throwaway board ${coord}: ${err.message}`); - } - } + // permanent stray in the owner's portfolio — and BEFORE the rmSync below, + // because `rd board archive` runs inside the project dir under tmp. + // + // guard.close() archives whatever the RELAY says this run published, + // reads the archived marker back off the relay (an `rd board archive` + // that exits 0 without the event landing is not success), and re-checks + // the owner's unarchived board count against the one taken before the + // run. Its failures count toward this script's exit code — an archive + // problem that is only logged lets the process exit 0 with the stray + // still in the portfolio. + step("ready-153: archive the throwaway board, and prove the owner's board count is unchanged"); + failures += reportCleanup(await guard.close({ rdBin, cwd: projectDir, home: writerHome }), log); if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } - // ready-153: THE PROOF THAT CLEANUP ACTUALLY RAN, not merely that it was - // attempted. A count taken before this process touched the relay and a count - // taken after everything above (including the finally block) has finished - // must agree — the only board this run is entitled to add is the one it also - // archives. Delete the `rd board archive` call above and this is the - // assertion that goes red; nothing before it would notice. - step("ready-153: the owner's board count is unchanged after this run"); - const boardsAfter = await ownedBoardCount(RELAY, identity.pubkey_hex); - const cleanupHeld = boardsAfter === boardsBefore; - if (!cleanupHeld) failures++; - log(` boards before: ${boardsBefore} boards after: ${boardsAfter}`); - log( - ` ${cleanupHeld ? "PASS" : "FAIL"} ${ - cleanupHeld - ? "the run left the owner's board count exactly where it started" - : "the run LEFT A STRAY BEHIND — board count grew and was not cleaned up" - }`, - ); - process.exit(failures === 0 ? 0 : 1); } diff --git a/web/board/scripts/live-stranger-walk.mjs b/web/board/scripts/live-stranger-walk.mjs index f2e829a..52f4790 100644 --- a/web/board/scripts/live-stranger-walk.mjs +++ b/web/board/scripts/live-stranger-walk.mjs @@ -87,6 +87,11 @@ import https from "node:https"; import os from "node:os"; import path from "node:path"; import { createServer } from "vite"; +// ready-153: the throwaway board this run provisions must not survive it. The +// contract — archive, prove the marker landed on the relay, and bracket the +// run with the owner's unarchived board count — lives in one module that +// scripts/throwaway-board.test.mjs exercises hermetically in CI. +import { openThrowawayBoardGuard, reportCleanup } from "./throwaway-board.mjs"; const BOARD_DIR = path.resolve(import.meta.dirname, ".."); const REPO_ROOT = path.resolve(BOARD_DIR, "../.."); @@ -552,10 +557,30 @@ async function main() { const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-48f-")); const cleanup = []; + // Hoisted out of the try block so the `finally` clause below can archive the + // throwaway board EVEN WHEN THE SCRIPT FAILS PARTWAY — a red run must not + // leave a permanent stray in the owner's portfolio (ready-153). + let rdBin, projectDir, writerHome; + + const idPath = path.join( + process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), + "nostr-identity.json", + ); + const identity = JSON.parse(readFileSync(idPath, "utf8")); + + // ready-153: opened BEFORE anything is created, so it holds the owner's + // unarchived board count as it was before this process existed, and BOARD_D + // is registered before `rd init` runs — a run that dies inside `rd init` has + // no coordinate to hand back but may already have published the board. + // Until this landed, every run of this script added one permanent node to + // the owner's portfolio and nothing removed it. + step("ready-153: count this key's unarchived boards BEFORE the run"); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: identity.pubkey_hex, log }); + guard.expect(BOARD_D); try { step("build rd from this tree"); - const rdBin = path.join(tmp, "rd"); + rdBin = path.join(tmp, "rd"); execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); const vite = await createServer({ @@ -574,16 +599,11 @@ async function main() { log(` ${ext.name} ${ext.version} @ ${NOS2X_COMMIT.slice(0, 12)} -> ${ext.dir}`); step("provision the throwaway CONFIDENTIAL board (owner key, fresh board-d)"); - const writerHome = path.join(tmp, "writer-home"); + writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); - const idPath = path.join( - process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), - "nostr-identity.json", - ); - const identity = JSON.parse(readFileSync(idPath, "utf8")); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); - const projectDir = path.join(tmp, BOARD_D); + projectDir = path.join(tmp, BOARD_D); mkdirSync(projectDir, { recursive: true }); const initOut = JSON.parse( rd(rdBin, projectDir, writerHome, [ @@ -907,6 +927,21 @@ async function main() { /* best effort */ } } + // ready-153: this board exists ONLY to be thrown away. Cleaned up here, in + // `finally` rather than after a happy-path return, so a run that fails + // partway (or even before Chromium ever opens) still does not leave a + // permanent stray in the owner's portfolio — and BEFORE the rmSync below, + // because `rd board archive` runs inside the project dir under tmp. + // + // guard.close() archives whatever the RELAY says this run published, + // reads the archived marker back off the relay (an `rd board archive` + // that exits 0 without the event landing is not success), and re-checks + // the owner's unarchived board count against the one taken before the + // run. Its failures count toward this script's exit code — an archive + // problem that is only logged lets the process exit 0 with the stray + // still in the portfolio. + step("ready-153: archive the throwaway board, and prove the owner's board count is unchanged"); + failures += reportCleanup(await guard.close({ rdBin, cwd: projectDir, home: writerHome }), log); if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } diff --git a/web/board/scripts/live-write-roundtrip.mjs b/web/board/scripts/live-write-roundtrip.mjs index 81dee61..ae347af 100644 --- a/web/board/scripts/live-write-roundtrip.mjs +++ b/web/board/scripts/live-write-roundtrip.mjs @@ -173,6 +173,11 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; import { createServer } from "vite"; +// ready-153: the throwaway board this run provisions must not survive it. The +// contract — archive, prove the marker landed on the relay, and bracket the +// run with the owner's unarchived board count — lives in one module that +// scripts/throwaway-board.test.mjs exercises hermetically in CI. +import { openThrowawayBoardGuard, reportCleanup } from "./throwaway-board.mjs"; const BOARD_DIR = path.resolve(import.meta.dirname, ".."); const REPO_ROOT = path.resolve(BOARD_DIR, "../.."); @@ -763,92 +768,6 @@ async function settle(cdp, ms = 9000) { return cdp.evaluate(`return document.querySelector(".transient-error")?.textContent ?? "";`); } -/** - * ownedBoardCount answers "how many unarchived boards does this key own, right - * now, on the relay" — a kind-only walk paged backwards with `until` at limit - * 500, exactly the shape archive-stray-boards.mjs and live-portfolio.mjs's own - * oracle use, and for the same reason (relay measurement discipline: an - * `authors` filter silently under-returns on wss://relay.3dl.network — measured - * 42/56 vs 56/56 for the same walk, ready-5c5). - * - * WHY THIS EXISTS (ready-153 rework): the finally block below archives this - * run's own throwaway board, but a harness that only trusts its own archive - * call proves nothing if that call is deleted or starts failing — the earlier - * version of this file did exactly that and still reported 18/18, green. This - * is the assertion that makes a stopped cleanup show up as a FAILED run: a - * count taken before this script does anything and a count taken after - * everything (including the finally block) has run must agree, because the - * only board this process is allowed to add to the relay is the one it also - * removes. - */ -async function ownedBoardCount(relay, ownerPubkey) { - const seen = new Map(); - let until; - for (let page = 0; page < 40; page++) { - const filter = { kinds: [30301], limit: 500 }; - if (until !== undefined) filter.until = until; - const got = await new Promise((resolve, reject) => { - const ws = new WebSocket(relay); - const out = []; - const sub = `cnt${Math.random().toString(36).slice(2, 10)}`; - const t = setTimeout(() => { - try { - ws.close(); - } catch { - /* closed */ - } - resolve(out); - }, 45000); - ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); - ws.onmessage = (m) => { - const f = JSON.parse(m.data); - if (f[0] === "EVENT" && f[1] === sub) out.push(f[2]); - else if (f[0] === "EOSE" && f[1] === sub) { - clearTimeout(t); - try { - ws.send(JSON.stringify(["CLOSE", sub])); - ws.close(); - } catch { - /* closed */ - } - resolve(out); - } - }; - ws.onerror = () => { - clearTimeout(t); - reject(new Error(`relay ${relay}: connection failed`)); - }; - }); - let added = 0; - let oldest = until; - for (const e of got) { - if (!seen.has(e.id)) { - seen.set(e.id, e); - added++; - } - if (oldest === undefined || e.created_at < oldest) oldest = e.created_at; - } - if (added === 0 || got.length < 500) break; - if (oldest === undefined) break; - until = oldest - 1; - } - const byCoord = new Map(); - for (const e of seen.values()) { - if (e.pubkey !== ownerPubkey) continue; - const d = (e.tags ?? []).find((t) => t[0] === "d")?.[1]; - if (!d) continue; - const coord = `30301:${e.pubkey}:${d}`; - const prev = byCoord.get(coord); - if (!prev || e.created_at > prev.created_at) byCoord.set(coord, e); - } - let count = 0; - for (const e of byCoord.values()) { - const archived = ((e.tags ?? []).find((t) => t[0] === "archived")?.[1] ?? "") !== ""; - if (!archived) count++; - } - return count; -} - async function main() { if (!existsSync(CHROME)) throw new Error(`no Chromium at ${CHROME} (set CHROME_PATH)`); @@ -860,17 +779,19 @@ async function main() { // leave a permanent stray in the owner's portfolio (ready-153). let rdBin, projectDir, writerHome, coord; - // Loaded BEFORE the try block, and used to bracket the ENTIRE run (ready-153): - // the owner's own board count, taken before this process creates anything and - // compared against the same count taken after the finally block has run. const idPath = path.join( process.env.RD_HOME ?? path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "rd"), "nostr-identity.json", ); const identity = JSON.parse(readFileSync(idPath, "utf8")); + + // ready-153: opened BEFORE anything is created, so it holds the owner's + // unarchived board count as it was before this process existed, and BOARD_D + // is registered before `rd init` runs — a run that dies inside `rd init` has + // no coordinate to hand back but may already have published the board. step("ready-153: count this key's unarchived boards BEFORE the run"); - const boardsBefore = await ownedBoardCount(RELAY, identity.pubkey_hex); - log(` ${boardsBefore} unarchived board(s) owned by this key, before this run`); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: identity.pubkey_hex, log }); + guard.expect(BOARD_D); try { step("build rd from this tree"); @@ -2051,48 +1972,25 @@ async function main() { /* best effort */ } } - // ready-153: this board exists ONLY to be thrown away. Archived here, in + // ready-153: this board exists ONLY to be thrown away. Cleaned up here, in // `finally` rather than after a happy-path return, so a run that fails // partway (or even before Chromium ever opens) still does not leave a - // permanent stray in the owner's portfolio. `rd board archive` only - // republishes the board's own kind-30301 definition — every card and - // status event already written is untouched. - if (coord && rdBin) { - try { - rd(rdBin, projectDir, writerHome, ["board", "archive", coord]); - log(` archived throwaway board ${coord}`); - } catch (err) { - // An archive failure is a FAILURE, not a warning (ready-153 rework): the - // whole reason this finally block exists is to keep a stray out of the - // owner's permanent portfolio, and an error that is only logged still - // lets the process exit 0 while that stray persists. - failures++; - console.error(`FAILURE: could not archive throwaway board ${coord}: ${err.message}`); - } - } + // permanent stray in the owner's portfolio — and BEFORE the rmSync below, + // because `rd board archive` runs inside the project dir under tmp. + // + // guard.close() archives whatever the RELAY says this run published, + // reads the archived marker back off the relay (an `rd board archive` + // that exits 0 without the event landing is not success), and re-checks + // the owner's unarchived board count against the one taken before the + // run. Its failures count toward this script's exit code — an archive + // problem that is only logged lets the process exit 0 with the stray + // still in the portfolio. + step("ready-153: archive the throwaway board, and prove the owner's board count is unchanged"); + failures += reportCleanup(await guard.close({ rdBin, cwd: projectDir, home: writerHome }), log); if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } - // ready-153: THE PROOF THAT CLEANUP ACTUALLY RAN, not merely that it was - // attempted. A count taken before this process touched the relay and a count - // taken after everything above (including the finally block) has finished - // must agree — the only board this run is entitled to add is the one it also - // archives. Delete the `rd board archive` call above and this is the - // assertion that goes red; nothing before it would notice. - step("ready-153: the owner's board count is unchanged after this run"); - const boardsAfter = await ownedBoardCount(RELAY, identity.pubkey_hex); - const cleanupHeld = boardsAfter === boardsBefore; - if (!cleanupHeld) failures++; - log(` boards before: ${boardsBefore} boards after: ${boardsAfter}`); - log( - ` ${cleanupHeld ? "PASS" : "FAIL"} ${ - cleanupHeld - ? "the run left the owner's board count exactly where it started" - : "the run LEFT A STRAY BEHIND — board count grew and was not cleaned up" - }`, - ); - process.exit(failures === 0 ? 0 : 1); } diff --git a/web/board/scripts/throwaway-board.mjs b/web/board/scripts/throwaway-board.mjs new file mode 100644 index 0000000..285e313 --- /dev/null +++ b/web/board/scripts/throwaway-board.mjs @@ -0,0 +1,344 @@ +/** + * throwaway-board.mjs — the cleanup contract every live harness that runs + * `rd init` is bound to (ready-153). + * + * THE PROBLEM. A live harness provisions a fresh kind-30301 board per run so it + * has something disposable to write to. Before this module, those boards were + * never removed: 44 of the 75 nodes in the owner's portfolio were per-run + * throwaway boards published by the test suite itself, and every future run + * added another, permanently, to the product's primary view. + * + * WHY THIS IS A MODULE AND NOT A LINE IN EACH HARNESS. The first two attempts + * at this fix were a `finally`-block one-liner per script. Both were untestable + * in CI (a live harness needs Chromium, a real relay and a real key), both + * drifted — one harness merged with no cleanup at all — and both had the same + * three silent leak paths: + * + * 1. a failed `rd board archive` was logged as a WARNING and the run still + * exited 0, leaving the stray behind exactly as if no cleanup existed; + * 2. nothing read the archived marker back, so an `rd board archive` that + * exits 0 without the event reaching the relay looked like success; + * 3. the cleanup was guarded on a local `coord` variable assigned from + * `JSON.parse(rd init --json)`, so any failure at or before that parse — + * the exact case a `finally` block exists for — skipped the archive while + * the board itself was already published and permanent. + * + * All three are closed here, in ONE place, by deriving everything from the + * RELAY rather than from the harness's local variables: + * + * - what to archive is whatever the relay says this key owns under the + * board-ds this run registered — no local `coord` required, so a run that + * dies mid-`rd init` still cleans up (leak 3); + * - `rd board archive` failing is a recorded failure, never a warning + * (leak 1); + * - the archived marker is READ BACK off the relay after the command, and + * the run fails if it is not there, whatever the command's exit code + * said (leak 2); + * - and the whole run is bracketed by a count of this key's unarchived + * boards taken before it starts and after cleanup finishes. They must + * agree. That is the item's done condition as an executable assertion. + * + * Every one of those behaviours is covered hermetically by + * throwaway-board.test.mjs (vitest, in CI via board-ci.yml) against a fake + * relay and a fake `rd`. Delete the `exec(...)` call in archiveBoard below and + * that suite goes red — which is the point: before this module, deleting the + * cleanup turned nothing red anywhere. + * + * RELAY MEASUREMENT DISCIPLINE. The walk below is kind-only, paged backwards + * with `until` at limit 500, and NEVER uses an `authors` filter: an `authors` + * filter silently under-returns on wss://relay.3dl.network (measured 42/56 vs + * 56/56 for the same walk, ready-5c5). Ownership is filtered client-side after + * the walk instead, and the test suite asserts every filter this module sends + * carries no `authors` key. + */ + +import { execFileSync } from "node:child_process"; + +export const KIND_BOARD = 30301; + +/** PAGE_LIMIT/MAX_PAGES: 500 per page (the relay's practical cap) walked + * backwards by `until`; 40 pages is 20k board events, far past this key's + * real count, and bounds a pathological relay. */ +export const PAGE_LIMIT = 500; +export const MAX_PAGES = 40; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** + * wsReq issues ONE REQ against a relay and resolves with everything received + * up to EOSE. This is the only part of the walk that touches a socket, and it + * is injectable (`deps.req`) so the paging, dedupe, latest-wins and + * archived-marker logic above it can be exercised without a network. + */ +export async function wsReq(relay, filter, { timeoutMs = 45000 } = {}) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(relay); + const out = []; + const sub = `tbg${Math.random().toString(36).slice(2, 10)}`; + const t = setTimeout(() => { + try { + ws.close(); + } catch { + /* already closed */ + } + resolve(out); + }, timeoutMs); + ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); + ws.onmessage = (m) => { + const f = JSON.parse(m.data); + if (f[0] === "EVENT" && f[1] === sub) out.push(f[2]); + else if (f[0] === "EOSE" && f[1] === sub) { + clearTimeout(t); + try { + ws.send(JSON.stringify(["CLOSE", sub])); + ws.close(); + } catch { + /* already closed */ + } + resolve(out); + } + }; + ws.onerror = () => { + clearTimeout(t); + reject(new Error(`relay ${relay}: connection failed`)); + }; + }); +} + +/** + * fetchBoardEvents walks every kind-30301 event the relay will serve, paging + * backwards with `until`. Kind-only by design — see the header's relay + * measurement discipline note. + */ +export async function fetchBoardEvents(relay, { req = wsReq } = {}) { + const seen = new Map(); + let until; + for (let page = 0; page < MAX_PAGES; page++) { + const filter = { kinds: [KIND_BOARD], limit: PAGE_LIMIT }; + if (until !== undefined) filter.until = until; + const got = await req(relay, filter); + let added = 0; + let oldest = until; + for (const e of got) { + if (!seen.has(e.id)) { + seen.set(e.id, e); + added++; + } + if (oldest === undefined || e.created_at < oldest) oldest = e.created_at; + } + if (added === 0 || got.length < PAGE_LIMIT) break; + if (oldest === undefined) break; + until = oldest - 1; + } + return [...seen.values()]; +} + +/** + * latestBoardsOwnedBy applies the two rules the board page itself applies to a + * kind-30301 stream: latest-wins per coordinate (they are replaceable events, + * and an archive is a REPUBLISH of the same coordinate), then read the + * `archived` marker off that winner. Returns a Map coord -> descriptor. + */ +export function latestBoardsOwnedBy(events, ownerPubkey) { + const byCoord = new Map(); + for (const e of events) { + if (e.pubkey !== ownerPubkey) continue; + const d = (e.tags ?? []).find((t) => t[0] === "d")?.[1]; + if (!d) continue; + const coord = `${KIND_BOARD}:${e.pubkey}:${d}`; + const prev = byCoord.get(coord); + if (!prev || e.created_at > prev.createdAt) { + byCoord.set(coord, { + coord, + boardD: d, + createdAt: e.created_at, + archived: ((e.tags ?? []).find((t) => t[0] === "archived")?.[1] ?? "") !== "", + }); + } + } + return byCoord; +} + +/** ownedBoards: the live relay's answer to "which boards does this key own, + * and which of them are archived, right now". */ +export async function ownedBoards(relay, ownerPubkey, deps = {}) { + return latestBoardsOwnedBy(await fetchBoardEvents(relay, deps), ownerPubkey); +} + +/** ownedBoardCount: how many of them are still UNARCHIVED — i.e. how many + * nodes the owner's portfolio shows. This is the number ready-153's done + * condition is written in terms of. */ +export async function ownedBoardCount(relay, ownerPubkey, deps = {}) { + let n = 0; + for (const b of (await ownedBoards(relay, ownerPubkey, deps)).values()) if (!b.archived) n++; + return n; +} + +/** + * archiveBoard runs the REAL `rd board archive` against a throwaway board. + * + * This one call is the entire cleanup action, deliberately in a module a + * hermetic test can drive: delete the `exec(...)` line and every read-back and + * board-count assertion in throwaway-board.test.mjs goes red. + * + * `rd board archive` republishes only the board's own kind-30301 definition + * with an `archived` tag — every card and status event already written to the + * board is untouched. + */ +export function archiveBoard({ rdBin, cwd, home, relay, coord, exec = execFileSync }) { + if (!rdBin) throw new Error("no rd binary available to archive with"); + return exec(rdBin, ["board", "archive", coord], { + cwd, + env: { ...process.env, RD_HOME: home, RD_NOSTR_RELAY_URL: relay }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +/** + * openThrowawayBoardGuard takes the BEFORE measurement and hands back the + * handle a harness closes in its `finally` clause. + * + * Usage, and the shape scripts_cleanup.test.mjs enforces on every live + * harness that runs `rd init`: + * + * const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey, log }); + * guard.expect(BOARD_D); // BEFORE `rd init` runs + * try { ...the run... } finally { + * failures += (await guard.close({ rdBin, cwd: projectDir, home: writerHome })).failures.length; + * } + * + * `expect` takes the board-d, not the coordinate, precisely so it can be + * called before `rd init` — the run that dies inside `rd init` is the one that + * leaks, and it is the one with no coordinate to hand. + */ +export async function openThrowawayBoardGuard({ relay, ownerPubkey, log = () => {}, boardDs = [], deps = {} }) { + const expected = new Set(boardDs); + const before = await ownedBoardCount(relay, ownerPubkey, deps); + log(` ready-153: ${before} unarchived board(s) owned by this key, before this run`); + + return { + before, + expected, + expect(boardD) { + expected.add(boardD); + return boardD; + }, + + /** + * close archives every board this run put on the relay, PROVES each one + * carries the archived marker by reading it back, and re-checks the + * owner's unarchived board count against the before measurement. + * + * Deriving the work from the relay is also what makes "the run died early" + * correct rather than merely safe. `rd init` appends the kind-30301 board + * event to the LOCAL log only — measured 2026-07-30: init plus `rd relay + * flush` leaves nothing on the relay, and the board first appears there on + * the run's first write. So a run that died before writing anything has no + * board in the owner's portfolio to clean up, and this correctly archives + * nothing instead of publishing an archive for a board that never existed. + * + * Returns { ok, before, after, archived, failures } — `failures` is a list + * of human-readable reasons, and a harness MUST add its length to its own + * failure count. Nothing here throws for a cleanup problem: the caller is + * a `finally` clause and must not lose the original error. + */ + async close({ + rdBin, + cwd, + home, + exec = execFileSync, + readBackTimeoutMs = 30000, + readBackIntervalMs = 2000, + now = () => Date.now(), + wait = sleep, + } = {}) { + const failures = []; + const archived = []; + + // WHAT TO ARCHIVE COMES FROM THE RELAY, never from a local `coord` + // variable: a run that failed inside `rd init` has no coordinate but may + // very well have published the board. Leak 3 in this file's header. + let owned; + try { + owned = await ownedBoards(relay, ownerPubkey, deps); + } catch (err) { + failures.push(`could not read this key's boards back off ${relay}: ${err.message}`); + return { ok: false, before, after: undefined, archived, failures }; + } + const mine = [...owned.values()].filter((b) => expected.has(b.boardD)); + const stray = mine.filter((b) => !b.archived); + + for (const b of stray) { + try { + archiveBoard({ rdBin, cwd, home, relay, coord: b.coord, exec }); + archived.push(b.coord); + log(` ready-153: archived throwaway board ${b.coord}`); + } catch (err) { + // A FAILURE, not a warning. An error that is only logged lets the + // process exit 0 with the stray still in the owner's portfolio — + // the exact outcome this guard exists to prevent. + failures.push(`could not archive throwaway board ${b.coord}: ${err.message}`); + } + } + + // READ THE MARKER BACK. `rd board archive` exiting 0 says the command + // ran, not that the relay took the event. The only acceptable evidence + // is the relay's own copy carrying the archived tag. + // + // Polled rather than slept: a relay takes an event when it takes it, and + // a fixed sleep is either flaky or slow. The loop exits the moment both + // conditions hold, and gives up at the deadline. + const deadline = now() + readBackTimeoutMs; + let after; + let unproven = []; + for (;;) { + try { + owned = await ownedBoards(relay, ownerPubkey, deps); + } catch (err) { + failures.push(`could not read this key's boards back off ${relay}: ${err.message}`); + break; + } + unproven = mine.filter((b) => !owned.get(b.coord)?.archived).map((b) => b.coord); + after = 0; + for (const b of owned.values()) if (!b.archived) after++; + if (unproven.length === 0 && after === before) break; + if (now() >= deadline) break; + await wait(readBackIntervalMs); + } + + for (const coord of unproven) { + failures.push( + `the archived marker for ${coord} is NOT on ${relay} — the board is still in the owner's portfolio`, + ); + } + + // THE DONE CONDITION, AS AN ASSERTION: a full run leaves the owner's + // portfolio with the board count it started with. It is deliberately the + // WHOLE portfolio's count and not just this run's board — a cleanup that + // only checks its own coordinate cannot see a board it forgot to + // register. The corollary is that these harnesses must be run one at a + // time on a given key: a concurrent run's board is, correctly, a stray + // from this run's point of view. + if (after === undefined) { + /* the read-back never completed; the failure is already recorded */ + } else if (after !== before) { + failures.push( + `the run changed the owner's unarchived board count: ${before} before, ${after} after — it left a stray behind`, + ); + } else { + log(` ready-153: board count unchanged, ${before} before and ${after} after`); + } + + return { ok: failures.length === 0, before, after, archived, failures }; + }, + }; +} + +/** reportCleanup prints a guard result the way the harnesses' own summaries + * print theirs, and returns the number to add to `failures`. */ +export function reportCleanup(result, log = console.log) { + log(` ${result.ok ? "PASS" : "FAIL"} ready-153: the run left the owner's board count exactly where it started`); + for (const f of result.failures) console.error(`FAILURE: ${f}`); + return result.failures.length; +} diff --git a/web/board/scripts/throwaway-board.test.mjs b/web/board/scripts/throwaway-board.test.mjs new file mode 100644 index 0000000..684a474 --- /dev/null +++ b/web/board/scripts/throwaway-board.test.mjs @@ -0,0 +1,391 @@ +/** + * throwaway-board.test.mjs — the CI-executable half of ready-153. + * + * The item's done condition is "a full run of every live harness leaves the + * owner's portfolio with the same board count it started with". Twice that was + * argued in a commit message from a manual before/after count. A number in a + * commit message is not a test: it does not run again, and nothing goes red + * when the cleanup stops running. This suite is the assertion. + * + * WHAT IS REAL HERE. The code under test — the relay walk's paging and dedupe, + * latest-wins per coordinate, the archived-marker read, which boards get + * archived, what argv `rd board archive` is invoked with, whether a failure is + * a failure, and the before/after board-count bracket — is the real + * throwaway-board.mjs, unmodified. + * + * WHAT IS FAKED, AND WHY THAT IS NOT FAKING THE THING UNDER TEST. Two + * collaborators only: + * + * - the relay socket (`deps.req`), replaced by an in-memory relay that + * applies REAL nostr semantics: it honours `kinds`, `limit` and `until`, + * serves newest-first, and stores an archive as a REPUBLISH of the same + * coordinate at a later created_at — which is exactly what makes + * latest-wins load-bearing. The paging loop really pages against it. + * - the `rd` binary (`exec`), replaced by a fake that behaves like the real + * one: given ["board","archive",coord] it publishes the archived + * republish to the fake relay. Each failure mode this suite cares about is + * produced by making that fake behave the way a broken `rd` would (throw, + * or exit 0 without publishing) — the failure is never simulated at the + * level of the module's own return value. + * + * A live relay and a real signing key cannot run in CI; the harnesses that use + * them need Chromium, a Go toolchain and the owner's key. The contract those + * harnesses depend on is what runs here, on every PR. + */ + +import { describe, expect, test } from "vitest"; +import { + KIND_BOARD, + PAGE_LIMIT, + archiveBoard, + fetchBoardEvents, + latestBoardsOwnedBy, + openThrowawayBoardGuard, + ownedBoardCount, + reportCleanup, +} from "./throwaway-board.mjs"; + +const OWNER = "a".repeat(64); +const STRANGER = "b".repeat(64); +const RELAY = "wss://relay.example.invalid"; + +/** + * fakeRelay is an in-memory nostr relay that implements the parts of NIP-01 + * this walk depends on: a stored event list, `kinds`/`limit`/`until` + * filtering, and newest-first delivery. It records every filter it is asked + * for, so a test can assert what was (and was not) sent. + */ +function fakeRelay(events = []) { + const store = [...events]; + const filters = []; + let nextTs = 2_000_000; + return { + store, + filters, + publish(e) { + store.push(e); + return e; + }, + /** board() mints a kind-30301 board event, as `rd init` publishes one. */ + board({ pubkey = OWNER, d, archived = false, created_at = nextTs++ } = {}) { + const tags = [["d", d]]; + if (archived) tags.push(["archived", "2026-07-30T00:00:00Z"]); + return this.publish({ id: `${pubkey}:${d}:${created_at}`, pubkey, kind: KIND_BOARD, created_at, tags }); + }, + /** archiveOnRelay is what a WORKING `rd board archive` does: republish the + * same coordinate, later, with the archived tag. */ + archiveOnRelay(coord) { + const [, pubkey, d] = coord.split(":"); + return this.board({ pubkey, d, archived: true, created_at: nextTs++ }); + }, + req(_relay, filter) { + filters.push(structuredClone(filter)); + const matched = store + .filter((e) => (filter.kinds ? filter.kinds.includes(e.kind) : true)) + .filter((e) => (filter.until === undefined ? true : e.created_at <= filter.until)) + .sort((a, b) => b.created_at - a.created_at); + return Promise.resolve(matched.slice(0, filter.limit ?? matched.length)); + }, + }; +} + +/** fakeRd stands in for the compiled `rd` binary. `onArchive` decides how it + * behaves; the default is a correct one that publishes the archive. */ +function fakeRd(relay, onArchive) { + const calls = []; + const exec = (bin, args) => { + calls.push({ bin, args }); + if (args[0] === "board" && args[1] === "archive") { + return (onArchive ?? ((coord) => relay.archiveOnRelay(coord)))(args[2]); + } + throw new Error(`fake rd: unexpected argv ${args.join(" ")}`); + }; + return { calls, exec }; +} + +/** noWait collapses the read-back poll's backoff so the suite stays fast + * without changing which conditions the loop tests. */ +const noWait = { wait: async () => {}, readBackTimeoutMs: 50, readBackIntervalMs: 1 }; + +const RD = { rdBin: "/tmp/rd", cwd: "/tmp/proj", home: "/tmp/home" }; + +describe("the relay walk (ready-153 / relay measurement discipline)", () => { + test("pages backwards with `until` and never sends an `authors` filter", async () => { + const relay = fakeRelay(); + // 1,200 boards is well past one page, so the walk MUST page to see them + // all — the shape that made a one-page walk under-report. + for (let i = 0; i < 1200; i++) relay.board({ d: `board-${i}` }); + + const events = await fetchBoardEvents(RELAY, { req: relay.req.bind(relay) }); + + expect(events.length).toBe(1200); + expect(relay.filters.length).toBeGreaterThan(1); + for (const f of relay.filters) { + // An `authors` filter silently under-returns on wss://relay.3dl.network + // (42/56 vs 56/56 for the same walk, ready-5c5). Ownership is filtered + // client-side, after the walk. + expect(f).not.toHaveProperty("authors"); + expect(f.kinds).toEqual([KIND_BOARD]); + expect(f.limit).toBeGreaterThanOrEqual(PAGE_LIMIT); + } + expect(relay.filters.slice(1).every((f) => typeof f.until === "number")).toBe(true); + }); + + test("latest-wins per coordinate: an archive republish beats the original", () => { + const relay = fakeRelay(); + relay.board({ d: "b4359x", created_at: 100 }); + relay.board({ d: "b4359x", archived: true, created_at: 200 }); + + const boards = latestBoardsOwnedBy(relay.store, OWNER); + + expect(boards.size).toBe(1); + expect(boards.get(`${KIND_BOARD}:${OWNER}:b4359x`).archived).toBe(true); + }); + + test("an older archive republish does NOT un-archive a newer live board", () => { + const relay = fakeRelay(); + relay.board({ d: "b4359x", archived: true, created_at: 100 }); + relay.board({ d: "b4359x", created_at: 200 }); + + const boards = latestBoardsOwnedBy(relay.store, OWNER); + + expect(boards.get(`${KIND_BOARD}:${OWNER}:b4359x`).archived).toBe(false); + }); + + test("counts only this key's unarchived boards", async () => { + const relay = fakeRelay(); + relay.board({ d: "live-1" }); + relay.board({ d: "live-2" }); + relay.board({ d: "gone", archived: true }); + relay.board({ pubkey: STRANGER, d: "someone-elses" }); + + expect(await ownedBoardCount(RELAY, OWNER, { req: relay.req.bind(relay) })).toBe(2); + }); +}); + +describe("archiveBoard runs the real `rd board archive`", () => { + test("invokes the binary with exactly ['board','archive',coord] in the project dir", () => { + const relay = fakeRelay(); + const rd = fakeRd(relay); + + archiveBoard({ ...RD, relay: RELAY, coord: `${KIND_BOARD}:${OWNER}:b4359x`, exec: rd.exec }); + + expect(rd.calls).toEqual([{ bin: "/tmp/rd", args: ["board", "archive", `${KIND_BOARD}:${OWNER}:b4359x`] }]); + }); + + test("a missing rd binary is an error, not a silent skip", () => { + expect(() => archiveBoard({ ...RD, rdBin: undefined, relay: RELAY, coord: "c", exec: () => {} })).toThrow( + /no rd binary/, + ); + }); +}); + +describe("the guard's cleanup contract", () => { + /** run models a harness: open the guard, register the board-d, let `create` + * decide what the run actually publishes, then close. */ + async function run({ relay, create, onArchive, rdBin = RD.rdBin, boardD = "b4359run" }) { + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect(boardD); + create?.(boardD); + const rd = fakeRd(relay, onArchive); + const result = await guard.close({ ...RD, rdBin, exec: rd.exec, ...noWait }); + return { guard, rd, result }; + } + + test("the happy path: the run's board is archived and the count comes back level", async () => { + const relay = fakeRelay(); + relay.board({ d: "unrelated-real-board" }); + + const { rd, result } = await run({ relay, create: (d) => relay.board({ d }) }); + + expect(result.failures).toEqual([]); + expect(result.ok).toBe(true); + expect(result.before).toBe(1); + expect(result.after).toBe(1); + expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:b4359run`]); + expect(rd.calls.map((c) => c.args[0] + " " + c.args[1])).toEqual(["board archive"]); + }); + + // THIS IS THE ONE THE REVIEW ASKED FOR: delete the archive call and this + // goes red. `onArchive: () => ""` is a `rd board archive` that runs and + // publishes nothing — indistinguishable, from the harness's point of view, + // from the call never having been made at all. + test("cleanup that does not actually archive turns the run RED", async () => { + const relay = fakeRelay(); + + const { result } = await run({ relay, create: (d) => relay.board({ d }), onArchive: () => "" }); + + expect(result.ok).toBe(false); + expect(result.before).toBe(0); + expect(result.after).toBe(1); + expect(result.failures.join("\n")).toMatch(/archived marker for 30301:a+:b4359run is NOT on/); + expect(result.failures.join("\n")).toMatch(/0 before, 1 after — it left a stray behind/); + }); + + test("`rd board archive` exiting 0 without the marker landing is NOT success", async () => { + const relay = fakeRelay(); + // A `rd` that returns cleanly — the exit code says everything worked — but + // whose event never reaches the relay. Only a read-back can tell. + const { rd, result } = await run({ relay, create: (d) => relay.board({ d }), onArchive: () => "archived\n" }); + + expect(rd.calls.length).toBe(1); // the command DID run and DID succeed + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/archived marker for .* is NOT on/); + }); + + test("a failing `rd board archive` is a failure, not a warning", async () => { + const relay = fakeRelay(); + + const { result } = await run({ + relay, + create: (d) => relay.board({ d }), + onArchive: () => { + throw new Error("relay refused: rate-limited"); + }, + }); + + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/could not archive throwaway board .*rate-limited/); + expect(result.failures.join("\n")).toMatch(/it left a stray behind/); + }); + + // Leak 3 from throwaway-board.mjs's header: the previous cleanup was guarded + // on `if (coord && rdBin)`, and `coord` only ever got a value from + // JSON.parse(rd init --json). A board published by an `rd init` that then + // failed leaked forever — the exact case the finally block exists for. + test("a board published by an `rd init` that then FAILED is still cleaned up", async () => { + const relay = fakeRelay(); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + + // The harness registers the board-d BEFORE running `rd init`... + guard.expect("s48fcrash"); + // ...`rd init` publishes the board and then dies. No coordinate is ever + // returned to the harness, no local variable is assigned. + relay.board({ d: "s48fcrash" }); + + const rd = fakeRd(relay); + const result = await guard.close({ ...RD, exec: rd.exec, ...noWait }); + + expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:s48fcrash`]); + expect(result.failures).toEqual([]); + expect(result.after).toBe(result.before); + }); + + test("a run that failed BEFORE publishing anything passes without calling rd", async () => { + const relay = fakeRelay(); + relay.board({ d: "unrelated-real-board" }); + + const { rd, result } = await run({ relay, create: undefined, rdBin: null }); + + expect(rd.calls).toEqual([]); + expect(result.ok).toBe(true); + expect(result.after).toBe(result.before); + }); + + test("no rd binary but a board WAS published is a failure, not a pass", async () => { + const relay = fakeRelay(); + + const { result } = await run({ relay, create: (d) => relay.board({ d }), rdBin: null }); + + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/no rd binary available to archive with/); + }); + + test("an already-archived board is left alone rather than archived twice", async () => { + const relay = fakeRelay(); + + const { rd, result } = await run({ relay, create: (d) => relay.board({ d, archived: true }) }); + + expect(rd.calls).toEqual([]); + expect(result.ok).toBe(true); + }); + + test("every registered board is archived, and one failure does not skip the rest", async () => { + const relay = fakeRelay(); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359a"); + guard.expect("b4359b"); + relay.board({ d: "b4359a" }); + relay.board({ d: "b4359b" }); + + const rd = fakeRd(relay, (coord) => { + if (coord.endsWith("b4359a")) throw new Error("boom"); + return relay.archiveOnRelay(coord); + }); + const result = await guard.close({ ...RD, exec: rd.exec, ...noWait }); + + expect(rd.calls.length).toBe(2); + expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:b4359b`]); + expect(result.failures.join("\n")).toMatch(/b4359a/); + }); + + test("a board the run did NOT create is never archived", async () => { + const relay = fakeRelay(); + relay.board({ d: "the-owners-real-project" }); + + const { rd, result } = await run({ relay, create: (d) => relay.board({ d }) }); + + expect(rd.calls.map((c) => c.args[2])).toEqual([`${KIND_BOARD}:${OWNER}:b4359run`]); + expect(result.ok).toBe(true); + }); + + test("the marker read-back is polled, so a relay that is slow to serve it still passes", async () => { + const relay = fakeRelay(); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359slow"); + relay.board({ d: "b4359slow" }); + + // The archive lands on the relay two poll cycles after the command + // returns — a real relay's propagation, not an error. + let pending; + const rd = fakeRd(relay, (coord) => { + pending = coord; + return ""; + }); + let waits = 0; + const result = await guard.close({ + ...RD, + exec: rd.exec, + readBackTimeoutMs: 10_000, + readBackIntervalMs: 1, + wait: async () => { + if (++waits === 2 && pending) relay.archiveOnRelay(pending); + }, + }); + + expect(waits).toBe(2); + expect(result.ok).toBe(true); + }); + + test("a relay that goes away before cleanup fails the run rather than passing it", async () => { + const relay = fakeRelay(); + let live = true; + const req = (r, f) => (live ? relay.req(r, f) : Promise.reject(new Error("connection failed"))); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359gone"); + relay.board({ d: "b4359gone" }); + + // The board is published, then the relay stops answering — cleanup cannot + // even establish what needs archiving. Fail closed: an unverifiable + // cleanup is not a clean one. + live = false; + const rd = fakeRd(relay); + const result = await guard.close({ ...RD, exec: rd.exec, ...noWait }); + + expect(rd.calls).toEqual([]); + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/could not read this key's boards back off/); + }); + + test("reportCleanup returns the number a harness must add to its failure count", () => { + const lines = []; + expect(reportCleanup({ ok: true, failures: [] }, (l) => lines.push(l))).toBe(0); + expect(reportCleanup({ ok: false, failures: ["a", "b"] }, (l) => lines.push(l))).toBe(2); + expect(lines[0]).toMatch(/PASS/); + expect(lines[1]).toMatch(/FAIL/); + }); +}); diff --git a/web/board/vitest.config.ts b/web/board/vitest.config.ts index 26e21f7..c3cd425 100644 --- a/web/board/vitest.config.ts +++ b/web/board/vitest.config.ts @@ -9,6 +9,14 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + // scripts/**: ready-153. The live harnesses under scripts/ can never run + // in CI (Chromium, a Go toolchain, the owner's signing key, a real relay), + // but the cleanup contract they depend on — throwaway-board.mjs — is pure + // logic over an injectable relay and an injectable `rd`, and the check + // that every board-creating harness is actually bound to that contract is + // a source-shape assertion. Both run here, on every PR. Before this, the + // only evidence a harness cleaned up after itself was prose in a commit + // message. + include: ["src/**/*.test.ts", "scripts/**/*.test.mjs"], }, }); From c4f9c143af7f1412f4ed4a8680fb30ef8efd35bd Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 30 Jul 2026 20:37:23 +0000 Subject: [PATCH 4/5] fix(board): the throwaway-board invariant can no longer pass for the wrong reason (ready-153 round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3 made the cleanup contract executable. Two holes in the harness it was executed against meant the contract could still pass on a run that left a stray behind — the same defect class the invariant was filed to catch. 1. THE FAKE COULD NOT PRODUCE THE CASE THE CLEANUP EXISTS FOR. The fake relay served a published board on the very next read, so the run's own board had ALWAYS propagated by cleanup time and the archive could not fail. That hid a real defect: close() took its "what do I have to archive" snapshot ONCE, before the read-back loop. A board this run published that had not propagated by that first read was never in the snapshot, so it was never archived — and then nothing contradicted it, because the marker read-back had nothing to prove and the board-count bracket cannot see a board the relay is withholding either. 1 before, 1 after, green, permanent stray. The fake can now withhold an event it has accepted for the next N reads, and the ownership read moved INSIDE the poll loop: what to archive is re-derived every poll, so a board that appears on poll 3 is archived on poll 3 (archived once, not once per poll). A board the relay never serves inside the window now fails the run — it is indistinguishable from a board that was never published, the two have opposite consequences, and the guard is not allowed to guess. The honest cost, asserted: a run that died before publishing anything is reported too. That run has already failed for its own reason; one loud line on a red run beats a silent stray. 2. A TIMEOUT READ AS "NO BOARDS". wsReq resolved with whatever partial page had arrived when its timeout fired, so an unresponsive relay was indistinguishable from a key that owns nothing: fetchBoards reported a clean portfolio, and the board-count invariant passed vacuously — 0 before, 0 after, equal, green, on a dead relay. wsReq now REJECTS on silence, and the rejection propagates: a dead relay cannot even open the guard, so the BEFORE count is never invented. Asserted on the real wsReq against a fake WebSocket, with the distinction that has to survive tested both ways: a live relay with nothing to say resolves empty and closes the subscription; a silent one rejects, as does a page that arrives without EOSE. All seven new assertions go RED against round 3's module (verified by running this suite against c13c9f0's throwaway-board.mjs). Board suite 936 passed, go test ./... clean, typecheck and build clean. Co-Authored-By: Claude Opus 5 (1M context) --- web/board/scripts/throwaway-board.mjs | 153 ++++++++--- web/board/scripts/throwaway-board.test.mjs | 285 ++++++++++++++++++++- 2 files changed, 389 insertions(+), 49 deletions(-) diff --git a/web/board/scripts/throwaway-board.mjs b/web/board/scripts/throwaway-board.mjs index 285e313..8d02ccb 100644 --- a/web/board/scripts/throwaway-board.mjs +++ b/web/board/scripts/throwaway-board.mjs @@ -28,7 +28,9 @@ * * - what to archive is whatever the relay says this key owns under the * board-ds this run registered — no local `coord` required, so a run that - * dies mid-`rd init` still cleans up (leak 3); + * dies mid-`rd init` still cleans up (leak 3) — and it is RE-DERIVED ON + * EVERY POLL, so a board the relay had not propagated yet at the first + * read is archived when it does appear; * - `rd board archive` failing is a recorded failure, never a warning * (leak 1); * - the archived marker is READ BACK off the relay after the command, and @@ -38,6 +40,19 @@ * boards taken before it starts and after cleanup finishes. They must * agree. That is the item's done condition as an executable assertion. * + * THE COUNT INVARIANT MUST NOT BE ABLE TO PASS FOR THE WRONG REASON. Two ways + * it could, both closed here and both asserted in the test suite, because an + * invariant that passes vacuously is the same defect class it was filed to + * catch: + * + * - a relay that answers nothing would otherwise read as a key that owns no + * boards: 0 before, 0 after, equal, green, on a dead relay. `wsReq` + * therefore REJECTS when no EOSE arrives rather than resolving with a + * partial page (see below), and a failed count read is a failed run; + * - the count cannot see a board the relay is withholding, so cleanup also + * requires positive sight of every board-d the run registered, and fails + * closed when the relay never serves one. + * * Every one of those behaviours is covered hermetically by * throwaway-board.test.mjs (vitest, in CI via board-ci.yml) against a fake * relay and a fake `rd`. Delete the `exec(...)` call in archiveBoard below and @@ -69,6 +84,14 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); * up to EOSE. This is the only part of the walk that touches a socket, and it * is injectable (`deps.req`) so the paging, dedupe, latest-wins and * archived-marker logic above it can be exercised without a network. + * + * EOSE OR NOTHING. A relay that never answers REJECTS; it does not resolve + * with whatever partial page arrived. This is load-bearing for the board-count + * invariant above: if a silent relay resolved to `[]`, then "this key owns no + * boards" and "nothing came back" would be the same value, `ownedBoardCount` + * would report a clean portfolio, and the bracket would read 0 before and 0 + * after and call an unresponsive relay a passing run. Only EOSE means the + * relay has finished answering; anything else is an error the caller must see. */ export async function wsReq(relay, filter, { timeoutMs = 45000 } = {}) { return new Promise((resolve, reject) => { @@ -81,7 +104,12 @@ export async function wsReq(relay, filter, { timeoutMs = 45000 } = {}) { } catch { /* already closed */ } - resolve(out); + reject( + new Error( + `relay ${relay}: no EOSE within ${timeoutMs}ms (${out.length} event(s) received) — ` + + `a silent relay is not an empty one, and must not be read as "this key owns no boards"`, + ), + ); }, timeoutMs); ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); ws.onmessage = (m) => { @@ -211,6 +239,12 @@ export function archiveBoard({ rdBin, cwd, home, relay, coord, exec = execFileSy * `expect` takes the board-d, not the coordinate, precisely so it can be * called before `rd init` — the run that dies inside `rd init` is the one that * leaks, and it is the one with no coordinate to hand. + * + * The BEFORE count is not defended against: if the relay will not answer, + * this REJECTS and the harness dies here, before `rd init` has published + * anything. That is the correct end for a run whose cleanup could never have + * been verified — and it is why `wsReq` must reject on silence rather than + * hand back an empty page that would open the guard with a made-up count. */ export async function openThrowawayBoardGuard({ relay, ownerPubkey, log = () => {}, boardDs = [], deps = {} }) { const expected = new Set(boardDs); @@ -235,8 +269,11 @@ export async function openThrowawayBoardGuard({ relay, ownerPubkey, log = () => * event to the LOCAL log only — measured 2026-07-30: init plus `rd relay * flush` leaves nothing on the relay, and the board first appears there on * the run's first write. So a run that died before writing anything has no - * board in the owner's portfolio to clean up, and this correctly archives - * nothing instead of publishing an archive for a board that never existed. + * board in the owner's portfolio to clean up, and this archives nothing + * instead of publishing an archive for a board that never existed — but it + * does NOT call that case clean, because from the relay's answers it is + * identical to a board that has simply not propagated yet. It waits the + * read-back window out and then reports the board as never served. * * Returns { ok, before, after, archived, failures } — `failures` is a list * of human-readable reasons, and a harness MUST add its length to its own @@ -255,54 +292,75 @@ export async function openThrowawayBoardGuard({ relay, ownerPubkey, log = () => } = {}) { const failures = []; const archived = []; + /** coords `rd board archive` has already been run for, so a board that + * takes several polls to prove is not archived once per poll. */ + const attempted = new Set(); + /** boardD -> coord, for every registered board the relay has ACTUALLY + * served at least once. Sticky: once seen, a board stays this run's + * responsibility even if a later read stops showing it. */ + const seen = new Map(); + const coordOf = (boardD) => `${KIND_BOARD}:${ownerPubkey}:${boardD}`; - // WHAT TO ARCHIVE COMES FROM THE RELAY, never from a local `coord` - // variable: a run that failed inside `rd init` has no coordinate but may - // very well have published the board. Leak 3 in this file's header. - let owned; - try { - owned = await ownedBoards(relay, ownerPubkey, deps); - } catch (err) { - failures.push(`could not read this key's boards back off ${relay}: ${err.message}`); - return { ok: false, before, after: undefined, archived, failures }; - } - const mine = [...owned.values()].filter((b) => expected.has(b.boardD)); - const stray = mine.filter((b) => !b.archived); - - for (const b of stray) { - try { - archiveBoard({ rdBin, cwd, home, relay, coord: b.coord, exec }); - archived.push(b.coord); - log(` ready-153: archived throwaway board ${b.coord}`); - } catch (err) { - // A FAILURE, not a warning. An error that is only logged lets the - // process exit 0 with the stray still in the owner's portfolio — - // the exact outcome this guard exists to prevent. - failures.push(`could not archive throwaway board ${b.coord}: ${err.message}`); - } - } - - // READ THE MARKER BACK. `rd board archive` exiting 0 says the command - // ran, not that the relay took the event. The only acceptable evidence - // is the relay's own copy carrying the archived tag. + // ONE POLLED LOOP, and the ownership read is INSIDE it. An earlier + // version took the "what do I have to archive" snapshot once, before the + // loop, and only re-read to confirm the marker. That is wrong against + // any relay that is not instantaneous: a board this run published but + // that had not propagated by the first read was never in the snapshot, + // so it was never archived — and then the read-back had nothing to + // prove and the count invariant, which also cannot see a board the relay + // is withholding, agreed with itself. Green run, permanent stray. The + // work is therefore re-derived from the relay on every poll: + // + // - WHAT TO ARCHIVE comes from the relay, never from a local `coord` + // variable: a run that failed inside `rd init` has no coordinate but + // may very well have published the board (leak 3 in this file's + // header) — and a board that shows up on poll 3 is archived on + // poll 3; + // - THE MARKER IS READ BACK. `rd board archive` exiting 0 says the + // command ran, not that the relay took the event; + // - EVERY REGISTERED BOARD MUST BE SEEN. A board the relay never + // serves is not evidence of a board that was never published — it is + // the absence of evidence either way, and it fails closed. // // Polled rather than slept: a relay takes an event when it takes it, and - // a fixed sleep is either flaky or slow. The loop exits the moment both - // conditions hold, and gives up at the deadline. + // a fixed sleep is either flaky or slow. The loop exits the moment every + // condition holds, and gives up at the deadline. const deadline = now() + readBackTimeoutMs; let after; let unproven = []; + let unseen = [...expected]; for (;;) { + let owned; try { owned = await ownedBoards(relay, ownerPubkey, deps); } catch (err) { failures.push(`could not read this key's boards back off ${relay}: ${err.message}`); break; } - unproven = mine.filter((b) => !owned.get(b.coord)?.archived).map((b) => b.coord); + + for (const b of owned.values()) if (expected.has(b.boardD)) seen.set(b.boardD, b.coord); + + for (const coord of seen.values()) { + const b = owned.get(coord); + if (!b || b.archived || attempted.has(coord)) continue; + attempted.add(coord); + try { + archiveBoard({ rdBin, cwd, home, relay, coord, exec }); + archived.push(coord); + log(` ready-153: archived throwaway board ${coord}`); + } catch (err) { + // A FAILURE, not a warning. An error that is only logged lets the + // process exit 0 with the stray still in the owner's portfolio — + // the exact outcome this guard exists to prevent. + failures.push(`could not archive throwaway board ${coord}: ${err.message}`); + } + } + + unproven = [...seen.values()].filter((coord) => !owned.get(coord)?.archived); + unseen = [...expected].filter((d) => !seen.has(d)); after = 0; for (const b of owned.values()) if (!b.archived) after++; - if (unproven.length === 0 && after === before) break; + if (unproven.length === 0 && unseen.length === 0 && after === before) break; if (now() >= deadline) break; await wait(readBackIntervalMs); } @@ -313,6 +371,23 @@ export async function openThrowawayBoardGuard({ relay, ownerPubkey, log = () => ); } + // FAIL CLOSED ON A BOARD THAT NEVER APPEARED. The measured behaviour is + // that `rd init` writes the kind-30301 board to the LOCAL log only and + // the board reaches the relay on the run's first write, so a run that + // died before writing anything genuinely has nothing to clean up. But + // from here that run is indistinguishable from one whose board the relay + // simply has not served yet, and the two have opposite consequences: one + // is clean, the other is a permanent stray in the owner's portfolio. The + // guard waits the full read-back window for the board to appear and then + // reports it, deliberately preferring a loud line on a run that has + // already failed for another reason over a silent stray. + for (const boardD of unseen) { + failures.push( + `${relay} never served ${coordOf(boardD)}, the board this run registered — ` + + `cleanup cannot establish whether it was published, and a board it cannot see it cannot archive`, + ); + } + // THE DONE CONDITION, AS AN ASSERTION: a full run leaves the owner's // portfolio with the board count it started with. It is deliberately the // WHOLE portfolio's count and not just this run's board — a cleanup that @@ -322,6 +397,10 @@ export async function openThrowawayBoardGuard({ relay, ownerPubkey, log = () => // from this run's point of view. if (after === undefined) { /* the read-back never completed; the failure is already recorded */ + } else if (unseen.length > 0) { + /* the count agrees, but only because the relay is not showing a board + * this run registered. An invariant cannot see what it is not shown; + * the unseen failure above is the honest report. */ } else if (after !== before) { failures.push( `the run changed the owner's unarchived board count: ${before} before, ${after} after — it left a stray behind`, diff --git a/web/board/scripts/throwaway-board.test.mjs b/web/board/scripts/throwaway-board.test.mjs index 684a474..08474a8 100644 --- a/web/board/scripts/throwaway-board.test.mjs +++ b/web/board/scripts/throwaway-board.test.mjs @@ -18,9 +18,17 @@ * * - the relay socket (`deps.req`), replaced by an in-memory relay that * applies REAL nostr semantics: it honours `kinds`, `limit` and `until`, - * serves newest-first, and stores an archive as a REPUBLISH of the same + * serves newest-first, stores an archive as a REPUBLISH of the same * coordinate at a later created_at — which is exactly what makes - * latest-wins load-bearing. The paging loop really pages against it. + * latest-wins load-bearing — and can WITHHOLD an event it has accepted + * for the next few reads, because a relay that answers a just-published + * board with "no such board" is the ordinary case cleanup exists for, and + * a fake that always serves the run's own board on the very next read can + * never exercise the path where the archive does not happen. + * - `wsReq`'s socket itself, in the last describe block, replaced by a fake + * `WebSocket` — so the EOSE-versus-silence distinction that keeps the + * board-count invariant from passing on a dead relay is tested on the + * real `wsReq`, not on a stand-in for it. * - the `rd` binary (`exec`), replaced by a fake that behaves like the real * one: given ["board","archive",coord] it publishes the archived * republish to the fake relay. Each failure mode this suite cares about is @@ -33,7 +41,7 @@ * harnesses depend on is what runs here, on every PR. */ -import { describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { KIND_BOARD, PAGE_LIMIT, @@ -43,6 +51,7 @@ import { openThrowawayBoardGuard, ownedBoardCount, reportCleanup, + wsReq, } from "./throwaway-board.mjs"; const OWNER = "a".repeat(64); @@ -58,10 +67,25 @@ const RELAY = "wss://relay.example.invalid"; function fakeRelay(events = []) { const store = [...events]; const filters = []; + const withheld = new Map(); let nextTs = 2_000_000; return { store, filters, + /** + * withhold makes this relay answer the next `reads` REQs as though it had + * never heard of board-d `d`, while still holding the event — a relay that + * has ACCEPTED a write and has not propagated it yet. Real, ordinary, and + * the reason cleanup cannot take a single snapshot of what to archive. + * + * `reads` is counted in REQs; every walk in these tests fits in one page, + * so one read = one walk = one `ownedBoards` call. Pass `Infinity` for a + * relay that never serves the board at all. + */ + withhold(d, reads) { + withheld.set(d, reads); + return d; + }, publish(e) { store.push(e); return e; @@ -80,7 +104,13 @@ function fakeRelay(events = []) { }, req(_relay, filter) { filters.push(structuredClone(filter)); + const hidden = new Set(); + for (const [d, reads] of withheld) { + if (reads > 0) hidden.add(d); + withheld.set(d, reads - 1); + } const matched = store + .filter((e) => !hidden.has(e.tags.find((t) => t[0] === "d")?.[1])) .filter((e) => (filter.kinds ? filter.kinds.includes(e.kind) : true)) .filter((e) => (filter.until === undefined ? true : e.created_at <= filter.until)) .sort((a, b) => b.created_at - a.created_at); @@ -103,9 +133,29 @@ function fakeRd(relay, onArchive) { return { calls, exec }; } -/** noWait collapses the read-back poll's backoff so the suite stays fast - * without changing which conditions the loop tests. */ -const noWait = { wait: async () => {}, readBackTimeoutMs: 50, readBackIntervalMs: 1 }; +/** + * polls() gives the read-back loop a VIRTUAL clock: `wait` advances time + * instead of spending it, so a test that has to let the whole read-back window + * expire (a relay that never serves the board) costs a handful of iterations + * rather than 30 real seconds — and costs exactly the same number of them + * every run, which a wall clock would not. + * + * PROOF that the budget is real and not a way of dodging the poll loop: the + * "a relay that never serves the run's board" test below asserts the walk was + * retried, and "the marker read-back is polled" runs on the real clock. + */ +const POLLS = 5; +function polls({ intervalMs = 1000 } = {}) { + let t = 1_000_000; + return { + readBackTimeoutMs: intervalMs * POLLS, + readBackIntervalMs: intervalMs, + now: () => t, + wait: async (ms) => { + t += ms; + }, + }; +} const RD = { rdBin: "/tmp/rd", cwd: "/tmp/proj", home: "/tmp/home" }; @@ -189,7 +239,7 @@ describe("the guard's cleanup contract", () => { guard.expect(boardD); create?.(boardD); const rd = fakeRd(relay, onArchive); - const result = await guard.close({ ...RD, rdBin, exec: rd.exec, ...noWait }); + const result = await guard.close({ ...RD, rdBin, exec: rd.exec, ...polls() }); return { guard, rd, result }; } @@ -266,22 +316,110 @@ describe("the guard's cleanup contract", () => { relay.board({ d: "s48fcrash" }); const rd = fakeRd(relay); - const result = await guard.close({ ...RD, exec: rd.exec, ...noWait }); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:s48fcrash`]); expect(result.failures).toEqual([]); expect(result.after).toBe(result.before); }); - test("a run that failed BEFORE publishing anything passes without calling rd", async () => { + // ── the relay is not instantaneous ────────────────────────────────────── + // + // A relay that serves this run's board on the very next read is a relay + // that can never make the cleanup fail, and a fake that only models that + // relay tests nothing about the case cleanup exists for. These three drive + // the fake's `withhold`: a board it has accepted and is not serving yet. + + test("a board the relay is not serving YET is archived when it appears", async () => { + const relay = fakeRelay(); + relay.board({ d: "unrelated-real-board" }); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359lag"); + + // The run publishes its board and the relay takes it — but the next two + // reads answer as though it were not there. A cleanup that decides what to + // archive from a single snapshot archives NOTHING here, and then agrees + // with itself: the marker read-back has nothing to prove and the count + // invariant cannot see the board either. 1 before, 1 after, green, and a + // permanent stray the moment the relay catches up. + relay.board({ d: "b4359lag" }); + relay.withhold("b4359lag", 2); + + const rd = fakeRd(relay); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:b4359lag`]); + expect(rd.calls.map((c) => c.args)).toEqual([["board", "archive", `${KIND_BOARD}:${OWNER}:b4359lag`]]); + expect(result.failures).toEqual([]); + expect(result.ok).toBe(true); + expect(result.after).toBe(result.before); + }); + + test("a board that appears late is archived ONCE, not once per poll", async () => { + const relay = fakeRelay(); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359once"); + relay.board({ d: "b4359once" }); + relay.withhold("b4359once", 1); + + // The archive republish is withheld for a poll too, so the loop keeps + // going after the command has already run. Re-issuing `rd board archive` + // every poll would spam the relay with republishes of a board it is simply + // slow to serve. + const rd = fakeRd(relay, (coord) => { + relay.archiveOnRelay(coord); + relay.withhold(coord.split(":")[2], 1); + return ""; + }); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(rd.calls.length).toBe(1); + expect(result.ok).toBe(true); + }); + + test("a relay that NEVER serves the run's board fails the run rather than passing it clean", async () => { const relay = fakeRelay(); relay.board({ d: "unrelated-real-board" }); + const req = relay.req.bind(relay); + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: { req } }); + guard.expect("b4359dark"); + + // Published, accepted, and never served back inside the read-back window. + // From here this is indistinguishable from a run that died before it ever + // published — and the two have opposite consequences, so the guard is not + // allowed to guess. The count invariant AGREES (1 before, 1 after): it + // cannot see a board the relay will not show it, which is exactly why the + // count alone is not enough to call a run clean. + relay.board({ d: "b4359dark" }); + relay.withhold("b4359dark", Infinity); + + const readsBefore = relay.filters.length; + const rd = fakeRd(relay); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); + + expect(relay.filters.length - readsBefore).toBe(POLLS + 1); // it really waited the window out + expect(rd.calls).toEqual([]); + expect(result.after).toBe(result.before); // the invariant passes... + expect(result.ok).toBe(false); // ...and the run is red anyway + expect(result.failures.join("\n")).toMatch(/never served 30301:a+:b4359dark, the board this run registered/); + }); + test("a run that published nothing is reported, not quietly passed", async () => { + const relay = fakeRelay(); + relay.board({ d: "unrelated-real-board" }); + + // The honest cost of the rule above: a run that died before `rd init` + // published anything looks the same from the relay and is reported the + // same way. That run has already failed for its own reason; one more loud + // line on a red run is the deliberate trade against a silent stray. const { rd, result } = await run({ relay, create: undefined, rdBin: null }); expect(rd.calls).toEqual([]); - expect(result.ok).toBe(true); expect(result.after).toBe(result.before); + expect(result.ok).toBe(false); + expect(result.failures.join("\n")).toMatch(/never served .* the board this run registered/); }); test("no rd binary but a board WAS published is a failure, not a pass", async () => { @@ -315,7 +453,7 @@ describe("the guard's cleanup contract", () => { if (coord.endsWith("b4359a")) throw new Error("boom"); return relay.archiveOnRelay(coord); }); - const result = await guard.close({ ...RD, exec: rd.exec, ...noWait }); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); expect(rd.calls.length).toBe(2); expect(result.archived).toEqual([`${KIND_BOARD}:${OWNER}:b4359b`]); @@ -374,7 +512,7 @@ describe("the guard's cleanup contract", () => { // cleanup is not a clean one. live = false; const rd = fakeRd(relay); - const result = await guard.close({ ...RD, exec: rd.exec, ...noWait }); + const result = await guard.close({ ...RD, exec: rd.exec, ...polls() }); expect(rd.calls).toEqual([]); expect(result.ok).toBe(false); @@ -388,4 +526,127 @@ describe("the guard's cleanup contract", () => { expect(lines[0]).toMatch(/PASS/); expect(lines[1]).toMatch(/FAIL/); }); + + test("a dead relay cannot open the guard at all, so the count is never invented", async () => { + // The BEFORE measurement is where a silent relay does its worst damage: it + // would hand back 0, the run would publish a board, cleanup would read 0 + // again, and 0 === 0 would call it clean. The read must propagate. + const dead = { req: () => Promise.reject(new Error("no EOSE within 45000ms")) }; + await expect(openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: OWNER, deps: dead })).rejects.toThrow( + /no EOSE/, + ); + + // ...and the difference that has to survive: a LIVE relay this key simply + // owns nothing on opens the guard and reports a real 0. + const empty = fakeRelay(); + empty.board({ pubkey: STRANGER, d: "someone-elses" }); + const guard = await openThrowawayBoardGuard({ + relay: RELAY, + ownerPubkey: OWNER, + deps: { req: empty.req.bind(empty) }, + }); + expect(guard.before).toBe(0); + }); +}); + +/** + * wsReq is the one function in the module that touches a socket, so the fake + * here is the `WebSocket` class itself and the code under test is the real + * `wsReq` — its timeout, its EOSE handling, its CLOSE. + * + * WHY THIS BLOCK EXISTS. `wsReq` used to `resolve(out)` on timeout. Every + * caller above then read an unresponsive relay as an answer: `fetchBoardEvents` + * returned a short page, `ownedBoardCount` returned a number, and the guard's + * whole board-count bracket read 0 before and 0 after and reported a clean + * portfolio for a relay that had said nothing at all. An invariant that passes + * when the measurement fails is the same defect class this item was filed to + * catch, so the distinction is asserted rather than assumed. + */ +describe("wsReq tells an empty answer from no answer", () => { + afterEach(() => vi.unstubAllGlobals()); + + /** fakeSocket installs a `WebSocket` whose behaviour on REQ is `script`. */ + function fakeSocket(script) { + const sockets = []; + class FakeWebSocket { + constructor(url) { + this.url = url; + this.sent = []; + this.closed = false; + sockets.push(this); + // A MICROTASK, not a 0ms timer. `wsReq` assigns `onopen` after this + // constructor returns, so the open cannot be synchronous — but a timer + // would queue behind the timeout timer's macrotask if the event loop + // were blocked, and the "events arrived, EOSE did not" test would then + // reject for the wrong reason on a loaded runner. A microtask always + // runs before any timer. + queueMicrotask(() => this.onopen?.()); + } + send(raw) { + this.sent.push(raw); + const [verb, sub, filter] = JSON.parse(raw); + // Only a REQ produces an answer. A real relay does not re-serve the + // subscription when the client CLOSEs it. + if (verb === "REQ") script(this, sub, filter); + } + close() { + this.closed = true; + } + } + vi.stubGlobal("WebSocket", FakeWebSocket); + return sockets; + } + + const deliver = (ws, sub, e) => ws.onmessage({ data: JSON.stringify(["EVENT", sub, e]) }); + const eose = (ws, sub) => ws.onmessage({ data: JSON.stringify(["EOSE", sub]) }); + + test("a live relay with nothing to say resolves empty, and closes the subscription", async () => { + const sockets = fakeSocket((ws, sub) => eose(ws, sub)); + + await expect(wsReq(RELAY, { kinds: [KIND_BOARD], limit: PAGE_LIMIT })).resolves.toEqual([]); + expect(JSON.parse(sockets[0].sent[0])[0]).toBe("REQ"); + expect(JSON.parse(sockets[0].sent[1])[0]).toBe("CLOSE"); + }); + + test("a live relay's events are returned once EOSE says it has finished", async () => { + const sockets = fakeSocket((ws, sub) => { + deliver(ws, sub, { id: "e1" }); + deliver(ws, sub, { id: "e2" }); + eose(ws, sub); + }); + + await expect(wsReq(RELAY, { kinds: [KIND_BOARD] })).resolves.toEqual([{ id: "e1" }, { id: "e2" }]); + expect(sockets[0].closed).toBe(true); + }); + + test("a relay that never answers REJECTS — it is not a key that owns no boards", async () => { + const sockets = fakeSocket(() => { + /* accepts the REQ and says nothing, ever */ + }); + + await expect(wsReq(RELAY, { kinds: [KIND_BOARD] }, { timeoutMs: 20 })).rejects.toThrow(/no EOSE within 20ms/); + expect(sockets[0].closed).toBe(true); + }); + + test("a page that arrives without EOSE is rejected, not resolved as if complete", async () => { + // The nastier half: some events DID arrive. Resolving the partial page + // would under-report the walk, and an under-reported walk is a stray the + // cleanup never sees. + fakeSocket((ws, sub) => { + deliver(ws, sub, { id: "e1" }); + deliver(ws, sub, { id: "e2" }); + }); + + await expect(wsReq(RELAY, { kinds: [KIND_BOARD] }, { timeoutMs: 20 })).rejects.toThrow(/2 event\(s\) received/); + }); + + test("the rejection propagates all the way to the board count, which never returns a number", async () => { + fakeSocket(() => {}); + + // fetchBoardEvents/ownedBoardCount default to the real wsReq: a dead relay + // must not be able to produce a count at all. + await expect(ownedBoardCount(RELAY, OWNER, { req: (r, f) => wsReq(r, f, { timeoutMs: 20 }) })).rejects.toThrow( + /no EOSE/, + ); + }); }); From f5df8d83802afb228e69a8b98854929d7554f98a Mon Sep 17 00:00:00 2001 From: alice Date: Thu, 30 Jul 2026 21:29:08 +0000 Subject: [PATCH 5/5] fix(board): the throwaway-board invariant is measured against the live relay, and the sweep sees the Go strays too (ready-153 closing round) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four rounds made the tests better and left the outcome unmeasured. This round takes the measurement and fixes what it exposed. THE DONE CONDITION, ACTUALLY RUN. Every live harness in web/board/scripts was run against wss://relay.3dl.network: live-write-roundtrip (public and confidential), live-roundtrip-both-ways (public and confidential), live-stranger-walk, live-cache, live-cache --only a, live-portfolio, live-portfolio-timing. Every board-provisioning run printed "board count unchanged, 25 before and 25 after"; the portfolio measured 25 unarchived boards before the sequence and 25 after. Two of those runs FAILED partway on an unrelated rail defect and cleaned up anyway — the path the `finally` clause exists for, and the one no previous round had exercised. THE SWEEP'S SCOPE WAS ITS OWN DIFF. archive-stray-boards.mjs carried a five-prefix list that was exactly the files round 1 had touched. Walked live it covered 7 of the owner's 17 strays; 10 `ready-livetest-*` came from pkg/sync/live_relay_key_test.go and nothing in the sweep had ever looked at the Go side. The stray set is now DERIVED (stray-boards.mjs): harness sources are found by shape — web/board/scripts/live-*.mjs, plus any *_test.go gated on RD_NOSTR_LIVE_RELAY — their board-d expressions are read out of the source, and run-varying parts become wildcards. It classifies the live walk exactly: 17 strays, 25 real projects, each stray naming the file and line that made it. All 17 are archived; the owner's portfolio is now his 25 projects and nothing else. The derivation legitimately produces ^ready$, because live tests write BoardD: "ready" — so the protected list is load-bearing, and a test asserts that removing it puts this repo's own production board on the archive list. ONE WALK, NOT TWO. archive-stray-boards.mjs re-implemented the relay walk, and its copy resolved with a partial page on the 45s timeout instead of rejecting: a mute relay printed "0 unarchived stray(s) ... nothing to archive" and exited 0. It now imports fetchBoardEvents/latestBoardsOwnedBy, which reject on silence, and reads the archived markers back rather than trusting exit codes. THE READ-BACK TIMEOUT IS MEASURED. It decides a verdict — past it a board the relay has not shown fails the run — so prose was not support for it. Measured live, three trials: the board appears 139/157/170ms after the run's first write, the archived marker 148/153/193ms after `rd board archive`. (Re-confirmed too: `rd init` plus `rd relay flush` puts nothing on the relay.) 30000ms is now a stated ~150x margin over a measurement, not a guess. live-cache.mjs arrived on main after round 4 and provisions a board with no cleanup — the tree-derived harness list caught it on rebase, which is what that list is for. It is now bound, and registers its board-d inside the branch that provisions it, so `--only a` (which provisions none) does not fail for a board it never made. A second, independent detector (BOARD_D assignment) must agree with the `rd init` detector, so neither can silently shrink. NOT COVERED, and said plainly rather than left to be assumed: the Go live-relay tests still publish per-run boards with no cleanup. The sweep recognises them; that is cleanup, not prevention. Follow-up item filed. Co-Authored-By: Claude Opus 5 (1M context) --- web/board/scripts/archive-stray-boards.mjs | 203 +++++++------------ web/board/scripts/harness-cleanup.test.mjs | 40 +++- web/board/scripts/live-cache.mjs | 38 +++- web/board/scripts/stray-boards.mjs | 225 +++++++++++++++++++++ web/board/scripts/stray-boards.test.mjs | 163 +++++++++++++++ web/board/scripts/throwaway-board.mjs | 47 ++++- 6 files changed, 574 insertions(+), 142 deletions(-) create mode 100644 web/board/scripts/stray-boards.mjs create mode 100644 web/board/scripts/stray-boards.test.mjs diff --git a/web/board/scripts/archive-stray-boards.mjs b/web/board/scripts/archive-stray-boards.mjs index c526935..beaa2db 100644 --- a/web/board/scripts/archive-stray-boards.mjs +++ b/web/board/scripts/archive-stray-boards.mjs @@ -1,56 +1,46 @@ #!/usr/bin/env node -// archive-stray-boards.mjs — ready-153's one-shot cleanup for the live -// harnesses' own throwaway boards. +// archive-stray-boards.mjs — ready-153's sweep for boards this repo's own tests +// left in the owner's portfolio. // -// THE PROBLEM: live-write-roundtrip.mjs and live-roundtrip-both-ways.mjs each -// provision a fresh, PUBLIC-visible board on every run and (as of ready-153) -// archive it themselves in a `finally` clause. Every run BEFORE that fix left -// its board behind, permanently, in the owner's `rd board` portfolio — 44 of -// them, measured 2026-07-30. +// WHAT CHANGED IN THE CLOSING ROUND, and why both changes are the same defect: // -// WHAT THIS DOES: walks wss://relay.3dl.network for every kind-30301 board -// definition owned by the LOCAL machine's rd key, finds the ones whose "d" tag -// matches one of the naming prefixes those harnesses (and their earlier, -// renamed incarnations) have used, and archives every one that is not already -// archived — via the real `rd board archive`, so the marker is the exact -// signed event the CLI itself publishes; nothing is invented here. +// 1. THE WALK IS NO LONGER RE-IMPLEMENTED HERE. This file used to carry its +// own copy of the relay walk, and that copy's REQ helper resolved with a +// partial page on its 45s timeout instead of rejecting. A relay that +// answered nothing therefore printed "0 unarchived stray(s) … nothing to +// archive" and exited 0 — a clean bill of health from a dead relay, the +// exact silent-relay defect throwaway-board.mjs's `wsReq` was fixed for. +// Two copies of a walk means one of them is always the unfixed one, so now +// there is one: `fetchBoardEvents`/`latestBoardsOwnedBy` are imported. // -// NO `authors` FILTER (relay measurement discipline: wss://relay.3dl.network -// silently under-returns on one — measured 42/56 vs 56/56 for a paged -// kind-only walk, same relay, same run, ready-5c5). The walk below is -// kind-only, paged backwards with `until` at limit 500, exactly as -// live-portfolio.mjs's own oracle does; ownership is applied CLIENT-SIDE on -// the events that came back. +// 2. THE STRAY SET IS DERIVED FROM THE TREE, not typed here. It used to be a +// five-prefix list — the files that round's diff had touched. Walked live +// on 2026-07-30 that list covered 7 of 17 strays and missed 10 +// `ready-livetest-*`, which come from a Go live-relay test nobody had +// looked at. See stray-boards.mjs. // -// THE PREFIX LIST IS THE NAMING CONVENTION ITSELF, not a stray count: every -// throwaway board these harnesses (and their prior item-numbered names) have -// ever provisioned derives its "d" tag from one of these five prefixes. HOW -// MANY boards match is always counted live against the relay below — never -// asserted as a fixed number here or anywhere this script's output is read. +// Both are the same mistake: scoping a hygiene check to what the author already +// knew about, so it reports clean for the wrong reason. // -// Usage: node scripts/archive-stray-boards.mjs [--relay wss://…] [--dry-run] -// Exits non-zero if any matched board fails to archive. +// DRY RUN BY DEFAULT. Pass --archive to actually publish the archive events. +// Every board this key owns is printed with its classification and, for a +// stray, the source line that claims it — so the archive list is auditable +// before anything is written. `rd board unarchive ` reverses any of it. +// +// Usage: node scripts/archive-stray-boards.mjs [--relay wss://…] [--archive] import { execFileSync } from "node:child_process"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { archiveBoard, fetchBoardEvents, latestBoardsOwnedBy } from "./throwaway-board.mjs"; +import { classify, strayPatterns } from "./stray-boards.mjs"; + const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const argv = process.argv.slice(2); const RELAY = argv.includes("--relay") ? argv[argv.indexOf("--relay") + 1] : "wss://relay.3dl.network"; -const DRY_RUN = argv.includes("--dry-run"); - -const KIND_BOARD = 30301; -// The relay caps a single REQ at 500 (measured); paging less would just page -// more often. -const PAGE_LIMIT = 500; - -// b2blive/c191live: live-write-roundtrip.mjs (public/confidential, ready-b2b -// then ready-191). b4359/c4359: live-roundtrip-both-ways.mjs (ready-4359, -// same public/confidential split). s48f: live-stranger-walk.mjs (ready-48f, -// not yet merged to main but already run against the live relay). -const STRAY_PREFIXES = ["b2blive", "b4359", "c191live", "c4359", "s48f"]; +const DO_ARCHIVE = argv.includes("--archive"); const log = (...a) => console.log(...a); @@ -60,84 +50,19 @@ function rdHome() { return path.join(xdg, "rd"); } -function reqOnce(relay, filter) { - return new Promise((resolve, reject) => { - const ws = new WebSocket(relay); - const out = []; - const sub = `strays${Math.random().toString(36).slice(2, 10)}`; - const t = setTimeout(() => { - try { - ws.close(); - } catch { - /* closed */ - } - resolve(out); - }, 45000); - ws.onopen = () => ws.send(JSON.stringify(["REQ", sub, filter])); - ws.onmessage = (m) => { - const f = JSON.parse(m.data); - if (f[0] === "EVENT" && f[1] === sub) out.push(f[2]); - else if (f[0] === "EOSE" && f[1] === sub) { - clearTimeout(t); - try { - ws.send(JSON.stringify(["CLOSE", sub])); - ws.close(); - } catch { - /* closed */ - } - resolve(out); - } - }; - ws.onerror = () => { - clearTimeout(t); - reject(new Error(`relay ${relay}: connection failed`)); - }; - }); -} - /** - * discoverBoards walks the relay for EVERY kind-30301 event (no `authors` - * filter — see this file's header) and returns the latest-per-coordinate - * definition for each one authored by `ownerPubkey`. + * protectedBoardDs are board-ds this sweep will never call a stray, whatever a + * derived pattern says. `pkg/sync/nostroutbound.go` names this repo's own + * production board in `reservedProductionBoardD`, and several live-relay tests + * write that same literal into a `BoardD:` field — so the derivation legitimately + * produces `^ready$`, and without this shield the sweep's first act would be to + * archive the board it is being run to clean up. */ -async function discoverBoards(relay, ownerPubkey) { - const seen = new Map(); - let until; - for (let page = 0; page < 40; page++) { - const filter = { kinds: [KIND_BOARD], limit: PAGE_LIMIT }; - if (until !== undefined) filter.until = until; - const got = await reqOnce(relay, filter); - let added = 0; - let oldest = until; - for (const e of got) { - if (!seen.has(e.id)) { - seen.set(e.id, e); - added++; - } - if (oldest === undefined || e.created_at < oldest) oldest = e.created_at; - } - log(` page ${page + 1}: ${got.length} events, ${added} new, ${seen.size} total`); - if (added === 0 || got.length < PAGE_LIMIT) break; - if (oldest === undefined) break; - until = oldest - 1; - } - const mine = []; - for (const e of seen.values()) { - if (e.pubkey !== ownerPubkey) continue; - const d = (e.tags ?? []).find((t) => t[0] === "d")?.[1]; - if (!d) continue; - const archived = ((e.tags ?? []).find((t) => t[0] === "archived")?.[1] ?? "") !== ""; - mine.push({ coord: `${KIND_BOARD}:${e.pubkey}:${d}`, boardD: d, archived, createdAt: e.created_at }); - } - // Latest-wins per coordinate — an addressable (30301) event means the relay - // itself only ever serves one per (kind, pubkey, d), but this walk pages - // backwards through history and can see a superseded copy too. - const byCoord = new Map(); - for (const b of mine) { - const prev = byCoord.get(b.coord); - if (!prev || b.createdAt > prev.createdAt) byCoord.set(b.coord, b); - } - return [...byCoord.values()]; +export function protectedBoardDs(repoRoot) { + const src = readFileSync(path.join(repoRoot, "pkg/sync/nostroutbound.go"), "utf8"); + const m = /reservedProductionBoardD\s*=\s*"([^"]+)"/.exec(src); + if (!m) throw new Error("pkg/sync/nostroutbound.go: could not read reservedProductionBoardD — refusing to sweep blind"); + return [m[1]]; } async function main() { @@ -145,21 +70,31 @@ async function main() { const identity = JSON.parse(readFileSync(idPath, "utf8")); const owner = identity.pubkey_hex; - log(`walking ${RELAY} for every kind-30301 board owned by ${owner}`); - const all = await discoverBoards(RELAY, owner); - log(`\n${all.length} board(s) total on the relay for this key`); + const patterns = strayPatterns(REPO_ROOT).filter((p) => p.usable); + const shielded = protectedBoardDs(REPO_ROOT); + log(`${patterns.length} board-d pattern(s) derived from the tree; protected: ${shielded.join(", ")}`); + + log(`\nwalking ${RELAY} for every kind-30301 board owned by ${owner}`); + // fetchBoardEvents REJECTS on a relay that never sends EOSE — a silent relay + // must never read as "this key owns nothing". + const owned = latestBoardsOwnedBy(await fetchBoardEvents(RELAY), owner); + const unarchived = [...owned.values()].filter((b) => !b.archived).sort((a, b) => a.boardD.localeCompare(b.boardD)); + log(`${owned.size} board(s) for this key, ${unarchived.length} unarchived`); - const strays = all.filter((b) => !b.archived && STRAY_PREFIXES.some((p) => b.boardD.startsWith(p))); - log(`${strays.length} unarchived stray(s) matching [${STRAY_PREFIXES.join(", ")}]`); - for (const b of strays) log(` ${b.boardD}`); + const { strays, unclaimed } = classify(unarchived, patterns, shielded); + + log(`\n${unclaimed.length} unarchived board(s) NOT claimed by any harness in this tree — left alone:`); + for (const b of unclaimed) log(` ${b.boardD}${b.protected ? " (protected)" : ""}`); + + log(`\n${strays.length} unarchived stray(s) claimed by a harness in this tree:`); + for (const b of strays) log(` ${b.boardD} <- ${b.claimedBy.map((p) => p.source).join(", ")}`); if (strays.length === 0) { log("\nnothing to archive."); return; } - - if (DRY_RUN) { - log("\n--dry-run: not archiving."); + if (!DO_ARCHIVE) { + log("\ndry run — pass --archive to publish the archive events."); return; } @@ -188,23 +123,29 @@ async function main() { let failures = 0; for (const b of strays) { try { - execFileSync(rdBin, ["board", "archive", b.coord], { - cwd: dir, - env: { ...process.env, RD_HOME: home, RD_NOSTR_RELAY_URL: RELAY }, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); + archiveBoard({ rdBin, cwd: dir, home, relay: RELAY, coord: b.coord }); log(` archived ${b.coord}`); } catch (err) { failures++; console.error(` FAILED to archive ${b.coord}: ${err.message}`); } } + + // READ THE MARKERS BACK. `rd board archive` exiting 0 says the command ran, + // not that the relay took the event — the same read-back the per-run guard + // does, for the same reason. + const after = latestBoardsOwnedBy(await fetchBoardEvents(RELAY), owner); + const stillLive = strays.filter((b) => !after.get(b.coord)?.archived); + for (const b of stillLive) console.error(` NOT ARCHIVED ON THE RELAY: ${b.coord}`); + failures += stillLive.length; + + const nowUnarchived = [...after.values()].filter((b) => !b.archived).length; + log(`\nowner's unarchived board count: ${unarchived.length} before, ${nowUnarchived} after`); if (failures > 0) { - console.error(`\n${failures}/${strays.length} archive(s) failed`); + console.error(`${failures} archive(s) failed`); process.exit(1); } - log(`\narchived ${strays.length}/${strays.length} stray board(s)`); + log(`archived ${strays.length}/${strays.length} stray board(s)`); } finally { rmSync(tmp, { recursive: true, force: true }); } diff --git a/web/board/scripts/harness-cleanup.test.mjs b/web/board/scripts/harness-cleanup.test.mjs index 3ffe422..a8d10d6 100644 --- a/web/board/scripts/harness-cleanup.test.mjs +++ b/web/board/scripts/harness-cleanup.test.mjs @@ -47,12 +47,40 @@ test("the harness list is derived from the tree and is not empty", () => { expect(harnesses.length).toBeGreaterThanOrEqual(5); }); -test("at least one harness provisions a board (or this whole file is vacuous)", () => { - expect(harnesses.filter(createsBoard).map((h) => h.name)).toEqual([ - "live-roundtrip-both-ways.mjs", - "live-stranger-walk.mjs", - "live-write-roundtrip.mjs", - ]); +/** Every harness known to provision a board. Deliberately a FLOOR, not an + * exact list: a harness added tomorrow is covered by the describe.each below + * the moment it is written, but a harness that DROPS out of `createsBoard` + * because the detector drifted must fail here rather than quietly stop being + * checked. */ +const KNOWN_BOARD_CREATORS = [ + "live-cache.mjs", + "live-roundtrip-both-ways.mjs", + "live-stranger-walk.mjs", + "live-write-roundtrip.mjs", +]; + +test("every harness known to provision a board is still detected as one", () => { + const detected = harnesses.filter(createsBoard).map((h) => h.name); + for (const name of KNOWN_BOARD_CREATORS) expect(detected).toContain(name); +}); + +/** + * A SECOND, INDEPENDENT DETECTOR — because the first one was scoped to what its + * author already knew about. `createsBoard` looks for the `rd init` call; + * `namesABoard` looks for the `BOARD_D` the harness gives that board, which is + * the same fact reached by a different shape and is what stray-boards.mjs reads + * to recognise the strays on the relay. + * + * They must agree. A harness that names a board but never inits it (or the + * reverse) means one of the two detectors has drifted, and a drifted detector + * is how "EVERY live harness is covered" comes to be true by definition: the + * set shrinks to the files that still match, and the check goes green over a + * harness it stopped looking at. + */ +const namesABoard = (h) => /\bBOARD_D\s*=/.test(h.src); + +test("the `rd init` detector and the BOARD_D detector name the same harnesses", () => { + expect(harnesses.filter(namesABoard).map((h) => h.name)).toEqual(harnesses.filter(createsBoard).map((h) => h.name)); }); describe.each(harnesses.filter(createsBoard).map((h) => [h.name, h]))( diff --git a/web/board/scripts/live-cache.mjs b/web/board/scripts/live-cache.mjs index 5dc6a70..0680b08 100644 --- a/web/board/scripts/live-cache.mjs +++ b/web/board/scripts/live-cache.mjs @@ -59,6 +59,12 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; +// ready-153: this harness provisions a throwaway board (PART B below runs `rd +// init`), so it is bound to the shared cleanup contract like every other live +// harness that does. See throwaway-board.mjs; the binding itself is asserted +// for every live-*.mjs in the tree by harness-cleanup.test.mjs. +import { openThrowawayBoardGuard, reportCleanup } from "./throwaway-board.mjs"; + const BOARD_DIR = path.resolve(import.meta.dirname, ".."); const REPO_ROOT = path.resolve(BOARD_DIR, "../.."); const CHROME = @@ -381,13 +387,25 @@ async function main() { const tmp = mkdtempSync(path.join(os.tmpdir(), "rd-fe4-cache-")); const cleanup = []; + // ready-153. The BEFORE board count, taken before anything is published: the + // guard is opened here so the count is honest even for `--only a`, which + // provisions no board at all. Nothing is registered with it yet — + // `guard.expect(BOARD_D)` happens in PART B, immediately before the `rd init` + // that publishes it, so a run that never reaches PART B has nothing to clean + // up and the guard says so rather than reporting a board that was never made. + const guard = await openThrowawayBoardGuard({ relay: RELAY, ownerPubkey: identity.pubkey_hex, log }); + let failures = 0; + let rdBin = null; + let projectDir = null; + let writerHome = null; + try { log(`relay: ${RELAY}`); log(`viewer: ${identity.pubkey_hex}`); log(`scratch: ${tmp}`); step("build rd from this tree"); - const rdBin = path.join(tmp, "rd"); + rdBin = path.join(tmp, "rd"); execFileSync("go", ["build", "-o", rdBin, "./cmd/rd"], { cwd: REPO_ROOT, stdio: "inherit" }); step("build the shipped bundle and serve it"); @@ -546,11 +564,15 @@ async function main() { step("PART B — condition 4, by the method the condition names: the rd CLI mutates, the OPEN page converges"); log("\n provision a throwaway PUBLIC board (nothing here touches a real project board)"); - const writerHome = path.join(tmp, "writer-home"); + writerHome = path.join(tmp, "writer-home"); mkdirSync(writerHome, { recursive: true }); writeFileSync(path.join(writerHome, "nostr-identity.json"), JSON.stringify(identity), { mode: 0o600 }); - const projectDir = path.join(tmp, BOARD_D); + projectDir = path.join(tmp, BOARD_D); mkdirSync(projectDir, { recursive: true }); + // ready-153: register BEFORE `rd init`, so a run that dies inside init — + // the run with a published board and no coordinate to hand — still cleans + // up. The guard derives what to archive from the relay, not from initOut. + guard.expect(BOARD_D); const initOut = JSON.parse( rd(rdBin, projectDir, writerHome, [ "init", @@ -680,8 +702,15 @@ async function main() { for (const r of results) log(` ${r.ok ? "PASS" : "FAIL"} ${r.name}${r.detail ? ` — ${r.detail}` : ""}`); const bad = results.filter((r) => !r.ok).length; log(`\n${results.length - bad}/${results.length} assertions held`); - process.exitCode = bad === 0 ? 0 : 1; + failures += bad; } finally { + // ready-153: archive whatever this run put on the relay and prove the + // owner's board count is back where it started, BEFORE the scratch dir goes + // away — `rd board archive` needs the project dir and writer home that live + // in it. Its failures count toward this run's exit code; a cleanup that + // only warns exits 0 with a permanent stray in the owner's portfolio. + failures += reportCleanup(await guard.close({ rdBin, cwd: projectDir, home: writerHome }), log); + for (const fn of cleanup.reverse()) { try { fn(); @@ -692,6 +721,7 @@ async function main() { if (KEEP) log(`\nkept: ${tmp}`); else rmSync(tmp, { recursive: true, force: true }); } + process.exit(failures === 0 ? 0 : 1); } /** titleOf is what the LIVE DOM says this item's title is, right now. */ diff --git a/web/board/scripts/stray-boards.mjs b/web/board/scripts/stray-boards.mjs new file mode 100644 index 0000000..d6997d3 --- /dev/null +++ b/web/board/scripts/stray-boards.mjs @@ -0,0 +1,225 @@ +/** + * stray-boards.mjs — "which of the boards this key owns were made by a test?", + * answered from the TREE rather than from a list somebody typed (ready-153). + * + * WHY THIS EXISTS. The first version of the stray sweep carried a hand-written + * prefix list — `["b2blive", "b4359", "c191live", "c4359", "s48f"]` — and that + * list was exactly the set of files that round's diff had touched. Measured + * against the live relay on 2026-07-30 it covered 7 of the owner's 17 stray + * boards and missed 10 `ready-livetest-*` outright, because those come from a + * Go live-relay test (pkg/sync/live_relay_key_test.go's `liveTestBoardD`) and + * no one had thought about the Go side. A sweep whose scope is "the files I + * edited" reports a clean portfolio and leaves the strays where they are. + * + * So the patterns are DERIVED. Every source file in this repo that can publish + * a board to a live relay is found by shape, its board-d expression is read out + * of the source, and the run-varying parts become wildcards: + * + * web/board/scripts/live-*.mjs `const BOARD_D = `fe4${RUN}`;` -> ^fe4.*$ + * *_test.go w/ RD_NOSTR_LIVE_RELAY `fmt.Sprintf("ready-7ec-live-%d"` -> ^ready-7ec-live-.*$ + * + * A harness added tomorrow is covered the day it is written, and a harness + * whose board-d nobody here knows about still shows up — as an UNCLAIMED board, + * printed rather than silently skipped, which is the only honest answer for a + * board this repo cannot account for. + * + * NOTHING HERE ARCHIVES ANYTHING. This module only classifies; the caller + * decides. See archive-stray-boards.mjs. + */ + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +/** + * A pattern must have this much literal text before its first wildcard to be + * usable. `fmt.Sprintf("%s-live-%d")` would otherwise derive `^.*-live-.*$`, + * which is not evidence about any particular board — it is a wildcard wearing a + * pattern's clothes, and it would match real project boards. + */ +export const MIN_LITERAL_PREFIX = 3; + +/** Source files that can put a board on a live relay, found by shape. */ +export function harnessSources(repoRoot) { + const out = []; + + const scripts = path.join(repoRoot, "web/board/scripts"); + for (const f of safeReaddir(scripts)) { + if (!f.startsWith("live-") || !f.endsWith(".mjs")) continue; + out.push({ file: path.relative(repoRoot, path.join(scripts, f)), src: readFileSync(path.join(scripts, f), "utf8") }); + } + + // A Go test only reaches a real relay if it is gated on RD_NOSTR_LIVE_RELAY — + // that env var IS the live-test convention in this repo, so "contains it" is + // the shape, not a list of directories. + for (const p of walkGoTests(repoRoot)) { + const src = readFileSync(p, "utf8"); + if (!src.includes("RD_NOSTR_LIVE_RELAY")) continue; + out.push({ file: path.relative(repoRoot, p), src }); + } + + return out.sort((a, b) => a.file.localeCompare(b.file)); +} + +function safeReaddir(dir) { + try { + return readdirSync(dir).sort(); + } catch { + return []; + } +} + +function* walkGoTests(dir, depth = 0) { + if (depth > 6) return; + for (const name of safeReaddir(dir)) { + if (name === "node_modules" || name === ".git" || name === ".claude" || name === "dist") continue; + const p = path.join(dir, name); + let st; + try { + st = statSync(p); + } catch { + continue; + } + if (st.isDirectory()) yield* walkGoTests(p, depth + 1); + else if (name.endsWith("_test.go")) yield p; + } +} + +/** + * A `${...}` hole in a JS template literal expands to the string literals it + * contains, and to a bare wildcard when it contains none: + * + * `${CONFIDENTIAL ? "c191live" : "b2blive"}${RUN}` -> c191live.* AND b2blive.* + * + * Two separate patterns, not one alternation, because a pattern's usability is + * judged on the literal text before its first wildcard, and `(c191live|b2blive).*` + * has none. Collapsing the ternary to `.*` instead would derive `^.*.*$` — a + * pattern that matches every board this key owns, the owner's real projects + * included. That is the difference between a derived pattern and a dangerous + * one. + */ +function holeOptions(inner) { + const lits = [...inner.matchAll(/"([^"]*)"/g)].map((m) => m[1]).filter(Boolean); + if (lits.length === 0) return [".*"]; + return lits.map(escapeRe); +} + +const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +/** A Go format verb (%d, %s, %v, %02d…) is a run-varying hole. */ +function goFormatToRegex(lit) { + let out = ""; + let i = 0; + while (i < lit.length) { + const m = /^%[-+ #0]*[\d.]*[a-zA-Z]/.exec(lit.slice(i)); + if (m) { + out += ".*"; + i += m[0].length; + } else { + out += escapeRe(lit[i]); + i += 1; + } + } + return out; +} + +/** jsTemplateToRegexes returns one regex body per combination of hole choices. */ +function jsTemplateToRegexes(tpl) { + let outs = [""]; + let i = 0; + const append = (opts) => { + outs = outs.flatMap((o) => opts.map((x) => o + x)); + }; + while (i < tpl.length) { + if (tpl.startsWith("${", i)) { + let depth = 1; + let j = i + 2; + for (; j < tpl.length && depth > 0; j++) { + if (tpl[j] === "{") depth++; + else if (tpl[j] === "}") depth--; + } + append(holeOptions(tpl.slice(i + 2, j - 1))); + i = j; + } else { + append([escapeRe(tpl[i])]); + i += 1; + } + } + return outs; +} + +const lineOf = (src, index) => src.slice(0, index).split("\n").length; + +/** + * boardDPatterns reads every board-d expression out of one harness source. + * + * Returns `{ pattern, body, source, raw }` records. `pattern` is anchored; + * `body` is the un-anchored regex text, kept so a caller (and the test suite) + * can see what was derived. + */ +export function boardDPatterns({ file, src }) { + const found = []; + const add = (bodies, raw, index) => { + for (const body of bodies) { + const prefix = /^((?:\\.|[^\\.([])*)/.exec(body)?.[1] ?? ""; + const literalPrefixLen = prefix.replace(/\\(.)/g, "$1").length; + found.push({ + body, + pattern: new RegExp(`^${body}$`), + raw, + source: `${file}:${lineOf(src, index)}`, + usable: literalPrefixLen >= MIN_LITERAL_PREFIX, + literalPrefixLen, + exact: !body.includes(".*"), + }); + } + }; + + // JS live harness: `const BOARD_D = `...`;` or `const BOARD_D = "...";` + for (const m of src.matchAll(/\bBOARD_D\s*=\s*`([^`]*)`/g)) add(jsTemplateToRegexes(m[1]), m[0], m.index); + for (const m of src.matchAll(/\bBOARD_D\s*=\s*"([^"]*)"/g)) add([escapeRe(m[1])], m[0], m.index); + + // Go live test: a board-d built with fmt.Sprintf, or written as a literal on + // a BoardD field / boardD variable. + for (const m of src.matchAll(/\b(?:boardD|BoardD|board|d)\s*(?::=|=|:)\s*fmt\.Sprintf\("([^"]+)"/g)) + add([goFormatToRegex(m[1])], m[0], m.index); + for (const m of src.matchAll(/\b(?:boardD|BoardD)\s*(?::=|=|:)\s*"([^"]+)"/g)) add([escapeRe(m[1])], m[0], m.index); + + return found; +} + +/** + * strayPatterns is every usable board-d pattern this repo can produce, with the + * source line each one came from — provenance, so a board about to be archived + * can be traced back to the test that made it. + */ +export function strayPatterns(repoRoot) { + const out = []; + for (const s of harnessSources(repoRoot)) out.push(...boardDPatterns(s)); + return out; +} + +/** + * classify splits the boards a key owns into `strays` (a tree-derived pattern + * claims it, with the source that claims it) and `unclaimed` (nothing in the + * tree accounts for it — the owner's real projects, and any harness this repo + * does not contain). + * + * `protectedDs` are board-ds that are NEVER strays whatever the patterns say. + * A live test that writes `BoardD: "ready"` in a comparison would otherwise + * derive `^ready$` and put this repo's own production board on the list. + */ +export function classify(boards, patterns, protectedDs = []) { + const shielded = new Set(protectedDs); + const strays = []; + const unclaimed = []; + for (const b of boards) { + if (shielded.has(b.boardD)) { + unclaimed.push({ ...b, protected: true }); + continue; + } + const claimedBy = patterns.filter((p) => p.usable && p.pattern.test(b.boardD)); + if (claimedBy.length > 0) strays.push({ ...b, claimedBy }); + else unclaimed.push({ ...b, protected: false }); + } + return { strays, unclaimed }; +} diff --git a/web/board/scripts/stray-boards.test.mjs b/web/board/scripts/stray-boards.test.mjs new file mode 100644 index 0000000..832e9c4 --- /dev/null +++ b/web/board/scripts/stray-boards.test.mjs @@ -0,0 +1,163 @@ +/** + * stray-boards.test.mjs — the sweep's classifier, checked against the boards + * that were ACTUALLY on wss://relay.3dl.network (ready-153). + * + * WHY THE FIXTURE IS A REAL WALK. The previous version of the sweep carried a + * hand-typed prefix list and no test at all, and it "worked" — it archived + * every board it had been told about and reported the portfolio clean, while + * 17 strays sat in it, 10 of them from a Go live test the list's author had + * never looked at. A classifier tested only against examples its author + * invented reproduces exactly that blind spot. + * + * So the fixture below is the verbatim output of a live paged kind-30301 walk + * of the owner's key on 2026-07-30 (42 unarchived boards), and the expected + * split is the one an operator independently walked and reported. The patterns + * are NOT in the fixture: they are re-derived from this repo's tree on every + * run, so a harness whose board-d naming changes, or a new live test that + * starts publishing boards, is exercised against real board names rather than + * against a story about them. + */ + +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { boardDPatterns, classify, harnessSources, strayPatterns, MIN_LITERAL_PREFIX } from "./stray-boards.mjs"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +const OWNER = "a9f766ae56bbf466d2d361e5b1788b7cd689fd8e3b418e35b002b313f478db25"; + +/** The owner's 25 real project boards, as walked live on 2026-07-30. */ +const REAL_PROJECTS = [ + "3dl", + "3dlbooks", + "agenticinternet", + "agenticinternetops", + "analyst0", + "augur", + "automataisland", + "dontguess", + "enterpriseaiframework", + "forge", + "galtrader", + "mainframe", + "mallcoppro", + "nalu", + "nostrrelay", + "olmo3dl", + "os", + "pcjsvax", + "producer", + "proj", + "ready", + "resonant", + "social", + "vat", + "website", +]; + +/** The 17 test-generated boards in the same walk. */ +const STRAYS = [ + "fe4ms7w3eif", + "fe4ms7xd7ir", + "fe4ms7z4rp7", + "ready-7ec-live-1785435017380425687", + "ready-7ec-live-1785435017380425687-other", + "ready-82c-live", + "ready-866-live-1785435016895244229", + "ready-livetest-1785434740827693994", + "ready-livetest-1785434742353373698", + "ready-livetest-1785434896387861513", + "ready-livetest-1785435020678889082", + "ready-livetest-1785435023904464777", + "ready-livetest-1785435030107422940", + "ready-livetest-1785435031736030172", + "ready-livetest-1785435055151740338", + "ready-livetest-1785435329509598406", + "ready-livetest-1785435332073358502", +]; + +const board = (d) => ({ boardD: d, coord: `30301:${OWNER}:${d}`, archived: false }); +const patterns = () => strayPatterns(REPO_ROOT).filter((p) => p.usable); +const run = () => classify([...REAL_PROJECTS, ...STRAYS].map(board), patterns(), ["ready"]); + +describe("the stray set is derived from the tree, against a real live walk", () => { + test("every one of the 17 boards the live walk found is claimed by a source line in this tree", () => { + const { strays } = run(); + expect(strays.map((b) => b.boardD).sort()).toEqual([...STRAYS].sort()); + }); + + test("each stray names the file and line that made it, so an archive is auditable", () => { + for (const b of run().strays) { + expect(b.claimedBy.length).toBeGreaterThan(0); + for (const c of b.claimedBy) expect(c.source).toMatch(/^[\w./-]+:\d+$/); + } + }); + + test("no real project board is classified as a stray", () => { + const { strays, unclaimed } = run(); + expect(strays.map((b) => b.boardD).filter((d) => REAL_PROJECTS.includes(d))).toEqual([]); + expect(unclaimed.map((b) => b.boardD).sort()).toEqual([...REAL_PROJECTS].sort()); + }); + + test("the Go live tests are in scope, not just the .mjs harnesses this item's diff touched", () => { + // The whole failure this file exists for: 10 of the 17 came from + // pkg/sync/live_relay_key_test.go, and the previous prefix list — scoped to + // web/board/scripts — did not mention it. + const sources = new Set(run().strays.flatMap((b) => b.claimedBy.map((c) => c.source.split(":")[0]))); + expect([...sources].some((f) => f.endsWith("_test.go"))).toBe(true); + expect([...sources].some((f) => f.startsWith("web/board/scripts/"))).toBe(true); + }); +}); + +describe("the shield, which is load-bearing and not decorative", () => { + test("this repo's own production board-d IS derived as a pattern — the shield is what stops it", () => { + // Several live-relay tests write `BoardD: "ready"`, so the derivation + // legitimately produces ^ready$. Without the protected list, the sweep's + // first act would be to archive the board it is run to clean up. + expect(patterns().some((p) => p.pattern.test("ready"))).toBe(true); + expect(classify([board("ready")], patterns(), ["ready"]).strays).toEqual([]); + expect(classify([board("ready")], patterns(), []).strays.map((b) => b.boardD)).toEqual(["ready"]); + }); +}); + +describe("pattern derivation", () => { + test("a ternary board-d expands to one pattern per branch, never to a bare wildcard", () => { + const found = boardDPatterns({ + file: "web/board/scripts/live-x.mjs", + src: 'const BOARD_D = `${CONFIDENTIAL ? "c191live" : "b2blive"}${RUN}`;', + }); + expect(found.map((p) => p.body).sort()).toEqual(["b2blive.*", "c191live.*"]); + expect(found.every((p) => p.usable)).toBe(true); + // The dangerous derivation this replaced: collapsing the ternary to `.*` + // too would give ^.*.*$, which matches every board the key owns. + expect(found.some((p) => p.pattern.test("galtrader"))).toBe(false); + }); + + test("a Go format verb becomes the wildcard, and the literal before it is kept", () => { + const found = boardDPatterns({ + file: "pkg/sync/x_test.go", + src: '\tboardD := fmt.Sprintf("ready-7ec-live-%d", run)\n', + }); + expect(found.map((p) => p.body)).toEqual(["ready-7ec-live-.*"]); + expect(found[0].pattern.test("ready-7ec-live-1785435017380425687")).toBe(true); + expect(found[0].pattern.test("ready")).toBe(false); + }); + + test("a pattern with too little literal text to be evidence is not usable", () => { + const found = boardDPatterns({ + file: "pkg/sync/x_test.go", + src: '\tboardD := fmt.Sprintf("%s-live-%d", who, run)\n', + }); + expect(found[0].usable).toBe(false); + expect(MIN_LITERAL_PREFIX).toBeGreaterThan(0); + }); + + test("harness sources are found by shape: live-*.mjs, and Go tests gated on RD_NOSTR_LIVE_RELAY", () => { + const files = harnessSources(REPO_ROOT).map((s) => s.file); + expect(files).toContain("web/board/scripts/live-cache.mjs"); + expect(files).toContain("pkg/sync/live_relay_key_test.go"); + // A Go test that never touches a live relay must not contribute patterns — + // hermetic tests use invented board names freely. + expect(files.some((f) => f.endsWith("pkg/sync/boardarchive_test.go"))).toBe(false); + }); +}); diff --git a/web/board/scripts/throwaway-board.mjs b/web/board/scripts/throwaway-board.mjs index 8d02ccb..263fe89 100644 --- a/web/board/scripts/throwaway-board.mjs +++ b/web/board/scripts/throwaway-board.mjs @@ -59,6 +59,25 @@ * that suite goes red — which is the point: before this module, deleting the * cleanup turned nothing red anywhere. * + * MEASURED, NOT ASSERTED (2026-07-30). Every live harness in web/board/scripts + * was run against wss://relay.3dl.network with this guard in place — the four + * that provision a board (live-write-roundtrip public and confidential, + * live-roundtrip-both-ways public and confidential, live-stranger-walk, + * live-cache and live-cache --only a) and the two that do not (live-portfolio, + * live-portfolio-timing). Every run printed `board count unchanged, 25 before + * and 25 after`, and the owner's portfolio measured 25 unarchived boards before + * the sweep and 25 after it. Two of those runs FAILED partway (an unrelated + * rail defect in live-write-roundtrip) and cleaned up anyway, which is the path + * a `finally` clause exists for and the one the earlier attempts never proved. + * + * WHAT THIS MODULE DOES NOT COVER, stated here because a reader will otherwise + * assume it does. Only the JS harnesses under web/board/scripts are bound to + * it. The Go live-relay tests (`RD_NOSTR_LIVE_RELAY=1`) publish their own + * per-run boards and have no cleanup: 11 of the 17 strays swept on 2026-07-30 + * came from pkg/sync's live tests, and their next run will leave more. + * `archive-stray-boards.mjs` recognises and sweeps them — that is cleanup, not + * prevention. See ready-153's follow-up item. + * * RELAY MEASUREMENT DISCIPLINE. The walk below is kind-only, paged backwards * with `until` at limit 500, and NEVER uses an `authors` filter: an `authors` * filter silently under-returns on wss://relay.3dl.network (measured 42/56 vs @@ -77,6 +96,32 @@ export const KIND_BOARD = 30301; export const PAGE_LIMIT = 500; export const MAX_PAGES = 40; +/** + * READ_BACK_TIMEOUT_MS — how long `close()` waits for the relay to start + * serving a board this run published, before reporting it as never served. + * + * THIS NUMBER IS MEASURED, NOT ASSUMED. It decides a real verdict — past it, a + * board the relay has not shown fails the run — so "30s feels like enough" + * written in a comment is not support for it. Measured against + * wss://relay.3dl.network on 2026-07-30, three trials, by + * `rd init` + first write + polling the same walk this module uses: + * + * board coordinate served after the run's first write: 139ms 157ms 170ms + * archived marker served after `rd board archive`: 148ms 153ms 193ms + * + * (The same run also re-confirmed that `rd init` plus `rd relay flush` puts + * NOTHING on the relay — the board first appears on the run's first write. All + * three trials: `after rd init alone, relay serves the board: false`.) + * + * Worst observed is 193ms, and a whole walk costs ~250ms, so both halves are + * one poll. 30000ms is ~150x the worst measurement — chosen as a margin over a + * measured figure rather than as a guess, and large enough that reaching it + * means the relay is not going to serve the board, not that it is slow today. + * If the relay's real behaviour ever moves, re-measure and move this with it; + * do not raise it to make a run pass. + */ +export const READ_BACK_TIMEOUT_MS = 30000; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** @@ -285,7 +330,7 @@ export async function openThrowawayBoardGuard({ relay, ownerPubkey, log = () => cwd, home, exec = execFileSync, - readBackTimeoutMs = 30000, + readBackTimeoutMs = READ_BACK_TIMEOUT_MS, readBackIntervalMs = 2000, now = () => Date.now(), wait = sleep,