commit 093efc10a5ae7f19ebbc7a3737f4bd5346207734
parent 85d56ad907bdaed9c18234c84115b63184dfd593
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 11:40:41 -0400
ops: pnpm ops reads its token from editor/.env; --wait asks /api/ops/job/<id>
Release 19 slice A1. When WORKER_TOKEN or ARCHILYZER_EDITOR_URL is unset,
scripts/archilyzer-ops.mjs fills it from editor/.env.local and editor/.env of
the checkout the script lives in (never the cwd), then of the main worktree
when it runs in a linked one; only those two keys are taken, a variable
already set wins, and a 401/503 names where the token came from.
--wait no longer probes /api/jobs/active, the UI's live view (a next.config
rewrite onto /api/view/activeJobs that builds the whole payload). After three
failed log polls it asks GET /api/ops/job/<id> (behind the token): one record
or sidecar. An "archived" log status resolves to the sidecar's terminal status
instead of exiting 1, and a waiting job's queue position is printed as it
changes. The route answers kind, status, times, queue {key, position, queued,
head} and ?tail=N lines (common readLogTail).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 769 insertions(+), 90 deletions(-)
diff --git a/common/jobs/listJobs.test.ts b/common/jobs/listJobs.test.ts
@@ -9,6 +9,7 @@ import {
listAllJobs,
getJobEntry,
pruneJobLogs,
+ readLogTail,
} from "./listJobs";
// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test common/jobs/listJobs.test.ts
@@ -176,3 +177,21 @@ test("pruneJobLogs all clears everything", async () => {
assert.equal(await logCount(paths), 0);
});
});
+
+test("readLogTail returns the last N lines, read from the end, never a half line", async () => {
+ const dir = await mkdtemp(path.join(tmpdir(), "log-tail-"));
+ try {
+ const file = path.join(dir, "a.log");
+ await writeFile(file, Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n") + "\n\n");
+ assert.deepEqual(await readLogTail(file, 3), ["line 97", "line 98", "line 99"]);
+ // A window smaller than the file: the first (partial) line is dropped.
+ const small = await readLogTail(file, 1000, 20);
+ assert.ok(small.length > 0 && small.length < 100);
+ assert.ok(small.every((l) => /^line \d+$/.test(l)));
+ assert.equal(small[small.length - 1], "line 99");
+ assert.deepEqual(await readLogTail(file, 0), []);
+ assert.deepEqual(await readLogTail(path.join(dir, "missing.log"), 5), []);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+});
diff --git a/common/jobs/listJobs.ts b/common/jobs/listJobs.ts
@@ -1,5 +1,5 @@
import path from "node:path";
-import { readdir, stat, readFile, rm } from "node:fs/promises";
+import { open, readdir, stat, readFile, rm } from "node:fs/promises";
import type { Paths } from "../lib/paths";
import { mapConcurrent } from "../lib/concurrency";
import { getRegistry, type JobRecord, type JobStatus } from "./registry";
@@ -246,6 +246,38 @@ export async function readLogChunk(
return { content: raw.slice(fromBytes), nextOffset: raw.length };
}
+// THE LAST `lines` LINES OF A LOG, read from its END. A transcription job's log
+// runs to megabytes; an agent asking "what is it doing" wants the last screen
+// of it, not a download of the whole file (`readLogChunk` from 0 is that). At
+// most `maxBytes` are read: a log whose last window holds fewer lines answers
+// with what it has, and the first line of a mid-file window is dropped, so a
+// half line is never handed back as a whole one. Missing file → [].
+export async function readLogTail(
+ logPath: string,
+ lines: number,
+ maxBytes = 256 * 1024,
+): Promise<string[]> {
+ if (lines <= 0) return [];
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
+ try {
+ handle = await open(logPath, "r");
+ const { size } = await handle.stat();
+ const start = Math.max(0, size - maxBytes);
+ const length = size - start;
+ if (length <= 0) return [];
+ const buf = Buffer.alloc(length);
+ await handle.read(buf, 0, length, start);
+ const all = buf.toString("utf8").split("\n");
+ if (start > 0) all.shift();
+ while (all.length && all[all.length - 1] === "") all.pop();
+ return all.slice(-lines);
+ } catch {
+ return [];
+ } finally {
+ await handle?.close().catch(() => {});
+ }
+}
+
// Delete `.log` + `.meta.json` pairs by retention policy. Never touches a job
// the registry currently reports running or queued. `all` clears every finished
// job; otherwise `keepLast` drops the tail past the newest N and `olderThanMs`
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **`pnpm ops` finds the editor's token itself.** When `WORKER_TOKEN` or `ARCHILYZER_EDITOR_URL` is not set, it reads them from `editor/.env.local` and `editor/.env` of the checkout the script is in — wherever it is run from — and, in a linked worktree, which has no `editor/.env`, from the main worktree's; nothing else is taken from those files, and a variable already set wins. A 401 or 503 now says where the token came from (never what it is). `--wait` asks `GET /api/ops/job/<id>` (behind the token) whether a job it can no longer follow is still there, instead of the UI's live-jobs view; a job the editor has forgotten since (100 later jobs, or a restart) is reported with the status it ended with — it used to print `archived` and exit 1 for a job that finished `done` — and while a job waits, `--wait` prints its place in the queue whenever it changes. `GET /api/ops/job/<id>[?tail=N]` answers one job: its kind, channel, status, times and exit code, from its record or its sidecar; where it waits (`queue: {key, position, queued, head}`); and with `tail` its log's last N lines. Needs a restart of the editor.
- **A curated tag can exist on some sites only.** A tag's new **Sites** field on /tags (`sites` in `transcripts/tags.json`; `pnpm ops tags` takes it in a define) names the sites it exists on. Its rules then fire, and its pins apply, only to videos on those sites' channels, and every other site drops it from its records, its counts and its `/tags.json` — where **Hidden** only hid the chip. Empty is every site, as before. Setting it, or changing the channels of those sites, re-derives the corpus's tags once at the next index update. The Eva tags are what this is for: they belong on Anilyzer alone.
- **The publish lane.** Publishing can run itself: turn it on at **/operations/publish** (the runner's Start, Drain and Stop, the hold, and the lane's settings; or `publish.enabled` in settings) and the lane checks every `checkEveryMinutes` (10) whether the index is stale; when it is — and its last update is at least `refreshEveryMinutes` (360) old — it updates it, then builds every site whose channels changed or whose data the new index moved, one stage at a time on the `publish` queue. What it may do with a site is the site's own — the **Publish policy** on the site's settings form, `site.json` `publish.auto` —: `off` (the default: left alone), `build`, `preview` (built and deployed to the preview branch `publish.previewBranch`) or `production`; the hub and the homepage have `publish.hub` and `publish.homepage`. A private site is only ever built, and a site needs its Cloudflare Pages project before it may deploy. Hold the lane and the stage running finishes and no next one starts; quiet hours (`publish.quietHours`) do the same; Drain finishes the stage and ends the runner. The lane never forces a stage: a stage that finds its target current does nothing. On /jobs every stage of one run reads `run <id> · <target>`, and a stage still queued when the editor restarts is cancelled, never re-queued — the lane works out again what is stale from what is on disk. `archilyzer publish now` runs the same plan from the command line, one stage after another in its own process.
- **One index for every site.** The index is updated once and every site, the hub and the homepage are built from it; `archilyzer publish status` says, per site, whether its build is current — "stale: 3 channels changed (a, b, c)" as soon as a download, transcription or digest on one of its channels finishes, before any index runs; "stale: data changed" once the index has run and the site's data moved; "stale: config changed" after its site.json, tags or aliases changed — and whether what is deployed is that build, with a build made by older code marked "code newer" but not stale.
diff --git a/editor/app/api/ops/_jobs.ts b/editor/app/api/ops/_jobs.ts
@@ -0,0 +1,153 @@
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ getJobEntry,
+ listAllJobs,
+ readLogTail,
+ type JobListEntry,
+} from "yt-dlp-transcript-common/jobs/listJobs";
+import { getRegistry, type JobRecord } from "yt-dlp-transcript-common/jobs/registry";
+
+// THE JOBS, AS AN AGENT READS THEM — `GET /api/ops/job/<id>` and
+// `GET /api/ops/jobs`.
+//
+// Read-only shaping over the two places a job is known, the same two the /jobs
+// page reads: the live registry (`getRegistry()`) and the `.jobs` directory
+// (`getJobEntry` / `listAllJobs`, which fall back to the `.meta.json`
+// sidecar). No rule lives here, and nothing is constructed: no channel stats,
+// no disk gate, no runner status — which is the difference from the
+// `/api/jobs/active` view, a UI poll that builds the whole live payload (and
+// walks channel stats for progress) on every request.
+
+export type OpsQueueView = {
+ // The scheduler queue the job waits on ("" = runs in parallel, never queued).
+ key: string;
+ // 0 = running at the head, 1.. = queued that many places back.
+ position: number;
+ // How many jobs wait on that queue in all.
+ queued: number;
+ // The job holding the queue's head, when it is not this one.
+ head?: { id: string; kind: string; channelSlug?: string; startedAt?: number };
+};
+
+export type OpsJobView = Omit<JobListEntry, "logPath"> & {
+ // Only while the job is queued or running.
+ queue?: OpsQueueView;
+ // Live jobs only: what the runner last reported.
+ progress?: JobRecord["progress"];
+ tasks?: number;
+ draining?: boolean;
+ background?: boolean;
+};
+
+export function queueOf(id: string): OpsQueueView | undefined {
+ const registry = getRegistry();
+ const record = registry.get(id);
+ if (!record || (record.status !== "queued" && record.status !== "running")) {
+ return undefined;
+ }
+ const position = registry.positionInQueue(id);
+ if (position < 0) return undefined;
+ const snap = registry.listQueues().find((q) => q.name === record.queueKey);
+ const head = snap?.running;
+ return {
+ key: record.queueKey,
+ position,
+ queued: snap?.queued.length ?? 0,
+ ...(head && head.id !== id
+ ? {
+ head: {
+ id: head.id,
+ kind: head.kind,
+ ...(head.channelSlug ? { channelSlug: head.channelSlug } : {}),
+ ...(head.startedAt ? { startedAt: head.startedAt } : {}),
+ },
+ }
+ : {}),
+ };
+}
+
+export function viewOf(entry: JobListEntry): OpsJobView {
+ const { logPath: _logPath, ...rest } = entry;
+ void _logPath;
+ const record = getRegistry().get(entry.id);
+ const queue = queueOf(entry.id);
+ return {
+ ...rest,
+ ...(queue ? { queue } : {}),
+ ...(record?.progress ? { progress: record.progress } : {}),
+ ...(record?.tasks?.length ? { tasks: record.tasks.length } : {}),
+ ...(record?.draining ? { draining: true } : {}),
+ ...(record?.background ? { background: true } : {}),
+ };
+}
+
+// One job, or null when neither the registry nor `.jobs/` knows the id.
+export async function readJob(
+ id: string,
+ tail: number,
+): Promise<{ job: OpsJobView; tail?: string[] } | null> {
+ const entry = await getJobEntry(getPaths(), id);
+ if (!entry) return null;
+ return {
+ job: viewOf(entry),
+ ...(tail > 0 ? { tail: await readLogTail(entry.logPath, tail) } : {}),
+ };
+}
+
+export type JobsFilter = {
+ active?: boolean;
+ failed?: boolean;
+ kind?: string;
+ slug?: string;
+ limit: number;
+};
+
+// How far back a FILTERED list looks. A page of the newest `limit` jobs
+// filtered afterwards would answer "no failed syncs" whenever the last fifty
+// jobs were refresh-reports; reading this many sidecars (32 at a time) is what
+// the /jobs page's own "Load more" costs at its ceiling.
+export const JOBS_SCAN = 2000;
+
+export async function listJobs(
+ filter: JobsFilter,
+): Promise<{ jobs: OpsJobView[]; scanned: number; total: number }> {
+ const match = (e: { status: string; kind?: string; channelSlug?: string }) =>
+ (!filter.failed || e.status === "failed") &&
+ (!filter.kind || e.kind === filter.kind) &&
+ (!filter.slug || e.channelSlug === filter.slug);
+
+ if (filter.active) {
+ // The live head, straight from the registry: queue order, running first.
+ const registry = getRegistry();
+ const live = registry
+ .list()
+ .filter((r) => r.status === "running" || r.status === "queued")
+ .filter(match);
+ const paths = getPaths();
+ const entries = await Promise.all(live.map((r) => getJobEntry(paths, r.id)));
+ const views = entries
+ .filter((e): e is JobListEntry => e !== null)
+ .map(viewOf);
+ views.sort(
+ (a, b) =>
+ (a.queue?.key ?? "").localeCompare(b.queue?.key ?? "") ||
+ (a.queue?.position ?? 0) - (b.queue?.position ?? 0),
+ );
+ return {
+ jobs: views.slice(0, filter.limit),
+ scanned: live.length,
+ total: views.length,
+ };
+ }
+
+ const filtered = Boolean(filter.failed || filter.kind || filter.slug);
+ const page = await listAllJobs(getPaths(), {
+ limit: filtered ? JOBS_SCAN : filter.limit,
+ });
+ const hits = page.entries.filter(match);
+ return {
+ jobs: hits.slice(0, filter.limit).map(viewOf),
+ scanned: page.entries.length,
+ total: page.total,
+ };
+}
diff --git a/editor/app/api/ops/job/[id]/route.ts b/editor/app/api/ops/job/[id]/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from "next/server";
+import { opsAuth, opsFail } from "../../_lib";
+import { readJob } from "../../_jobs";
+
+export const dynamic = "force-dynamic";
+
+// GET /api/ops/job/<id>[?tail=<lines>]
+//
+// ONE JOB'S STATE, behind the token: its kind, channel, status, times, exit
+// code and replay flag — from the registry while it is live, from its
+// `.meta.json` sidecar after — and, while it is queued or running, WHERE it
+// waits: `queue: {key, position, queued, head}` (0 = running at the head; the
+// head is the job holding the queue when it is not this one). `?tail=N` adds the
+// log's last N lines (at most 500), read from the end of the file.
+//
+// What `pnpm ops --wait` asks when the log endpoint stops answering, in place of
+// the `/api/jobs/active` view: that is a UI poll — ungated, a rewrite onto
+// /api/view/activeJobs that builds the whole live payload (channel stats, the
+// disk gate, every runner's status) to answer "is job X still there?". This
+// reads one record and one sidecar, and it can say how the job ended after the
+// registry forgot it, which the active list never could.
+//
+// An id neither the registry nor `.jobs/` knows is a 404 `{ ok: false }`.
+const MAX_TAIL = 500;
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const denied = opsAuth(request);
+ if (denied) return denied;
+ const { id } = await params;
+ // The log route's own id rule: the id names a file under .jobs/.
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) return opsFail(`"${id}" is not a job id`);
+ const url = new URL(request.url);
+ const unknown = [...url.searchParams.keys()].filter((k) => k !== "tail");
+ if (unknown.length) {
+ return opsFail(`unknown query key(s): ${unknown.join(", ")} — accepted: tail`);
+ }
+ const rawTail = url.searchParams.get("tail");
+ let tail = 0;
+ if (rawTail !== null) {
+ tail = Number(rawTail);
+ if (!Number.isInteger(tail) || tail <= 0) {
+ return opsFail('"tail" must be a whole number of lines above zero');
+ }
+ tail = Math.min(tail, MAX_TAIL);
+ }
+ const found = await readJob(id, tail);
+ if (!found) return opsFail(`no job "${id}"`, 404);
+ return NextResponse.json({ ok: true, ...found });
+}
diff --git a/editor/app/api/ops/job/route.test.ts b/editor/app/api/ops/job/route.test.ts
@@ -0,0 +1,129 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+// Run with:
+// pnpm -C editor exec tsx --test "app/api/ops/job/route.test.ts"
+//
+// The jobs over ops, against a temp `.jobs/` and the process's own registry:
+// one job's record (live and archived), where a queued job waits, the log tail,
+// and the gate. Every answer here is a read — nothing revalidates.
+
+const ROOT = await mkdtemp(path.join(os.tmpdir(), "ops-job-route-"));
+process.env.WORKER_TOKEN = "test-token";
+process.env.TRANSCRIPTS_DIR = ROOT;
+process.env.SETTINGS_FILE = path.join(ROOT, "settings.json");
+const JOBS = path.join(ROOT, ".jobs");
+await mkdir(JOBS, { recursive: true });
+
+const { getRegistry } = await import("yt-dlp-transcript-common/jobs/registry");
+const one = (await import("./[id]/route")).GET;
+test.after(() => rm(ROOT, { recursive: true, force: true }));
+
+type Res = { status: number; json: Record<string, unknown> };
+async function get(id: string, query = "", token = "test-token"): Promise<Res> {
+ const res = await one(
+ new Request(`http://localhost/api/ops/job/${id}${query}`, {
+ headers: token ? { authorization: `Bearer ${token}` } : {},
+ }),
+ { params: Promise.resolve({ id }) },
+ );
+ return { status: res.status, json: (await res.json()) as Record<string, unknown> };
+}
+
+// An archived job: a log and a sidecar, no registry record.
+async function archived(id: string, status: string, lines: number): Promise<void> {
+ await writeFile(
+ path.join(JOBS, `${id}.log`),
+ Array.from({ length: lines }, (_, i) => `line ${i}`).join("\n") + "\n",
+ );
+ await writeFile(
+ path.join(JOBS, `${id}.meta.json`),
+ JSON.stringify({
+ id,
+ kind: "sync",
+ queueKey: "youtube",
+ channelSlug: "demo",
+ status,
+ queuedAt: 1,
+ startedAt: 2,
+ endedAt: 3,
+ exitCode: status === "done" ? 0 : 1,
+ }),
+ );
+}
+
+// A live job on queue `key`, registered and submitted like a real one.
+function live(id: string, key: string): void {
+ const record = {
+ id,
+ kind: "fetch-window",
+ queueKey: key,
+ channelSlug: "demo",
+ status: "queued" as const,
+ queuedAt: Date.now(),
+ logPath: path.join(JOBS, `${id}.log`),
+ };
+ getRegistry().register(record);
+ getRegistry().enqueue(record, { start: () => {}, onCancel: () => {} });
+}
+
+test("the gate: no token is a 401, and the id is checked before anything is read", async () => {
+ assert.equal((await get("abc", "", "")).status, 401);
+ assert.equal((await get("abc", "", "wrong")).status, 401);
+ const bad = await get("..%2Fx");
+ assert.equal(bad.status, 400);
+ assert.match(String(bad.json.error), /is not a job id/);
+});
+
+test("an unknown id is a 404 { ok: false }, which --wait reads as 'gone'", async () => {
+ const res = await get("NOSUCHJOB");
+ assert.equal(res.status, 404);
+ assert.equal(res.json.ok, false);
+ assert.equal(res.json.error, 'no job "NOSUCHJOB"');
+});
+
+test("an archived job answers with its sidecar's status — not 'archived'", async () => {
+ await archived("ARCH1", "done", 3);
+ const res = await get("ARCH1");
+ assert.equal(res.status, 200);
+ const job = res.json.job as Record<string, unknown>;
+ assert.equal(job.status, "done");
+ assert.equal(job.kind, "sync");
+ assert.equal(job.inRegistry, false);
+ assert.equal(job.queue, undefined);
+ // The log's path on the server is not part of the answer.
+ assert.equal(job.logPath, undefined);
+ assert.equal(res.json.tail, undefined);
+});
+
+test("?tail=N adds the log's last N lines, and refuses a bad N or an unknown key", async () => {
+ await archived("ARCH2", "failed", 50);
+ const res = await get("ARCH2", "?tail=3");
+ assert.deepEqual(res.json.tail, ["line 47", "line 48", "line 49"]);
+ assert.equal((await get("ARCH2", "?tail=0")).status, 400);
+ assert.equal((await get("ARCH2", "?tail=x")).status, 400);
+ const stray = await get("ARCH2", "?tial=3");
+ assert.equal(stray.status, 400);
+ assert.match(String(stray.json.error), /unknown query key\(s\): tial/);
+});
+
+test("a queued job says where it waits and who holds the head", async () => {
+ live("HEAD1", "rumble");
+ live("WAIT1", "rumble");
+ live("WAIT2", "rumble");
+ const head = (await get("HEAD1")).json.job as Record<string, unknown>;
+ assert.equal(head.status, "running");
+ assert.deepEqual(head.queue, { key: "rumble", position: 0, queued: 2 });
+ const waiting = (await get("WAIT2")).json.job as Record<string, unknown>;
+ assert.equal(waiting.status, "queued");
+ const queue = waiting.queue as Record<string, unknown>;
+ assert.equal(queue.position, 2);
+ assert.equal(queue.queued, 2);
+ assert.deepEqual(
+ { ...(queue.head as Record<string, unknown>), startedAt: undefined },
+ { id: "HEAD1", kind: "fetch-window", channelSlug: "demo", startedAt: undefined },
+ );
+});
diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs
@@ -23,6 +23,12 @@
// Unset on the SERVER => every route 503s; unset here
// => every route 401s.
//
+// Either variable, when unset, is read from the editor's own env files —
+// `editor/.env.local`, then `editor/.env` — of THIS checkout (found from the
+// script's path, never the cwd), then of the main worktree when this is a
+// linked one (a worktree has no `editor/.env`; the editor it talks to by
+// default is the primary's). See loadEditorEnv.
+//
// EXAMPLES
//
// pnpm ops sync --json '{"slug":"the-quartering"}' --wait
@@ -79,12 +85,14 @@
// The response JSON is printed verbatim on stdout (log lines from --wait go to
// stderr), so `pnpm ops … | jq` works.
+import { readFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
-import { pathToFileURL } from "node:url";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
const DEFAULT_URL = "http://localhost:3001";
-// Consecutive polls where NEITHER the job's log NOR the active list answered,
+// Consecutive polls where NEITHER the job's log NOR its ops record 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.
@@ -408,7 +416,8 @@ export function usage() {
"",
"--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.",
+ " failures, asks /api/ops/job/<id> whether the job is still there and how",
+ " it ended. While the job waits it prints its queue position on stderr.",
"--wait-timeout <seconds> gives up and exits 1 instead of waiting forever.",
" Default: no timeout — the queue may legitimately hold a job for hours.",
"",
@@ -561,12 +570,103 @@ export function usage() {
" stderr), so `pnpm ops transcribe ... --wait | jq -r .text` works.",
"",
"Env: ARCHILYZER_EDITOR_URL (default http://localhost:3001), WORKER_TOKEN,",
- " ARCHILYZER_AGENT (provenance of a tag write; default \"cli\")",
+ " ARCHILYZER_AGENT (provenance of a tag write; default \"cli\").",
+ " WORKER_TOKEN and ARCHILYZER_EDITOR_URL, when unset, are read from",
+ " editor/.env.local and editor/.env of this checkout (found from the",
+ " script, not the cwd), then of the main worktree.",
].join("\n");
}
+// THE REPO ROOT, FROM THIS FILE — never from the cwd. `pnpm ops` runs with the
+// repo root as its cwd, but `node scripts/archilyzer-ops.mjs` from a subdir,
+// or a tool that shells out from its own project directory, does not; every
+// session used to write a wrapper that sourced editor/.env first.
+const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+
+// The two variables this client reads from the editor's env files. Nothing
+// else is taken from them: they also hold deploy credentials, which have no
+// business in this process.
+const EDITOR_ENV_KEYS = ["WORKER_TOKEN", "ARCHILYZER_EDITOR_URL"];
+
+// `KEY=value` lines, as Next's own loader reads a .env: `#` comments, an
+// optional `export `, single or double quotes stripped. No interpolation.
+export function parseDotenv(text) {
+ const out = {};
+ for (const raw of text.split(/\r?\n/)) {
+ const line = raw.trim();
+ if (!line || line.startsWith("#")) continue;
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
+ if (!m) continue;
+ let value = m[2].trim();
+ const q = value[0];
+ if ((q === '"' || q === "'") && value.length >= 2 && value.endsWith(q)) {
+ value = value.slice(1, -1);
+ } else {
+ // An unquoted value ends at an inline comment.
+ value = value.replace(/\s+#.*$/, "");
+ }
+ out[m[1]] = value;
+ }
+ return out;
+}
+
+// The main worktree of a LINKED worktree, or null. A linked worktree's `.git`
+// is a file `gitdir: <main>/.git/worktrees/<name>`, and that directory's
+// `commondir` names the main `.git`. Read off disk — no git process.
+export function mainWorktreeOf(root, read = (p) => readFileSync(p, "utf8")) {
+ try {
+ const m = /^gitdir:\s*(.+)$/m.exec(read(path.join(root, ".git")));
+ if (!m) return null;
+ const gitdir = path.resolve(root, m[1].trim());
+ const common = path.resolve(gitdir, read(path.join(gitdir, "commondir")).trim());
+ return path.dirname(common);
+ } catch {
+ return null;
+ }
+}
+
+// The env files consulted, in order: this checkout's, then the main worktree's.
+export function editorEnvFiles(root = REPO_ROOT, read) {
+ const roots = [root];
+ const main = mainWorktreeOf(root, read);
+ if (main && path.resolve(main) !== path.resolve(root)) roots.push(main);
+ return roots.flatMap((r) => [
+ path.join(r, "editor", ".env.local"),
+ path.join(r, "editor", ".env"),
+ ]);
+}
+
+// FILL WHAT THE ENVIRONMENT LEFT UNSET from the editor's env files. A variable
+// already set — even to "" — wins: that is an explicit choice. The first file
+// that names a key supplies it. Returns where each came from, for the 401/503
+// hint (never the value).
+export function loadEditorEnv(
+ env = process.env,
+ files = editorEnvFiles(),
+ read = (p) => readFileSync(p, "utf8"),
+) {
+ const sources = {};
+ for (const key of EDITOR_ENV_KEYS) {
+ if (env[key] !== undefined) sources[key] = "environment";
+ }
+ for (const file of files) {
+ let parsed;
+ try {
+ parsed = parseDotenv(read(file));
+ } catch {
+ continue;
+ }
+ for (const key of EDITOR_ENV_KEYS) {
+ if (sources[key] || parsed[key] === undefined) continue;
+ env[key] = parsed[key];
+ sources[key] = file;
+ }
+ }
+ return sources;
+}
+
function baseUrl() {
- return (process.env.ARCHILYZER_EDITOR_URL ?? DEFAULT_URL).replace(/\/+$/, "");
+ return (process.env.ARCHILYZER_EDITOR_URL || DEFAULT_URL).replace(/\/+$/, "");
}
function authHeaders() {
@@ -574,15 +674,30 @@ function authHeaders() {
return token ? { authorization: `Bearer ${token}` } : {};
}
+// What a 401 or a 503 means HERE, with where the token came from — never what
+// it is. A 503 is the server's own switch (it runs without WORKER_TOKEN); a 401
+// is this side sending none, or one the server does not hold.
+export function tokenHint(status, sources, url = baseUrl()) {
+ const from = sources.WORKER_TOKEN;
+ if (status === 503) {
+ return `hint: the editor at ${url} runs without WORKER_TOKEN, so /api/ops is off — set it in its editor/.env and restart it`;
+ }
+ if (!from) {
+ return `hint: no WORKER_TOKEN in the environment or in ${editorEnvFiles().join(", ")}`;
+ }
+ return `hint: the WORKER_TOKEN from ${from} is not the one the editor at ${url} runs with`;
+}
+
// 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.
-// NO AUTH HEADER, and that is not an omission. `/api/jobs/<id>/log` is
-// deliberately ungated (see its route for what it does and does not leak, and
-// why gating it would switch every run panel's log off on a default install) —
-// 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.
+// NO AUTH HEADER on the log poll, and that is not an omission.
+// `/api/jobs/<id>/log` is deliberately ungated (see its route for what it does
+// and does not leak, and why gating it would switch every run panel's log off
+// on a default install) — 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.
//
// 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
@@ -594,10 +709,23 @@ function authHeaders() {
//
// 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.
+// `GET /api/ops/job/<id>` answers it (behind the token): one registry record
+// and one sidecar. It used to be `/api/jobs/active`, the UI's live view — a
+// next.config REWRITE onto /api/view/activeJobs, kept at its old path for the
+// pages and pinned widgets that poll it, which builds the whole live payload
+// (channel stats, the disk gate, every runner's status) to answer a yes/no, on
+// a server already too busy to answer the log poll. Queued or running ⇒ keep
+// waiting, however long that takes. Ended ⇒ the status it ended with, after
+// one last log poll for the lines we missed. Unknown (404) ⇒ the follow gives
+// up rather than claiming an outcome it never read.
+//
+// "archived" IS NOT AN OUTCOME EITHER. The log route says it for any id the
+// registry no longer holds (evicted past 100, or the editor restarted); the
+// job's sidecar still says how it ended, and the job route reads it.
+//
+// QUEUE POSITION is printed (stderr, unless --quiet) whenever it changes while
+// the job waits: "queued — position 3 on youtube". A job queued behind hours of
+// other work otherwise looks exactly like a hung command.
//
// `--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
@@ -606,15 +734,26 @@ 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 say = opts.say ?? ((line) => process.stderr.write(`${line}\n`));
const deadline =
opts.timeoutSeconds > 0 ? now() + opts.timeoutSeconds * 1000 : null;
const base = opts.baseUrl ?? baseUrl();
+ const headers = opts.headers ?? authHeaders();
const id = encodeURIComponent(jobId);
let from = 0;
let failures = 0;
let probeFailures = 0;
let backoff = 1000;
+ let lastPlace = null;
+
+ const notePlace = (status, queueKey, position) => {
+ if (quiet || status !== "queued" || !(position > 0)) return;
+ const place = `${position}@${queueKey ?? ""}`;
+ if (place === lastPlace) return;
+ lastPlace = place;
+ say(`[${jobId}] queued — position ${position}${queueKey ? ` on ${queueKey}` : ""}`);
+ };
// One log poll. Returns the status, or null when the poll itself failed —
// never throws, so a transient fetch rejection cannot end the follow.
@@ -633,19 +772,24 @@ export async function followJob(jobId, quiet, opts = {}) {
// Only advance once the chunk is in hand: a poll that failed halfway
// must re-ask for the same offset.
from = payload.nextOffset ?? from;
+ notePlace(payload.status, payload.queueKey, payload.queuePosition);
return payload.status ?? null;
} catch {
return null;
}
};
- const stillListed = async () => {
+ // The job route: { status } when it answered, "gone" when the editor does
+ // not know the id at all, null when the probe itself failed (or the editor
+ // predates the route — an HTML 404 is not an answer).
+ const probe = 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);
+ const res = await doFetch(`${base}/api/ops/job/${id}`, { headers });
+ const payload = await res.json().catch(() => null);
+ if (!payload || typeof payload !== "object") return null;
+ if (res.status === 404 && payload.ok === false) return "gone";
+ if (!res.ok || !payload.job) return null;
+ return payload.job.status ?? null;
} catch {
return null;
}
@@ -654,6 +798,7 @@ export async function followJob(jobId, quiet, opts = {}) {
// "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";
+ const ended = (s) => s === "done" || s === "failed" || s === "cancelled";
for (;;) {
const status = await pollLog();
@@ -661,30 +806,28 @@ export async function followJob(jobId, quiet, opts = {}) {
failures = 0;
probeFailures = 0;
backoff = 1000;
+ if (status === "archived") {
+ // Not in the registry: the sidecar knows how it ended.
+ const real = await probe();
+ return ended(real) ? real : status;
+ }
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) {
+ const real = await probe();
+ if (ended(real)) {
+ // It ended while the log endpoint was unreachable. One more try for
+ // the lines we missed — its status does not override the record's.
+ await pollLog();
+ return real;
+ }
+ if (real === "gone") {
+ throw new Error(
+ `lost contact with job ${jobId}: the editor no longer knows it and its log could not be read`,
+ );
+ }
+ if (real === "queued" || real === "running") {
// A job we can still see is a job to wait for.
failures = 0;
probeFailures = 0;
@@ -697,7 +840,7 @@ export async function followJob(jobId, quiet, opts = {}) {
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`,
+ `lost contact with the editor at ${base}: ${MAX_PROBE_FAILURES} consecutive failed polls of job ${jobId} and of /api/ops/job/${jobId}`,
);
}
}
@@ -713,6 +856,34 @@ export async function followJob(jobId, quiet, opts = {}) {
}
}
+// Follow every id to its end, one after another, and say how each ended.
+// 0 only when every one finished `done`. A job still queued says where it waits
+// (followJob), so a list of ids behind one long job reads as a queue, not a
+// hang. In result mode (transcribe) each job's RESULT goes to stdout.
+async function followAll(jobIds, parsed, resultMode = false) {
+ let worst = 0;
+ for (const jobId of jobIds) {
+ const capture = resultMode ? makeResultCapture(parsed.resultMarker) : null;
+ const status = await followJob(jobId, parsed.quiet, {
+ timeoutSeconds: parsed.waitTimeout ?? 0,
+ ...(capture ? { onContent: capture.feed } : {}),
+ });
+ if (capture) {
+ const { echo, result } = capture.finish();
+ if (echo && !parsed.quiet) process.stderr.write(echo);
+ if (result !== null) {
+ console.log(JSON.stringify(result, null, 2));
+ } else if (status === "done") {
+ console.error(`[${jobId}] finished but its log carries no result`);
+ worst = 1;
+ }
+ }
+ console.error(`[${jobId}] ${status}`);
+ if (status !== "done") worst = 1;
+ }
+ return worst;
+}
+
async function main() {
const parsed = parseArgs(process.argv.slice(2));
if (parsed.help) {
@@ -755,6 +926,7 @@ async function main() {
if (parsed.defaultSource && parsed.body && parsed.body.source === undefined) {
parsed.body = { ...parsed.body, source: parsed.defaultSource };
}
+ const envSources = loadEditorEnv();
const url = `${baseUrl()}${parsed.path}`;
const res = await fetch(url, {
method: parsed.method,
@@ -772,6 +944,9 @@ async function main() {
console.error(`HTTP ${res.status}: ${text.slice(0, 500)}`);
return 1;
}
+ if (res.status === 401 || res.status === 503) {
+ console.error(tokenHint(res.status, envSources));
+ }
// A result-carrying action under --wait keeps stdout for the RESULT: the
// response goes to stderr with the log.
const resultMode = Boolean(parsed.wait && parsed.resultMarker);
@@ -798,26 +973,7 @@ async function main() {
printPreviewUrls(payload);
return 0;
}
- let worst = 0;
- for (const jobId of jobIds) {
- const capture = resultMode ? makeResultCapture(parsed.resultMarker) : null;
- const status = await followJob(jobId, parsed.quiet, {
- timeoutSeconds: parsed.waitTimeout ?? 0,
- ...(capture ? { onContent: capture.feed } : {}),
- });
- if (capture) {
- const { echo, result } = capture.finish();
- if (echo && !parsed.quiet) process.stderr.write(echo);
- if (result !== null) {
- console.log(JSON.stringify(result, null, 2));
- } else if (status === "done") {
- console.error(`[${jobId}] finished but its log carries no result`);
- worst = 1;
- }
- }
- console.error(`[${jobId}] ${status}`);
- if (status !== "done") worst = 1;
- }
+ const worst = await followAll(jobIds, parsed, resultMode);
// LAST, after the logs: with --wait the response scrolled off minutes ago,
// and the alias is the one thing the operator came for.
printPreviewUrls(payload);
diff --git a/scripts/archilyzer-ops.test.mjs b/scripts/archilyzer-ops.test.mjs
@@ -7,10 +7,15 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
TRANSCRIBE_RESULT_MARKER,
+ editorEnvFiles,
followJob,
+ loadEditorEnv,
+ mainWorktreeOf,
makeResultCapture,
parseArgs,
+ parseDotenv,
previewUrlsIn,
+ tokenHint,
usage,
} from "./archilyzer-ops.mjs";
@@ -170,16 +175,24 @@ test("--wait-timeout is parsed, and refuses a non-number", () => {
});
// 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++;
+// thrown at the caller the way a starved server makes `fetch` reject. `job` is
+// the sequence of /api/ops/job/<id> answers: a status string, "gone" (the
+// editor does not know the id: a JSON 404) or an Error.
+function fakeEditor({ log = [], job = [] } = {}) {
+ const calls = { log: 0, job: 0, jobHeaders: [] };
+ const reply = (body, status = 200) => ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => body,
+ });
+ const doFetch = async (url, init) => {
+ if (url.includes("/api/ops/job/")) {
+ const next = job[Math.min(calls.job, job.length - 1)];
+ calls.job++;
+ calls.jobHeaders.push(init?.headers ?? {});
if (next instanceof Error) throw next;
- return reply({ jobs: next });
+ if (next === "gone") return reply({ ok: false, error: "no job" }, 404);
+ return reply({ ok: true, job: { id: "j1", status: next } });
}
const next = log[Math.min(calls.log, log.length - 1)];
calls.log++;
@@ -194,6 +207,7 @@ const follow = (jobId, editor, opts = {}) =>
fetch: editor.doFetch,
sleep: async () => {},
baseUrl: "http://editor",
+ headers: { authorization: "Bearer t" },
...opts,
});
@@ -210,35 +224,78 @@ test("a poll that rejects does not end the follow", async () => {
});
assert.equal(await follow("j1", editor), "done");
// Two failures is below the probe threshold, so it never asked.
- assert.equal(editor.calls.active, 0);
+ assert.equal(editor.calls.job, 0);
});
-test("after three failures it asks whether the job is still there", async () => {
+test("after three failures it asks the job route 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
+ // Still running ⇒ 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" }]],
+ job: ["running"],
});
assert.equal(await follow("j1", waiting), "done");
- assert.equal(waiting.calls.active, 1);
+ assert.equal(waiting.calls.job, 1);
+ // The job route is behind the token, and the probe carries it.
+ assert.deepEqual(waiting.calls.jobHeaders[0], { authorization: "Bearer t" });
- // 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.
+ // Ended while the log was unreachable ⇒ the record's status is the answer,
+ // even when the last log poll still fails. `failed` is reported, not hidden.
const gone = fakeEditor({
- log: [boom, boom, boom, { content: "", nextOffset: 0, status: "failed" }],
- active: [[]],
+ log: [boom, boom, boom, boom],
+ job: ["failed"],
});
assert.equal(await follow("j1", gone), "failed");
});
-test("a job that is gone AND unreadable refuses to claim an outcome", async () => {
+test("a job the editor no longer knows, with an unreadable log, refuses to claim an outcome", async () => {
const boom = new Error("fetch failed");
- const editor = fakeEditor({ log: [boom], active: [[]] });
+ const editor = fakeEditor({ log: [boom], job: ["gone"] });
await assert.rejects(follow("j1", editor), /lost contact with job j1/);
});
+test("'archived' is not an outcome: the sidecar's status is", async () => {
+ // The log route says "archived" for any id the registry forgot (evicted, or
+ // the editor restarted); --wait used to print it and exit 1 for a job that
+ // finished done.
+ const done = fakeEditor({
+ log: [{ content: "", nextOffset: 0, status: "archived" }],
+ job: ["done"],
+ });
+ assert.equal(await follow("j1", done), "done");
+ // A sidecar that says nothing terminal leaves "archived" standing.
+ const unknown = fakeEditor({
+ log: [{ content: "", nextOffset: 0, status: "archived" }],
+ job: ["archived"],
+ });
+ assert.equal(await follow("j1", unknown), "archived");
+});
+
+test("a waiting job's queue position is said once per change", async () => {
+ const lines = [];
+ const editor = fakeEditor({
+ log: [
+ { content: "", nextOffset: 0, status: "queued", queueKey: "youtube", queuePosition: 3 },
+ { content: "", nextOffset: 0, status: "queued", queueKey: "youtube", queuePosition: 3 },
+ { content: "", nextOffset: 0, status: "queued", queueKey: "youtube", queuePosition: 1 },
+ { content: "", nextOffset: 0, status: "running", queueKey: "youtube", queuePosition: 0 },
+ { content: "", nextOffset: 0, status: "done" },
+ ],
+ });
+ const status = await followJob("j1", false, {
+ fetch: editor.doFetch,
+ sleep: async () => {},
+ baseUrl: "http://editor",
+ say: (l) => lines.push(l),
+ });
+ assert.equal(status, "done");
+ assert.deepEqual(lines, [
+ "[j1] queued — position 3 on youtube",
+ "[j1] queued — position 1 on youtube",
+ ]);
+});
+
test("--wait-timeout gives up on a job that never ends", async () => {
const editor = fakeEditor({
log: [{ content: "", nextOffset: 0, status: "running" }],
@@ -314,9 +371,10 @@ test("previewUrlsIn is empty for a production deploy", () => {
assert.deepEqual(previewUrlsIn(null), []);
});
-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
+
+test("a job route answering 'running' keeps the follow going", async () => {
+ // THE OLD BUG, kept pinned: the absent-from-active branch returned whatever
+ // the final poll said, so a stale list plus a recovered log endpoint 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({
@@ -327,20 +385,99 @@ test("a recovered log endpoint answering 'running' is not an outcome", async ()
{ content: "", nextOffset: 0, status: "running" },
{ content: "", nextOffset: 0, status: "done" },
],
- active: [[]],
+ job: ["running"],
});
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.
+ // never answers AND the job route 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] });
+ const editor = fakeEditor({ log: [boom], job: [boom] });
await assert.rejects(follow("j1", editor), /lost contact with the editor/);
});
+// THE TOKEN FROM THE EDITOR'S ENV FILES (A1). Every session used to write a
+// wrapper that sourced editor/.env before `pnpm ops`.
+test("parseDotenv reads KEY=value lines, quotes, export and comments", () => {
+ assert.deepEqual(
+ parseDotenv(
+ [
+ "# a comment",
+ "",
+ "WORKER_TOKEN=abc123",
+ "export ARCHILYZER_EDITOR_URL='http://localhost:3101'",
+ 'QUOTED="has # hash"',
+ "INLINE=value # trailing comment",
+ "not a line",
+ ].join("\n"),
+ ),
+ {
+ WORKER_TOKEN: "abc123",
+ ARCHILYZER_EDITOR_URL: "http://localhost:3101",
+ QUOTED: "has # hash",
+ INLINE: "value",
+ },
+ );
+});
+
+test("loadEditorEnv fills only what is unset, first file wins, and takes only its two keys", () => {
+ const files = {
+ "/a/editor/.env.local": "WORKER_TOKEN=local\n",
+ "/a/editor/.env": "WORKER_TOKEN=plain\nARCHILYZER_EDITOR_URL=http://x:1\nCLOUDFLARE_API_TOKEN=secret\n",
+ };
+ const read = (p) => {
+ if (!(p in files)) throw new Error("ENOENT");
+ return files[p];
+ };
+ const env = {};
+ const sources = loadEditorEnv(env, ["/missing/.env", ...Object.keys(files)], read);
+ assert.deepEqual(env, { WORKER_TOKEN: "local", ARCHILYZER_EDITOR_URL: "http://x:1" });
+ assert.deepEqual(sources, {
+ WORKER_TOKEN: "/a/editor/.env.local",
+ ARCHILYZER_EDITOR_URL: "/a/editor/.env",
+ });
+ // A variable already set — even to "" — is an explicit choice and wins.
+ const set = { WORKER_TOKEN: "" };
+ const s2 = loadEditorEnv(set, Object.keys(files), read);
+ assert.equal(set.WORKER_TOKEN, "");
+ assert.equal(s2.WORKER_TOKEN, "environment");
+});
+
+test("a linked worktree also reads the main worktree's env files, found off disk", () => {
+ const disk = {
+ "/repo/.claude/worktrees/w/.git": "gitdir: /repo/.git/worktrees/w\n",
+ "/repo/.git/worktrees/w/commondir": "../..\n",
+ };
+ const read = (p) => {
+ if (!(p in disk)) throw new Error("ENOENT");
+ return disk[p];
+ };
+ assert.equal(mainWorktreeOf("/repo/.claude/worktrees/w", read), "/repo");
+ assert.deepEqual(editorEnvFiles("/repo/.claude/worktrees/w", read), [
+ "/repo/.claude/worktrees/w/editor/.env.local",
+ "/repo/.claude/worktrees/w/editor/.env",
+ "/repo/editor/.env.local",
+ "/repo/editor/.env",
+ ]);
+ // The main checkout: `.git` is a directory, so reading it as a file fails.
+ assert.equal(mainWorktreeOf("/repo", read), null);
+ assert.deepEqual(editorEnvFiles("/repo", read), [
+ "/repo/editor/.env.local",
+ "/repo/editor/.env",
+ ]);
+});
+
+test("the token hint names where the token came from, never its value", () => {
+ assert.match(tokenHint(503, {}, "http://e"), /runs without WORKER_TOKEN/);
+ assert.match(tokenHint(401, {}, "http://e"), /no WORKER_TOKEN in the environment or in /);
+ const h = tokenHint(401, { WORKER_TOKEN: "/r/editor/.env" }, "http://e");
+ assert.match(h, /from \/r\/editor\/\.env is not the one the editor at http:\/\/e runs with/);
+});
+
+
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.