Archilyzer · Source

archilyzer

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

commit 5106875a9a200591c9e636470c25407aabfb716e
parent 0d043404065374f061b33c6bd91d5a91c81474ea
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 17:45:06 -0400

Add the ops CLI, and say where the HTTP door is

`pnpm ops <action> --json '{…}' [--wait]` against ARCHILYZER_EDITOR_URL with
WORKER_TOKEN. --wait follows /api/jobs/<id>/log to the end and exits with the
job's status; 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.

parseArgs is exported and pure so the arg surface is pinned by node:test rather
than by a live server: an unknown action is refused BY NAME with the list, and
`pnpm ops sync the-quartering` (which reads naturally and would otherwise post an
empty body) is an error that names --json.

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

Diffstat:
MRUNNING_IN_DOCKER.md | 47+++++++++++++++++++++++++++++++++++++++++++++++
MSETUP.md | 2+-
Mpackage.json | 3++-
Mplans/FACTS.md | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/archilyzer-ops.mjs | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ascripts/archilyzer-ops.test.mjs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 405 insertions(+), 2 deletions(-)

diff --git a/RUNNING_IN_DOCKER.md b/RUNNING_IN_DOCKER.md @@ -224,6 +224,53 @@ boot self-updates it. Or, once: docker compose exec editor yt-dlp -U ``` +### Driving the editor without a browser + +Every editor gesture is a server action, which is fine for a person and hostile +to a script: there is no URL to POST to. `/api/ops/*` is a thin layer over the +**same actions** — one route per gesture, no rule of its own — so a shell, a cron +job or an agent can run the archive without Playwright. + +It is gated by the **same `WORKER_TOKEN`** as `/api/worker/*`, deliberately: that +variable already means "this instance takes instructions from something that is +not the browser in front of it". Unset on the server and every route answers +**503** (the surface is off until you opt in); wrong or missing on the caller and +it answers **401**. + +```sh +export ARCHILYZER_EDITOR_URL=http://localhost:3001 +export WORKER_TOKEN=<the same secret the editor is running with> + +pnpm ops sync --json '{"slug":"the-quartering"}' --wait +pnpm ops metadata-scan --json '{"slug":"the-quartering"}' +pnpm ops channel-config --json '{"slug":"the-quartering","patch":{"downloadFilterExclude":"rerun"}}' +pnpm ops channel-priority --json '{"slugs":["the-quartering"],"operation":"download","tier":"paused"}' +pnpm ops lane --json '{"lane":"download","held":true}' +pnpm ops refresh-report --json '{"all":true}' +pnpm ops get channel the-quartering +pnpm ops list # every action name +``` + +Three things to know before you script against it: + +- **A job-starting action returns a `jobId` and does not stream.** The job may + sit in a platform queue behind other work for hours, so "started" is the + honest answer; `--wait` follows `/api/jobs/<id>/log` to the end and exits with + the job's status. +- **Unknown body keys are a 400.** A misspelled `downloadFilterExclude` would + otherwise save cleanly and leave a channel downloading everything. +- **`channel-config` patch keys are the CONFIGURE FORM's field names**, not + `config.json`'s — `downloadFilterInclude` / `downloadFilterExclude` rather than + a `downloadFilter` object. That is what routes them through the form's own + validators, so a bad regex is refused here with the sentence the form shows. + `""` clears a field, exactly as clearing the input does. + +The read side needs no new routes for jobs: `/api/jobs/active`, +`/api/jobs/<id>/log`, `/api/scheduler/status` and `/api/auto-queue/status` +already exist. `GET /api/ops/channel/<slug>` is the one addition — config, +report totals, bucket sizes, priority and, the part no directory listing can +tell you, whether the channel's media is actually **reachable**. + ### Booting without resuming work `editor/instrumentation.ts` arms the sync heartbeat and every enabled auto-queue diff --git a/SETUP.md b/SETUP.md @@ -273,7 +273,7 @@ any of them via environment variables before launching: | `PARAKEET_CLI` / `PARAKEET_MODEL` / `PARAKEET_STITCH_BIN` | `parakeet-cli` / — / `scripts/parakeet-stitch.mjs` | parakeet.cpp CLI, model, and wrapper. | | `FFMPEG_BIN` / `FFPROBE_BIN` | `ffmpeg` / `ffprobe` (PATH) | Audio transcode + duration checks. | | `RSYNC_BIN` | `rsync` (PATH) | Saved-video backup. | -| `WORKER_TOKEN` | — | Bearer token for the remote-worker transcription API (set on both ends when used). | +| `WORKER_TOKEN` | — | Bearer token for the remote-worker transcription API (set on both ends when used), and for the `/api/ops/*` HTTP layer over the editor's actions — see [RUNNING_IN_DOCKER.md](RUNNING_IN_DOCKER.md#driving-the-editor-without-a-browser) and `pnpm ops`. Unset means both surfaces are off. | Feature-area docs cover their own env vars: [SCHEDULED_SYNC.md](SCHEDULED_SYNC.md) (`SYNC_HEARTBEAT_SECONDS`, `SYNC_TICK_URL`, `SYNC_TICK_TOKEN`) and diff --git a/package.json b/package.json @@ -22,7 +22,8 @@ "wt": "node scripts/worktree.mjs", "e2e:sharded": "node scripts/run-sharded-e2e.mjs", "test:scripts": "node --test scripts/*.test.mjs umtool/report-to-video/*.test.mjs", - "lint": "pnpm --filter export run lint" + "lint": "pnpm --filter export run lint", + "ops": "node scripts/archilyzer-ops.mjs" }, "devDependencies": { "tsx": "^4.21.0" diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -4148,3 +4148,59 @@ never pulls `reader-fs.ts` into a client chunk. has no `runner`, so the operator presses Run. The obvious next step is for the download lane to run it for a channel whose filter has unscanned listed videos — which is exactly the backlog the snapshot already carries. + +--- + +## The ops API (`/api/ops/*`) — added 2026-09-20 + +**It is adapters, and nothing else.** Each route under +`editor/app/api/ops/<action>/route.ts` is ~5 lines: validate a JSON body, call +ONE existing server action, map its result. No route contains a rule the UI does +not already enforce — the point of the layer is that an agent over HTTP and an +operator clicking the same button get the same refusal, with the same sentence, +from the same code. A check written in a route would be a second opinion nobody +maintains. `editor/app/api/ops/_lib.ts` holds auth, body parsing and the three +result mappers (`jobResponse` / `actionResponse` / `queueResponse`). + +**`WORKER_TOKEN` is shared with `/api/worker/*` by design.** It already means +"this instance accepts instructions from something that is not the browser in +front of it", and the failure modes are identical, so the gate is: unset → 503 +(the surface is off until you opt in), wrong → 401. `authorizeWorkerRequest` +(`common/lib/workerToken.ts`) is the single implementation; nothing new was +written. A second secret would be a second thing to distribute, rotate and leave +unset. + +**A job-starting route returns `{ ok: true, jobId }` and NEVER streams.** +`runManagedFunction` hands back a `ReadableStream` the browser consumes; an HTTP +caller wants to hang up and poll. Every job adapter calls `result.stream.cancel()` +— which stops pushing into the controller and leaves the on-disk log running +(`streamCommand.ts`'s `cancel()` note) — and the caller follows +`/api/jobs/<id>/log`. Returning the id is also the honest answer: the queue may +hold the job behind other work for hours, so "started" is not "running". + +**Unknown body keys are a 400, never a silent ignore.** The allow-list passed to +`ops()` IS the route's documented body shape. A caller that misspells +`downloadFilterExclude` would otherwise get `{ ok: true }` and a channel that +still downloads everything. + +**`ops/channel-config`'s patch keys are the FORM's field names, not +`ChannelConfig`'s** — `downloadFilterInclude` / `downloadFilterExclude` rather +than a `downloadFilter` object, `ytdlpExtraArgs` as a string or an array of +lines. That is what routes them through `parseChannelForm`'s validators. The +patch is laid over the channel's CURRENT form representation +(`editor/app/channels/components/channelConfigToForm.ts`) rather than posted +alone, because `updateChannelAction` deletes every `CHANNEL_FORM_FIELDS` key from +the stored config before layering the parse result on — a FormData carrying only +a patch would clear everything the patch did not name. + +**No action needed a refactor to be callable from a route.** `revalidatePath` is +supported in Route Handlers (Next 16 — +`docs/01-app/03-api-reference/04-functions/revalidatePath.md:10`), no adapted +action calls `cookies()`, and the only actions that `redirect()` +(`createChannelAction`, `gotoVideoAction`) are deliberately not exposed. + +**The 503-when-unset branch is not covered by e2e.** The editor test server runs +with `WORKER_TOKEN=test-worker-token` in `editor/package.json`'s `dev:test`, one +server for the whole suite, so no spec can observe the disabled state. +`editor/e2e/ops-api.spec.ts` covers 401 (missing and wrong); the 503 is +`authorizeWorkerRequest`'s own first branch, shared with `/api/worker/*`. diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -0,0 +1,234 @@ +#!/usr/bin/env node +// archilyzer-ops — drive a running editor over HTTP, without a browser. +// +// Every editor gesture used to be reachable only as a server action, which meant +// an agent that wanted to sync a channel or fix a download filter had to drive +// Playwright. /api/ops is a thin adapter layer over those same actions, and this +// is its client. +// +// USAGE +// +// pnpm ops <action> [--json '<body>'] [--wait] [--quiet] +// pnpm ops get channel <slug> +// pnpm ops list +// +// ARCHILYZER_EDITOR_URL editor base URL (default http://localhost:3001) +// WORKER_TOKEN the shared secret the editor is running with. +// Unset on the SERVER => every route 503s; unset here +// => every route 401s. +// +// EXAMPLES +// +// pnpm ops sync --json '{"slug":"the-quartering"}' --wait +// pnpm ops metadata-scan --json '{"slug":"the-quartering"}' +// pnpm ops channel-config --json '{"slug":"x","patch":{"downloadFilterExclude":"rerun"}}' +// pnpm ops channel-priority --json '{"slugs":["x"],"operation":"download","tier":"paused"}' +// 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 get channel the-quartering +// +// --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. +// +// The response JSON is printed verbatim on stdout (log lines from --wait go to +// stderr), so `pnpm ops … | jq` works. + +const DEFAULT_URL = "http://localhost:3001"; + +// 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. +const GETTERS = { + channel: (slug) => `/api/ops/channel/${encodeURIComponent(slug)}`, +}; + +const ACTIONS = [ + "channel-priority", + "channel-config", + "metadata-scan", + "import-video", + "refresh-report", + "sync", + "download-missing", + "retry-bucket", + "build-index", + "build-deploy", + "build-site", + "relocate", + "relocate-back", + "lane", +]; + +export function parseArgs(argv) { + const positional = []; + let json = null; + let wait = false; + let quiet = false; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--wait") { + wait = true; + } else if (arg === "--quiet") { + quiet = true; + } else if (arg === "--json") { + json = argv[++i]; + if (json === undefined) { + return { error: "--json needs a JSON object argument" }; + } + } else if (arg.startsWith("--json=")) { + json = arg.slice("--json=".length); + } else if (arg === "--help" || arg === "-h") { + return { help: true }; + } else if (arg.startsWith("-")) { + return { error: `unknown flag: ${arg}` }; + } else { + positional.push(arg); + } + } + if (positional.length === 0) return { help: true }; + let body = {}; + if (json !== null) { + try { + body = JSON.parse(json); + } catch (e) { + return { error: `--json is not valid JSON: ${e.message}` }; + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return { error: "--json must be a JSON object" }; + } + } + if (positional[0] === "list") { + return { list: true }; + } + if (positional[0] === "get") { + const noun = positional[1]; + if (!noun || !GETTERS[noun]) { + return { + error: `get: unknown noun "${noun ?? ""}" — known: ${Object.keys(GETTERS).join(", ")}`, + }; + } + if (!positional[2]) return { error: `get ${noun}: needs an argument` }; + return { method: "GET", path: GETTERS[noun](positional[2]), wait: false, quiet }; + } + const action = positional[0]; + if (!ACTIONS.includes(action)) { + return { + error: `unknown action "${action}" — known: ${ACTIONS.join(", ")}`, + }; + } + if (positional.length > 1) { + return { + error: `"${action}" takes no positional arguments — pass its body with --json`, + }; + } + return { method: "POST", path: `/api/ops/${action}`, body, wait, quiet }; +} + +export function usage() { + return [ + "Usage: pnpm ops <action> [--json '<body>'] [--wait]", + " pnpm ops get channel <slug>", + " pnpm ops list", + "", + `Actions: ${ACTIONS.join(", ")}`, + "", + "Env: ARCHILYZER_EDITOR_URL (default http://localhost:3001), WORKER_TOKEN", + ].join("\n"); +} + +function baseUrl() { + return (process.env.ARCHILYZER_EDITOR_URL ?? DEFAULT_URL).replace(/\/+$/, ""); +} + +function authHeaders() { + const token = process.env.WORKER_TOKEN ?? ""; + return token ? { authorization: `Bearer ${token}` } : {}; +} + +// Follow a job's log to its terminal state. Returns the status string. +// Deliberately polls the SAME endpoint the editor's own log panel does, so a +// job started here and a job started by a click are observed identically. +async function followJob(jobId, quiet) { + let from = 0; + for (;;) { + const res = await fetch( + `${baseUrl()}/api/jobs/${encodeURIComponent(jobId)}/log?from=${from}`, + { headers: authHeaders() }, + ); + 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)); + } +} + +async function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (parsed.help) { + console.log(usage()); + return 0; + } + if (parsed.error) { + console.error(parsed.error); + console.error(""); + console.error(usage()); + return 2; + } + if (parsed.list) { + console.log(ACTIONS.join("\n")); + return 0; + } + const url = `${baseUrl()}${parsed.path}`; + const res = await fetch(url, { + method: parsed.method, + headers: { + ...authHeaders(), + ...(parsed.method === "POST" ? { "content-type": "application/json" } : {}), + }, + ...(parsed.method === "POST" ? { body: JSON.stringify(parsed.body) } : {}), + }); + const text = await res.text(); + let payload; + try { + payload = JSON.parse(text); + } catch { + console.error(`HTTP ${res.status}: ${text.slice(0, 500)}`); + return 1; + } + console.log(JSON.stringify(payload, null, 2)); + if (!res.ok || payload.ok === false) return 1; + if (!parsed.wait) return 0; + const jobIds = payload.jobId + ? [payload.jobId] + : Array.isArray(payload.jobs) + ? payload.jobs.map((j) => j.jobId) + : []; + if (jobIds.length === 0) { + // Not a job-starting action (or it queued nothing). --wait is satisfied. + return 0; + } + let worst = 0; + for (const jobId of jobIds) { + const status = await followJob(jobId, parsed.quiet); + console.error(`[${jobId}] ${status}`); + if (status !== "done") worst = 1; + } + return worst; +} + +// Importable for the arg-parsing tests; only the CLI entry point runs main(). +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + main().then( + (code) => process.exit(code), + (e) => { + console.error(e.message); + process.exit(1); + }, + ); +} diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs @@ -0,0 +1,65 @@ +// Arg parsing for scripts/archilyzer-ops.mjs. No network: parseArgs is pure and +// returns the request it WOULD make, which is the whole surface worth pinning — +// the routes themselves are covered by editor/e2e/ops-api.spec.ts. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseArgs, usage } from "./archilyzer-ops.mjs"; + +test("no arguments prints usage", () => { + assert.equal(parseArgs([]).help, true); + assert.match(usage(), /pnpm ops <action>/); +}); + +test("an action becomes a POST to its route", () => { + const p = parseArgs(["sync", "--json", '{"slug":"x","full":true}']); + assert.equal(p.method, "POST"); + assert.equal(p.path, "/api/ops/sync"); + assert.deepEqual(p.body, { slug: "x", full: true }); + assert.equal(p.wait, false); +}); + +test("--wait and --json= are both accepted", () => { + const p = parseArgs(["metadata-scan", '--json={"slug":"x"}', "--wait"]); + assert.equal(p.wait, true); + assert.deepEqual(p.body, { slug: "x" }); +}); + +test("an action with no body posts an empty object", () => { + const p = parseArgs(["build-index"]); + assert.deepEqual(p.body, {}); +}); + +test("an unknown action is refused by name, with the list", () => { + const p = parseArgs(["sinc"]); + assert.match(p.error, /unknown action "sinc"/); + assert.match(p.error, /metadata-scan/); +}); + +test("malformed --json is refused before any request", () => { + assert.match(parseArgs(["sync", "--json", "{"]).error, /not valid JSON/); + assert.match(parseArgs(["sync", "--json", "[1]"]).error, /must be a JSON object/); + assert.match(parseArgs(["sync", "--json"]).error, /needs a JSON object/); +}); + +test("positional arguments after an action are refused", () => { + // `pnpm ops sync the-quartering` reads naturally and would otherwise be a + // silent no-op body, so it is an error that names the fix. + assert.match(parseArgs(["sync", "the-quartering"]).error, /--json/); +}); + +test("get channel becomes a GET on the read route", () => { + const p = parseArgs(["get", "channel", "the quartering"]); + assert.equal(p.method, "GET"); + assert.equal(p.path, "/api/ops/channel/the%20quartering"); +}); + +test("get refuses an unknown noun and a missing argument", () => { + assert.match(parseArgs(["get", "site", "x"]).error, /unknown noun/); + assert.match(parseArgs(["get", "channel"]).error, /needs an argument/); +}); + +test("an unknown flag is refused", () => { + assert.match(parseArgs(["sync", "--force"]).error, /unknown flag/); +});