Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 8885166c47ff136c00b360cd66a1469aac04ede2
parent 8d46c5578545b91b181d8d919a3e0b99cfc419a2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 09:58:51 -0400

doctor: publish-lock, index-stamp, export-builds from built.json bytes, and node against the pinned wrangler's floor

- publish/publish-lock (S1's stageLock.ts): free ok; a live holder on this host
  a note; a holder the lock's own rule (holderIsGone) calls gone, or a lock that
  has not parsed past the torn grace, a warning with the rm; another host's
  lock named, never judged. Never cleared here.
- publish/index-stamp (S1's stamps.ts): the stamp's id, age and generation;
  the built targets whose indexStampId is older; no stamp = "update the index
  first" (a warning once anything is built or configured).
- export-builds sums each bundle's built.json `bytes`; a bundle with no stamp
  is still walked.
- workspace/node also grades against wrangler's engines.node when wrangler is
  installed in common/node_modules (a warning below it: every deploy refuses).
- stamps.ts re-exports imageBuildFacts from lib/envVars (the one definition).
4 tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/bin/doctor.test.ts | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/doctor.ts | 141++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcommon/publish/stamps.ts | 17+++--------------
3 files changed, 247 insertions(+), 25 deletions(-)

diff --git a/common/bin/doctor.test.ts b/common/bin/doctor.test.ts @@ -69,6 +69,7 @@ function checkout(): { root: string; bin: string; paths: Paths } { parakeetCliBin: "parakeet-cli", configDir: path.join(root, ".config"), exportBuildsDir: path.join(root, "export", ".export-builds"), + exportIndexDir: path.join(root, "export", ".export-index"), sourceScrubFile: path.join(root, ".config", "source-scrub.txt"), sourceDenylistFile: path.join(root, ".config", "source-denylist.txt"), // Outside the checkout, as the XDG cache is: the tests that compare the @@ -809,3 +810,116 @@ test("source publish: source-repo — the variable naming nothing fails, a reada assert.match(find(r, "source publish", "source-repo")!.detail, /^\/data\/source\.git \(ARCHILYZER_SOURCE_REPO\): main does not read — fatal: detected dubious ownership$/); assert.deepEqual(tree(c.root), before); }); + +// ── release 18, second half: the lock, the index stamp, the wrangler floor ─ + +function indexStampJson(stampId: string, builtAt: number) { + return { + v: 1, stampId, generation: 7, scannedAt: builtAt - 60_000, builtAt, templatesAt: builtAt, commit: null, + index: { shortCircuited: false, added: 1, changed: 0, removed: 0, heldChannels: [] }, + stats: { shortCircuited: false, notIndexedYet: 0, notIndexable: 0 }, + sites: {}, hubSig: "h", + }; +} + +function builtJson(target: string, indexStampId: string | null, bytes: number) { + return { + v: 1, stampId: `b-${target}`, target, kind: target === "_hub" ? "hub" : "site", indexStampId, inputSig: "s", + builtAt: Date.UTC(2026, 9, 6, 10), commit: null, branch: null, runner: "local", audience: "public", + corpusGeneratedAt: null, files: 3, bytes, archivesStaged: 0, + }; +} + +test("publish: publish-lock — free is ok, a running holder a note, a dead one stale with the rm, another host's named and never stale", async () => { + const c = checkout(); + const h = home(c); + const builds = c.paths.exportBuildsDir; + mkdirSync(builds, { recursive: true }); + const file = path.join(builds, ".publish.lock"); + let r = await run(c, { HOME: h }); + assert.equal(find(r, "publish", "publish-lock")?.status, "ok"); + const { pidStartOf } = await import("../publish/stageLock"); + const hold = (holder: Record<string, unknown>) => writeFileSync(file, JSON.stringify(holder)); + // This process holds it: alive, same host. + hold({ pid: process.pid, host: "doctor-host", kind: "build-site", target: "alpha", since: Date.now() - 5_000, pidStart: pidStartOf(process.pid) }); + r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "doctor-host" }); + assert.equal(find(r, "publish", "publish-lock")?.status, "info"); + assert.match(find(r, "publish", "publish-lock")!.detail, /^held: build-site alpha \(pid \d+ on doctor-host/); + // A pid that is not running, same host: stale. + hold({ pid: 2 ** 22 - 3, host: "doctor-host", kind: "deploy-site", target: "alpha", since: Date.now() - 60_000 }); + r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "doctor-host" }); + assert.equal(find(r, "publish", "publish-lock")?.status, "warn"); + assert.match(find(r, "publish", "publish-lock")!.detail, new RegExp(`^stale: deploy-site alpha .* the next stage takes it over, or clear it, when nothing is publishing: rm ${file.replace(/[.]/g, "\\.")}$`)); + // The same dead pid on ANOTHER host: named, never judged. + r = await run(c, { HOME: h, ARCHILYZER_HOST_ID: "this-host" }); + assert.equal(find(r, "publish", "publish-lock")?.status, "warn"); + assert.match(find(r, "publish", "publish-lock")!.detail, /^held by ANOTHER host: deploy-site alpha \(pid \d+ on doctor-host.*this host is "this-host"/); + // A lock that does not parse: being written, then (past the grace) torn. + writeFileSync(file, "{"); + r = await run(c, { HOME: h }); + assert.equal(find(r, "publish", "publish-lock")?.status, "info"); + const before = tree(c.root); + r = await run(c, { HOME: h }, { now: new Date(Date.now() + 10 * 60_000) }); + assert.equal(find(r, "publish", "publish-lock")?.status, "warn"); + assert.match(find(r, "publish", "publish-lock")!.detail, /does not parse.*a taker died writing it/); + assert.equal(r.ok, true, "a warning, never a failure"); + assert.deepEqual(tree(c.root), before, "the doctor never clears a lock"); +}); + +test("publish: index-stamp — none is a note (a warning once something is built); its age; targets built from an older stamp; export-builds counts built.json bytes", async () => { + const c = checkout(); + const h = home(c); + const builds = c.paths.exportBuildsDir; + let r = await run(c, { HOME: h }); + assert.equal(find(r, "publish", "index-stamp")?.status, "info"); + assert.match(find(r, "publish", "index-stamp")!.detail, /no index stamp at .*stamp\.json — update the index first: archilyzer publish index$/); + // Built bundles with no stamp: a warning. + for (const [target, id, bytes] of [["alpha", "old-stamp", 7_000_000], ["_hub", "cur-stamp", 2_000_000]] as const) { + mkdirSync(path.join(builds, target, "out"), { recursive: true }); + writeFileSync(path.join(builds, target, "out", "index.html"), "x"); + writeFileSync(path.join(builds, target, "built.json"), JSON.stringify(builtJson(target, id, bytes))); + } + r = await run(c, { HOME: h }, { freeBytes: () => 5e9 }); + assert.equal(find(r, "publish", "index-stamp")?.status, "warn"); + // export-builds sums the stamps' bytes, not the one-byte files on disk. + assert.match(find(r, "publish", "export-builds")!.detail, /: 2 bundles, 9 MB; 5\.00 GB free$/); + // A stamp: alpha was built from an older one. + const now = Date.UTC(2026, 9, 6, 12); + mkdirSync(c.paths.exportIndexDir, { recursive: true }); + writeFileSync(path.join(c.paths.exportIndexDir, "stamp.json"), JSON.stringify(indexStampJson("cur-stamp", now - 3 * 3_600_000))); + const before = tree(c.root); + r = await run(c, { HOME: h }, { now: new Date(now) }); + assert.equal(find(r, "publish", "index-stamp")?.status, "warn"); + assert.match(find(r, "publish", "index-stamp")!.detail, /^cur-stamp, built 2026-10-06 09:00 \(3 hours ago\), generation 7; built from an older stamp: alpha — archilyzer publish build <id> rebuilds each$/); + // Everything from the current stamp: ok. + writeFileSync(path.join(builds, "alpha", "built.json"), JSON.stringify(builtJson("alpha", "cur-stamp", 7_000_000))); + const before2 = tree(c.root); + r = await run(c, { HOME: h }, { now: new Date(now) }); + assert.equal(find(r, "publish", "index-stamp")?.status, "ok"); + assert.match(find(r, "publish", "index-stamp")!.detail, /generation 7; 2 bundles built from it$/); + assert.equal(r.ok, true); + assert.notDeepEqual(before, before2); + assert.deepEqual(tree(c.root), before2); +}); + +test("workspace: node is graded against the pinned wrangler's engines floor when wrangler is installed — a warning below it, never a failure", async () => { + const c = checkout(); + // No wrangler installed: next's floor only. + let r = await run(c, {}, { nodeVersion: "20.11.0" }); + assert.equal(find(r, "workspace", "node")?.status, "ok"); + assert.equal(find(r, "workspace", "node")!.detail, "v20.11.0 (needs >= 20.9.0)"); + const pkg = path.join(c.root, "common", "node_modules", "wrangler"); + mkdirSync(pkg, { recursive: true }); + writeFileSync(path.join(pkg, "package.json"), JSON.stringify({ name: "wrangler", version: "4.147.0", engines: { node: ">=22.0.0" } })); + const before = tree(c.root); + r = await run(c, {}, { nodeVersion: "20.11.0" }); + assert.equal(find(r, "workspace", "node")?.status, "warn"); + assert.match(find(r, "workspace", "node")!.detail, /^v20\.11\.0 — runs the apps .* the pinned wrangler 4\.147\.0 needs node >=22\.0\.0: every deploy refuses; use Node 22$/); + assert.equal(r.ok, true); + r = await run(c, {}, { nodeVersion: "22.23.3" }); + assert.equal(find(r, "workspace", "node")?.status, "ok"); + assert.equal(find(r, "workspace", "node")!.detail, "v22.23.3 (needs >= 20.9.0; deploys: >= 22.0.0, wrangler 4.147.0)"); + r = await run(c, {}, { nodeVersion: "18.20.0" }); + assert.equal(find(r, "workspace", "node")?.status, "fail"); + assert.deepEqual(tree(c.root), before); +}); diff --git a/common/bin/doctor.ts b/common/bin/doctor.ts @@ -9,7 +9,9 @@ // there and older than its Dockerfile (common/publish/build.ts), and what a // publish needs from this machine (release 18): which yt-dlp (the image's or // an override), whether deploy credentials are SET (never their values), room -// for the bundles, and the repository the source mirror reads. +// for the bundles, the publish lock (free, held, stale — never cleared here), +// the index stamp and what was built from an older one, the repository the +// source mirror reads, and node against the pinned wrangler's floor. // // STRICTLY READ-ONLY. It stats, reads and runs version flags, plus the engine's // `image inspect`, a lock-free `git status` / `git log` and a `git rev-parse` @@ -134,9 +136,21 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor // ── workspace ──────────────────────────────────────────────────────────── const W = "workspace"; const nodeV = deps.nodeVersion ?? process.versions.node; - add(W, "node", versionAtLeast(nodeV, MIN_NODE) ? "ok" : "fail", - `v${nodeV} (needs >= ${MIN_NODE.join(".")})`); const root = paths.monorepoRoot; + // Two floors: next's (a failure — nothing runs below it) and the pinned + // wrangler's engines (a warning — every deploy refuses below it; wrangler 4 + // wants 22). The wrangler floor is read from its package.json when it is + // installed, as the image's drift test reads it. + const wranglerFloor = wranglerNodeFloor(root); + if (!versionAtLeast(nodeV, MIN_NODE)) { + add(W, "node", "fail", `v${nodeV} (needs >= ${MIN_NODE.join(".")})`); + } else if (wranglerFloor && !versionAtLeast(nodeV, wranglerFloor.min)) { + add(W, "node", "warn", + `v${nodeV} — runs the apps (>= ${MIN_NODE.join(".")}), but the pinned wrangler ${wranglerFloor.version} needs node ${wranglerFloor.range}: every deploy refuses; use Node ${wranglerFloor.min[0]}`); + } else { + add(W, "node", "ok", + `v${nodeV} (needs >= ${MIN_NODE.join(".")}${wranglerFloor ? `; deploys: >= ${wranglerFloor.min.join(".")}, wrangler ${wranglerFloor.version}` : ""})`); + } if (existsSync(path.join(root, "pnpm-workspace.yaml"))) { add(W, "checkout", "ok", root); } else { @@ -509,7 +523,7 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor } else if (!writable(builds)) { add(PB, "export-builds", "warn", `${builds} is not writable — every site build fails (${freeText})`); } else { - const bundles = await bundleBytes(builds); + const bundles = await bundleBytes(paths); const need = Math.ceil(bundles.bytes * 1.5); const what = `${builds}: ${bundles.count} bundle${bundles.count === 1 ? "" : "s"}, ${gigabytes(bundles.bytes)}; ${freeText}`; if (free !== null && bundles.count > 0 && free < need) { @@ -520,6 +534,63 @@ export async function collectDoctorReport(deps: DoctorDeps): Promise<DoctorRepor } } } + + // The publish lock (common/publish/stageLock.ts): free, held by a stage + // that is running, or left by one that is gone — judged by the lock's own + // rule (holderIsGone), never removed here. + if (builds) { + const lock = await import("../publish/stageLock"); + const file = lock.publishLockPath(paths); + const st = statOrNull(file); + const clear = `clear it, when nothing is publishing: rm ${file}`; + if (!st) { + add(PB, "publish-lock", "ok", "free — no stage is publishing"); + } else { + const holder = lock.parseLockHolder(readOrNull(file) ?? ""); + const host = lock.lockHostId(env); + const nowMs = (deps.now ?? new Date()).getTime(); + if (!holder) { + const torn = nowMs - st.mtime.getTime() > lock.LOCK_TORN_GRACE_MS; + add(PB, "publish-lock", torn ? "warn" : "info", + torn + ? `${file} does not parse, and has not for ${ago(st.mtime, new Date(nowMs)).replace(/ ago$/, "")} — a taker died writing it; the next stage takes it over, or ${clear}` + : `${file} is being written — a stage is taking the lock`); + } else if (holder.host !== host) { + add(PB, "publish-lock", "warn", + `held by ANOTHER host: ${lock.describeHolder(holder)} — never taken over from here (this host is "${host}"); if that host is gone, ${clear}`); + } else if (lock.holderIsGone(holder, { host })) { + add(PB, "publish-lock", "warn", + `stale: ${lock.describeHolder(holder)} — its process is gone; the next stage takes it over, or ${clear}`); + } else { + add(PB, "publish-lock", "info", `held: ${lock.describeHolder(holder)} — a stage is running`); + } + } + } + + // The index stamp (common/publish/stamps.ts): how old, and which built + // targets came from an older one (they rebuild on their next build). + if (paths.exportIndexDir) { + const stamps = await import("../publish/stamps"); + const idx = await stamps.readIndexStamp(paths); + const built = builds ? await builtStamps(paths) : []; + const now = deps.now ?? new Date(); + if (!idx) { + const something = built.length > 0 || (await configuredSiteIds(paths)).length > 0; + add(PB, "index-stamp", something ? "warn" : "info", + `no index stamp at ${stamps.indexStampPath(paths)} — update the index first: archilyzer publish index`); + } else { + const at = new Date(idx.builtAt); + const head = `${idx.stampId}, built ${stamp(at)} (${ago(at, now)}), generation ${idx.generation}`; + const older = built.filter((b) => b.indexStampId !== idx.stampId).map((b) => b.target); + if (older.length > 0) { + add(PB, "index-stamp", "warn", + `${head}; built from an older stamp: ${older.join(", ")} — archilyzer publish build <id> rebuilds each`); + } else { + add(PB, "index-stamp", "ok", + `${head}${built.length > 0 ? `; ${built.length} bundle${built.length === 1 ? "" : "s"} built from it` : "; nothing built yet"}`); + } + } + } } // ── source publish ─────────────────────────────────────────────────────── @@ -983,9 +1054,12 @@ function statfsFree(dir: string): number | null { } } -// Every `<builds>/<target>/out` bundle and their bytes, by stat (links not -// followed). A bundle's own size is what a rebuild writes again beside it. -async function bundleBytes(buildsDir: string): Promise<{ count: number; bytes: number }> { +// Every `<builds>/<target>/out` bundle and its bytes: the `bytes` its +// built.json recorded (common/publish/stamps.ts), else — a bundle no stamp +// describes, from before the stages — a stat walk (links not followed). A +// bundle's own size is what a rebuild writes again beside it. +async function bundleBytes(paths: Paths): Promise<{ count: number; bytes: number }> { + const { readBuiltStamp } = await import("../publish/stamps"); let count = 0; let bytes = 0; const walk = async (d: string): Promise<void> => { @@ -995,16 +1069,61 @@ async function bundleBytes(buildsDir: string): Promise<{ count: number; bytes: n else if (ent.isFile()) bytes += statOrNull(p)?.size ?? 0; } }; - for (const ent of await readdir(buildsDir, { withFileTypes: true }).catch(() => [])) { - if (!ent.isDirectory()) continue; - const out = path.join(buildsDir, ent.name, "out"); + for (const ent of await readdir(paths.exportBuildsDir, { withFileTypes: true }).catch(() => [])) { + if (!ent.isDirectory() || ent.name.startsWith(".")) continue; + const out = path.join(paths.exportBuildsDir, ent.name, "out"); if (!statOrNull(out)?.isDirectory()) continue; count += 1; - await walk(out); + const built = await readBuiltStamp(paths, ent.name); + if (built) bytes += built.bytes; + else await walk(out); } return { count, bytes }; } +// Every built.json under the builds dir (sites, `_hub`, `_homepage`), by +// target name. Read-only; an unreadable stamp is skipped. +async function builtStamps(paths: Paths): Promise<{ target: string; indexStampId: string | null }[]> { + const { readBuiltStamp } = await import("../publish/stamps"); + const out: { target: string; indexStampId: string | null }[] = []; + for (const ent of await readdir(paths.exportBuildsDir, { withFileTypes: true }).catch(() => [])) { + if (!ent.isDirectory() || ent.name.startsWith(".")) continue; + const built = await readBuiltStamp(paths, ent.name); + if (built) out.push({ target: built.target, indexStampId: built.indexStampId }); + } + return out.sort((x, y) => x.target.localeCompare(y.target)); +} + +// The configured site ids (a sites/<id>/site.json), `_`-dirs excluded. +async function configuredSiteIds(paths: Paths): Promise<string[]> { + if (!paths.sitesDir) return []; + const out: string[] = []; + for (const e of await readdir(paths.sitesDir, { withFileTypes: true }).catch(() => [])) { + if (e.isDirectory() && !e.name.startsWith("_") && existsSync(path.join(paths.sitesDir, e.name, "site.json"))) out.push(e.name); + } + return out.sort(); +} + +// The pinned wrangler's Node floor, from its package.json `engines.node` +// (">=22.0.0"), when wrangler is installed in common/node_modules; else null. +function wranglerNodeFloor(root: string): { version: string; range: string; min: [number, number, number] } | null { + const text = readOrNull(path.join(root, "common", "node_modules", "wrangler", "package.json")); + if (text === null) return null; + try { + const pkg = JSON.parse(text) as { version?: unknown; engines?: { node?: unknown } }; + const range = typeof pkg.engines?.node === "string" ? pkg.engines.node : ""; + const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(range); + if (!m) return null; + return { + version: typeof pkg.version === "string" ? pkg.version : "?", + range, + min: [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)], + }; + } catch { + return null; + } +} + // The sites whose site.json names a Cloudflare Pages project — what "this // machine is configured to deploy" means. Read-only; an unreadable file is // skipped. diff --git a/common/publish/stamps.ts b/common/publish/stamps.ts @@ -139,20 +139,9 @@ export function deployedPath(paths: Pick<Paths, "exportBuildsDir">, target: stri } // The runtime image's build facts (Dockerfile build args → ENV): the stamps' -// `commit` / `branch` where there is no .git to ask. Release 18 S5 declares -// the two names and exports the same helper (`imageBuildFacts`, -// lib/envVars.ts); this local one is swapped for it in one line once both -// slices are merged. Empty = null. -const IMAGE_COMMIT_NAME = "ARCHILYZER_COMMIT"; -const IMAGE_BRANCH_NAME = "ARCHILYZER_BRANCH"; - -export function imageBuildFacts(env: NodeJS.ProcessEnv = process.env): { - commit: string | null; - branch: string | null; -} { - const v = (k: string) => env[k]?.trim() || null; - return { commit: v(IMAGE_COMMIT_NAME), branch: v(IMAGE_BRANCH_NAME) }; -} +// `commit` / `branch` where there is no checkout to ask. Declared once, beside +// the two names, in lib/envVars.ts (release 18 S5); re-exported for the stages. +export { imageBuildFacts } from "../lib/envVars"; /** A fresh, sortable, unique stamp id. */ export function newStampId(now = Date.now()): string {