Archilyzer · Source

archilyzer

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

commit 78a25c89cfbe0ee48dd462bace8d87d0c74aac72
parent 838cff4a97f9c3a279827b20004344f2f6cefc5f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:11:39 -0400

ops: both build routes take siteId or siteIds

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 find out which was to get a 400 — a note in plans/curated-tags.md exists to
warn the next reader, which is the tell. `reqSiteIds` in _lib.ts is now the one
reader for both: either key works, both at once is still a 400 (a caller with
two ideas about what to build should not have one picked for it), neither is a
400 naming both spellings.

build-deploy loops the way build-site does and returns the same
{ok, jobs, skipped} plus `jobId` when exactly one job started — additive, so
every single-site caller and `pnpm ops --wait` (which already reads jobs[]) is
unchanged. Queueing NOTHING stays a 400 carrying the reason rather than a
cheerful {ok:true, jobs:[]}: --wait would exit 0 on that and report success
about a deploy that never began, which is the bug queueResponse's jobIds were
added to kill.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/app/api/ops/_lib.ts | 23+++++++++++++++++++++++
Meditor/app/api/ops/build-deploy/route.ts | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Meditor/app/api/ops/build-site/route.ts | 44+++++++++++++++++++++++---------------------
Meditor/e2e/ops-api.spec.ts | 65++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mscripts/archilyzer-ops.mjs | 4++++
Mscripts/archilyzer-ops.test.mjs | 6++++++
6 files changed, 191 insertions(+), 40 deletions(-)

diff --git a/editor/app/api/ops/_lib.ts b/editor/app/api/ops/_lib.ts @@ -158,6 +158,29 @@ 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. +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'); + } + if (one !== undefined) return [reqString(body, "siteId")]; + if (many !== undefined) return reqStringArray(body, "siteIds"); + throw new OpsInputError( + '"siteId" (a string) or "siteIds" (a non-empty array of strings) is required', + ); +} + 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,82 @@ 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)) { + const result = await buildAndDeployAction(siteId, skipArchives); + 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,43 +3,45 @@ 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())) { + for (const siteId of reqSiteIds(body)) { const result = await buildExportAction( siteId, undefined, 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,64 @@ 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. +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"); + } +}); + +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/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -31,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 @@ -237,6 +239,8 @@ export function usage() { "--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"); diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -212,3 +212,9 @@ test("--wait-timeout gives up on a job that never ends", async () => { /--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"/); +});