Archilyzer · Source

archilyzer

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

commit bda861cacd0e4c14dea093356315bc0ddcd9b814
parent 2d6b38b445f177b227864e22aa772452e406efb0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 10:21:23 -0400

report-to-video: post links after review -- one failure memo (url -> {err, at, refresh}) read by both resolver passes, expiring after refreshAfterMs (never in a build, ten minutes in umtool's server) and cleared by a success, so an archive that stalls costs one timeout per URL, not per post; resolvePostLinks skips hidden posts and cuts without the deck, takes a deadline (the preview's is 10 s), and its no-origin note says to set provenance.siteOrigin; null is unset for siteChannel/siteUrl/postId; createJsonCache shares an in-flight fetch; cues.mjs notes the shared, refreshed corpus.json; the preview links the posts as the draft leaves them

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

Diffstat:
Mumtool/lib/report/onscreen.mjs | 35++++++++++++++++++++++++++---------
Mumtool/lib/report/onscreen.test.mjs | 18++++++++++++++++++
Mumtool/report-to-video/cues.mjs | 47++++++++++++++++++++++++++++++++++-------------
Mumtool/report-to-video/deck.mjs | 7++++---
Mumtool/report-to-video/post-links.mjs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mumtool/report-to-video/post-links.test.mjs | 99++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 238 insertions(+), 42 deletions(-)

