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:
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/);
+});