Archilyzer · Source

archilyzer

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

commit cd756a8f03451f50b851e92e0893d5fcb93c4306
parent 8b7173d3a5d8bc228eb437082e792077a8e06c4a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:33:52 -0400

merge main into export/tags-followups

Diffstat:
Mcommon/controller/autoRunner.test.ts | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/autoRunner.ts | 21+++++++++++++++++++--
Mcommon/lib/archive/contract.test.ts | 21+++++++++++++++++++++
Mcommon/lib/archive/contract.ts | 34+++++++++++++++++++++++-----------
Mcommon/lib/curatedTags.test.ts | 40++++++++++++++++++++++++++++++++++++++--
Mcommon/lib/curatedTags.ts | 36++++++++++++++++++++++++------------
Mcommon/lib/curatedTagsStore.ts | 15+++++++++------
Meditor/app/api/ops/_lib.ts | 42++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/ops/build-deploy/route.ts | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Meditor/app/api/ops/build-site/route.ts | 64+++++++++++++++++++++++++++++++++++++---------------------------
Meditor/app/operations/status.ts | 11+++++++++--
Meditor/e2e/ops-api.spec.ts | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/scripts/measure-nav.mjs | 2+-
Mscripts/archilyzer-ops.mjs | 185++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mscripts/archilyzer-ops.test.mjs | 137++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mscripts/worktree.mjs | 39++++++++++++++++++++++++++++++---------
Ascripts/worktree.test.mjs | 46++++++++++++++++++++++++++++++++++++++++++++++
17 files changed, 834 insertions(+), 107 deletions(-)