diff --git a/umtool/lib/report/onscreen.mjs b/umtool/lib/report/onscreen.mjs @@ -331,24 +331,41 @@ async function readBuiltSchedule(dir, variant) { export async function scheduleForPreview(project, manifest, variant, draft = new Map(), postsDraft = {}, { resolver = previewPostResolver() } = {}) { const selected = selectVariant(manifest, variant); const entries = selected.timeline ?? []; + const posts = Array.isArray(selected.posts) ? selected.posts : []; const [built, metas, linked] = await Promise.all([ readBuiltSchedule(project.dir, variant), deckMetas(project.dir, manifest, entries), - resolvePostLinks({ posts: selected.posts ?? [], provenance: selected.provenance ?? {}, render: selected.render ?? {}, resolver }), + // The posts as the draft leaves them, so a post the draft un-hides is + // linked too (a hidden one is not looked up). + resolvePostLinks({ + posts: applyPostsDraft(posts, postsDraft), + provenance: selected.provenance ?? {}, + render: selected.render ?? {}, + resolver, + deadlineMs: PREVIEW_LINK_DEADLINE_MS, + }), ]); - // The posts with the archive channel each is kept in, found as the build - // finds it (post-links.mjs, the same cache on disk), so the preview's QRs - // are the build's. Never written to the manifest. - const variantManifest = linked.posts === selected.posts ? selected : { ...selected, posts: linked.posts }; + // The archive channel each post is kept in, found as the build finds it + // (post-links.mjs, the same cache on disk), so the preview's QRs are the + // build's. Set on this request's copy of the posts only, never written. + const found = new Map(linked.posts.filter((p) => p.siteChannel).map((p) => [p.id, p.siteChannel])); + const variantManifest = found.size + ? { ...selected, posts: posts.map((p) => (!p.siteChannel && found.has(p.id) ? { ...p, siteChannel: found.get(p.id) } : p)) } + : selected; return { variantManifest, metas, schedule: previewSchedule({ variantManifest, built, draft, metas, postsDraft }) }; } +/** The most a preview request waits on the archive for its posts' links, all of them together. */ +const PREVIEW_LINK_DEADLINE_MS = 10000; + /** * The preview's post-link resolver: one for the server's life, so its memory - * cache spans requests. A post missing from the cached archive is looked for - * in a fresh copy at most every ten minutes, and a request waits at most eight - * seconds on an archive that does not answer (then the QR links the original, - * as the build's would). + * cache spans requests. Each archive request may take eight seconds; a URL + * that failed is not asked again for ten minutes, and a post missing from the + * cached archive is looked for in a fresh copy at most that often. A request + * waits at most PREVIEW_LINK_DEADLINE_MS for all its posts; one not found by + * then links the original, as the build's would when the archive does not + * answer, and its lookup finishes in the background for the next request. */ let previewResolver = null; function previewPostResolver() { diff --git a/umtool/lib/report/onscreen.test.mjs b/umtool/lib/report/onscreen.test.mjs @@ -412,3 +412,21 @@ test("scheduleForPreview: the posts are linked by the resolver the build uses, i await rm(dir, { recursive: true, force: true }); } }); + +test("scheduleForPreview: a post the draft un-hides is linked; a hidden one is not asked about", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "onscreen-links-")); + try { + const manifest = withPosts([{ ...POSTS[1], hide: true }]); + const asked = []; + const resolver = { find: async (origin, post) => (asked.push(post.id), "b-x") }; + const hidden = await scheduleForPreview({ dir }, manifest, "sourced", new Map(), {}, { resolver }); + assert.deepEqual(asked, []); + assert.ok(!("posts" in hidden.schedule)); + const shown = await scheduleForPreview({ dir }, manifest, "sourced", new Map(), { p2: { hide: false } }, { resolver }); + assert.deepEqual(asked, ["p2"]); + assert.equal(shown.schedule.posts[0].qrUrl, "https://example.test/?v=b-x%2F2&vm=post"); + assert.equal(shown.variantManifest.posts[0].hide, true, "the draft is the preview's, not the returned manifest's"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/umtool/report-to-video/cues.mjs b/umtool/report-to-video/cues.mjs @@ -47,6 +47,14 @@ // reader following the citation will actually see, and the only // option that is reproducible on a machine with no corpus. // Whatever answers, the returned record carries `from` so a caller can record it. +// +// THE ON-DISK CACHE IS SHARED, AND ONE READER REFRESHES IT. post-links.mjs (a +// post's archive channel, for its QR) reads `/corpus.json` and the posts +// manifests through createJsonCache below, the same files on disk, and on a +// post it cannot find it fetches them again and rewrites them. The cue walk +// never refreshes, but it can read a corpus.json post-links rewrote: newer, so +// a channel the stale copy lacked is found. Manifest and shard URLs carry no +// version, so a cue window does not move because of it. import { readFile, writeFile, mkdir, lstat, readlink } from "node:fs/promises"; import path from "node:path"; @@ -137,6 +145,8 @@ export function createJsonCache({ const mem = new Map(); /** URL -> when this cache last fetched it from the network (ms). */ const fetchedAt = new Map(); + /** URL -> the network fetch of it in progress. */ + const inflight = new Map(); async function getJson(url, { refresh = false } = {}) { const recent = fetchedAt.has(url) && Date.now() - fetchedAt.get(url) < refreshAfterMs; @@ -152,21 +162,32 @@ export function createJsonCache({ /* cold cache */ } } - log(`fetch ${url}`); - const res = await fetchImpl(url); - if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); - const json = await res.json(); - mem.set(url, json); - fetchedAt.set(url, Date.now()); - if (disk) { - try { - await mkdir(path.dirname(disk), { recursive: true }); - await writeFile(disk, JSON.stringify(json)); - } catch { - // A cache we cannot write is a slow run, not a failed one. + // Callers that ask for the same URL while it is on its way share the one + // fetch (umtool's preview routes run side by side). + if (inflight.has(url)) return inflight.get(url); + const pending = (async () => { + log(`fetch ${url}`); + const res = await fetchImpl(url); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`); + const json = await res.json(); + mem.set(url, json); + fetchedAt.set(url, Date.now()); + if (disk) { + try { + await mkdir(path.dirname(disk), { recursive: true }); + await writeFile(disk, JSON.stringify(json)); + } catch { + // A cache we cannot write is a slow run, not a failed one. + } } + return json; + })(); + inflight.set(url, pending); + try { + return await pending; + } finally { + inflight.delete(url); } - return json; } return { getJson }; diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs @@ -719,13 +719,14 @@ export function validatePosts(posts, timeline = [], render = null) { // Where the archive keeps the post: its own channel's slug (a post channel // is not the video channel -- `piratesoftware-bsky`, not `piratesoftware`), // a page to link instead, or the post's id when its url does not carry one. - if (p.siteChannel !== undefined && (typeof p.siteChannel !== "string" || !SLUG_RE.test(p.siteChannel))) { + // `null` is unset, as `attachTo: null` is. + if (p.siteChannel != null && (typeof p.siteChannel !== "string" || !SLUG_RE.test(p.siteChannel))) { errors.push(`${w}.siteChannel must be the archive's channel slug (letters, digits, dots, dashes, underscores)`); } - if (p.siteUrl !== undefined && (typeof p.siteUrl !== "string" || !/^https?:\/\/\S+$/.test(p.siteUrl))) { + if (p.siteUrl != null && (typeof p.siteUrl !== "string" || !/^https?:\/\/\S+$/.test(p.siteUrl))) { errors.push(`${w}.siteUrl must be an http(s) link`); } - if (p.postId !== undefined && (typeof p.postId !== "string" || !POST_ID_RE.test(p.postId))) { + if (p.postId != null && (typeof p.postId !== "string" || !POST_ID_RE.test(p.postId))) { errors.push(`${w}.postId must be the post's id on its platform (letters, digits, dashes, underscores)`); } }); diff --git a/umtool/report-to-video/post-links.mjs b/umtool/report-to-video/post-links.mjs @@ -23,9 +23,10 @@ // cue walk reads -- with one refresh: a post missing from a CACHED corpus or // manifest is looked for again in a fresh copy (once per process for a build, // once per `refreshAfterMs` for a server), so a post published since the cache -// was filled is found. +// was filled is found. A URL that failed is not asked again for as long (the +// failure memo in createPostChannelResolver). import { createJsonCache } from "./cues.mjs"; -import { postNativeId, resolveDeck } from "./deck.mjs"; +import { deckOn, postNativeId, resolveDeck } from "./deck.mjs"; /** How long one archive request may take before the post links its original. */ export const POST_LINK_TIMEOUT_MS = 15000; @@ -61,20 +62,34 @@ export function createPostChannelResolver({ log = () => {}, timeoutMs = POST_LINK_TIMEOUT_MS, refreshAfterMs = Infinity, + now = Date.now, } = {}) { const timed = (url) => fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) }); const get = getJson ?? createJsonCache({ ...(cacheDir !== undefined ? { cacheDir } : {}), fetchImpl: timed, log, refreshAfterMs }).getJson; - // One failed read of an archive is remembered for the resolver's life, so a - // cut of twenty posts against an archive that does not answer waits once. + // ONE memo of failed reads, `url -> { err, at, refresh }`, consulted by both + // passes (the cached read and the fresh one): a URL that failed is not asked + // again until `refreshAfterMs` has passed -- never again in a build + // (Infinity), after ten minutes in umtool's server -- and a read of it that + // succeeds clears it. So each URL of an archive that does not answer costs + // one timeout per build, not one per post. + // + // A cached read that failed went to the network (nothing was cached), so it + // bars both passes. A fresh read that failed bars only fresh reads: the copy + // already in hand still answers the cached pass, and a cached read that + // succeeds does not clear it -- it proves nothing about the network. const failed = new Map(); async function read(url, refresh) { - if (failed.has(url) && !refresh) throw failed.get(url); + const f = failed.get(url); + const live = f && now() - f.at < refreshAfterMs; + if (live && (refresh || !f.refresh)) throw f.err; try { - return await get(url, { refresh }); + const doc = await get(url, { refresh }); + if (f && (refresh || !f.refresh || !live)) failed.delete(url); + return doc; } catch (e) { const err = e instanceof Error ? e : new Error(String(e)); - failed.set(url, err); + failed.set(url, { err, at: now(), refresh }); throw err; } } @@ -111,28 +126,47 @@ export function createPostChannelResolver({ /** * The posts with their archive channel found, and a note per post saying - * where its QR goes. Only under `posts.links: "archive"` with an archive named - * (`provenance.siteOrigin`) and posts to draw; otherwise the posts come back - * as they were, with no notes. + * where its QR goes. Only under the deck, with `posts.links: "archive"`, an + * archive named (`provenance.siteOrigin`) and posts to draw; otherwise the + * posts come back as they were, with no notes. A hidden post is not drawn, so + * it is not looked up either. * * Never throws for the archive: a post it cannot place keeps its own link. + * `deadlineMs` bounds the whole call (umtool's preview passes one): a post + * still unresolved when it passes keeps its own link, and its lookup goes on + * in the background, filling the cache for the next call. * * @param {{ posts?: Array<Record<string, any>>, provenance?: Record<string, any>, - * render?: Record<string, any>, resolver: ReturnType<typeof createPostChannelResolver> }} args + * render?: Record<string, any>, resolver: ReturnType<typeof createPostChannelResolver>, + * deadlineMs?: number }} args * @returns {Promise<{ posts: Array<Record<string, any>>, notes: string[] }>} */ -export async function resolvePostLinks({ posts = [], provenance = {}, render = {}, resolver }) { +export async function resolvePostLinks({ posts = [], provenance = {}, render = {}, resolver, deadlineMs = Infinity }) { const settings = resolveDeck(render).posts; const base = origin(provenance); - if (!Array.isArray(posts) || !posts.length || !settings.show || settings.links !== "archive") { + if (!deckOn(render) || !Array.isArray(posts) || !posts.length || !settings.show || settings.links !== "archive") { return { posts, notes: [] }; } if (!base) { - return { posts, notes: ["posts: no provenance.siteOrigin names an archive -- every post's QR links the original"] }; + return { + posts, + notes: ["posts: no provenance.siteOrigin names an archive -- every post's QR links the original (set provenance.siteOrigin to the site that keeps them)"], + }; } const notes = []; const out = []; + const until = Date.now() + deadlineMs; + const timeUp = Symbol("time up"); + const inTime = (promise) => { + const left = until - Date.now(); + if (!Number.isFinite(left)) return promise; + let timer; + const t = new Promise((res) => { timer = setTimeout(() => res(timeUp), left); }); + promise.catch(() => {}); + return Promise.race([promise, t]).finally(() => clearTimeout(timer)); + }; for (const p of posts) { + if (p.hide) { out.push(p); continue; } if (p.siteUrl) { notes.push(`${p.id}: QR links ${p.siteUrl} (siteUrl)`); out.push(p); continue; } if (p.siteChannel) { notes.push(`${p.id}: archive link via ${p.siteChannel} (pinned)`); out.push(p); continue; } if (!postNativeId(p)) { @@ -140,9 +174,17 @@ export async function resolvePostLinks({ posts = [], provenance = {}, render = { out.push(p); continue; } + if (Date.now() >= until) { + notes.push(`${p.id}: no time left to ask the archive at ${base} -- QR links the original`); + out.push(p); + continue; + } try { - const slug = await resolver.find(base, p); - if (slug) { + const slug = await inTime(resolver.find(base, p)); + if (slug === timeUp) { + notes.push(`${p.id}: the archive at ${base} was still being asked when time ran out -- QR links the original`); + out.push(p); + } else if (slug) { notes.push(`${p.id}: archive link via ${slug}`); out.push({ ...p, siteChannel: slug }); } else { diff --git a/umtool/report-to-video/post-links.test.mjs b/umtool/report-to-video/post-links.test.mjs @@ -199,7 +199,8 @@ test("resolvePostLinks: pinned posts, links \"original\", no siteOrigin, no id", // No archive named: one note for the lot. const none = await resolvePostLinks({ posts: [BSKY], provenance: {}, render: DECK, resolver }); assert.equal(none.posts[0], BSKY); - assert.deepEqual(none.notes, ["posts: no provenance.siteOrigin names an archive -- every post's QR links the original"]); + assert.equal(none.notes.length, 1); + assert.match(none.notes[0], /^posts: no provenance\.siteOrigin names an archive -- every post's QR links the original \(set provenance\.siteOrigin/); // No id in the url. const noId = await resolvePostLinks({ posts: [{ ...BSKY, url: "https://bsky.app/p/1" }], provenance: PROV, render: DECK, resolver }); assert.deepEqual(noId.notes, [`${BSKY.id}: no post id in its url -- QR links the original`]); @@ -228,3 +229,99 @@ test("createJsonCache: a refresh refetches once per process, then reads what it await rm(dir, { recursive: true, force: true }); } }); + +// ---- the failure memo, the deadline, what is not looked up ------------------- + +test("failure memo: an archive that hangs costs each URL one try per build, across both passes and every post", async () => { + // corpus.json is cached (the cached pass answers); every fresh read and the + // uncached posts manifest fail, as an archive that connects and stalls would. + const a = archive([{ slug: "piratesoftware-bsky", name: "piratesoftware.live (BlueSky)", ids: ["other"] }]); + const manifest = `${ORIGIN}/posts/piratesoftware-bsky/manifest.json`; + const asked = []; + const getJson = async (url, { refresh = false } = {}) => { + asked.push({ url, refresh }); + if (url.endsWith("/corpus.json") && !refresh) return a.docs.get(url); + throw new Error(`GET ${url} -> timed out`); + }; + const resolver = createPostChannelResolver({ getJson }); + const posts = [BSKY, { ...BSKY, id: "b2", url: "https://bsky.app/profile/piratesoftware.live/post/rk2" }, { ...BSKY, id: "b3", url: "https://bsky.app/profile/piratesoftware.live/post/rk3" }]; + const r = await resolvePostLinks({ posts, provenance: PROV, render: DECK, resolver }); + assert.ok(r.posts.every((p) => p.siteChannel === undefined)); + assert.ok(r.notes.every((n) => /did not answer .* -- QR links the original/.test(n)), r.notes.join("\n")); + // The manifest: asked once, by the first post's cached pass, never again. + assert.deepEqual(asked.filter((x) => x.url === manifest), [{ url: manifest, refresh: false }]); + // corpus.json fresh: asked once, by the first post's second pass. + assert.equal(asked.filter((x) => x.url.endsWith("/corpus.json") && x.refresh).length, 1); +}); + +test("failure memo: a server's resolver asks again once refreshAfterMs has passed, and a success re-arms it", async () => { + let t = 0; + let down = true; + const a = archive([{ slug: "piratesoftware-bsky", name: "piratesoftware.live (BlueSky)", ids: ["3l6uxj6esfr2z"] }]); + let asks = 0; + const getJson = async (url, opts) => { + asks += 1; + if (down) throw new Error(`GET ${url} -> 503`); + return a.getJson(url, opts); + }; + const resolver = createPostChannelResolver({ getJson, refreshAfterMs: 600_000, now: () => t }); + await assert.rejects(resolver.find(ORIGIN, BSKY), /503/); + assert.equal(asks, 1); + // Within the window: refused from the memo, nothing asked, even after the archive is back. + down = false; + t = 599_999; + await assert.rejects(resolver.find(ORIGIN, BSKY), /503/); + assert.equal(asks, 1); + // Past it: asked, found. + t = 600_000; + assert.equal(await resolver.find(ORIGIN, BSKY), "piratesoftware-bsky"); + // The success cleared the memo; a new failure is remembered from its own time. + down = true; + const before = asks; + await assert.rejects(resolver.find(ORIGIN, { ...BSKY, url: "https://bsky.app/profile/piratesoftware.live/post/zz" }), /503/); + t = 600_001; + await assert.rejects(resolver.find(ORIGIN, { ...BSKY, url: "https://bsky.app/profile/piratesoftware.live/post/zz" }), /503/); + assert.equal(asks, before + 1, "one failed ask, then the memo"); +}); + +test("resolvePostLinks: a deadline bounds the whole call; the rest keep their own links", async () => { + const resolver = { find: () => new Promise(() => {}) }; // an archive that never answers + const posts = [BSKY, { ...BSKY, id: "b2" }]; + const started = Date.now(); + const r = await resolvePostLinks({ posts, provenance: PROV, render: DECK, resolver, deadlineMs: 50 }); + assert.ok(Date.now() - started < 1000); + assert.ok(r.posts.every((p) => p.siteChannel === undefined)); + assert.match(r.notes[0], /still being asked when time ran out -- QR links the original/); + assert.match(r.notes[1], /no time left to ask the archive/); +}); + +test("resolvePostLinks: hidden posts and a cut without the deck are not looked up", async () => { + const asked = []; + const resolver = { find: async (o, p) => (asked.push(p.id), "piratesoftware-bsky") }; + const r = await resolvePostLinks({ posts: [{ ...BSKY, hide: true }, X], provenance: PROV, render: DECK, resolver }); + assert.deepEqual(asked, [X.id]); + assert.equal(r.posts[0].siteChannel, undefined); + assert.equal(r.notes.length, 1); + const off = await resolvePostLinks({ posts: [BSKY], provenance: PROV, render: {}, resolver }); + assert.deepEqual(off, { posts: [BSKY], notes: [] }); + assert.deepEqual(asked, [X.id]); +}); + +test("validatePosts: null is unset for siteChannel, siteUrl and postId, as the README's example writes them", () => { + assert.deepEqual(validatePosts([{ ...BSKY, attachTo: null, hide: false, siteChannel: null, siteUrl: null, postId: null }]), []); + assert.equal(postQrUrl({ ...BSKY, siteChannel: null, siteUrl: null, postId: null }, PROV), BSKY.url); +}); + +test("createJsonCache: callers asking for one URL at once share one fetch", async () => { + let n = 0; + let release; + const gate = new Promise((r) => { release = r; }); + const fetchImpl = async () => { n += 1; await gate; return { ok: true, status: 200, json: async () => ({ n }) }; }; + const c = createJsonCache({ cacheDir: null, fetchImpl }); + const both = Promise.all([c.getJson("https://a.example/x"), c.getJson("https://a.example/x", { refresh: true })]); + release(); + assert.deepEqual(await both, [{ n: 1 }, { n: 1 }]); + assert.equal(n, 1); + // Once it has landed, a refresh is a new fetch only when allowed (once per process here: not again). + assert.deepEqual(await c.getJson("https://a.example/x", { refresh: true }), { n: 1 }); +});