Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions web/board/scripts/archive-stray-boards.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env node
// archive-stray-boards.mjs — ready-153's sweep for boards this repo's own tests
// left in the owner's portfolio.
//
// WHAT CHANGED IN THE CLOSING ROUND, and why both changes are the same defect:
//
// 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.
//
// 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.
//
// Both are the same mistake: scoping a hygiene check to what the author already
// knew about, so it reports clean for the wrong reason.
//
// 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 <coord>` 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 DO_ARCHIVE = argv.includes("--archive");

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");
}

/**
* 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.
*/
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() {
const idPath = path.join(rdHome(), "nostr-identity.json");
const identity = JSON.parse(readFileSync(idPath, "utf8"));
const owner = identity.pubkey_hex;

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, 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 (!DO_ARCHIVE) {
log("\ndry run — pass --archive to publish the archive events.");
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 {
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(`${failures} archive(s) failed`);
process.exit(1);
}
log(`archived ${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);
});
123 changes: 123 additions & 0 deletions web/board/scripts/harness-cleanup.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* 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);
});

/** 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]))(
"%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"/);
});
},
);
38 changes: 34 additions & 4 deletions web/board/scripts/live-cache.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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();
Expand All @@ -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. */
Expand Down
Loading
Loading