diff --git a/common/controller/autoRunner.test.ts b/common/controller/autoRunner.test.ts @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { + computeLeafPending, focusHoldLine, laneDispatchRoot, makeFocusHoldReporter, @@ -19,6 +20,7 @@ import { type FocusSummary, } from "../lib/channelPriority"; import { LANES } from "../lib/autoQueueTypes"; +import { emptyAutoQueueState } from "../jobs/autoQueueState"; import type { AutoQueueGroup, AutoQueuePolicy, @@ -479,3 +481,65 @@ test("a focus holds the scan the same way it holds every download pick", () => { ); }); + +// --- computeLeafPending's `shared`: the four-lane poll reads once ----------- +// +// /operations asks all four lanes for their pending work on a ~3 s poll, and +// each call used to list every channel's config off disk and re-parse the +// auto-queue state — both of which the caller had already read for the rest of +// the payload, and neither of which is per-lane. + +function pendingFixture(): Paths { + const dir = mkdtempSync(path.join(tmpdir(), "leaf-pending-")); + const channelsDir = path.join(dir, "channels"); + // A channel that IS on disk. An injected listing has to be believed over it. + mkdirSync(path.join(channelsDir, "on-disk"), { recursive: true }); + writeFileSync( + path.join(channelsDir, "on-disk", "config.json"), + JSON.stringify({ url: "https://www.youtube.com/@ondisk" }), + ); + return { + channelsDir, + sitesDir: path.join(dir, "sites"), + autoQueueStateFile: path.join(dir, "auto-queue.json"), + } as Paths; +} + +test("computeLeafPending takes its channel listing from `shared`", async () => { + const paths = pendingFixture(); + const result = await computeLeafPending(LANES[0], paths, { + configs: [], + state: emptyAutoQueueState(), + }); + // The leaves the compiled root defines, all at zero: an empty listing is no + // channels, so there is no work to attribute and nothing to pick. + assert.deepEqual(Object.values(result.counts), [0]); + assert.deepEqual(Object.values(result.head), [[]]); + assert.deepEqual(result.owner, {}); + assert.equal(result.nextUp, null); +}); + +test("an injected auto-queue state changes nothing about the answer", async () => { + const paths = pendingFixture(); + // The state carries SWRR weights, which only matter once there is work to + // pick between — so what `shared.state` must never do is change the result. + // It saves a read; it is not an input to the numbers. + const state = emptyAutoQueueState(); + state[LANES[0]].runtime.currentWeights = { "prio-all": 17 }; + const injected = await computeLeafPending(LANES[0], paths, { + configs: [], + state, + }); + const read = await computeLeafPending(LANES[0], paths, { configs: [] }); + assert.deepEqual(injected.counts, read.counts); + assert.deepEqual(injected.head, read.head); + assert.deepEqual(injected.owner, read.owner); + assert.equal(injected.nextUp, read.nextUp); + + // AND THE CALLER'S OBJECT IS NOT TOUCHED. selectNextWork advances fairness by + // mutating currentWeights in place, and this runs on a 3-second poll: the + // deep clone that stops merely HAVING the page open from skewing a + // round-robin group now has to protect a state the caller still holds and + // will hand to the next lane. + assert.deepEqual(state[LANES[0]].runtime.currentWeights, { "prio-all": 17 }); +}); diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -354,8 +354,12 @@ async function listChannelMeta( paths: Paths, kind: AutoQueueKind, priority: ChannelPriority, + // A listing the CALLER already has. The status poll reads every channel's + // config once and then asked four lanes for their pending work, each of which + // re-read the whole directory — see computeLeafPending's `shared`. + sharedConfigs?: readonly { slug: string; config: ChannelConfig }[], ): Promise<{ meta: ChannelMeta[]; slugs: string[] }> { - const configs = await listChannelConfigs(paths); + const configs = sharedConfigs ?? (await listChannelConfigs(paths)); return { meta: configs .filter(({ slug }) => !isChannelPaused(priority, slug, kind)) @@ -904,9 +908,21 @@ export type LeafPending = { // operator sees as "cornbreadman: 12 pending". Uses the same matching AND the // same ordering as the runner, so the numbers and the drill-down line up with // what would actually be picked. +// +// `shared` IS THE FOUR-LANE POLL'S WAY OUT OF READING EVERYTHING FOUR TIMES. +// /operations asks all four lanes on a ~3 s poll, and each call listed every +// channel's config (a readdir plus a read per channel — 30 on Jeralyzer) and +// parsed the auto-queue state document again, having already read both itself +// for the rest of the payload. Nothing about either read is per-lane, so a +// caller that holds them passes them in and the poll pays once. Omitted, each +// is read here exactly as before, so every other caller is unchanged. export async function computeLeafPending( kind: AutoQueueKind, paths: Paths = getPaths(), + shared?: { + configs?: readonly { slug: string; config: ChannelConfig }[]; + state?: AutoQueueState; + }, ): Promise<LeafPending> { const settings = getSettings(); const policy = settings.autoQueue[kind]; @@ -920,6 +936,7 @@ export async function computeLeafPending( paths, kind, settings.channelPriority, + shared?.configs, ); const ctx = priorityContextFor(paths, settings, slugs); const root = laneDispatchRoot(kind, policy, ctx, meta.map((m) => m.slug)); @@ -974,7 +991,7 @@ export async function computeLeafPending( // runtime.currentWeights in place. This runs on a 3-second status poll, so // asking the live runtime would let merely HAVING the page open skew a // round-robin group's rotation. Deep-clone first; the clone is discarded. - const state = await readAutoQueueState(paths); + const state = shared?.state ?? (await readAutoQueueState(paths)); const runtime = { currentWeights: { ...state[kind].runtime.currentWeights }, }; diff --git a/common/lib/archive/contract.test.ts b/common/lib/archive/contract.test.ts @@ -261,3 +261,24 @@ test("the search index worker's private pageFileName matches CONTRACT.pagePad", assert.ok(src.includes("`/transcripts/${slug}/${pageFileName(p)}`")); assert.equal(pageUrl("transcripts", "alpha", 12), "/transcripts/alpha/page-0012.json"); }); + +// shipsPwa is the one function in this module that reads the ambient +// environment, and the guard in front of that read is not observable from its +// return value — `typeof process` is true in every runtime the test suite has. +// So the assertion is on the SOURCE, the same way the service-worker checks +// above are: what is being pinned is that the read cannot throw at import time +// in a browser, not what it answers. +test("shipsPwa guards its process.env read", () => { + const src = readSource("common/lib/archive/contract.ts"); + const fn = /export function shipsPwa\([\s\S]*?\n}/.exec(src); + assert.ok(fn, "shipsPwa not found — did it move or get renamed?"); + assert.match(fn[0], /typeof process !== "undefined"/); + // The guard must come BEFORE the dereference, which is the only arrangement + // that stops `ReferenceError: process is not defined`. + assert.ok( + fn[0].indexOf('typeof process !== "undefined"') < + fn[0].indexOf("process.env.INSTANCE_MODE"), + "the guard must precede the read", + ); + // What it ANSWERS is unchanged, and is pinned by the behaviour test above. +}); diff --git a/common/lib/archive/contract.ts b/common/lib/archive/contract.ts @@ -180,18 +180,30 @@ export function pageUrl( // Was two copies with "keep in sync" comments on each (compose-site.ts and // export/app/lib/mode.ts); S2c deleted both, and this is the one. // -// `process.env.INSTANCE_MODE` is read bare, exactly as both copies read it, and -// the reason that is safe is NOT that Next inlines it — it does not: -// INSTANCE_MODE is neither `NEXT_PUBLIC_` nor listed in a next.config `env:` -// block, so a client bundle would read `undefined` here. (An earlier draft of -// this comment claimed the opposite; the S1 review checked.) It is safe because -// no browser evaluates it: the only caller in the export app is -// export/app/lib/mode.ts, whose only caller is export/app/layout.tsx, a SERVER -// component, and the other caller is compose-site.ts, a build script. If this -// ever becomes reachable from a client module it needs a -// `typeof process !== "undefined"` guard AND an env var the client can see. +// THE `typeof process` GUARD STOPS A CRASH. IT DOES NOT MAKE HUB DETECTION +// WORK CLIENT-SIDE, and that distinction is the whole comment. +// +// `INSTANCE_MODE` is neither `NEXT_PUBLIC_` nor listed in a next.config `env:` +// block, so Next does not inline it and a client bundle reads `undefined` here. +// (An earlier draft of this comment claimed the opposite; the S1 review +// checked.) Hub detection is therefore SERVER-ONLY, guard or no guard, until +// somebody ships the value to the client deliberately — which would need an env +// var the client can actually see, not a change to this line. +// +// Today that costs nothing: the export app reaches this only through +// export/app/lib/mode.ts ← export/app/layout.tsx, a SERVER component, and the +// other caller is compose-site.ts, a build script. What the guard buys is the +// failure mode when that stops being true. An unguarded `process.env` in a +// module some client component pulls in throws `ReferenceError: process is not +// defined` at import time and takes the page down with it; guarded, the same +// import yields `false` — no PWA, which is the right answer for every site but +// a hub and a recoverable one for a hub. A wrong answer nobody notices beats a +// white screen. export function shipsPwa(site: { pwa?: boolean }): boolean { - return site.pwa === true || process.env.INSTANCE_MODE === "hub"; + return ( + site.pwa === true || + (typeof process !== "undefined" && process.env.INSTANCE_MODE === "hub") + ); } // ─── The hub's member entry, once ─── diff --git a/common/lib/curatedTags.test.ts b/common/lib/curatedTags.test.ts @@ -226,7 +226,40 @@ test("sanitizeTagsConfig is idempotent", () => { // ─── mergeTagDefs ─── -test("mergeTagDefs overlays presentation fields and appends site rules", () => { +test("the site layer carries no rules, on read and on write", () => { + const raw = { + tags: [ + { + id: "eva-collab", + rules: [{ id: "site", kind: "metadata", pattern: "extra" }], + }, + { + id: "site-only", + label: "Site only", + rules: [{ id: "r1", kind: "caption", pattern: "x" }], + }, + ], + }; + const site = sanitizeTagsConfig(raw, { layer: "site" }); + // Not emptied — DROPPED: no `rules` key at all, so what a site writes has + // none and nothing downstream is ever handed a rule that cannot fire. + for (const def of site.tags) { + assert.equal(Object.prototype.hasOwnProperty.call(def, "rules"), false); + } + // The same coercion runs on read, so a hand-edited site file cannot smuggle + // one back in. + assert.deepEqual( + sanitizeTagsConfig(JSON.parse(JSON.stringify(site)), { layer: "site" }), + site, + ); + // And the corpus layer is untouched by this: the identical input keeps them. + assert.deepEqual( + (sanitizeTagsConfig(raw).tags[0].rules ?? []).map((r) => r.id), + ["site"], + ); +}); + +test("mergeTagDefs overlays presentation fields and cannot add a rule", () => { const merged = mergeTagDefs(DEFS, [ { id: "eva-collab", @@ -235,6 +268,9 @@ test("mergeTagDefs overlays presentation fields and appends site rules", () => { color: "#b48ead", order: 9, hidden: true, + // A rule reaching the merge at all means someone bypassed the site + // coercion; it is still not appended, because a site rule can never tag + // a record and "harmless on a published def" is how it looks like it can. rules: [{ id: "site", kind: "metadata", pattern: "extra", enabled: true }], }, ]); @@ -246,7 +282,7 @@ test("mergeTagDefs overlays presentation fields and appends site rules", () => { assert.equal(collab.hidden, true); assert.deepEqual( (collab.rules ?? []).map((r) => r.id), - ["meta", "site"], + ["meta"], ); // Untouched fields survive, and the global input is not mutated. assert.equal(collab.group, "eva"); diff --git a/common/lib/curatedTags.ts b/common/lib/curatedTags.ts @@ -222,11 +222,21 @@ function coerceTagDef(raw: unknown, layer: TagLayer): CuratedTagDef | null { // override", and inventing one here would rename the tag on that site, since // mergeTagDefs applies every field the overlay carries. const label = trimmedString(r.label) ?? (layer === "site" ? undefined : id); - const rules = Array.isArray(r.rules) - ? r.rules - .map((rule, i) => coerceRule(rule, i)) - .filter((rule): rule is CuratedTagRule => rule !== null) - : []; + // THE SECOND FIELD THE LAYERS TREAT DIFFERENTLY: a site layer carries no + // rules at all, and one written there is dropped — on read AND on write, so + // it can never reach a file, a merge or a published def. + // + // A rule is evaluated once at index time, over records SHARED by every site + // that carries the channel. There is no per-site record for a per-site hit to + // live in, so a site rule could only ever LOOK like it worked. Dropping it in + // the coercion is the one place that covers every writer. Rules bind from + // transcripts/tags.json alone; promote one there to make it fire. + const rules = + layer === "site" || !Array.isArray(r.rules) + ? [] + : r.rules + .map((rule, i) => coerceRule(rule, i)) + .filter((rule): rule is CuratedTagRule => rule !== null); const order = typeof r.order === "number" && Number.isFinite(r.order) ? r.order @@ -348,10 +358,13 @@ export function sanitizeTagsConfig( // Layer a site's tags.json over the corpus one. // // For an id the corpus already defines, the site entry is a FIELD-WISE OVERLAY -// of label/groupLabel/color/order/hidden and may APPEND rules. It can never -// delete a corpus rule (and assignments do not live at the site layer at all — -// an assignment is a fact about a video, not a presentation choice). A site id -// the corpus does not define becomes a full site-only tag. +// of label/groupLabel/color/order/hidden — presentation, and nothing else. It +// can never delete a corpus rule, and it cannot add one: coerceTagDef drops +// rules from a site layer entirely (see there for why a per-site rule cannot +// tag anything), so there is nothing here to append. Assignments do not live at +// the site layer at all — an assignment is a fact about a video, not a +// presentation choice. A site id the corpus does not define becomes a full +// site-only tag, rule-less like every other site row. // // Deliberately NOT mergeAliases' wholesale replacement: aliases are suggestions, // tags are a shared vocabulary that a site may dress up but not gut. @@ -375,9 +388,8 @@ export function mergeTagDefs( if (overlay.color !== undefined) base.color = overlay.color; if (overlay.order !== undefined) base.order = overlay.order; if (overlay.hidden !== undefined) base.hidden = overlay.hidden; - if (overlay.rules && overlay.rules.length > 0) { - base.rules = [...(base.rules ?? []), ...overlay.rules]; - } + // No rule branch: a sanitized site def has none, and the corpus rules on + // `base` are left exactly as the corpus wrote them. } return out; } diff --git a/common/lib/curatedTagsStore.ts b/common/lib/curatedTagsStore.ts @@ -7,12 +7,15 @@ // site-only tags. See common/lib/curatedTags.ts for the pure model, the // coercion and mergeTagDefs. // -// **A site-layer RULE never tags a record.** mergeTagDefs will append one (it -// is harmless on a published /tags.json def), but rules are evaluated once at -// index time over records SHARED by every site that carries the channel — -// there is no per-site record for a per-site hit to live in. Rules bind only -// from transcripts/tags.json; promote one there to make it fire. This is why -// the editor offers no site-side rule editor. +// **A site-layer RULE never tags a record, so a site layer carries none.** +// Rules are evaluated once at index time over records SHARED by every site that +// carries the channel — there is no per-site record for a per-site hit to live +// in, so a rule written at the site layer could only ever LOOK like it worked. +// The site coercion therefore DROPS rules outright: on READ, so a hand-edited +// sites/<id>/tags.json cannot smuggle one in, and on WRITE, so neither can a +// caller — and mergeTagDefs has no rule branch left to append through. Rules +// bind from transcripts/tags.json alone; promote one there to make it fire. +// This is why the editor offers no site-side rule editor. // // Unlike aliasesStore there are NO seeded defaults: a fresh install has no // tags, and an absent global file reads as an empty config. diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { authorizeWorkerRequest } from "yt-dlp-transcript-common/lib/workerToken"; import { isValidChannelSlug } from "yt-dlp-transcript-common/controller/channels"; +import { isValidSiteId } from "yt-dlp-transcript-common/lib/site"; import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; import type { QueueOutcome } from "../../channels/lib/queueForSlugs"; @@ -158,6 +159,47 @@ export function reqStringArray(body: OpsBody, key: string): string[] { return (v as string[]).map((s) => s.trim()); } +// ONE SITE OR SEVERAL, SPELLED EITHER WAY. The two build routes disagreed — +// build-site took `siteIds` (a list), build-deploy took `siteId` (one) — so the +// same body worked on one and 400'd on the other, and the fix people reached +// for was to guess, which costs a round trip every time. Both routes now accept +// both keys, and this reader is the single place that says what that means. +// +// Both keys at once is still a 400 rather than a merge: a caller that sent both +// has two ideas about what it wants, and picking one on its behalf is how a +// deploy of the wrong site becomes somebody's afternoon. Neither key is a 400 +// naming both, because "which did you mean" is the actual question. +// +// A SITE ID, NOT MERELY A STRING, and checked BEFORE any job starts — the same +// reason reqSlug exists, with a worse failure behind it. `getSite` throws a +// plain Error on an id failing SITE_ID_RE and `ops()` maps that to a 500, so +// ["good", "BAD"] used to queue the first build, throw on the second and answer +// 500 with no jobs and no ids: a real build running that nothing was watching, +// and a `--wait` exiting 1 about it. +export function reqSiteIds(body: OpsBody): string[] { + const one = body.siteId; + const many = body.siteIds; + if (one !== undefined && many !== undefined) { + throw new OpsInputError('send either "siteId" or "siteIds", not both'); + } + let ids: string[]; + if (one !== undefined) ids = [reqString(body, "siteId")]; + else if (many !== undefined) ids = reqStringArray(body, "siteIds"); + else { + throw new OpsInputError( + '"siteId" (a string) or "siteIds" (a non-empty array of strings) is required', + ); + } + for (const id of ids) { + if (!isValidSiteId(id)) { + throw new OpsInputError( + `"${id}" is not a valid site id (lowercase letters, digits and "-"; must start with a letter or digit)`, + ); + } + } + return ids; +} + export function oneOf<T extends string>( body: OpsBody, key: string, diff --git a/editor/app/api/ops/build-deploy/route.ts b/editor/app/api/ops/build-deploy/route.ts @@ -3,29 +3,93 @@ import { buildAndDeployAction, buildAndDeployAllSitesAction, } from "../../../sites/lib/buildAction"; -import { jobResponse, OpsInputError, ops, optBool, optString } from "../_lib"; +import { + jobResponse, + OpsInputError, + ops, + opsFail, + optBool, + reqSiteIds, +} from "../_lib"; export const dynamic = "force-dynamic"; -// POST { siteId: string, skipArchives? } | { all: true, skipArchives? } -// -> { ok: true, jobId } +// POST { siteId: string | siteIds: string[], skipArchives? } +// | { all: true, skipArchives? } +// -> { ok: true, jobs: [{ siteId, jobId }], skipped: [{ siteId, reason }], +// jobId? } +// +// Build THEN deploy: one managed job per site (one log, one Cancel each), so +// the caller polls /api/jobs/<jobId>/log exactly as it does for build-site. +// +// `siteId` and `siteIds` are the same key (reqSiteIds), and the response is +// build-site's: this route took one id and that one a list, so the two halves +// of the same sentence in a runbook needed different JSON. `jobId` is still +// there when exactly one job started, so every existing single-site caller — +// and jobResponse's own shape — is unchanged; `jobs`/`skipped` are additive, +// and `pnpm ops --wait` already reads `jobs[]`. // -// One managed job either way (build then deploy, one log, one Cancel), so the -// caller polls /api/jobs/<jobId>/log exactly as for a single site. +// Asking for builds and getting NONE is still a 400 carrying the reason, not a +// cheerful `{ ok: true, jobs: [] }`: --wait would exit 0 on it and report +// success about a deploy that never started. export async function POST(request: Request) { - return ops(request, ["siteId", "all", "skipArchives"], async (body) => { - const skipArchives = optBool(body, "skipArchives"); - const all = optBool(body, "all"); - const siteId = optString(body, "siteId"); - if (all) { - if (siteId) throw new OpsInputError('send either "siteId" or "all", not both'); - return jobResponse(await buildAndDeployAllSitesAction(skipArchives)); - } - if (!siteId?.trim()) { - throw new OpsInputError('"siteId" is required (or send { "all": true })'); - } - return jobResponse(await buildAndDeployAction(siteId.trim(), skipArchives)); - }); + return ops( + request, + ["siteId", "siteIds", "all", "skipArchives"], + async (body) => { + const skipArchives = optBool(body, "skipArchives"); + if (optBool(body, "all")) { + if (body.siteId !== undefined || body.siteIds !== undefined) { + throw new OpsInputError( + 'send either "siteId"/"siteIds" or "all", not both', + ); + } + return jobResponse(await buildAndDeployAllSitesAction(skipArchives)); + } + const jobs: { siteId: string; jobId: string }[] = []; + const skipped: { siteId: string; reason: string }[] = []; + let info = false; + for (const siteId of reqSiteIds(body)) { + // ONE SITE'S THROW CANNOT COST THE OTHERS THEIR JOB IDS. An exception + // out of here becomes a 500 carrying no `jobs` at all, while the builds + // already queued run on with nobody holding their ids. reqSiteIds + // rejects the malformed-id case before any job starts; this catches + // whatever else the action can raise. + let result: Awaited<ReturnType<typeof buildAndDeployAction>>; + try { + result = await buildAndDeployAction(siteId, skipArchives); + } catch (e) { + skipped.push({ siteId, reason: (e as Error).message }); + continue; + } + if (!result.ok) { + skipped.push({ siteId, reason: result.error }); + info = info || result.info === true; + continue; + } + // The stream is cancelled, never returned — see _lib's header. + void result.stream.cancel(); + jobs.push({ siteId, jobId: result.jobId }); + } + if (jobs.length === 0) { + // One site asked for, one reason: the bare sentence the action gave, + // exactly as jobResponse has always returned it. + return opsFail( + skipped.length === 1 + ? skipped[0].reason + : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; "), + 400, + info ? { info: true } : undefined, + ); + } + return NextResponse.json({ + ok: true, + jobs, + skipped, + ...(jobs.length === 1 ? { jobId: jobs[0].jobId } : {}), + }); + }, + ); } export function GET() { diff --git a/editor/app/api/ops/build-site/route.ts b/editor/app/api/ops/build-site/route.ts @@ -3,49 +3,59 @@ import { buildAllSitesAction, buildExportAction, } from "../../../sites/lib/buildAction"; -import { jobResponse, OpsInputError, ops, optBool } from "../_lib"; +import { + jobResponse, + OpsInputError, + ops, + optBool, + reqSiteIds, +} from "../_lib"; export const dynamic = "force-dynamic"; -// POST { siteIds: string[], skipData?, skipArchives? } | { all: true, skipArchives? } +// POST { siteIds: string[] | siteId: string, skipData?, skipArchives? } +// | { all: true, skipArchives? } +// +// BUILD WITHOUT DEPLOYING. One build-export job per site on the shared build +// queue (they run one at a time, as they do from /sites), returning +// `{ jobs: [{ siteId, jobId }] }` — a list, because there is a job per site and +// a caller waiting on them needs all the ids. `{ all: true }` is the single +// build-all job instead, which is the docker fan-out. // -// BUILD WITHOUT DEPLOYING. `siteIds` queues one build-export job per site on the -// shared build queue (they run one at a time, as they do from /sites) and -// returns `{ jobs: [{ siteId, jobId }] }` — a list, because there is a job per -// site and a caller waiting on them needs all the ids. `{ all: true }` is the -// single build-all job instead, which is the docker fan-out. +// `siteId` and `siteIds` both work, via reqSiteIds: this route and build-deploy +// used to disagree about the spelling, which made the pair unguessable. export async function POST(request: Request) { return ops( request, - ["siteIds", "all", "skipData", "skipArchives"], + ["siteId", "siteIds", "all", "skipData", "skipArchives"], async (body) => { const skipArchives = optBool(body, "skipArchives"); if (optBool(body, "all")) { - if (body.siteIds !== undefined) { - throw new OpsInputError('send either "siteIds" or "all", not both'); + if (body.siteIds !== undefined || body.siteId !== undefined) { + throw new OpsInputError( + 'send either "siteId"/"siteIds" or "all", not both', + ); } return jobResponse(await buildAllSitesAction(skipArchives)); } - const raw = body.siteIds; - if ( - !Array.isArray(raw) || - raw.length === 0 || - raw.some((s) => typeof s !== "string" || !s.trim()) - ) { - throw new OpsInputError( - '"siteIds" is required and must be a non-empty array of strings (or send { "all": true })', - ); - } const skipData = optBool(body, "skipData"); const jobs: { siteId: string; jobId: string }[] = []; const skipped: { siteId: string; reason: string }[] = []; - for (const siteId of (raw as string[]).map((s) => s.trim())) { - const result = await buildExportAction( - siteId, - undefined, - skipData, - skipArchives, - ); + for (const siteId of reqSiteIds(body)) { + // One site's throw cannot cost the others their job ids — see the same + // guard in build-deploy. + let result: Awaited<ReturnType<typeof buildExportAction>>; + try { + result = await buildExportAction( + siteId, + undefined, + skipData, + skipArchives, + ); + } catch (e) { + skipped.push({ siteId, reason: (e as Error).message }); + continue; + } if (!result.ok) { skipped.push({ siteId, reason: result.error }); continue; diff --git a/editor/app/operations/status.ts b/editor/app/operations/status.ts @@ -27,7 +27,8 @@ import { readPriorityView } from "./channelPriorityView"; // set costs a channel listing and, for a site focus, a sites read; the // auto-queue state document is one JSON parse — it was read once PER LANE, four // times per poll, because `buildKind` did its own reading; and the channel -// briefs are shared with the lanes builder through the per-request cache. +// briefs are shared with the lanes builder through the per-request cache, and +// with the four computeLeafPending calls through their `shared` argument. export async function buildAutoQueueStatusPayload(): Promise<AutoQueueStatusPayload> { const paths = getPaths(); const settings = getSettings(); @@ -37,8 +38,14 @@ export async function buildAutoQueueStatusPayload(): Promise<AutoQueueStatusPayl readAutoQueueState(paths), getChannelBriefs(paths), ]); + // …and the four lanes' pending work re-reads NEITHER. `briefs` is the channel + // listing and `state` the auto-queue document, both already in hand one line + // up; without them each of the four calls listed every channel's config off + // disk again and re-parsed the state, on a ~3 s poll. const pendingByKind = await Promise.all( - LANES.map((lane) => computeLeafPending(lane, paths)), + LANES.map((lane) => + computeLeafPending(lane, paths, { configs: briefs, state }), + ), ); const byLane = <T>(values: readonly T[]): Record<AutoQueueKind, T> => Object.fromEntries(LANES.map((lane, i) => [lane, values[i]])) as Record< diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -25,6 +25,7 @@ import { resolvePath, writeChannelConfig, writeSettings, + writeSite, } from "./helpers"; const TOKEN = "test-worker-token"; @@ -36,7 +37,8 @@ type OpsResponse = { jobId?: string; queued?: string[]; jobIds?: string[]; - skipped?: { slug: string; reason: string }[]; + skipped?: { slug?: string; siteId?: string; reason: string }[]; + jobs?: { siteId: string; jobId: string }[]; }; async function ops( @@ -651,3 +653,83 @@ test("tag-videos refuses a traversing slug, a bad op and an unknown key", async // Nothing was written by any of the three. expect(await pathExists("test-transcripts/tags.json")).toBe(false); }); + +// --- the two build routes speak the same body --------------------------------- +// +// They did not. build-site took `siteIds` (a list) and build-deploy took +// `siteId` (one), so the two halves of the same runbook sentence needed +// different JSON and the only way to learn which was to get a 400. Both now +// accept both keys; sending BOTH is still refused, because a caller with two +// ideas about what to build should not have one picked for it. +// Job sidecars on disk — the only way to say "and nothing was queued". +async function listJobIds(): Promise<string[]> { + return (await readdir(resolvePath("test-transcripts/.jobs")).catch(() => [])) + .filter((f) => f.endsWith(".meta.json")) + .sort(); +} + +test("build-site and build-deploy each take siteId or siteIds, and refuse both or neither", async ({ + request, +}) => { + await resetData("title-filter-channel"); + await settings(); + + for (const action of ["build-site", "build-deploy"]) { + const both = await ops(request, action, { + siteId: "a", + siteIds: ["a"], + }); + expect(both.status, action).toBe(400); + expect(both.body.error, action).toContain("not both"); + + const neither = await ops(request, action, {}); + expect(neither.status, action).toBe(400); + expect(neither.body.error, action).toContain("siteId"); + + // `all` is still exclusive of either spelling. + const withAll = await ops(request, action, { all: true, siteId: "a" }); + expect(withAll.status, action).toBe(400); + expect(withAll.body.error, action).toContain("not both"); + + // A MALFORMED ID IS A 400 BEFORE ANY JOB STARTS, and that is the point of + // validating in reqSiteIds rather than letting getSite throw: an id list of + // ["good", "BAD"] used to queue the first build, throw on the second and + // answer 500 with no ids at all — a real build running that nothing was + // watching, and a --wait exiting 1 about it. + const before = await listJobIds(); + const bad = await ops(request, action, { siteIds: ["buildsite", "BAD ID"] }); + expect(bad.status, action).toBe(400); + expect(bad.body.error, action).toContain("not a valid site id"); + expect(bad.body.jobs, action).toBeUndefined(); + expect(await listJobIds(), action).toEqual(before); + } +}); + +test("build-site with a bare siteId starts one build-export job", async ({ + request, +}) => { + test.setTimeout(120_000); + await resetData("title-filter-channel"); + await settings(); + await writeSite("buildsite", {}); + + const { status, body } = await ops(request, "build-site", { + siteId: "buildsite", + }); + expect(status).toBe(200); + expect(body.ok).toBe(true); + // The response is the LIST shape whichever key was used — one job, named. + expect(body.jobs?.length).toBe(1); + expect(body.jobs?.[0].siteId).toBe("buildsite"); + expect(body.skipped).toEqual([]); + + const jobId = body.jobs![0].jobId; + await expect + .poll(async () => { + const meta = await readJson<{ kind: string }>( + `test-transcripts/.jobs/${jobId}.meta.json`, + ).catch(() => null); + return meta?.kind ?? null; + }) + .toBe("build-export"); +}); diff --git a/editor/scripts/measure-nav.mjs b/editor/scripts/measure-nav.mjs @@ -36,7 +36,7 @@ const BASE = argOf("base", "http://localhost:3001").replace(/\/$/, ""); const RUNS = Number(argOf("runs", "3")); // Routes worth timing, with the segment name used to build a state tree. THE -// TWELVE SINGLE-SEGMENT PAGES IN THE SIDEBAR (editor/app/lib/nav.ts), and +// THIRTEEN SINGLE-SEGMENT PAGES IN THE SIDEBAR (editor/app/lib/nav.ts), and // only those: stateTree() below encodes ONE segment, so /sites/<id>/charts // and /operations/<id> cannot be listed — each would need the nested // [segment, {children: [param, …]}] form, a different tree per route. A diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -8,7 +8,8 @@ // // USAGE // -// pnpm ops <action> [--json '<body>' | --file <path>] [--wait] [--quiet] +// pnpm ops <action> [--json '<body>' | --file <path>] [--wait] +// [--wait-timeout <seconds>] [--quiet] // pnpm ops get channel <slug> [--counts] // pnpm ops get tags [<tagId>] // pnpm ops list @@ -30,6 +31,8 @@ // pnpm ops lane --json '{"lane":"download","held":true}' // pnpm ops refresh-report --json '{"all":true}' // pnpm ops relocate --json '{"slugs":["x"],"locationId":"platter"}' +// pnpm ops build-site --json '{"siteId":"anilyzer"}' --wait +// pnpm ops build-deploy --json '{"siteIds":["anilyzer","jeralyzer"]}' --wait // pnpm ops get channel the-quartering // pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}' // pnpm ops tag-videos --file ids.json @@ -42,15 +45,24 @@ // --wait follows /api/jobs/<jobId>/log to the end for a job-starting action and // exits 0 only if the job finished `done`. Without it the command returns as // soon as the job is QUEUED, which is the honest answer: the queue may hold it -// behind other work for hours. +// behind other work for hours. A poll that fails does NOT end the follow — see +// followJob — and --wait-timeout <seconds> is there for a caller that cannot +// wait indefinitely. // // The response JSON is printed verbatim on stdout (log lines from --wait go to // stderr), so `pnpm ops … | jq` works. import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; const DEFAULT_URL = "http://localhost:3001"; +// Consecutive polls where NEITHER the job's log NOR the active list answered, +// after which --wait gives up. At the 30 s backoff ceiling that is ~5 minutes +// of an editor saying nothing at all, which is not a busy server — it is a +// server that is gone. +const MAX_PROBE_FAILURES = 10; + // The read-side routes, reachable as `get <noun> <arg>`. Kept tiny and explicit: // an ops API that let a caller assemble arbitrary GET paths would be a proxy, // not an adapter. @@ -104,10 +116,32 @@ export function parseArgs(argv) { let wait = false; let quiet = false; let counts = false; + let waitTimeout = null; + const readTimeout = (raw) => { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + return { error: "--wait-timeout needs a positive number of seconds" }; + } + waitTimeout = n; + // A timeout on a wait nobody asked for is not a preference, it is a typo + // with no effect — so it IMPLIES --wait rather than being ignored. + wait = true; + return null; + }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--wait") { wait = true; + } else if (arg === "--wait-timeout") { + const raw = argv[++i]; + if (raw === undefined) { + return { error: "--wait-timeout needs a number of seconds" }; + } + const err = readTimeout(raw); + if (err) return err; + } else if (arg.startsWith("--wait-timeout=")) { + const err = readTimeout(arg.slice("--wait-timeout=".length)); + if (err) return err; } else if (arg === "--quiet") { quiet = true; } else if (arg === "--counts") { @@ -167,6 +201,7 @@ export function parseArgs(argv) { path: GETTERS[noun](positional[2], counts), wait: false, quiet, + waitTimeout, }; } const action = positional[0]; @@ -194,18 +229,28 @@ export function parseArgs(argv) { ...(action === "tag-videos" ? { defaultSource: agentSource() } : {}), wait, quiet, + waitTimeout, }; } export function usage() { return [ "Usage: pnpm ops <action> [--json '<body>' | --file <path>] [--wait]", + " [--wait-timeout <seconds>] [--quiet]", " pnpm ops get channel <slug> [--counts]", " pnpm ops get tags [<tagId>]", " pnpm ops list", "", `Actions: ${ACTIONS.join(", ")}`, "", + "--wait follows the job's log and survives a poll that fails (a busy", + " in-process build starves the server): it backs off and, after three", + " failures, asks /api/jobs/active whether the job is still there.", + "--wait-timeout <seconds> gives up and exits 1 instead of waiting forever.", + " Default: no timeout — the queue may legitimately hold a job for hours.", + "", + 'build-site and build-deploy both take "siteId" (one) or "siteIds" (a list).', + "", "Env: ARCHILYZER_EDITOR_URL (default http://localhost:3001), WORKER_TOKEN,", " ARCHILYZER_AGENT (provenance of a tag write; default \"cli\")", ].join("\n"); @@ -229,19 +274,127 @@ function authHeaders() { // the browser polls it same-origin with no token. Sending one here implied a // gate that does not exist, which is worse than sending nothing: the next // person to read this would conclude the endpoint was protected. -async function followJob(jobId, quiet) { +// +// A POLL FAILURE IS NOT A JOB FAILURE, and this used to treat them as the same +// thing. The editor is single-process: a busy in-process build-index starves +// the event loop for long enough that `fetch` rejects outright, and one +// rejection ended the follow with a stack trace about a job that was running +// fine and went on to finish. So every poll is caught, the wait backs off +// 1→2→4…→30 s instead of hammering a server that is already struggling, and +// `from` is kept across the failure so not one line of log text is lost. +// +// After three consecutive failures the endpoint is no longer trusted to answer +// at all, and the question becomes a different one — IS THE JOB STILL THERE? +// `/api/jobs/active` is cheap and is what the editor's own head polls. Listed +// ⇒ keep waiting, however long that takes. Absent ⇒ it ended while we could not +// see it, so one last log poll reads the terminal status; if even that fails, +// the follow gives up rather than claiming an outcome it never read. +// +// `--wait-timeout` bounds the whole thing for a caller that cannot hang (CI, +// an agent). Default none, because the honest default for a queue that may hold +// a job behind hours of other work is to wait. +export async function followJob(jobId, quiet, opts = {}) { + const doFetch = opts.fetch ?? fetch; + const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + const now = opts.now ?? (() => Date.now()); + const deadline = + opts.timeoutSeconds > 0 ? now() + opts.timeoutSeconds * 1000 : null; + const base = opts.baseUrl ?? baseUrl(); + const id = encodeURIComponent(jobId); + let from = 0; + let failures = 0; + let probeFailures = 0; + let backoff = 1000; + + // One log poll. Returns the status, or null when the poll itself failed — + // never throws, so a transient fetch rejection cannot end the follow. + const pollLog = async () => { + try { + const res = await doFetch(`${base}/api/jobs/${id}/log?from=${from}`); + if (!res.ok) return null; + const payload = await res.json(); + if (payload.content && !quiet) process.stderr.write(payload.content); + // Only advance once the chunk is in hand: a poll that failed halfway + // must re-ask for the same offset. + from = payload.nextOffset ?? from; + return payload.status ?? null; + } catch { + return null; + } + }; + + const stillListed = async () => { + try { + const res = await doFetch(`${base}/api/jobs/active`); + if (!res.ok) return null; + const payload = await res.json(); + const jobs = Array.isArray(payload.jobs) ? payload.jobs : []; + return jobs.some((j) => j && j.id === jobId); + } catch { + return null; + } + }; + + // "queued" and "running" are the two NON-answers. Everything else is the job + // having ended, which is the only thing worth returning. + const terminal = (s) => s !== null && s !== "queued" && s !== "running"; + for (;;) { - const res = await fetch( - `${baseUrl()}/api/jobs/${encodeURIComponent(jobId)}/log?from=${from}`, - ); - if (!res.ok) throw new Error(`log poll failed: HTTP ${res.status}`); - const payload = await res.json(); - if (payload.content && !quiet) process.stderr.write(payload.content); - from = payload.nextOffset ?? from; - const status = payload.status; - if (status !== "queued" && status !== "running") return status; - await new Promise((r) => setTimeout(r, 1000)); + const status = await pollLog(); + if (status !== null) { + failures = 0; + probeFailures = 0; + backoff = 1000; + if (terminal(status)) return status; + } else { + failures++; + if (failures >= 3) { + const listed = await stillListed(); + if (listed === false) { + // Gone from the active list: it went terminal while the log endpoint + // was unreachable. One more try at the status it ended with — and + // ONLY a terminal one is an outcome. A log endpoint that came back + // answering "running" means the active list was stale, not that the + // job finished; returning that printed "running" as the result and + // exited 1 for a job that was fine. + const final = await pollLog(); + if (terminal(final)) return final; + if (final === null) { + throw new Error( + `lost contact with job ${jobId}: it is no longer active and its log could not be read`, + ); + } + // Readable again and still going: back to waiting, from scratch. + failures = 0; + probeFailures = 0; + backoff = 1000; + } else if (listed === true) { + // A job we can still see is a job to wait for. + failures = 0; + probeFailures = 0; + backoff = 1000; + } else { + // The probe failed too, so we now know nothing at all. Without a + // bound this waits forever on an editor that has gone away; with one + // it says so. Only CONSECUTIVE failures count — a single answer of + // either kind resets it. + probeFailures++; + if (probeFailures >= MAX_PROBE_FAILURES) { + throw new Error( + `lost contact with the editor at ${base}: ${MAX_PROBE_FAILURES} consecutive failed polls of job ${jobId} and of /api/jobs/active`, + ); + } + } + } + backoff = Math.min(backoff * 2, 30_000); + } + if (deadline !== null && now() >= deadline) { + throw new Error( + `--wait-timeout: gave up after ${opts.timeoutSeconds}s waiting for job ${jobId}; it is still running and can be followed on /jobs`, + ); + } + await sleep(status !== null ? 1000 : backoff); } } @@ -325,7 +478,9 @@ async function main() { } let worst = 0; for (const jobId of jobIds) { - const status = await followJob(jobId, parsed.quiet); + const status = await followJob(jobId, parsed.quiet, { + timeoutSeconds: parsed.waitTimeout ?? 0, + }); console.error(`[${jobId}] ${status}`); if (status !== "done") worst = 1; } @@ -333,7 +488,7 @@ async function main() { } // Importable for the arg-parsing tests; only the CLI entry point runs main(). -if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().then( (code) => process.exit(code), (e) => { diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -5,7 +5,7 @@ // Run with: pnpm test:scripts import assert from "node:assert/strict"; import test from "node:test"; -import { parseArgs, usage } from "./archilyzer-ops.mjs"; +import { followJob, parseArgs, usage } from "./archilyzer-ops.mjs"; test("no arguments prints usage", () => { assert.equal(parseArgs([]).help, true); @@ -118,3 +118,138 @@ test("get tags reads the whole vocabulary, or one tag's assignments", () => { // The optional argument is per-noun: `get channel` still demands a slug. assert.match(parseArgs(["get", "channel"]).error, /needs an argument/); }); + +// ─── --wait, and the poll that fails ─── + +test("--wait-timeout is parsed, and refuses a non-number", () => { + assert.equal(parseArgs(["build-index", "--wait", "--wait-timeout", "90"]).waitTimeout, 90); + assert.equal(parseArgs(["build-index", "--wait-timeout=90"]).waitTimeout, 90); + // Default is NONE: the queue may legitimately hold a job for hours. + assert.equal(parseArgs(["build-index", "--wait"]).waitTimeout, null); + assert.match(parseArgs(["build-index", "--wait-timeout"]).error, /needs a number/); + assert.match(parseArgs(["build-index", "--wait-timeout", "soon"]).error, /positive number/); + assert.match(parseArgs(["build-index", "--wait-timeout", "0"]).error, /positive number/); + assert.match(usage(), /--wait-timeout/); +}); + +// A fake editor. `log` is the sequence of log-poll outcomes — an Error is +// thrown at the caller the way a starved server makes `fetch` reject. +function fakeEditor({ log = [], active = [] } = {}) { + const calls = { log: 0, active: 0 }; + const reply = (body) => ({ ok: true, json: async () => body }); + const doFetch = async (url) => { + if (url.includes("/api/jobs/active")) { + const next = active[Math.min(calls.active, active.length - 1)]; + calls.active++; + if (next instanceof Error) throw next; + return reply({ jobs: next }); + } + const next = log[Math.min(calls.log, log.length - 1)]; + calls.log++; + if (next instanceof Error) throw next; + return reply(next); + }; + return { doFetch, calls }; +} + +const follow = (jobId, editor, opts = {}) => + followJob(jobId, true, { + fetch: editor.doFetch, + sleep: async () => {}, + baseUrl: "http://editor", + ...opts, + }); + +test("a poll that rejects does not end the follow", async () => { + // THE BUG THIS FIXES: one `fetch failed` while an in-process build-index + // starved the server used to abort --wait and report a job that was fine. + const editor = fakeEditor({ + log: [ + { content: "a", nextOffset: 1, status: "running" }, + new Error("fetch failed"), + new Error("fetch failed"), + { content: "b", nextOffset: 2, status: "done" }, + ], + }); + assert.equal(await follow("j1", editor), "done"); + // Two failures is below the probe threshold, so it never asked. + assert.equal(editor.calls.active, 0); +}); + +test("after three failures it asks whether the job is still there", async () => { + const boom = new Error("fetch failed"); + // Still listed ⇒ keep waiting, and the follow ends on the status it finally + // reads rather than on a guess. + const waiting = fakeEditor({ + log: [boom, boom, boom, { content: "", nextOffset: 0, status: "done" }], + active: [[{ id: "j1" }]], + }); + assert.equal(await follow("j1", waiting), "done"); + assert.equal(waiting.calls.active, 1); + + // Absent from the active list ⇒ it ended while the log was unreachable, so + // one final poll reads the terminal status. `failed` is reported, not hidden. + const gone = fakeEditor({ + log: [boom, boom, boom, { content: "", nextOffset: 0, status: "failed" }], + active: [[]], + }); + assert.equal(await follow("j1", gone), "failed"); +}); + +test("a job that is gone AND unreadable refuses to claim an outcome", async () => { + const boom = new Error("fetch failed"); + const editor = fakeEditor({ log: [boom], active: [[]] }); + await assert.rejects(follow("j1", editor), /lost contact with job j1/); +}); + +test("--wait-timeout gives up on a job that never ends", async () => { + const editor = fakeEditor({ + log: [{ content: "", nextOffset: 0, status: "running" }], + }); + let clock = 0; + await assert.rejects( + follow("j1", editor, { timeoutSeconds: 5, now: () => (clock += 3000) }), + /--wait-timeout: gave up after 5s/, + ); +}); + +// The two build routes used to disagree about the spelling of their one +// argument, so the usage text is where a reader finds out they no longer do. +test("usage says both build routes take siteId or siteIds", () => { + assert.match(usage(), /build-site and build-deploy both take "siteId".*"siteIds"/); +}); + +test("a recovered log endpoint answering 'running' is not an outcome", async () => { + // THE BUG: the absent-from-active branch returned whatever the final poll + // said. A stale active list plus a recovered log endpoint therefore printed + // "running" as the job's result and exited 1 for a job that was fine. + const boom = new Error("fetch failed"); + const editor = fakeEditor({ + log: [ + boom, + boom, + boom, + { content: "", nextOffset: 0, status: "running" }, + { content: "", nextOffset: 0, status: "done" }, + ], + active: [[]], + }); + assert.equal(await follow("j1", editor), "done"); +}); + +test("an editor that answers nothing at all is given up on, not waited on", async () => { + // Without --wait-timeout the null-probe path used to wait forever: the log + // never answers AND the active list never answers, so nothing ever resets + // the failure count and nothing ever concludes. + const boom = new Error("fetch failed"); + const editor = fakeEditor({ log: [boom], active: [boom] }); + await assert.rejects(follow("j1", editor), /lost contact with the editor/); +}); + +test("--wait-timeout implies --wait", () => { + // A timeout on a wait nobody asked for is a typo with no effect, not a + // preference to honour silently. + const p = parseArgs(["build-index", "--wait-timeout", "30"]); + assert.equal(p.wait, true); + assert.equal(p.waitTimeout, 30); +}); diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs @@ -9,6 +9,7 @@ import { execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { pathToFileURL } from "node:url"; // Base ports (offset 0 == main worktree). Mirrors the hardcoded defaults in // editor/export package.json scripts and the Playwright configs. @@ -67,6 +68,21 @@ function mainRoot(trees = listWorktrees()) { return trees.length ? trees[0].path : git(["rev-parse", "--show-toplevel"]); } +// WHERE A WORKTREE LIVES, asked the same way by `add` and by `rm`. +// +// A branch name may contain "/" and a sibling directory name may not, so `add` +// has always flattened `tags/site` to `tags-site`. `rm` did not, so +// `pnpm wt rm tags/site` pointed git at a path nothing had ever created — and +// that string is exactly what somebody who just ran `add` will type. One +// function, both callers, so the two spellings cannot drift apart again. +// +// An absolute path passes through untouched: `rm` accepts one (it is what +// `list` prints), and sanitizing it would eat its separators. +export function worktreeDirFor(main, name) { + if (path.isAbsolute(name)) return name; + return path.join(path.dirname(main), name.replace(/[^A-Za-z0-9._-]/g, "-")); +} + function offsetForIndex(index) { return index * OFFSET_STEP; } @@ -211,8 +227,7 @@ function cmdAdd(args) { } const main = mainRoot(); - const name = branch.replace(/[^A-Za-z0-9._-]/g, "-"); - const dir = path.join(path.dirname(main), name); + const dir = worktreeDirFor(main, branch); if (branchExists(branch)) { git(["worktree", "add", dir, branch], { stdio: "inherit" }); @@ -280,7 +295,9 @@ function cmdRm(args) { return 1; } const main = mainRoot(); - const dir = path.isAbsolute(name) ? name : path.join(path.dirname(main), name); + // Sanitized exactly as `add` sanitized it, so `rm <the branch you added>` + // finds the directory `add` actually made. + const dir = worktreeDirFor(main, name); // --share-data worktrees always carry an untracked .worktree-env, so git // refuses a plain remove; --force handles that (and any other local files). const rmArgs = ["worktree", "remove", ...(force ? ["--force"] : []), dir]; @@ -328,9 +345,13 @@ async function main() { } } -main() - .then((code) => process.exit(code ?? 0)) - .catch((err) => { - process.stderr.write(`${err?.stack ?? err}\n`); - process.exit(1); - }); +// Importable for the unit tests (worktreeDirFor is pure and needs no repo); +// only the CLI entry point runs main(), which shells out to git. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() + .then((code) => process.exit(code ?? 0)) + .catch((err) => { + process.stderr.write(`${err?.stack ?? err}\n`); + process.exit(1); + }); +} diff --git a/scripts/worktree.test.mjs b/scripts/worktree.test.mjs @@ -0,0 +1,46 @@ +// Where `pnpm wt` puts a worktree — the one thing `add` and `rm` have to agree +// about. Pure: no repo, no git, no filesystem. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; +import path from "node:path"; +import { worktreeDirFor } from "./worktree.mjs"; + +const MAIN = "/home/u/Projects/yt-dlp-transcript-browser"; +const SIBLING = path.dirname(MAIN); + +test("a branch with a slash becomes one sibling directory", () => { + // THE BUG: `add` flattened and `rm` did not, so `pnpm wt rm tags/site` — + // the exact string the person had just passed to `add` — pointed git at a + // path that had never existed. + assert.equal(worktreeDirFor(MAIN, "tags/site"), path.join(SIBLING, "tags-site")); + assert.equal( + worktreeDirFor(MAIN, "one-core/phase-2/gate-a"), + path.join(SIBLING, "one-core-phase-2-gate-a"), + ); +}); + +test("a plain name is unchanged, dots and dashes survive", () => { + assert.equal(worktreeDirFor(MAIN, "hotfix"), path.join(SIBLING, "hotfix")); + assert.equal( + worktreeDirFor(MAIN, "release-1.2_rc"), + path.join(SIBLING, "release-1.2_rc"), + ); +}); + +test("an absolute path passes through untouched", () => { + // `rm` accepts one because it is what `list` prints; sanitizing it would eat + // its separators and aim git at a directory that does not exist. + const abs = path.join(SIBLING, "tags-site"); + assert.equal(worktreeDirFor(MAIN, abs), abs); + assert.equal(worktreeDirFor(MAIN, "/tmp/wt/some thing"), "/tmp/wt/some thing"); +}); + +test("nothing escapes the sibling directory", () => { + // A relative name cannot climb out: every "/" and "." run that could form a + // traversal is flattened to dashes first. + const dir = worktreeDirFor(MAIN, "../../etc/passwd"); + assert.equal(dir, path.join(SIBLING, "..-..-etc-passwd")); + assert.equal(path.dirname(dir), SIBLING); +});