Archilyzer · Source

archilyzer

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

commit 4715bc6a6af711219874b8be02228fc52009bea5
parent 5eb7f7af962d2780240d23a5ed49d88cba208fcd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:54:01 -0400

merge: editor/debts — clip eviction on the channel page, busy-aware rename/delete, sync-all skips unmounted drives, worker auth 503 asserted, S0-pause lands

C1–C6 of the curated-tags follow-ups release. S0-pause is re-implemented from
one-core/s0-pause (2aeb358c) as the spec: the four legacy pause fields and their
migration are gone, `held` defaults per lane (backfill true, the rest false) and is
asserted through the sanitizer. Every /api/test/* route now 404s unless the e2e harness
started the server (EDITOR_TEST_ROUTES=1). Reviewed by Opus; the blocker and both
should-fix findings landed as b69a6629..9fde71d2.

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

Diffstat:
M.gitignore | 5+++++
Mcommon/controller/autoRunner.ts | 4++--
Mcommon/controller/laneGuards.test.ts | 13++++++++++++-
Mcommon/controller/laneGuards.ts | 2+-
Mcommon/controller/operationBatch.test.ts | 64++++++++++++++++++++++++++++++++++++++++------------------------
Mcommon/controller/operationBatchRelocation.test.ts | 9++++++++-
Mcommon/controller/videoOperations.test.ts | 13++++++++-----
Mcommon/jobs/autoQueuePolicy.test.ts | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/jobs/autoQueuePolicy.ts | 34++++++++++++++++++++++++++--------
Mcommon/jobs/laneMigration.test.ts | 94-------------------------------------------------------------------------------
Mcommon/lib/autoQueueTypes.ts | 22+++++++++++++---------
Mcommon/lib/laneMigration.ts | 93+++++++++++--------------------------------------------------------------------
Mcommon/lib/operations.ts | 2+-
Mcommon/lib/pauseGates.test.ts | 145++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcommon/lib/pauseGates.ts | 46+++++++++++++++++++++-------------------------
Mcommon/lib/settings.ts | 54++++++++----------------------------------------------
Acommon/lib/settingsWrite.test.ts | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/workerToken.test.ts | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/views/autoQueueStatus.ts | 7++++---
Mcommon/views/widgetSync.ts | 4++--
Mcommon/views/workers.ts | 3++-
Meditor/CHANGELOG.md | 1+
Aeditor/app/api/test/_guard.ts | 26++++++++++++++++++++++++++
Meditor/app/api/test/invalidate-cache/route.ts | 12++++++++++--
Meditor/app/api/test/resume-lane/route.ts | 10++++++++--
Meditor/app/api/test/stuck-job/route.ts | 9+++++++--
Meditor/app/api/test/uncaught-count/route.ts | 5+++++
Aeditor/app/api/test/worker-token/route.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/[slug]/components/stages/StorageStage.tsx | 23+++++++++++++++++++++++
Meditor/app/channels/[slug]/page.tsx | 17++++++++++++++++-
Meditor/app/channels/actions.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Meditor/app/channels/components/DeleteChannelForm.tsx | 17+++++++++++++++--
Meditor/app/channels/components/RenameChannelForm.tsx | 18++++++++++++++++--
Meditor/app/components/lanes/LaneDeck.tsx | 11+++++++----
Meditor/app/operations/actions.ts | 2+-
Meditor/app/operations/components/settings/LaneSettingsForm.tsx | 10+++++-----
Meditor/app/operations/settingsActions.ts | 12++----------
Meditor/app/settings/actions.ts | 8--------
Meditor/app/storage/actions.ts | 19+++++++++++++++++++
Meditor/app/storage/components/ClipWindowsCard.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Meditor/e2e/attribution.spec.ts | 7++++++-
Meditor/e2e/auto-queue.spec.ts | 58++++++++++++++++++++++++++++++++--------------------------
Meditor/e2e/backfill.spec.ts | 20+++++++++++++++-----
Meditor/e2e/channel-line.spec.ts | 5++++-
Meditor/e2e/channel-rename.spec.ts | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Meditor/e2e/channel-storage.spec.ts | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Meditor/e2e/lane-runner.spec.ts | 10+++++++---
Meditor/e2e/operation-settings.spec.ts | 5++---
Meditor/e2e/ops-api.spec.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Meditor/e2e/widget.spec.ts | 10+++++++---
Meditor/package.json | 4++--
Mplans/tools/phase1-numbers.ts | 15+++++++++++----
52 files changed, 1286 insertions(+), 477 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -103,6 +103,11 @@ yarn-error.log* /editor/playwright-report/ /editor/test-results/ /editor/blob-report/ +# An operator's symlink out of the repo (plans/FACTS.md, "editor/content is a +# symlink out of the repo"): Tailwind's source detection follows it, Turbopack +# panics on globals.css, and `pnpm e2e` dies before the first spec. Ignored so +# it stops showing up as an untracked file somebody might commit. +/editor/content # export e2e fixtures and ephemeral state /export/test-public/ diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -198,7 +198,7 @@ export type AutoRunnerIdleReason = | "disabled" // policy.snoozeUntil is in the future — idle on purpose, not stopped. | "snoozed" - // The global downloads pause (settings.downloadsPaused). Download only. + // The download lane's own gate (`autoQueue.download.held`). Download only. | "downloads-paused" // diskGate refused to start more work. Download only. | "disk-gate" @@ -211,7 +211,7 @@ export type AutoRunnerIdleReason = // no-workers: pauseAll disables every worker, so by slot count the two look // identical, and only one of them is fixed by enabling a worker. | "workers-paused" - // The lane's own gate is shut (digestsPaused, or backfill.enabled false). + // The lane's own gate is shut (`autoQueue[lane].held`). // DISTINCT from downloads-paused, which names one specific flag, and from // capped, which is contention rather than intent — the fix for this one is a // click on the Resume button beside it. diff --git a/common/controller/laneGuards.test.ts b/common/controller/laneGuards.test.ts @@ -22,11 +22,22 @@ function settingsWith(patch: Partial<SiteSettings["digest"]>): SiteSettings { return { digest: { ...defaultDigest(), ...patch } } as SiteSettings; } +// The same partial object with the digest lane's gate shut. The gate is +// `autoQueue.digest.held` and nothing else since S0-pause deleted +// `digest.digestsPaused`; `isGateHeld` reads `autoQueue` with `?.`, which is +// what lets a {digest}-only cast keep working everywhere else in this file. +function heldWith(patch: Partial<SiteSettings["digest"]> = {}): SiteSettings { + return { + digest: { ...defaultDigest(), ...patch }, + autoQueue: { digest: { held: true } }, + } as unknown as SiteSettings; +} + test("a pause holds the lane, and beats every other reason", () => { const gate = digestGate({ // Paused AND over a spend cap: an operator who paused the lane must be told // about the pause, not sent to look at a billing setting. - settings: settingsWith({ digestsPaused: true, spendCapUsd: 1 }), + settings: heldWith({ spendCapUsd: 1 }), appLane: "remote-api", metered: true, costUsd: 99, diff --git a/common/controller/laneGuards.ts b/common/controller/laneGuards.ts @@ -12,7 +12,7 @@ import { transcriptionActivity } from "./digestYield"; // // This exists because of a sentence in backfillLaneOperations' own header: // -// "digest carries digestsPaused, the yield-to-transcription carve-out (with +// "digest carries its own pause gate, the yield-to-transcription carve-out (with // its CPU-worker exemption), spendCapUsd on the metered lane, the // remoteEnabled fail-fast, the engine probe() fail-fast … and not one of // them is expressible as backfillLimit()'s single scalar." diff --git a/common/controller/operationBatch.test.ts b/common/controller/operationBatch.test.ts @@ -17,6 +17,7 @@ import { type SiteSettings, } from "../lib/settings"; import type { Operation, OperationClassification } from "../lib/operations"; +import { withGateHeld } from "../lib/pauseGates"; // Run with: // pnpm --filter yt-dlp-transcript-common exec tsx --test common/controller/operationBatch.test.ts @@ -59,8 +60,15 @@ test("the shipped default is off, and holds no disk", () => { // Stated as a test because these are promises the feature makes: nothing runs // until an operator says so, and nothing re-downloads media without an // explicit opt-in. + // + // "Off" is now TWO keys on the lane, and both ship off: the arm + // (`autoQueue.backfill.enabled`) and the gate (`autoQueue.backfill.held`, + // which `defaultHeldFor` shuts because the `backfill.enabled` this line used + // to read was inverted and defaulted false). S0-pause deleted that field; it + // did not change what a fresh install does. + assert.equal(defaultSiteSettings().autoQueue.backfill.enabled, false); + assert.equal(defaultSiteSettings().autoQueue.backfill.held, true); const d = defaultBackfill(); - assert.equal(d.enabled, false); assert.equal(d.allowRedownload, false); assert.equal(d.concurrency, 1); // Concurrency shares clampPositiveInt's floor of 1, so a hand-edited 0 cannot @@ -246,9 +254,13 @@ function settingsWith(over: Partial<SiteSettings>): SiteSettings { return { ...defaultSiteSettings(), ...over }; } -test("the backfill lane holds when the lane switch is off, and says so", () => { +test("the backfill lane holds when its gate is shut, and says so", () => { + // THE GATE IS `autoQueue.backfill.held`. It was the inverted + // `backfill.enabled` — the lane's "master switch" — until slice 1.4 moved it + // onto the lane and S0-pause deleted the field; `withGateHeld` is the one + // writer, so this is the shape every control produces. const verdict = laneLimit( - settingsWith({ backfill: { ...defaultBackfill(), enabled: false } }), + withGateHeld(settingsWith({ backfill: defaultBackfill() }), "backfill", true), { lane: "backfill", operations: [], @@ -265,10 +277,15 @@ test("the backfill lane holds when the lane switch is off, and says so", () => { }); test("an enabled backfill lane with no GPU-bound operation runs at its slots", () => { + // Released explicitly: the backfill lane's gate DEFAULTS shut + // (`defaultHeldFor`), which is the reading its inverted `backfill.enabled` + // always gave a file that named no gate. const verdict = laneLimit( - settingsWith({ - backfill: { ...defaultBackfill(), enabled: true, concurrency: 3 }, - }), + withGateHeld( + settingsWith({ backfill: { ...defaultBackfill(), concurrency: 3 } }), + "backfill", + false, + ), { lane: "backfill", operations: [], @@ -291,9 +308,11 @@ test("the GPU carve-out is keyed on contendsFor, not on laneFor existing", () => // run. Now the declaration itself answers, so an operation with a fixed // `lane: { contendsFor: "gpu" }` and NO laneFor is caught, and one with a // laneFor resolving to CPU is not. - const settings = settingsWith({ - backfill: { ...defaultBackfill(), enabled: true, concurrency: 4 }, - }); + const settings = withGateHeld( + settingsWith({ backfill: { ...defaultBackfill(), concurrency: 4 } }), + "backfill", + false, + ); const cpuOnly = laneLimit(settings, { lane: "backfill", operations: [fakeOperation("cpu-op", "cpu")], @@ -318,9 +337,11 @@ test("the GPU carve-out is keyed on contendsFor, not on laneFor existing", () => // two engines allocate the same 8 GB card. assert.equal(gpuBound.limit, 4, "idle: nothing is transcribing in this process"); const weightless = laneLimit( - settingsWith({ - backfill: { ...defaultBackfill(), enabled: true, concurrency: 4 }, - }), + withGateHeld( + settingsWith({ backfill: { ...defaultBackfill(), concurrency: 4 } }), + "backfill", + false, + ), { lane: "backfill", operations: [fakeOperation("gpu-op", "gpu")], @@ -337,7 +358,7 @@ test("the GPU carve-out is keyed on contendsFor, not on laneFor existing", () => test("the digest lane holds while digests are paused", () => { const verdict = laneLimit( - settingsWith({ digest: { ...defaultDigest(), digestsPaused: true } }), + withGateHeld(settingsWith({ digest: defaultDigest() }), "digest", true), { lane: "digest", appLane: "local-gpu", @@ -352,14 +373,12 @@ test("the digest lane holds while digests are paused", () => { assert.equal(verdict.hold?.reason, "paused"); }); -// HELD IS A HOLD, NEVER A STOP — at the runner, through the new key. +// HELD IS A HOLD, NEVER A STOP — at the runner, both lanes at once. // -// The two tests above hold their lane through a RETIRED field (`backfill. -// enabled`, `digest.digestsPaused`); this one holds it through -// `autoQueue[lane].held`, which is where slice 1.4 put the gate. Both routes -// have to reach the same zero, because a settings file can spell either one: -// the retired fields are the migration's input until every lane's key is on -// disk. +// The two tests above each hold ONE lane through `autoQueue[lane].held`; this +// one walks both and pins that the other lane is untouched by it. There is no +// second spelling to cross-check any more: slice 1.4 moved the gate onto the +// lane and S0-pause deleted the four retired fields it had migrated from. // // WHAT "A HOLD" MEANS HERE is `limit: 0` with `hold.reason === "paused"`, which // is what makes runPool idle-wait and the runner report `lane-held` while @@ -374,10 +393,7 @@ function heldLane(lane: "digest" | "backfill", held: boolean): SiteSettings { const base = defaultSiteSettings(); return { ...base, - // The retired fields say the OPPOSITE of `held`, so a reader that still - // consulted them would fail this in both directions. - digest: { ...defaultDigest(), digestsPaused: !held }, - backfill: { ...defaultBackfill(), enabled: held, concurrency: 2 }, + backfill: { ...defaultBackfill(), concurrency: 2 }, autoQueue: { ...base.autoQueue, [lane]: { ...base.autoQueue[lane], held }, diff --git a/common/controller/operationBatchRelocation.test.ts b/common/controller/operationBatchRelocation.test.ts @@ -36,7 +36,14 @@ process.env.SETTINGS_FILE = SETTINGS_FILE; writeFileSync( SETTINGS_FILE, JSON.stringify({ - backfill: { enabled: true, allowRedownload: false, concurrency: 1 }, + // THE LANE'S GATE, SPELLED. It used to be spelled by `backfill.enabled: + // true` — the inverted retired field, where `true` meant NOT held — + // and S0-pause deleted it. The backfill lane's gate DEFAULTS shut + // (`defaultHeldFor`, which is the reading that field always gave a file + // naming no gate), so a fixture that needs the batch to dispatch says so + // on the lane. Without it `runOperationBatch` holds and never resolves. + autoQueue: { backfill: { enabled: true, held: false } }, + backfill: { allowRedownload: false, concurrency: 1 }, diarization: { enabled: true, segModel: "/models/seg-1.onnx", diff --git a/common/controller/videoOperations.test.ts b/common/controller/videoOperations.test.ts @@ -235,11 +235,14 @@ test("shownOnVideoPage: off with nothing on disk hides; off with a sidecar shows // Lives here rather than in operationBatch.test.ts because countOperationWork // reads settings from disk and this file already owns the settings seam. test("countOperationWork sizes an ids-scoped run to those ids alone", async () => { - writeSettings( - settingsOn({ - backfill: { enabled: true, weight: 1, concurrency: 1 }, - }), - ); + // `enabled` and `weight` used to live on this block and are BOTH deleted — + // `weight` by slice 1.3, `enabled` (the lane's inverted pause) by S0-pause. + // Left in, they would have read as nothing at all while looking like the + // switch that makes the lane run. What arms the lane's OPERATIONS is + // `settingsOn`'s diarization/attribution above; counting never asks the + // lane's gate, so there is no `autoQueue.backfill.held` to spell here either + // (a test that did need the lane running would say exactly that). + writeSettings(settingsOn({ backfill: { concurrency: 1 } })); const idA = "vid-count-a"; const idB = "vid-count-b"; await seed(idA); diff --git a/common/jobs/autoQueuePolicy.test.ts b/common/jobs/autoQueuePolicy.test.ts @@ -371,6 +371,69 @@ test("sanitizeAutoQueue: empty/garbage -> defaults", () => { assert.equal(def.transcription.root.children.length, 0); }); +// THE GATE'S DEFAULT, THROUGH THE PATH EVERY SETTINGS.JSON TAKES. +// +// `defaultAutoQueue()` is asserted elsewhere, but no file on disk is built from +// it: `getSettings` runs `sanitizeAutoQueue` over whatever the file says, and +// before S0-pause that sanitizer deliberately left `held` UNDEFINED so +// `isGateHeld` could fall back to the four retired pause fields. The fallback +// is gone, so this is now the only thing that decides what a lane naming no +// gate reads as — and getting it wrong is silent in both directions: a missing +// default would resume the backfill lane on every corpus, a blanket `true` +// would hold all four. +// +// `defaultHeldFor` is not a new policy. It is the reading the retired fields +// gave such a file, preserved: `transcriptionsPaused`, `downloadsPaused` and +// `digest.digestsPaused` all defaulted false (free), and `backfill.enabled` +// defaulted false and was read INVERTED — so the backfill lane has shipped held +// since it existed. +test("sanitizeAutoQueue: a lane naming no gate gets the default its retired field gave it", () => { + // Lanes PRESENT but empty — the shape a hand-edited file has, and the one a + // `value == null` shortcut to `defaultAutoQueuePolicy` would never reach. + const bare = sanitizeAutoQueue({ backfill: {}, digest: {} }); + assert.equal(bare.backfill.held, true, "backfill ships held"); + assert.equal(bare.digest.held, false); + // And the two lanes the object did not mention at all. + assert.equal(bare.transcription.held, false); + assert.equal(bare.download.held, false); + + // A PRE-`held` SETTINGS OBJECT: four real policies, every other field spelled, + // and no `held` key anywhere — which is every settings.json written before + // slice 1.4. It comes out at the defaults, the same answer the fallback used + // to compute, and nothing else about it moves. + const preHeld = { + transcription: { enabled: true, maxWorkers: 2, order: "listed" }, + download: { enabled: true, maxWorkers: 1, order: "newest" }, + digest: { enabled: false, maxWorkers: null, order: "cheapest" }, + backfill: { enabled: true, maxWorkers: 1, order: "listed" }, + }; + const out = sanitizeAutoQueue(preHeld); + assert.deepEqual( + [ + out.transcription.held, + out.download.held, + out.digest.held, + out.backfill.held, + ], + [false, false, false, true], + ); + // The gate is the only thing the sanitizer supplied: the arms and the orders + // it was given survive, so this cannot pass by rebuilding the policies. + assert.deepEqual( + [out.transcription.enabled, out.download.enabled, out.digest.enabled, out.backfill.enabled], + [true, true, false, true], + ); + assert.equal(out.download.order, "newest"); + assert.equal(out.transcription.maxWorkers, 2); + + // IDEMPOTENT. Re-sanitizing the sanitized object is identity for the gate — + // an explicit `false` on the backfill lane is a real answer, not an absence, + // so a second pass must not hold it again. + const twice = sanitizeAutoQueue({ ...out, backfill: { ...out.backfill, held: false } }); + assert.equal(twice.backfill.held, false); + assert.equal(sanitizeAutoQueue(out).backfill.held, true); +}); + test("sanitizeAutoQueue: coerces a hand-written tree, assigns missing ids", () => { const raw = { transcription: { diff --git a/common/jobs/autoQueuePolicy.ts b/common/jobs/autoQueuePolicy.ts @@ -668,6 +668,23 @@ function defaultOrderFor(lane: AutoQueueKind): AutoQueueOrder { return lane === "digest" ? "cheapest" : "listed"; } +// THE DEFAULT GATE FOR A LANE, and only one lane ships held. +// +// It is not a new policy — it is the reading the four retired pause fields gave +// a file that named no gate, preserved. `transcriptionsPaused`, +// `downloadsPaused` and `digest.digestsPaused` all defaulted false (free); +// `backfill.enabled` defaulted FALSE and was INVERTED, so the backfill lane has +// shipped HELD since it existed. S0-pause deleted the fields, which is what +// makes defaulting this key correct — and required, because from slice 1.4 until +// S0-pause an absent `held` had somewhere else to ask, and now it has not. +// +// The backfill lane is therefore off twice over on a fresh install: unarmed +// (`enabled: false`) and held. That is gate B — never enable the backfill lane +// against ~66,540 missingInput videos by accident — kept as two deliberate acts. +function defaultHeldFor(lane: AutoQueueKind): boolean { + return lane === "backfill"; +} + export function defaultAutoQueuePolicy( lane: AutoQueueKind = "transcription", ): AutoQueuePolicy { @@ -677,6 +694,7 @@ export function defaultAutoQueuePolicy( replaceAutoSubs: false, order: defaultOrderFor(lane), snoozeUntil: null, + held: defaultHeldFor(lane), root: defaultRootFor(lane), }; } @@ -721,14 +739,14 @@ function sanitizePolicy(value: unknown, lane: AutoQueueKind): AutoQueuePolicy { ? defaultOrderFor(lane) : sanitizeAutoQueueOrder(r.order), snoozeUntil: sanitizeSnooze(r.snoozeUntil), - // A BOOLEAN OR NOTHING, and deliberately NOT defaulted. `held` is the lane's - // pause gate (AutoQueuePolicy.held); every settings.json written before - // slice 1.4 carries the four legacy pause fields instead, and `undefined` - // is what makes `isGateHeld` fall back to them. Defaulting it to false here - // would read a paused corpus as running — the one bug this field could - // introduce. `undefined` also never reaches the file: JSON.stringify drops - // it, so a lane that has not been through getSettings' copy stays absent. - held: typeof r.held === "boolean" ? r.held : undefined, + // THE LANE'S PAUSE GATE, and the only spelling of one since S0-pause deleted + // the four legacy fields it migrated from. DEFAULTED, which it deliberately + // was not while those fields existed: an absent key used to mean "ask the + // retired field", so filling it in here would have read a paused corpus as + // running. There is nothing left to ask, and `defaultHeldFor` is the + // reading those fields gave a file that named no gate — free everywhere + // except backfill, whose field was inverted and defaulted to held. + held: typeof r.held === "boolean" ? r.held : defaultHeldFor(lane), root: r.root === undefined ? defaultRootFor(lane) diff --git a/common/jobs/laneMigration.test.ts b/common/jobs/laneMigration.test.ts @@ -2,12 +2,8 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { laneRootFromScope, - migrateHeldToLanes, migrateSweepsToLanes, } from "../lib/laneMigration"; -import { isGateHeld } from "../lib/pauseGates"; -import { defaultSiteSettings, type SiteSettings } from "../lib/settings"; -import { LANES, type AutoQueueSettings } from "../lib/autoQueueTypes"; import { sanitizeAutoQueue } from "./autoQueuePolicy"; // The migration is asserted THROUGH THE SANITIZER, because that is how it is @@ -301,93 +297,3 @@ test("blank and duplicate scope entries are dropped rather than becoming leaves" ], ); }); - -// --------------------------------------------------------------------------- -// The pause fields' migration (slice 1.4): four flags become one key per lane. - -// A settings object shaped like the live file: the four RETIRED pause fields -// spelled, no `held` anywhere. Everything else is the default. -function withLegacyPauses(over: Partial<SiteSettings> = {}): SiteSettings { - const base = defaultSiteSettings(); - return { - ...base, - transcriptionsPaused: true, - downloadsPaused: false, - digest: { ...base.digest, digestsPaused: false }, - // The live file's value, and the one that makes this a real test: `enabled` - // is INVERTED, so `true` here must come out as `held: false`. - backfill: { ...base.backfill, enabled: true }, - ...over, - }; -} - -const heldByLane = (autoQueue: AutoQueueSettings) => - Object.fromEntries(LANES.map((l) => [l, autoQueue[l].held])); - -test("the live shape: transcriptionsPaused becomes transcription.held", () => { - const settings = withLegacyPauses(); - // The precondition — nothing carries a `held` before the copy, which is what - // makes the fallback in isGateHeld the thing answering today. - assert.deepEqual(heldByLane(settings.autoQueue), { - transcription: undefined, - download: undefined, - digest: undefined, - backfill: undefined, - }); - assert.deepEqual(heldByLane(migrateHeldToLanes(settings)), { - transcription: true, - download: false, - digest: false, - backfill: false, - }); -}); - -test("a lane that already carries `held` is untouched, false included", () => { - // `false` is an ANSWER, not an absence. Coercing it would let a retired field - // resurrect a pause an operator had lifted — and there is no version marker - // to tell "migrated to false" from "never migrated" apart from this. - const base = withLegacyPauses(); - const settings: SiteSettings = { - ...base, - autoQueue: { - ...base.autoQueue, - transcription: { ...base.autoQueue.transcription, held: false }, - backfill: { ...base.autoQueue.backfill, held: true }, - }, - }; - assert.deepEqual(heldByLane(migrateHeldToLanes(settings)), { - transcription: false, - download: false, - digest: false, - // Kept, though `backfill.enabled: true` says the opposite. - backfill: true, - }); -}); - -test("the pause migration is idempotent, by identity on the second pass", () => { - const settings = withLegacyPauses(); - const once = migrateHeldToLanes(settings); - const twice = migrateHeldToLanes({ ...settings, autoQueue: once }); - assert.equal(twice, once, "a migrated file must not be rebuilt"); -}); - -test("the pause migration never unholds a lane", () => { - // Every combination of the four retired fields: the copied value is exactly - // what isGateHeld was already returning, so no reading of a settings file can - // change on the day this runs. - for (let mask = 0; mask < 16; mask++) { - const base = defaultSiteSettings(); - const settings: SiteSettings = { - ...base, - transcriptionsPaused: (mask & 1) !== 0, - downloadsPaused: (mask & 2) !== 0, - digest: { ...base.digest, digestsPaused: (mask & 4) !== 0 }, - backfill: { ...base.backfill, enabled: (mask & 8) === 0 }, - }; - const before = LANES.map((l) => isGateHeld(settings, l)); - const after = LANES.map((l) => - isGateHeld({ ...settings, autoQueue: migrateHeldToLanes(settings) }, l), - ); - assert.deepEqual(after, before, `mask ${mask}`); - } -}); diff --git a/common/lib/autoQueueTypes.ts b/common/lib/autoQueueTypes.ts @@ -114,15 +114,19 @@ export type AutoQueuePolicy = { // lib/pauseGates.ts, whose limit()/guard returns 0 so runPool idle-waits. A // hold, never a stop — see that file's header. // - // OPTIONAL, AND ABSENT IS NOT `false`. Until slice 1.4 four separate settings - // fields carried this — `transcriptionsPaused`, `downloadsPaused`, - // `digest.digestsPaused` and (inverted) `backfill.enabled` — and every - // settings.json in existence still spells those and not this. So `undefined` - // means "ask the legacy field", which is exactly what `isGateHeld` does, and - // the sanitizer must NOT default it to false: doing so would read every - // pre-1.4 file as "no lane held" and quietly resume a paused corpus. - // `getSettings` copies the legacy answer onto this key on read, so the next - // write persists it and the fallback stops being consulted. + // OPTIONAL IN THE TYPE, FILLED BY THE SANITIZER. Until slice 1.4 four separate + // settings fields carried this — `transcriptionsPaused`, `downloadsPaused`, + // `digest.digestsPaused` and (inverted) `backfill.enabled` — so `undefined` + // meant "ask the legacy field" and `sanitizePolicy` deliberately refused to + // default it: a default would have read a paused corpus as running. S0-pause + // deleted those four, on the precondition that the live settings.json already + // carried every `held` key, and the default came in with them + // (`defaultHeldFor` — free everywhere except backfill, whose field was + // inverted and shipped held). + // + // It stays optional because a reader may be handed a PARTIAL settings object + // (laneGuards.test.ts casts one), and `isGateHeld` answers `false` for a lane + // that carries no key at all rather than throwing. held?: boolean; root: AutoQueueGroup; }; diff --git a/common/lib/laneMigration.ts b/common/lib/laneMigration.ts @@ -1,9 +1,15 @@ -// THE RETIRED FIELDS, ON READ. Two migrations live here, one per slice, and -// they share a shape: a settings key that used to live somewhere else is filled -// in from the field it replaced, in getSettings, when — and only when — the new -// spelling is absent. Slice 1.3's is the sweeps' scope becoming a lane's tree; -// slice 1.4's is four pause flags becoming one `held` per lane, at the foot of -// the file. +// THE RETIRED FIELDS, ON READ. A settings key that used to live somewhere else +// is filled in from the field it replaced, in getSettings, when — and only when +// — the new spelling is absent. What survives here is slice 1.3's: the sweeps' +// scope becoming a lane's tree. +// +// SLICE 1.4's PAUSE MIGRATION USED TO LIVE AT THE FOOT OF THIS FILE and is +// gone (slice S0-pause). It copied `transcriptionsPaused`, `downloadsPaused`, +// `digest.digestsPaused` and the inverted `backfill.enabled` onto +// `autoQueue[lane].held` for one release; those four fields no longer exist in +// `SiteSettings`, so there is nothing left to copy and `isGateHeld` reads the +// key alone. A settings.json still spelling one is read past and loses it on +// the next write. // // THE SWEEPS' LAST ACT: their persisted scope becomes a lane's tree. // @@ -45,14 +51,10 @@ // would be a second implementation of the sanitizer, and the two would drift. import { - LANES, type AutoQueueGroup, - type AutoQueueKind, type AutoQueueLeaf, type AutoQueueMatch, - type AutoQueueSettings, } from "./autoQueueTypes"; -import type { SiteSettings } from "./settings"; // A lane's scope in the terms the sweeps used: some channels, some operations. // Both empty means "everything", which is what an unscoped sweep meant. @@ -213,74 +215,3 @@ export function migrateSweepsToLanes( return autoQueue; } - -// --------------------------------------------------------------------------- -// THE PAUSE FIELDS' LAST ACT: four settings flags become one key per lane. -// -// Same shape as the sweep migration above, one slice later and one level down. -// A lane's gate lived in four unrelated fields with three different polarities -// (`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused`, and -// `backfill.enabled` INVERTED); it lives on `autoQueue[lane].held` now, which -// is where every other per-lane switch already lives. -// -// WHY THE LEGACY READ LIVES HERE AND NOT IN pauseGates.ts, which is otherwise -// the only place polarity is known: `getSettings` has to run this on every read -// of settings.json, and pauseGates.ts imports lib/operations.ts (for -// pauseLaneFor), which imports the controller layer. Pulling that into every -// reader of settings.json — bin/ scripts, the MCP server, the export build — -// to fill in one boolean would be a much larger change than the boolean is -// worth. So the RETIRED fields are read here, beside the other retired fields, -// and `isGateHeld` delegates to this function for its fallback. There is still -// exactly one place that knows the inversion. - -// The gate as the four RETIRED fields spell it. Touches only the named lane's -// field: laneGuards.test.ts casts a `{ digest }`-only object to SiteSettings, -// so a reader that reached for `settings.backfill` on the way past would throw. -export function legacyGateHeld( - settings: SiteSettings, - lane: AutoQueueKind, -): boolean { - switch (lane) { - case "transcription": - // The PERSISTED intent, not the live pool. See pauseGates.ts's header. - return settings.transcriptionsPaused === true; - case "download": - return settings.downloadsPaused === true; - case "digest": - return settings.digest.digestsPaused === true; - case "backfill": - // INVERTED, and this line is why pauseGates.ts exists. - return settings.backfill.enabled === false; - } -} - -// Copy the legacy answer onto `autoQueue[lane].held` for every lane that does -// not already carry one, so the next write persists it and the fallback stops -// being consulted. -// -// THREE RULES, the same three the sweep migration keeps: -// -// 1. IT NEVER UNHOLDS A LANE. The value copied is the one `isGateHeld` was -// already returning, so this moves where the answer is stored and never -// what it is. The live corpus has `transcriptionsPaused: true`; a -// migration that read it as "not held" would resume the GPU by itself. -// 2. A LANE THAT ALREADY CARRIES `held` IS UNTOUCHED — including a `false`, -// which is a real answer and not an absence. Idempotent by construction. -// 3. IT RETURNS THE INPUT OBJECT WHEN NOTHING CHANGED, so "this file was -// already migrated" is identity, not a deep compare. -// -// Takes the MERGED, sanitized settings rather than the parsed file: unlike the -// sweep migration, "absent" here is a property of the sanitized policy (which -// deliberately leaves `held` undefined) and the legacy fields it reads have -// been through their own sanitizers by this point. -export function migrateHeldToLanes(settings: SiteSettings): AutoQueueSettings { - const out = { ...settings.autoQueue }; - let changed = false; - for (const lane of LANES) { - const policy = out[lane]; - if (!policy || typeof policy.held === "boolean") continue; - out[lane] = { ...policy, held: legacyGateHeld(settings, lane) }; - changed = true; - } - return changed ? out : settings.autoQueue; -} diff --git a/common/lib/operations.ts b/common/lib/operations.ts @@ -1615,7 +1615,7 @@ export function allOperations(settings: SiteSettings): Operation[] { // backfillLimit(). Handing it digest would SERIALIZE the GPU digest lane // behind CPU diarization, when the entire reason they hold separate queue // keys is that they currently overlap. -// - GUARDS. digest carries digestsPaused, the yield-to-transcription +// - GUARDS. digest carries its own pause gate, the yield-to-transcription // carve-out (with its CPU-worker exemption), spendCapUsd on the metered // lane, the remoteEnabled fail-fast, shortest-first ordering, // duplicate-cluster sharing and the engine probe() fail-fast. Those are diff --git a/common/lib/pauseGates.test.ts b/common/lib/pauseGates.test.ts @@ -1,8 +1,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { + defaultBackfill, defaultDigest, defaultSiteSettings, + sanitizeBackfill, + sanitizeDigest, type SiteSettings, } from "./settings"; import { operationCatalog } from "./operations"; @@ -16,14 +19,16 @@ import { // Run with: node_modules/.bin/tsx --test common/lib/pauseGates.test.ts // // What these pin is the thing the file exists to stop: a second opinion about -// polarity. `backfill.enabled` is inverted and three readers used to spell that -// inversion for themselves, so the round trip and the explicit fallback case -// below are the contract, not decoration. +// polarity. `backfill.enabled` was inverted and three readers used to spell +// that inversion for themselves, so the round trip below is the contract, not +// decoration. // -// Since slice 1.4 there are TWO places a gate can be spelled — `autoQueue[lane] -// .held` and the retired field it replaced — which makes precedence part of -// that contract: the key wins, an absent key asks the field, and a write never -// touches the field. Those are the three tests after the round trip. +// Slice 1.4 moved the gate onto `autoQueue[lane].held` and read the four +// retired fields as a fallback; S0-pause deleted them once the live +// settings.json carried every key. So there is ONE place a gate can be spelled, +// and the two tests after the round trip pin the deletion from both ends: a +// file that still spells a retired field cannot hold a lane with it, and no +// sanitizer will carry that field back onto disk. const LANES: PauseLane[] = [ "transcription", @@ -51,53 +56,88 @@ test("held round-trips through withGateHeld on every lane", () => { } }); -test("a lane with no `held` falls back to its retired field, inversion and all", () => { - // The migration input, pinned. Every settings.json written before slice 1.4 - // spells these four and no `held`, so this is the answer the whole corpus is - // read with until its first write — and backfill's is INVERTED. +test("a settings.json still spelling a retired pause field cannot hold a lane", () => { + // THE DELETION, FROM THE READ SIDE. Until S0-pause an absent `held` asked + // four fields — `transcriptionsPaused`, `downloadsPaused`, + // `digest.digestsPaused` and the INVERTED `backfill.enabled`. They are gone + // from SiteSettings, so a hand-edited or long-unwritten file that still + // carries them is read past entirely. + // + // EVERY VALUE BELOW IS THE OPPOSITE OF THE ANSWER, which is what makes this a + // test rather than a coincidence: under the old fallback these four said + // held/held/held/free, and what comes out is the lane defaults — + // free/free/free/held. A reader that still consulted them fails on all four. + // + // The precondition that made the deletion safe is recorded in + // one-core-phase-2.md §S0-pause: the live settings.json carried all four + // `held` keys before the fields went, so nothing paused came back running. const base = defaultSiteSettings(); - for (const lane of LANES) { - assert.equal(base.autoQueue[lane].held, undefined, `${lane} defaults held`); - } - const legacy: SiteSettings = { + const stale = { ...base, transcriptionsPaused: true, - downloadsPaused: false, + downloadsPaused: true, digest: { ...base.digest, digestsPaused: true }, - // enabled TRUE is NOT held — the one line the whole file exists for. backfill: { ...base.backfill, enabled: true }, - }; - assert.equal(isGateHeld(legacy, "transcription"), true); - assert.equal(isGateHeld(legacy, "download"), false); - assert.equal(isGateHeld(legacy, "digest"), true); - assert.equal(isGateHeld(legacy, "backfill"), false); + } as unknown as SiteSettings; + assert.deepEqual( + LANES.map((lane) => isGateHeld(stale, lane)), + [false, false, false, true], + ); + // The lane defaults are where those answers come from, not the stale object. + assert.deepEqual( + LANES.map((lane) => isGateHeld(base, lane)), + [false, false, false, true], + ); + // And the key still answers on the same object: the retired fields are inert, + // not consulted-and-outranked. + assert.equal(isGateHeld(withGateHeld(stale, "digest", true), "digest"), true); + assert.equal( + isGateHeld(withGateHeld(stale, "backfill", false), "backfill"), + false, + ); +}); + +test("no sanitizer names a retired pause field", () => { + // THE PARTS `writeSettings` IS BUILT FROM. Its output is ONLY the known + // operational fields — `sanitizeDigest`, `sanitizeBackfill` and an explicit + // top-level literal — so a field no sanitizer names cannot survive a save. + // + // THIS IS NOT THE SAVE ITSELF, and the distinction matters: `writeSettings` + // writes `getPaths().settingsFile`, which memoizes at module scope, so a + // round trip needs an env seam this pure file deliberately does not have. + // `lib/settingsWrite.test.ts` is that round trip — it saves an object + // spelling all four retired fields into a temp dir and reads the file back — + // and the e2e "a retired pause field holds nothing, and does not survive a + // write" proves the same thing through a real editor saving a real file from + // a page. What is asserted HERE is the pieces: nothing that composes the + // output knows those names. + const digest = sanitizeDigest({ + ...defaultDigest(), + digestsPaused: true, + } as unknown) as Record<string, unknown>; + assert.equal("digestsPaused" in digest, false); + const backfill = sanitizeBackfill({ + ...defaultBackfill(), + enabled: false, + } as unknown) as Record<string, unknown>; + assert.equal("enabled" in backfill, false); + const base = defaultSiteSettings() as unknown as Record<string, unknown>; + assert.equal("transcriptionsPaused" in base, false); + assert.equal("downloadsPaused" in base, false); }); -test("`held` wins over the retired field, in both directions", () => { - // Once the key is on disk the retired field is dead config. A reader that - // still consulted it would resurrect a pause an operator had lifted (or the - // reverse), which is precisely why every writer of those fields was rewritten - // in the same slice. +test("the backfill lane ships held, as its inverted field always made it", () => { + // The one lane whose default gate is shut, and the reason is continuity: the + // retired `backfill.enabled` defaulted FALSE and `isGateHeld` read it + // inverted, so every settings.json that never named a gate has read this lane + // as held since it existed. `defaultHeldFor` is that reading, kept. const base = defaultSiteSettings(); - const disagreeing: SiteSettings = { - ...base, - transcriptionsPaused: true, - digest: { ...base.digest, digestsPaused: true }, - backfill: { ...base.backfill, enabled: true }, - }; - const held = withGateHeld( - withGateHeld(withGateHeld(disagreeing, "transcription", false), "digest", false), - "backfill", - true, - ); - assert.equal(isGateHeld(held, "transcription"), false); - assert.equal(isGateHeld(held, "digest"), false); - assert.equal(isGateHeld(held, "backfill"), true); - // And the retired fields are NOT rewritten to agree: withGateHeld writes the - // new key only. - assert.equal(held.transcriptionsPaused, true); - assert.equal(held.digest.digestsPaused, true); - assert.equal(held.backfill.enabled, true); + assert.equal(base.autoQueue.backfill.held, true); + assert.equal(base.autoQueue.backfill.enabled, false, "and unarmed as well"); + for (const lane of LANES) { + if (lane === "backfill") continue; + assert.equal(base.autoQueue[lane].held, false, `${lane} ships free`); + } }); test("holding a lane leaves the rest of its policy alone", () => { @@ -149,11 +189,16 @@ test("holding a lane leaves the rest of its policy alone", () => { ); }); -test("isGateHeld touches only its own lane's field", () => { - // laneGuards.test.ts casts a {digest}-only object to SiteSettings, so a - // reader that reached for settings.backfill on the way past would throw. - const partial = { digest: { ...defaultDigest(), digestsPaused: true } } as SiteSettings; +test("isGateHeld answers for a partial settings object", () => { + // laneGuards.test.ts casts a {digest, autoQueue}-only object to SiteSettings, + // so a reader that reached for settings.backfill on the way past would throw. + // `autoQueue` itself is read with `?.` for the same reason. + const partial = { + digest: { ...defaultDigest() }, + autoQueue: { digest: { held: true } }, + } as unknown as SiteSettings; assert.equal(isGateHeld(partial, "digest"), true); + assert.equal(isGateHeld({} as SiteSettings, "digest"), false); }); test("pauseLaneFor answers for every catalog id", () => { diff --git a/common/lib/pauseGates.ts b/common/lib/pauseGates.ts @@ -1,6 +1,5 @@ import type { SiteSettings } from "./settings"; import type { AutoQueueKind } from "./autoQueueTypes"; -import { legacyGateHeld } from "./laneMigration"; import { operationCatalog } from "./operations"; import { BACKFILL_QUEUE, @@ -12,11 +11,12 @@ import { // // Four lanes can be held. Their flag lived in four unrelated settings fields // read with three different polarities in nine places — `backfill.enabled` in -// particular is INVERTED, held means false, and every reader that forgot it +// particular was INVERTED, held meant false, and every reader that forgot it // reported a held lane as idle. This file was the one place that polarity was -// known; since slice 1.4 the gate is ONE KEY ON THE LANE, `autoQueue[lane].held`, -// and the four fields survive only as this file's read-time fallback (defined, -// with the inversion, in lib/laneMigration.ts beside the other retired fields). +// known; since slice 1.4 the gate is ONE KEY ON THE LANE, +// `autoQueue[lane].held`, and since S0-pause the four fields are DELETED: they +// are not in `SiteSettings`, no sanitizer keeps them, and `writeSettings` +// drops them from the file. There is no second spelling of a pause left. // // A PAUSE IS A HOLD, NEVER A STOP. Every gate below is consulted at DISPATCH // time by a limit() that returns 0, which makes runPool idle-wait: the job stays @@ -82,33 +82,29 @@ export function pauseLaneFor(operationId: string): PauseLane | null { // its pause was the one per-lane switch living somewhere else, in four fields // with three polarities. // -// THE FALLBACK IS THE MIGRATION. Every settings.json written before slice 1.4 -// carries the retired fields and no `held`, so an absent `held` — and only an -// absent one — asks `legacyGateHeld`. `getSettings` copies that answer onto the -// key on read, so the first write after this slice persists it and the fallback -// goes quiet; the fields themselves are deleted in a later slice, once the live -// file carries all four `held` keys. +// ONE FIELD ANSWERS, and that is now the whole rule. Slice 1.4 read an absent +// key through the four retired pause fields and `getSettings` copied the answer +// onto the lane; S0-pause deleted both, on the precondition that the live +// settings.json already carried all four `held` keys (it did — the migration +// had persisted them on its first write). A file that names no gate is not read +// past in silence either: `sanitizePolicy` fills the key with `defaultHeldFor`, +// which is the reading the retired fields gave such a file — free everywhere +// except backfill, whose field was inverted and shipped held. So this is a +// `=== true` on a key the sanitizer has already settled. // -// `autoQueue` is read defensively: laneGuards.test.ts casts a `{ digest }`-only -// object to SiteSettings, and this must answer for it the way it always has. +// `autoQueue` is read defensively: laneGuards.test.ts casts a partial object to +// SiteSettings, and this must answer for it the way it always has. export function isGateHeld(settings: SiteSettings, lane: PauseLane): boolean { - const held = settings.autoQueue?.[lane]?.held; - if (typeof held === "boolean") return held; - return legacyGateHeld(settings, lane); + return settings.autoQueue?.[lane]?.held === true; } // Set a lane's gate, returning a NEW settings object. Pure — no I/O; the caller // writes it. // -// IT WRITES THE NEW KEY ONLY, and never the retired field it replaced. Those -// fields are migration INPUT now: once `held` is on disk, `isGateHeld` stops -// reading them, so a writer that also flipped `downloadsPaused` would be -// maintaining a value nothing consults — which is how two sources of truth -// start. The one other CONTROL over a lane's gate — "Run the backfill lane", -// in operations/settingsActions.ts — comes through here too, for the -// mirror-image reason: a form still flipping the retired field would now be -// silently ignored. (The two settings forms that merely PRESERVE a retired -// field are not writers; they preserve the whole autoQueue beside it.) +// IT WRITES THE ONE KEY, and there is no longer a second field it could also +// write: the four retired flags are deleted. The one other CONTROL over a +// lane's gate — "Run the backfill lane", in operations/settingsActions.ts — +// comes through here too, so the two controls cannot answer differently. // // SPREAD-AND-OVERRIDE, never a rebuilt literal: the policy also carries the // lane's TREE, its order and its snooze, and a literal here would drop an diff --git a/common/lib/settings.ts b/common/lib/settings.ts @@ -25,10 +25,7 @@ import { sanitizeChannelPriority, type ChannelPriority, } from "./channelPriority"; -import { - migrateHeldToLanes, - migrateSweepsToLanes, -} from "./laneMigration"; +import { migrateSweepsToLanes } from "./laneMigration"; import { INTERNAL_LOCATION_ID, migrateMediaRootToLocations, @@ -162,16 +159,6 @@ export type SiteSettings = { // video once the stream ends. Per-channel override available // (ChannelConfig.skipLiveDownloads). skipLiveDownloads: boolean; - // Global, restart-surviving pause for transcription workers. When true, the - // worker pool is pause-all'd at boot (editor/instrumentation.ts) so no new - // transcriptions start until resumed. The Workers-page Pause button and the - // dashboard toggle both persist this. Default false. See pauseLaneAction. - transcriptionsPaused: boolean; - // Global, restart-surviving pause for downloads. When true, the auto-download - // runner skips dispatch (checked each loop iteration, like `enabled`) and - // manual download-bearing pipeline actions return a "Downloads are paused" - // result. Enumeration/store-playlist stay allowed. Default false. - downloadsPaused: boolean; // Whether the transcribed-audio cleanup sweep checks each candidate is still // available upstream before deleting its audio, pinning (do-not-clean) any // video found permanently gone. The delete is irreversible and a gone video's @@ -320,9 +307,6 @@ export type AttributionSettings = { // yield is the operation's declared `contendsFor`. Slice 1.3 retired the // `weight` scalar that used to mean both — see backfillLimit(). export type BackfillSettings = { - // Master switch for the lane. Off means the registry still REPORTS what is - // missing (that is the indicator's whole job) but nothing runs. - enabled: boolean; // Slots the lane may use when it is not standing aside. Kept at 1 by default // for the same reason diarization.concurrency is: this is CPU-bound work // competing with GPU feeding and the digest sweep for the same 8 threads. @@ -473,10 +457,6 @@ export type DigestSettings = { // Per-app config, keyed by app id — the same id-keyed sub-record shape as // transcriptionApps. apps: Record<string, DigestAppConfig>; - // Global pause. Read at DISPATCH time by the batch (the downloadsPaused - // pattern), so a pause survives a restart with no boot hook — unlike - // transcriptionsPaused, which needs editor/instrumentation.ts to re-apply it. - digestsPaused: boolean; // Yield the GPU to the transcription lane: while transcription is working, the // digest batch's limit() returns 0 and the pool idle-waits. ON by default, // because `digest:local` is deliberately on a different queue from @@ -1013,7 +993,6 @@ export function defaultDigest(): DigestSettings { // maxCuesForContext sizes the chunk to it). Seeding a copy of those values // here would give the same number two homes and let them drift. apps: {}, - digestsPaused: false, // ON. Real GPU contention with the transcription engine is a genuine cost // (re-priced: 11.2 s/chunk idle against 24.9 s/chunk on a contended box), so // the safe default is to step aside; turning it off is the deliberate choice. @@ -1087,7 +1066,6 @@ export function sanitizeDigest(value: unknown): DigestSettings { ? r.remoteAppId.trim() : d.remoteAppId, apps: sanitizeDigestApps(r.apps), - digestsPaused: r.digestsPaused === true, // Defaults to ON when absent — `=== false` rather than `!== true`, so a // settings file written before this field existed keeps the GPU-safe // behaviour instead of silently opting into contention. @@ -1182,8 +1160,6 @@ function defaults(): SiteSettings { parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT, inlineTranscribeOnFallback: false, skipLiveDownloads: true, - transcriptionsPaused: false, - downloadsPaused: false, verifyAvailabilityBeforeClean: true, buildArchives: true, archiveStorage: { bucket: "", publicBaseUrl: "" }, @@ -1215,7 +1191,6 @@ export function defaultSiteSettings(): SiteSettings { export function defaultBackfill(): BackfillSettings { return { - enabled: false, concurrency: 1, // See BackfillSettings.allowRedownload — this one holds disk. allowRedownload: false, @@ -1227,7 +1202,6 @@ export function sanitizeBackfill(value: unknown): BackfillSettings { if (!value || typeof value !== "object") return d; const r = value as Record<string, unknown>; return { - enabled: r.enabled === true, // Clamped rather than rejected: a hand-edited 5 means "as much as possible", // and reading it as 0 would be the opposite of the intent. concurrency: clampPositiveInt(r.concurrency, d.concurrency, 16), @@ -1544,12 +1518,6 @@ export function getSettings(): SiteSettings { if (typeof merged.skipLiveDownloads !== "boolean") { merged.skipLiveDownloads = true; } - if (typeof merged.transcriptionsPaused !== "boolean") { - merged.transcriptionsPaused = false; - } - if (typeof merged.downloadsPaused !== "boolean") { - merged.downloadsPaused = false; - } if (typeof merged.verifyAvailabilityBeforeClean !== "boolean") { merged.verifyAvailabilityBeforeClean = true; } @@ -1602,17 +1570,6 @@ export function getSettings(): SiteSettings { merged.diarization = sanitizeDiarization(merged.diarization); merged.backfill = sanitizeBackfill(merged.backfill); merged.attribution = sanitizeAttribution(merged.attribution); - // THE PAUSE FIELDS, ON READ. `autoQueue[lane].held` is the gate since slice - // 1.4; the four retired fields (`transcriptionsPaused`, `downloadsPaused`, - // `digest.digestsPaused` and the inverted `backfill.enabled`) fill it in for - // any lane whose policy does not carry one, so the next write persists the - // key and `isGateHeld` stops consulting them. It never unholds a lane — the - // value copied is the one isGateHeld was already returning. - // - // AFTER the digest and backfill sanitizers above, not beside the sweep - // migration: two of the four fields it reads live in those blocks, and it - // must read them normalized. See lib/laneMigration.ts. - merged.autoQueue = migrateHeldToLanes(merged); // Workers. When the file predates the worker model (no `workers` key), // synthesize a default list from the (now-settled) active app + per-app // configs so existing installs behave identically. Otherwise sanitize the @@ -1752,6 +1709,13 @@ export async function writeSettings(next: SiteSettings): Promise<void> { // `next`: getSettings() spreads the raw file, so a settings.json still // carrying pre-multi-site keys (siteTitle/groups/socialLinks) would otherwise // smuggle those stale keys back onto disk on every save. + // + // THIS IS ALSO WHAT RETIRES A FIELD. The four legacy pause flags + // (`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and the + // inverted `backfill.enabled`) are gone from the type, from the sanitizers + // and from this literal, so a settings.json that still spells one is read + // past on load and loses it on the next write. The gate is + // `autoQueue[lane].held` and nothing else — see lib/pauseGates.ts. const merged: SiteSettings = { adminTitle: typeof next.adminTitle === "string" && next.adminTitle.trim() @@ -1781,8 +1745,6 @@ export async function writeSettings(next: SiteSettings): Promise<void> { ), inlineTranscribeOnFallback: next.inlineTranscribeOnFallback === true, skipLiveDownloads: next.skipLiveDownloads !== false, - transcriptionsPaused: next.transcriptionsPaused === true, - downloadsPaused: next.downloadsPaused === true, verifyAvailabilityBeforeClean: next.verifyAvailabilityBeforeClean !== false, buildArchives: next.buildArchives !== false, diff --git a/common/lib/settingsWrite.test.ts b/common/lib/settingsWrite.test.ts @@ -0,0 +1,94 @@ +// WHAT A SAVE PUTS ON DISK. +// +// Run with: node_modules/.bin/tsx --test common/lib/settingsWrite.test.ts +// +// Its own file because it needs a SETTINGS SEAM: `writeSettings` writes +// `getPaths().settingsFile`, and `getPaths()` memoizes its first answer at +// module scope — so SETTINGS_FILE has to be set before anything can import the +// module under test. Same arrangement as controller/videoOperations.test.ts, +// and the reason the module is imported dynamically below. +// +// THE CLAIM IT PINS is the write half of S0-pause: the four retired pause +// fields (`transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused`, +// the inverted `backfill.enabled`) cannot survive a save, because +// `writeSettings` builds its output from ONLY the known operational fields and +// none of them is one any more. A read-side test can say the fields are +// ignored; only this can say they are GONE, which is what stops a stale file +// resurrecting a pause an operator has lifted. + +import { mkdtempSync, readFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +const ROOT = mkdtempSync(path.join(os.tmpdir(), "settings-write-")); +process.env.TRANSCRIPTS_DIR = ROOT; +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); + +// Type-only, so it is erased and cannot execute the module before the env above +// is set; the VALUES come through the dynamic import below. +import type { SiteSettings } from "./settings"; + +const { defaultSiteSettings, writeSettings } = await import("./settings"); +const { isGateHeld } = await import("./pauseGates"); + +after(() => rm(ROOT, { recursive: true, force: true })); + +type Stale = Record<string, unknown>; + +test("a save drops every retired pause field and keeps the lane's key", async () => { + // The pre-1.4 spelling, all four, each saying the OPPOSITE of the lane + // defaults: held/held/held/free against free/free/free/held. So a save that + // carried any of them through would be visible in both directions. + const base = defaultSiteSettings(); + const stale = { + ...base, + transcriptionsPaused: true, + downloadsPaused: true, + digest: { ...base.digest, digestsPaused: true }, + backfill: { ...base.backfill, enabled: true }, + } as unknown as SiteSettings; + + await writeSettings(stale); + + const onDisk = JSON.parse( + readFileSync(process.env.SETTINGS_FILE!, "utf8"), + ) as Stale; + assert.equal("transcriptionsPaused" in onDisk, false); + assert.equal("downloadsPaused" in onDisk, false); + assert.equal("digestsPaused" in (onDisk.digest as Stale), false); + assert.equal("enabled" in (onDisk.backfill as Stale), false); + + // And what DID land is the gate, one key per lane, at the lane defaults — + // so the next reader gets the same answer this object gave. + const autoQueue = onDisk.autoQueue as Record<string, { held?: boolean }>; + assert.deepEqual( + ["transcription", "download", "digest", "backfill"].map( + (lane) => autoQueue[lane]?.held, + ), + [false, false, false, true], + ); +}); + +test("a held lane survives its own round trip", async () => { + // The other direction, because "the field is dropped" would also be true of a + // save that dropped the gate with it. + const base = defaultSiteSettings(); + const held: SiteSettings = { + ...base, + autoQueue: { + ...base.autoQueue, + digest: { ...base.autoQueue.digest, held: true }, + backfill: { ...base.autoQueue.backfill, held: false }, + }, + }; + await writeSettings(held); + const onDisk = JSON.parse( + readFileSync(process.env.SETTINGS_FILE!, "utf8"), + ) as Stale; + const back = { ...base, autoQueue: onDisk.autoQueue } as SiteSettings; + assert.equal(isGateHeld(back, "digest"), true); + assert.equal(isGateHeld(back, "backfill"), false); +}); diff --git a/common/lib/workerToken.test.ts b/common/lib/workerToken.test.ts @@ -0,0 +1,121 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + authorizeWorkerRequest, + getWorkerToken, + workerEndpointEnabled, +} from "./workerToken"; + +// Run with: node_modules/.bin/tsx --test common/lib/workerToken.test.ts +// +// THE 503 BRANCH HAD NO COVERAGE ANYWHERE, and it is the one that decides +// whether an instance is an open transcription server. The e2e suite cannot +// reach it: the test server boots with WORKER_TOKEN=test-worker-token +// (editor/package.json, dev:test) and one server serves the whole suite, so no +// spec can observe the endpoint disabled without restarting it. That left the +// most consequential of the three answers — "unset means OFF, not means open" — +// asserted by nothing at all. +// +// `getWorkerToken()` reads `process.env` on every call, never at import, which +// is exactly what makes this testable: set the variable, ask, restore. Each +// test restores in a `finally` so a failure cannot leak a token into the next. + +function withToken<T>(value: string | undefined, fn: () => T): T { + const before = process.env.WORKER_TOKEN; + if (value === undefined) delete process.env.WORKER_TOKEN; + else process.env.WORKER_TOKEN = value; + try { + return fn(); + } finally { + if (before === undefined) delete process.env.WORKER_TOKEN; + else process.env.WORKER_TOKEN = before; + } +} + +// UNSET IS OFF, AND OFF IS 503 — never 401 and never ok. The distinction is the +// whole opt-in: 401 would tell a scanner "there is a secret here, guess it", +// and ok would make every instance that never set the variable a public +// transcription server. +test("no token configured: every request is 503, whatever it carries", () => { + withToken(undefined, () => { + assert.equal(getWorkerToken(), ""); + assert.equal(workerEndpointEnabled(), false); + for (const header of [ + null, + "", + "Bearer anything", + "Bearer ", + "Basic dXNlcjpwYXNz", + ]) { + const auth = authorizeWorkerRequest(header); + assert.equal(auth.ok, false); + assert.equal(auth.ok === false && auth.status, 503); + assert.match( + auth.ok === false ? auth.error : "", + /disabled \(set WORKER_TOKEN to enable\)/, + ); + } + }); +}); + +// An EMPTY string is unset. `WORKER_TOKEN=` in a compose file is a variable +// somebody meant to fill in, not a secret of length zero that every caller +// matches. +test("an empty token is not a token", () => { + withToken("", () => { + assert.equal(workerEndpointEnabled(), false); + const auth = authorizeWorkerRequest("Bearer "); + assert.equal(auth.ok === false && auth.status, 503); + }); +}); + +test("token configured: a missing or malformed header is 401", () => { + withToken("s3cret", () => { + assert.equal(workerEndpointEnabled(), true); + for (const header of [null, "", "s3cret", "Basic s3cret", "Bearer"]) { + const auth = authorizeWorkerRequest(header); + assert.equal(auth.ok, false); + assert.equal(auth.ok === false && auth.status, 401); + assert.match(auth.ok === false ? auth.error : "", /missing bearer token/); + } + }); +}); + +// A WRONG TOKEN IS 401 AND NOT 503: the surface is on, the caller is not +// welcome. Lengths that differ are checked before timingSafeEqual, which throws +// on mismatched buffers — a prefix of the real token is the case that proves it. +test("token configured: a mismatched token is 401, at any length", () => { + withToken("s3cret", () => { + for (const bad of ["wrong", "s3cre", "s3crets", "S3CRET", "s3cret "]) { + const auth = authorizeWorkerRequest(`Bearer ${bad}`); + assert.equal(auth.ok, false); + assert.equal(auth.ok === false && auth.status, 401); + assert.match(auth.ok === false ? auth.error : "", /invalid worker token/); + } + }); +}); + +test("token configured: the matching token is accepted", () => { + withToken("s3cret", () => { + assert.deepEqual(authorizeWorkerRequest("Bearer s3cret"), { ok: true }); + // The scheme is case-insensitive — a worker written against `bearer` is not + // a different caller. + assert.deepEqual(authorizeWorkerRequest("bearer s3cret"), { ok: true }); + // Several spaces after the scheme are still one separator — `\s+` eats + // them all, so leading whitespace in the value is not a different token. + // TRAILING whitespace is: it lands inside the capture and mismatches. + assert.deepEqual(authorizeWorkerRequest("Bearer s3cret"), { ok: true }); + }); +}); + +// THE TOKEN IS READ PER CALL, never captured at import. `/api/test/worker-token` +// leans on exactly this to unset and restore the variable inside a running +// server, and a module-level cache would silently make that route a no-op. +test("the token is read from the environment on every call", () => { + withToken("first", () => { + assert.deepEqual(authorizeWorkerRequest("Bearer first"), { ok: true }); + process.env.WORKER_TOKEN = "second"; + assert.equal(authorizeWorkerRequest("Bearer first").ok, false); + assert.deepEqual(authorizeWorkerRequest("Bearer second"), { ok: true }); + }); +}); diff --git a/common/views/autoQueueStatus.ts b/common/views/autoQueueStatus.ts @@ -62,9 +62,10 @@ export type AutoQueueKindStatus = { // the operations rail show a runner HOLDING for the first time. // // THE TWO KINDS ANSWER IT FROM DIFFERENT PLACES, on purpose. Transcription's - // hold is LIVE, on the worker pool; settings.transcriptionsPaused is only what - // the boot hook re-applies after a restart, and the e2e harness rewrites - // settings wholesale between tests while the pool keeps its pausedSnapshot. + // hold is LIVE, on the worker pool; the stored `autoQueue.transcription.held` + // is only what the boot hook re-applies after a restart, and the e2e harness + // rewrites settings wholesale between tests while the pool keeps its + // pausedSnapshot. // Download has no live counterpart: its flag IS the gate, read at dispatch. held: boolean; // THE TREE ABOVE IS GENERATED, so the editor for it is read-only and the diff --git a/common/views/widgetSync.ts b/common/views/widgetSync.ts @@ -95,8 +95,8 @@ export type WidgetSyncPayload = { needsMedia: number; // missing-input: needs an opt-in re-download first videos: number; // videos in the corpus (the denominator) // held — computed by isGateHeld; nothing downstream inverts anything. The - // field on disk is `backfill.enabled`, whose polarity is the opposite one; - // that lives in pauseGates.ts and reaches the wire already resolved. It was + // field on disk is `autoQueue.backfill.held`, in the plain polarity; the + // inverted `backfill.enabled` it replaced is deleted (S0-pause). It was // `enabled` here until slice 7, and a pinned widget tab that predates the // rename reads `undefined ?? false` — not held — until it is reloaded. held: boolean; diff --git a/common/views/workers.ts b/common/views/workers.ts @@ -41,7 +41,8 @@ export type WorkerView = { export type WorkersPayload = { paused: boolean; - // Persisted global downloads pause (settings.json downloadsPaused). Separate + // Persisted global downloads pause (settings.json `autoQueue.download.held`; + // this payload field keeps the older name, which pinned clients read). Separate // from `paused` (live transcription-worker pause). Surfaced on the same poll // so the dashboard/widget controls reflect both without a second request. downloadsPaused: boolean; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **A lane's pause is one key on the lane, and the four old pause fields are gone from `settings.json`.** Holding a lane has been `autoQueue.<lane>.held` since the runner work landed; until now the file also still carried the four flags that used to mean it — `transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and the backwards `backfill.enabled` (where *enabled* meant *not held*) — which were read only when a lane had no `held` yet, to carry an older file's pause across. Every lane now carries its own key, so those four are **deleted**: nothing reads them, no form writes them, and the next settings save drops them from the file. A settings.json that still spells one of them holds nothing with it, so a hand-edited file (or a very old backup restored over a newer one) can no longer resurrect a pause you had lifted, or lift one you had set. "Run the backfill lane" on the diarization page and the Hold/Pause buttons write the one key, as they already did. **UPGRADING: boot once on the release that writes `held` before taking this one.** That release is the one that moved the gate onto the lane and carried the old fields across on read; a single boot of it (any settings save, or just starting the editor and pausing/resuming anything) puts `autoQueue.<lane>.held` in your settings.json, after which **nothing you can see changes here** — the same buttons, the same labels, the same pauses. An install that jumps straight from an older release to this one has no `held` keys at all and **loses its pauses**: transcription, downloads and digests come up running, and the backfill lane comes up held. Re-set them from the dashboard, or add the keys by hand before starting. - **A relocate job says how far it has got.** `rsync` has been printing its progress the whole time (`--info=progress2`) and every frame of it went into the job log as a carriage-return redraw of one line — so a 131 GB move and a 3 MB one looked identical from `/jobs`: a spinner. Now each frame is parsed into the **task bar** every other long job on that page already draws, reading `12.3 GB of 45.6 GB · 27 % · 110.50MB/s · ETA 5:32`, and the log gets **one line per 10 %** instead of several thousand frames of one. The percentage is against the tree the job already measured for its space check, not rsync's own — under incremental recursion that one is a percentage of what it has enumerated so far and walks backwards. - **`/channels` is where storage is managed now.** Two new columns: **Location** (which volume this channel's media is on — *Internal* when it has not moved) and **Size** (every byte under its `data/`, from its last report, sortable biggest-first). Free space is *not* a column, because it is a fact about a disk and not about a channel: there is one read-out per **volume** in a new bar above the rack, and each chip is also a **filter** — `?location=platter` lists exactly the channels on that drive, and `/storage` links straight here with the biggest first. Beside them, **Free up N GB**: type a number, press *Select largest*, and the largest channels still on the internal disk are ticked until the target is met, ready for the Move button that was already there. Channels already on another volume are never picked (moving one frees nothing on the disk you are emptying) and channels whose report carries no size are **skipped and counted** rather than ranked as empty — which would have put the biggest thing on the disk at the bottom of the list. - **The corpus volume is a row on `/storage`, and it is the first one.** 523 GB on a disk with 67 GB left is not a footnote under the locations that were added to fix it. It shows the channels in place, what they hold and the free space, and links to its own list. It is the one row with nothing to refresh, re-point, mount, edit or delete — and it says so, once, rather than as five greyed buttons. Every location row grows the same size read-out, and a location whose channels have never had a report says **"size unknown until Refresh report"** rather than claiming a 2 TB drive holds nothing. diff --git a/editor/app/api/test/_guard.ts b/editor/app/api/test/_guard.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +// THE /api/test ROUTES EXIST ONLY WHEN THE E2E HARNESS STARTED THIS SERVER. +// +// Every route under this directory is UNAUTHENTICATED and every one of them +// mutates live process state: it drops the job registry and the runner +// singletons, fabricates a stuck job, restarts a lane — and `worker-token` +// SETS A CREDENTIAL. An unauthenticated `GET /api/test/worker-token?set=x` +// hands the caller a token of its own choosing for `/api/ops/*` and +// `/api/worker/*`; `?unset=1` is a remote kill switch for both. As plain GETs, +// all of that is reachable by CSRF from any page the operator's browser loads, +// which is why binding to loopback is not an answer — the browser is inside the +// loopback. The Caddyfile has no path rule for `/api/test` either. +// +// So the surface is opt-in, the way the worker endpoint is: `EDITOR_TEST_ROUTES=1` +// is set by `dev:test` and `start:test` (editor/package.json) — the two scripts +// Playwright's webServer, the sharded runner and Dockerfile.test all boot +// through — and by nothing else. A real editor never sets it. +// +// 404 AND NOT 403, deliberately: the answer must be indistinguishable from a +// route that was never built. A 403 advertises that the harness exists and that +// there is an env var worth guessing. +export function testRouteDenied(): NextResponse | null { + if (process.env.EDITOR_TEST_ROUTES === "1") return null; + return NextResponse.json({ error: "Not Found" }, { status: 404 }); +} diff --git a/editor/app/api/test/invalidate-cache/route.ts b/editor/app/api/test/invalidate-cache/route.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { resetSnapshotScheduler } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; import { resetChannelSnapshotMemo } from "yt-dlp-transcript-common/controller/channels"; import { resetStorageProbeMemo } from "yt-dlp-transcript-common/controller/storageLocations"; +import { testRouteDenied } from "../_guard"; export const dynamic = "force-dynamic"; @@ -39,13 +40,20 @@ function cancelLiveJobs() { } } -// E2E test harness only. Mounted unconditionally so it's reachable from the -// dev:test script; the editor is intended for localhost use, not deployment. +// E2E test harness only, and GUARDED BY `EDITOR_TEST_ROUTES` — which dev:test +// and start:test set, so it is still reachable from exactly the servers that +// need it. It cancels every live job and drops four singletons; an +// unauthenticated GET doing that is CSRF-able from any page the operator has +// open, loopback or not. See _guard.ts. export async function POST() { + const denied = testRouteDenied(); + if (denied) return denied; return invalidate(); } export async function GET() { + const denied = testRouteDenied(); + if (denied) return denied; return invalidate(); } diff --git a/editor/app/api/test/resume-lane/route.ts b/editor/app/api/test/resume-lane/route.ts @@ -6,6 +6,7 @@ import { } from "yt-dlp-transcript-common/controller/autoRunner"; import { LANES } from "yt-dlp-transcript-common/lib/autoQueueTypes"; import type { AutoQueueKind } from "yt-dlp-transcript-common/jobs/autoQueueState"; +import { testRouteDenied } from "../_guard"; export const dynamic = "force-dynamic"; @@ -19,9 +20,14 @@ export const dynamic = "force-dynamic"; // It replaced /api/test/resume-backfill-sweep, which did this for the sweep; // that route retired with the sweep in slice 1.3. // -// Mounted unconditionally, like the other /api/test routes: the editor is a -// localhost admin tool, not a deployed service. +// GUARDED BY `EDITOR_TEST_ROUTES`, like every other /api/test route. It was +// mounted unconditionally on the reasoning that the editor is a localhost admin +// tool — but the operator's browser is inside the loopback, so an +// unauthenticated GET that stops and restarts a lane runner is CSRF-able. +// See _guard.ts. export async function GET(req: Request) { + const denied = testRouteDenied(); + if (denied) return denied; const lane = new URL(req.url).searchParams.get("lane") ?? ""; if (!LANES.includes(lane as AutoQueueKind)) { return NextResponse.json( diff --git a/editor/app/api/test/stuck-job/route.ts b/editor/app/api/test/stuck-job/route.ts @@ -7,6 +7,7 @@ import { type JobRecord, } from "yt-dlp-transcript-common/jobs/registry"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { testRouteDenied } from "../_guard"; export const dynamic = "force-dynamic"; @@ -17,9 +18,13 @@ export const dynamic = "force-dynamic"; // NOT auto-healed, so it persists across polls and the test can prove // FORCE-RELEASE (not auto-heal) clears it. We reproduce it directly // (backdating startedAt) since a genuinely wedged child would be racy. -// Mounted unconditionally, like the other /api/test routes — the editor is a -// localhost admin tool, not deployed. +// GUARDED BY `EDITOR_TEST_ROUTES`, like every other /api/test route. It was +// mounted unconditionally on the reasoning that the editor is a localhost admin +// tool — but the operator's browser is inside the loopback, so an +// unauthenticated GET that writes into the registry is CSRF-able. See _guard.ts. export async function GET(request: Request) { + const denied = testRouteDenied(); + if (denied) return denied; const url = new URL(request.url); const queueKey = url.searchParams.get("queue") || "stuck-queue"; diff --git a/editor/app/api/test/uncaught-count/route.ts b/editor/app/api/test/uncaught-count/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { testRouteDenied } from "../_guard"; export const dynamic = "force-dynamic"; @@ -55,6 +56,8 @@ function getState(): CountState { } export async function GET() { + const denied = testRouteDenied(); + if (denied) return denied; const state = getState(); return NextResponse.json({ uncaught: state.uncaught, @@ -64,6 +67,8 @@ export async function GET() { } export async function DELETE() { + const denied = testRouteDenied(); + if (denied) return denied; const state = getState(); state.uncaught = 0; state.unhandled = 0; diff --git a/editor/app/api/test/worker-token/route.ts b/editor/app/api/test/worker-token/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { workerEndpointEnabled } from "yt-dlp-transcript-common/lib/workerToken"; +import { testRouteDenied } from "../_guard"; + +export const dynamic = "force-dynamic"; + +// E2E test harness only. Unsets and restores `WORKER_TOKEN` inside the running +// server. +// +// WHY A ROUTE AND NOT A SPEC FIXTURE. The 503 branch is the one that decides +// whether an instance is an open transcription server — unset means OFF, you +// opt in — and no spec could observe it: the test server boots with +// WORKER_TOKEN=test-worker-token (editor/package.json, dev:test) and ONE server +// serves the whole suite, so the only way to see the endpoint disabled is to +// turn it off in the process that is answering. A suite cannot restart the dev +// server mid-run, which is the same reason /api/test/resume-lane exists. +// +// IT WORKS BECAUSE `getWorkerToken()` READS process.env PER CALL, never at +// import (common/lib/workerToken.test.ts pins that). A module-level cache would +// make this route a silent no-op and the spec below would pass by proving +// nothing. +// +// ⚠️ THE SPEC MUST RESTORE IN A `finally`. The variable is process-wide and the +// server outlives the spec, so a run that unsets it and throws leaves every +// later /api/ops and /api/worker spec answering 503 — a whole suite red from +// one failure. `?set=` with no value is the restore, and it is idempotent. +// +// GUARDED BY `EDITOR_TEST_ROUTES`, and it is the route that made the guard +// necessary: an unauthenticated GET that SETS a credential is a CSRF-able +// token grant, and "the editor is a localhost admin tool" does not help — the +// operator's browser is inside the loopback. See _guard.ts. +export async function GET(req: Request) { + // FIRST LINE, BEFORE THE QUERY IS EVEN PARSED. This route sets a credential; + // off a harness-started server it does not exist. See _guard.ts. + const denied = testRouteDenied(); + if (denied) return denied; + const url = new URL(req.url); + // `?set=<token>` restores (or changes) it; `?unset=1` removes it entirely. + // Exactly one of the two, so a typo cannot silently do nothing. + const set = url.searchParams.get("set"); + const unset = url.searchParams.get("unset"); + if ((set === null) === (unset === null)) { + return NextResponse.json( + { ok: false, error: "pass exactly one of ?set=<token> or ?unset=1" }, + { status: 400 }, + ); + } + const before = workerEndpointEnabled(); + if (unset !== null) delete process.env.WORKER_TOKEN; + else process.env.WORKER_TOKEN = set as string; + return NextResponse.json({ + ok: true, + was: before, + // Never the token itself: this response goes into a test log. + enabled: workerEndpointEnabled(), + }); +} diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx @@ -7,6 +7,7 @@ import { formatBytes } from "yt-dlp-transcript-common/lib/format"; import type { ChannelMediaLocation } from "yt-dlp-transcript-common/lib/channelMedia"; import type { RelocationPreview } from "yt-dlp-transcript-common/controller/relocateChannelMedia"; import { MediaLocationBadge } from "../../../../components/MediaLocationBadge"; +import { ClipWindowsCard } from "../../../../storage/components/ClipWindowsCard"; import { cancelJobAction } from "../../../../jobs/actions"; import { clearRelocationMarkerAction, @@ -86,6 +87,10 @@ type Props = { // when the snapshot predates the field (or there is no snapshot), and rendered // as "—" rather than "0": a zero here would claim a measurement nobody took. mediaBytes: number | null; + // Bytes this channel's `data/<id>/clips/` windows occupy, from the same + // snapshot. Null when the snapshot predates the field, for the same reason + // mediaBytes is: "0 B of clips" is a measurement nobody took. + clipsBytes: number | null; // Free space on the volume the media is on RIGHT NOW — the platter for a // relocated channel, the corpus disk otherwise. freeBytes: number; @@ -113,6 +118,7 @@ export function StorageStage({ location, locationLabel, mediaBytes, + clipsBytes, freeBytes, volumeDir, blockedReason, @@ -215,6 +221,23 @@ export function StorageStage({ : null } /> + + {/* THE ONE THING UNDER data/ THAT NOTHING PRUNES, and the same card + /storage renders corpus-wide — mounted here with this channel's slug + so the sweep is scoped to it. It belongs on the Storage panel rather + than on a cleanup page because it is bytes on a drive, and the + operator reading "audio on disk" above is the one deciding whether + this channel's cache is worth keeping. + + BY AGE, AND THE CARD SAYS SO. Whether a window is still cited is a + fact about a umtool manifest this editor cannot see, so there is no + reference count to consult — an evicted window costs a fetch, not + data. That sentence is the card's, not a copy of it. */} + <ClipWindowsCard + slug={slug} + clipsBytes={clipsBytes} + blockedReason={blockedReason} + /> </div> ); } diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -616,6 +616,10 @@ export default async function ChannelDetailPage({ // snapshot predates the field or does not exist: a 0 would claim a // measurement nobody took. mediaBytes={snapshot.totalAudioBytes ?? null} + // The clips/ share of the same snapshot — the number the evict + // card acts on. Null, not 0, when the field is absent: the + // snapshot may predate it. + clipsBytes={snapshot.totalClipsBytes ?? null} freeBytes={freeBytes} volumeDir={volumeDir} blockedReason={blockedReason} @@ -639,11 +643,20 @@ export default async function ChannelDetailPage({ /> ); } - case "danger": + case "danger": { + // THE SAME SENTENCE THE ACTIONS REFUSE WITH, one click earlier. Both + // actions ask this themselves — a disabled button is a courtesy and the + // server is the guard — but a Danger-zone form that submits, moves + // nothing and comes back with a paragraph is the worst place to learn + // that a lane was mid-write. The verb differs per form so the reason + // reads as an instruction in each. + const renameBusy = channelMediaBusyReason(slug, "renaming it"); + const deleteBusy = channelMediaBusyReason(slug, "deleting it"); return ( <div className="flex flex-col gap-4"> <RenameChannelForm slug={slug} + busyReason={renameBusy} action={ renameChannelAction.bind(null, slug) as ( prev: ActionResult, @@ -654,6 +667,7 @@ export default async function ChannelDetailPage({ <hr className="border-border" /> <DeleteChannelForm slug={slug} + busyReason={deleteBusy} action={ deleteChannelAction.bind(null, slug) as ( prev: ActionResult, @@ -663,6 +677,7 @@ export default async function ChannelDetailPage({ /> </div> ); + } } } diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -21,6 +21,8 @@ import { writeChannelConfig, } from "yt-dlp-transcript-common/controller/channels"; import { renameChannel } from "yt-dlp-transcript-common/controller/renameChannel"; +import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; +import { channelMediaBusyReason } from "./lib/mediaBusy"; import { excludedDownloadIdSet, generateChannelSnapshot, @@ -545,11 +547,31 @@ export async function syncAllChannelsAction( a.localeCompare(b), ); const outcome = await queueForSlugs(order, { - skip: (slug) => { + // AN UNMOUNTED DRIVE IS NOT AN EMPTY CHANNEL, and a sync is exactly the job + // that acts on that mistake. Every enumerator of `data/` swallows ENOENT as + // "no videos" (AGENTS.md), so a sync of a channel whose platter is not + // there reads the whole back catalogue as undownloaded and hands the + // download lane an instruction to re-fetch hundreds of gigabytes onto the + // volume that was too full to hold them. + // + // `inspectChannelMedia` is the one module that can tell the two apart. Two + // stats and at most one small JSON read per channel — cheap enough for a + // 68-channel pool, and the reason `queueForSlugs.skip` may be async. + // + // ok and in-place are the two healthy answers: the media is where config + // says, or there is no relocation at all. Everything else — `unreachable`, + // `in-transition`, `inconsistent` — is a skip with the inspector's own + // sentence, so the bulk bar names the drive rather than reporting a + // successful sweep over a channel nothing could read. + skip: async (slug) => { const config = bySlug.get(slug); if (!config?.url) return "no url"; if (isChannelPaused(priority, slug, "sync")) return "paused for sync"; if (active.has(slug)) return "already running"; + const media = await inspectChannelMedia(paths, slug, config); + if (media.status !== "ok" && media.status !== "in-place") { + return `media ${media.status}: ${media.detail ?? "not reachable"}`; + } return null; }, run: (slug) => syncAction(slug, undefined, opts?.fullSweep), @@ -577,7 +599,27 @@ export async function deleteChannelAction( error: `Type the channel slug "${slug}" exactly to confirm deletion`, }; } - await deleteChannel(getPaths(), slug); + // THE RENAME'S GUARD, AND DELETE NEEDED IT MORE. Renaming while a job runs + // orphans a registry entry keyed by the old slug; DELETING while one runs + // pulls the directory out from under a writer — a download's `.part`, a + // transcribe's sidecar, a digest unit's JSON — and the lane units make no job + // record at all, so the registry alone never saw them. `deleteChannel` then + // races the writer for the tree and whichever loses reports an ENOENT nobody + // asked about. + const busy = channelMediaBusyReason(slug, "deleting it"); + if (busy) return { error: busy }; + // THE OTHER REFUSAL REACHES THE FORM THE SAME WAY. `deleteChannel` THROWS + // when `.relocating.json` is present — media in transition is not a channel + // anyone may delete — and an uncaught throw from a server action is a + // digest-shaped error page, not the sentence above it. Both refusals are + // refusals; they belong in the same place, on the same form, in the + // operator's words. The `redirect` below stays OUTSIDE this: it throws + // NEXT_REDIRECT as its control flow and a catch here would swallow it. + try { + await deleteChannel(getPaths(), slug); + } catch (e) { + return { error: (e as Error).message }; + } // Same reason as createChannelAction: the deleted channel keeps a leaf in // every compiled tree until something recompiles. A leaf matching nothing is // harmless to dispatch and confusing to read. @@ -627,18 +669,15 @@ export async function renameChannelAction( return { error: `Channel "${newSlug}" already exists` }; } - const activeJobs = getRegistry() - .list() - .filter( - (j) => - j.channelSlug === oldSlug && - (j.status === "running" || j.status === "queued"), - ); - if (activeJobs.length > 0) { - return { - error: `Finish or cancel ${activeJobs.length} running/queued job(s) for this channel before renaming.`, - }; - } + // THE REGISTRY IS HALF THE TRUTH, and this check used to be the other half's + // ancestor: it counted running/queued JOBS only. The auto-queue lanes run + // their per-video units in-process and make no job record (the omnimirror + // incident, lib/mediaBusy.ts), so a digest unit writing a sidecar into + // `data/` was invisible here — and renaming moves the directory out from + // under it. One question, one answer, the same sentence the Storage panel + // and the bulk move say. + const busy = channelMediaBusyReason(oldSlug, "renaming it"); + if (busy) return { error: busy }; let result; try { diff --git a/editor/app/channels/components/DeleteChannelForm.tsx b/editor/app/channels/components/DeleteChannelForm.tsx @@ -9,9 +9,12 @@ type Props = { prev: ActionResult, formData: FormData, ) => Promise<ActionResult>; + // Why the delete is refused right now, or null. Same shape and same reason as + // the rename form's: the action is the guard, this is the earlier warning. + busyReason?: string | null; }; -export function DeleteChannelForm({ slug, action }: Props) { +export function DeleteChannelForm({ slug, action, busyReason = null }: Props) { const [state, formAction] = useActionState<ActionResult, FormData>( action, undefined, @@ -22,6 +25,15 @@ export function DeleteChannelForm({ slug, action }: Props) { Deleting will remove <code>transcripts/channels/{slug}/</code> and all downloaded videos. Type the slug to confirm. </p> + {busyReason && ( + <p + role="status" + aria-label="delete blocked" + className="text-sm rounded border border-border bg-muted px-3 py-2" + > + {busyReason} + </p> + )} <div className="flex gap-2 items-start"> <input name="confirmSlug" @@ -32,7 +44,8 @@ export function DeleteChannelForm({ slug, action }: Props) { /> <button type="submit" - className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90" + disabled={busyReason !== null} + className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50" > Delete channel </button> diff --git a/editor/app/channels/components/RenameChannelForm.tsx b/editor/app/channels/components/RenameChannelForm.tsx @@ -6,9 +6,13 @@ import type { ActionResult } from "../actions"; type Props = { slug: string; action: (prev: ActionResult, formData: FormData) => Promise<ActionResult>; + // Why the rename is refused right now, or null. The ACTION asks the same + // question and refuses with the same sentence — this is the courtesy that + // says so before the form is filled in, not the guard. + busyReason?: string | null; }; -export function RenameChannelForm({ slug, action }: Props) { +export function RenameChannelForm({ slug, action, busyReason = null }: Props) { const [state, formAction] = useActionState<ActionResult, FormData>( action, undefined, @@ -31,6 +35,15 @@ export function RenameChannelForm({ slug, action }: Props) { className="rounded border border-border bg-card px-2 py-1 text-sm font-mono max-w-xs" /> </label> + {busyReason && ( + <p + role="status" + aria-label="rename blocked" + className="text-sm rounded border border-border bg-muted px-3 py-2" + > + {busyReason} + </p> + )} <div className="flex gap-2 items-start"> <input name="confirmSlug" @@ -41,7 +54,8 @@ export function RenameChannelForm({ slug, action }: Props) { /> <button type="submit" - className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90" + disabled={busyReason !== null} + className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50" > Rename channel </button> diff --git a/editor/app/components/lanes/LaneDeck.tsx b/editor/app/components/lanes/LaneDeck.tsx @@ -121,8 +121,8 @@ export function LaneDeck({ )} </> } - // `held` is the LIVE pool, off the workers payload — never - // settings.transcriptionsPaused, which only says what a restart would do. + // `held` is the LIVE pool, off the workers payload — never the persisted + // `autoQueue.transcription.held`, which only says what a restart would do. controls={[ pauseLaneControl({ lane: "transcription", @@ -134,7 +134,8 @@ export function LaneDeck({ ); // ── Downloads ───────────────────────────────────────────────────────────── - // Persisted in settings.json (downloadsPaused — survives a restart). Pausing + // Persisted in settings.json (`autoQueue.download.held` — survives a + // restart; the payload field below keeps the older name). Pausing // gates the auto-download runner on its next loop iteration and makes manual // download-bearing pipeline actions return a "Downloads are paused" notice; // store-playlist/enumeration stay allowed. @@ -332,7 +333,9 @@ export function LaneDeck({ // the operator needs a hold for reasons the scheduler cannot see. // // The pause writes THE SAME FIELD the Settings checkbox writes - // (settings.backfill.enabled) rather than a new `backfillPaused` flag. One + // (`autoQueue.backfill.held`, through withGateHeld — it was the inverted + // `settings.backfill.enabled` until slice 1.4 and S0-pause deleted that + // field) rather than a new `backfillPaused` flag. One // field, several places to set it, and they cannot drift — which is why // backfill.spec asserts the settings field through this button's label rather // than just watching the label flip. diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts @@ -189,7 +189,7 @@ async function setLaneHeld( held: boolean, ): Promise<LanePauseResult> { // TRANSCRIPTION IS THE ONE LANE WITH A LIVE HOLD, and it goes first. The pool - // is the machine; settings.transcriptionsPaused is only what + // is the machine; the stored `autoQueue.transcription.held` is only what // editor/instrumentation.ts re-applies at boot. Every UI surface reads the // pool, so flipping it first is what makes the button feel immediate. if (lane === "transcription") { diff --git a/editor/app/operations/components/settings/LaneSettingsForm.tsx b/editor/app/operations/components/settings/LaneSettingsForm.tsx @@ -15,11 +15,11 @@ import type { SaveResult } from "../../../settings/actions"; // That is why it takes no operation: there is nothing here that belongs to one. export function LaneSettingsForm({ initial, - // THE LANE'S GATE, read by the caller through `isGateHeld`. Not - // `initial.enabled`: since slice 1.4 the gate is `autoQueue.backfill.held`, - // and `backfill.enabled` is the retired field it migrated FROM — a checkbox - // reading it would show the pre-migration answer on a file that has since - // been written, and would disagree with the pause button beside it. + // THE LANE'S GATE, read by the caller through `isGateHeld`. The gate is + // `autoQueue.backfill.held` (slice 1.4); the `backfill.enabled` this checkbox + // used to read was the retired field it migrated FROM, and S0-pause deleted + // it — `initial` no longer has one. There is one key, and the pause button + // beside this checkbox writes it too. held, }: { initial: SiteSettings["backfill"]; diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts @@ -99,13 +99,6 @@ export async function saveDigestSettingsAction( // writeSettings runs sanitizeDigestApps over it. Narrowed at exactly // this field so every OTHER field in the block stays type-checked. apps: digestApps as SiteSettings["digest"]["apps"], - // Not edited by this form, and no longer the gate: since slice 1.4 the - // digest lane's pause is `autoQueue.digest.held`, which the `...current` - // spread above carries untouched. This field is the migration's INPUT and - // is preserved rather than rebuilt, because a save that reset it would - // change how a settings.json that has NOT yet been written through - // getSettings reads. - digestsPaused: dD.digestsPaused, spendCapUsd: Number.parseFloat( String(formData.get("digestSpendCapUsd") ?? "").trim(), ), @@ -206,9 +199,8 @@ export async function saveDiarizationSettingsAction( // `withGateHeld` — a deliberate SECOND WRITER of `autoQueue.backfill.held`, // beside the pause buttons. It has always been two controls over one switch // (the checkbox wrote `backfill.enabled`, which `isGateHeld` read inverted); -// what slice 1.4 changed is that the switch moved, and a form still writing the -// retired field would be silently ignored by every reader — the mirror image of -// the bug `withGateHeld` writing only the new key avoids on the other side. +// slice 1.4 moved the switch onto the lane and S0-pause deleted the old field, +// so there is exactly one key both controls write. // // LaneSettingsForm renders every field of this block and is the only form that // posts here. diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -217,14 +217,6 @@ export async function saveSettingsAction( parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT, inlineTranscribeOnFallback, skipLiveDownloads, - // The two RETIRED pause flags. They are not this form's business and they - // are not the gate any more either — since slice 1.4 that is - // `autoQueue[lane].held`, preserved a few lines below with the rest of the - // policy. These are the migration's INPUT for a settings.json that has not - // yet been written through getSettings, so they are preserved rather than - // rebuilt: resetting them here would change how such a file reads. - transcriptionsPaused: getSettings().transcriptionsPaused, - downloadsPaused: getSettings().downloadsPaused, verifyAvailabilityBeforeClean, buildArchives, archiveStorage, diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts @@ -26,6 +26,10 @@ import { readDirMarker, } from "yt-dlp-transcript-common/controller/relocateDir"; import { savedVideosMarkerPath } from "yt-dlp-transcript-common/controller/relocateSavedVideos"; +import { + channelExists, + isValidChannelSlug, +} from "yt-dlp-transcript-common/controller/channels"; import { channelMediaBusyReason } from "../channels/lib/mediaBusy"; import { enqueueRepointJob } from "./lib/repointJob"; import { enqueueSavedVideosRelocation } from "./lib/savedVideosJob"; @@ -425,6 +429,21 @@ export async function evictClipWindowsAction(opts: { error: "The age must be a number of days, zero or more.", }; } + // THE SLUG COMES OFF THE WIRE — `/api/ops/evict-clips` passes whatever the + // body said — and `evictChannel` path-joins it under `channelsDir` before + // walking and DELETING. Shape first, because that is what forbids "/" and + // ".." and it runs before any join; then existence, because a typo naming no + // channel should be a refusal an agent can read, not a silent zero-byte + // "success" over a directory that was never there. A server action is the + // guard here: the ops route is an adapter and may hold no rule of its own. + if (opts.slug !== undefined) { + if (!isValidChannelSlug(opts.slug)) { + return { ok: false, error: `"${opts.slug}" is not a valid channel slug` }; + } + if (!(await channelExists(getPaths(), opts.slug))) { + return { ok: false, error: `Channel "${opts.slug}" not found` }; + } + } return enqueueEvictClipWindows({ ...(opts.slug ? { slug: opts.slug } : {}), olderThanDays: Math.floor(opts.olderThanDays), diff --git a/editor/app/storage/components/ClipWindowsCard.tsx b/editor/app/storage/components/ClipWindowsCard.tsx @@ -19,6 +19,13 @@ import { evictClipWindowsAction } from "../actions"; // (the windows are wherever the channels are, on every drive at once), and the // figure it acts on is already in each row's Media line. // +// ONE CARD, TWO MOUNTS. `/storage` renders it corpus-wide; a channel's Storage +// panel renders the same card with a `slug`, which is the only difference the +// controller knows about (`evictClipWindows` takes an optional slug and walks +// one channel instead of all of them). Two components would be two sets of +// gates, two wordings of the by-age caveat and two chances for one of them to +// drift into claiming a reference count nobody has. +// // THE LIMITATION IS THE FIRST THING IT SAYS. Whether a window is still wanted // is a fact about a umtool manifest — a report being rendered to video cites // spans — and the editor cannot see those manifests: they live in a umtool @@ -52,7 +59,24 @@ import { evictClipWindowsAction } from "../actions"; const AGES = [0, 7, 30, 90, 180] as const; -export function ClipWindowsCard({ clipsBytes }: { clipsBytes: number }) { +export function ClipWindowsCard({ + clipsBytes, + slug, + blockedReason = null, +}: { + // Bytes this scope's windows occupy, or null when nothing has measured them + // — a channel whose snapshot predates `totalClipsBytes`, or has none. Null is + // NOT zero: "none measured" claims a walk that never happened. + clipsBytes: number | null; + // Present on a channel's Storage panel: every run is scoped to this channel. + // Absent on /storage, where the sweep is the whole corpus. + slug?: string; + // Why both buttons are off, or null. The channel page passes the same busy + // sentence its moves are gated on; the server is still the guard + // (`evict-clips` declares `needsMedia`), this is the courtesy that says so + // before the click rather than in a job log afterwards. + blockedReason?: string | null; +}) { const [days, setDays] = useState<number>(30); // Reset by any change of age: a preview of "older than 90 days" says nothing // about what "any age" would take, and an armed checkbox from a narrower @@ -60,7 +84,10 @@ export function ClipWindowsCard({ clipsBytes }: { clipsBytes: number }) { const [previewed, setPreviewed] = useState(false); const [confirmed, setConfirmed] = useState(false); const takesEverything = days === 0; - const canEvict = previewed && (!takesEverything || confirmed); + const blocked = blockedReason !== null; + const canEvict = previewed && (!takesEverything || confirmed) && !blocked; + const scope = slug ? { slug } : {}; + const where = slug ? "in this channel" : "across the corpus"; return ( <article aria-label="clip windows" @@ -72,9 +99,11 @@ export function ClipWindowsCard({ clipsBytes }: { clipsBytes: number }) { aria-label="clip windows bytes" className="text-sm text-muted-foreground tabular-nums" > - {clipsBytes > 0 - ? `${formatBytes(clipsBytes)} across the corpus` - : "none measured"} + {clipsBytes === null + ? "not measured" + : clipsBytes > 0 + ? `${formatBytes(clipsBytes)} ${where}` + : "none measured"} </span> </div> @@ -82,8 +111,10 @@ export function ClipWindowsCard({ clipsBytes }: { clipsBytes: number }) { A window is a few seconds of a video&rsquo;s source media, fetched for another tool and kept beside the video it came from. Nothing prunes one: the retention sweep is pointer-driven and the cleanup lanes are about{" "} - <code>audio.*</code>. They are already counted in each location&rsquo;s - Media figure above. + <code>audio.*</code>.{" "} + {slug + ? "They are already counted in the audio total above." + : "They are already counted in each location’s Media figure above."} </p> <p aria-label="clip eviction caveat" @@ -95,6 +126,16 @@ export function ClipWindowsCard({ clipsBytes }: { clipsBytes: number }) { of getting this wrong is one fetch, not data. Preview first. </p> + {blockedReason && ( + <p + role="status" + aria-label="clip eviction blocked" + className="text-sm rounded border border-border bg-muted px-3 py-2" + > + {blockedReason} + </p> + )} + <label className="flex items-center gap-2 text-sm"> <span className="text-muted-foreground">Older than</span> <select @@ -135,7 +176,11 @@ export function ClipWindowsCard({ clipsBytes }: { clipsBytes: number }) { <StreamActionLog key="evict-clips-preview-log" trigger={() => - evictClipWindowsAction({ olderThanDays: days, dryRun: true }) + evictClipWindowsAction({ + ...scope, + olderThanDays: days, + dryRun: true, + }) } // `started` is false when the action refused before a job existed — // a refusal is not a preview, so it arms nothing. @@ -146,10 +191,13 @@ export function ClipWindowsCard({ clipsBytes }: { clipsBytes: number }) { buttonLabel="Preview eviction" runningLabel="Walking…" label="Preview eviction" + disabled={blocked} /> <StreamActionLog key="evict-clips-log" - trigger={() => evictClipWindowsAction({ olderThanDays: days })} + trigger={() => + evictClipWindowsAction({ ...scope, olderThanDays: days }) + } cancelAction={cancelJobAction} buttonLabel="Evict fetched windows" runningLabel="Evicting…" @@ -159,9 +207,11 @@ export function ClipWindowsCard({ clipsBytes }: { clipsBytes: number }) { </div> {!canEvict && ( <p aria-label="clip eviction gate" className="text-xs text-muted-foreground"> - {previewed - ? "Tick the box above to evict every window." - : "Preview first — the eviction button unlocks once the dry run has reported what it would take."} + {blocked + ? blockedReason + : previewed + ? "Tick the box above to evict every window." + : "Preview first — the eviction button unlocks once the dry run has reported what it would take."} </p> )} </article> diff --git a/editor/e2e/attribution.spec.ts b/editor/e2e/attribution.spec.ts @@ -115,10 +115,15 @@ function attributionSettings(over: { // otherwise switching capture off would strand exactly the work it exists to // protect. backfill: { - enabled: true, concurrency: 1, allowRedownload: false, }, + // THE LANE'S GATE, SPELLED. It used to be spelled by `backfill.enabled: + // true` in the block above — the inverted retired field, where `true` meant + // NOT held. S0-pause deleted it, and the lane's gate defaults SHUT + // (`defaultHeldFor`), so a fixture that wants the lane to run says so on + // the lane. + autoQueue: { backfill: { held: false } }, attribution: { enabled: true, appId: "ollama-direct", diff --git a/editor/e2e/auto-queue.spec.ts b/editor/e2e/auto-queue.spec.ts @@ -1446,22 +1446,23 @@ test("the digest lane offers Shortest first, and has no Reach axis", async ({ .toBe("newest/gone/gone/held=true"); }); -// THE MIGRATION, FROM THE BROWSER. Every settings.json on disk today spells a -// lane's pause in one of the four RETIRED fields and carries no `held` at all, -// so "an unmigrated file still reads as held" is not a historical curiosity — -// it is how the live corpus reads until something writes it. The unit tests pin -// all four fields (common/lib/pauseGates.test.ts); this pins the half a unit -// test cannot: that a real editor, reading a real file, holds the lane, and -// that the first toggle moves the answer onto the lane where every writer now -// looks. +// THE DELETION, FROM THE BROWSER. Until S0-pause a settings.json spelling one +// of the four RETIRED pause fields and carrying no `held` still held its lane: +// `isGateHeld` fell back to the field and `getSettings` copied the answer onto +// the lane. Both are gone, on the precondition that the live settings.json had +// already been written with all four `held` keys — so a file that still spells +// a retired field must now hold NOTHING, and must lose the field the first time +// the editor writes it. +// +// That second half is what a unit test cannot see: the strip happens in +// `writeSettings`, which builds its output from only the known operational +// fields, and this asserts it through a real save from a real page. // // The DIGEST lane, not transcription, and that is the asymmetry rather than a // shortcut: transcription's live hold is the worker pool, and no UI surface may // read its stored value (see lib/pauseGates.ts), so a fixture flag could not be -// observed through a page without contradicting that rule. Its fallback is -// covered by the unit tests and by the phase-1 numbers script, which prints -// `held transcription` off the live file through isGateHeld. -test("a settings.json with no `held` is read through its retired pause field", async ({ +// observed through a page without contradicting that rule. +test("a retired pause field holds nothing, and does not survive a write", async ({ page, }) => { await resetData(null); @@ -1473,35 +1474,40 @@ test("a settings.json with no `held` is read through its retired pause field", a minFreeDiskGB: 0, workers: ONE_WORKER, // The pre-1.4 spelling, and nothing else: no `autoQueue.digest` block, so - // no `held` for the sanitizer to find. + // no `held` for the sanitizer to find either. digest: { digestsPaused: true }, }); const res = await page.request.get(`${baseUrl}/api/auto-queue/status`); expect(res.ok()).toBeTruthy(); const payload = (await res.json()) as Record<string, { held?: boolean }>; - expect(payload.digest?.held).toBe(true); + expect(payload.digest?.held).toBe(false); await page.goto("/operations/digest"); const digest = page.locator('section[data-lane="digest"]'); await awaitHydration(digest); - const resume = digest.getByRole("button", { name: "resume digests" }); - await expect(resume).toBeEnabled({ timeout: 30_000 }); - await resume.click(); + // The lane reads as running, so the control on offer is the pause. + const pause = digest.getByRole("button", { name: "pause digests" }); + await expect(pause).toBeEnabled({ timeout: 30_000 }); + await pause.click(); - // The first toggle persists the KEY. From here the retired field is dead - // config — which is why every writer of it was rewritten in the same slice. + // The click persists the KEY — and the write drops the retired field, so a + // stale settings.json cannot resurrect a pause after an operator lifts one. await expect .poll( - async () => - ( - await readJson<{ - autoQueue?: { digest?: { held?: boolean } }; - }>("test-settings.json").catch(() => null) - )?.autoQueue?.digest?.held ?? null, + async () => { + const s = await readJson<{ + autoQueue?: { digest?: { held?: boolean } }; + digest?: { digestsPaused?: boolean }; + }>("test-settings.json").catch(() => null); + if (!s) return null; + return `held=${String(s.autoQueue?.digest?.held)}/retired=${String( + "digestsPaused" in (s.digest ?? {}), + )}`; + }, { timeout: 20_000 }, ) - .toBe(false); + .toBe("held=true/retired=false"); }); test("/jobs: a runner is a lane on a strip, not a card", async ({ diff --git a/editor/e2e/backfill.spec.ts b/editor/e2e/backfill.spec.ts @@ -83,11 +83,16 @@ function backfillSettings(over: { ...over.diarization, }, backfill: { - enabled: true, concurrency: 1, allowRedownload: false, ...over.backfill, }, + // THE LANE'S GATE, SPELLED. It used to be spelled by `backfill.enabled: + // true` in the block above — the inverted retired field, where `true` meant + // NOT held. S0-pause deleted it, and the lane's gate defaults SHUT + // (`defaultHeldFor`), so a fixture that wants the lane to run says so on + // the lane. + autoQueue: { backfill: { held: false } }, }; } @@ -782,8 +787,9 @@ test("the dashboard pauses and resumes the backfill lane", async ({ page }) => { await writeSettings(backfillSettings()); // THE LANE'S GATE, on the lane. Slice 1.4 moved it off the inverted - // `backfill.enabled` and onto `autoQueue.backfill.held`, so the polarity here - // is the plain one: held means held. + // `backfill.enabled` (deleted by S0-pause) and onto + // `autoQueue.backfill.held`, so the polarity here is the plain one: held + // means held. const laneHeld = async () => ( await readJson<{ autoQueue?: { backfill?: { held?: boolean } } }>( @@ -827,8 +833,9 @@ test("an operation page holds the same lane the dashboard does", async ({ await writeSettings(backfillSettings()); // THE LANE'S GATE, on the lane. Slice 1.4 moved it off the inverted - // `backfill.enabled` and onto `autoQueue.backfill.held`, so the polarity here - // is the plain one: held means held. + // `backfill.enabled` (deleted by S0-pause) and onto + // `autoQueue.backfill.held`, so the polarity here is the plain one: held + // means held. const laneHeld = async () => ( await readJson<{ autoQueue?: { backfill?: { held?: boolean } } }>( @@ -1034,6 +1041,9 @@ test("re-acquired audio is handed to auto-transcribe when the policy would repla await writeSettings({ ...backfillSettings({ backfill: { allowRedownload: true } }), autoQueue: { + // Named here because this literal REPLACES backfillSettings' autoQueue, + // and the lane's gate defaults shut. + backfill: { held: false }, transcription: { enabled: true, maxWorkers: 1, diff --git a/editor/e2e/channel-line.spec.ts b/editor/e2e/channel-line.spec.ts @@ -53,10 +53,13 @@ function laneSettings(enabled: boolean) { concurrency: 1, }, backfill: { - enabled, concurrency: 1, allowRedownload: false, }, + // The lane's gate followed its `enabled` flag when that flag was the + // inverted `backfill.enabled`; S0-pause deleted the field, so the same + // thing is said on the lane. + autoQueue: { backfill: { held: !enabled } }, }; } diff --git a/editor/e2e/channel-rename.spec.ts b/editor/e2e/channel-rename.spec.ts @@ -1,6 +1,8 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; +import { baseUrl } from "./baseUrl"; import { channelStage, + generateReport, readJson, resetData, writeSite, @@ -55,3 +57,117 @@ test("rename requires the exact current slug and then moves the channel", async ); expect(site.channels.map((c) => c.slug)).toEqual([NEW]); }); + +// RENAMING AND DELETING ARE REFUSED WHILE SOMETHING IS WRITING INTO data/. +// +// The rename always asked, but it asked the JOB REGISTRY alone — and the +// registry is half the truth: the auto-queue lanes run their per-video units +// in-process and make no job record (lib/mediaBusy.ts, the omnimirror +// incident). Delete asked nothing at all, which is the worse of the two: a +// rename that orphans a registry entry is a nuisance, a delete racing a +// download for the tree loses bytes. +// +// Both now ask `channelMediaBusyReason`, the one question the Storage panel and +// the bulk move ask, and both say the same sentence with their own verb. +// +// THE JOB IS THE HALF AN E2E CAN STAGE. A lane unit is in flight for +// milliseconds at a time and cannot be held there from a browser; the fake +// yt-dlp's `--test-slow` holds a real job for 30 s, and it is the same registry +// read either way. +const SLOW = "slow-channel"; + +async function running(page: Page, slug: string): Promise<void> { + await expect + .poll( + async () => { + const res = await page.request.get(`${baseUrl}/api/jobs/active`); + const body = await res.json(); + const jobs: { channelSlug?: string; status: string }[] = Array.isArray( + body, + ) + ? body + : (body.jobs ?? []); + return jobs.filter( + (j) => + j.channelSlug === slug && + (j.status === "running" || j.status === "queued"), + ).length; + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0); +} + +test("rename and delete refuse while a job is writing into the channel", async ({ + page, +}) => { + test.setTimeout(120_000); + await resetData("slow-pipeline-channel"); + await generateReport(page, SLOW); + // Rendered while the channel is QUIET, so both forms come back live. The + // point of the next few lines is that the SERVER refuses — a page that + // already knows would prove only that the button can be greyed out. + await expect + .poll( + async () => { + const res = await page.request.get(`${baseUrl}/api/jobs/active`); + const body = await res.json(); + const jobs: { channelSlug?: string; status: string }[] = Array.isArray( + body, + ) + ? body + : (body.jobs ?? []); + return jobs.filter( + (j) => + j.channelSlug === SLOW && + (j.status === "running" || j.status === "queued"), + ).length; + }, + { timeout: 30_000 }, + ) + .toBe(0); + await page.goto(channelStage(SLOW, "danger")); + await expect( + page.getByRole("button", { name: "Rename channel" }), + ).toBeEnabled(); + + // Out of band, so this page's forms are the ones rendered before it started. + const started = await page.request.post(`${baseUrl}/api/ops/sync`, { + headers: { authorization: "Bearer test-worker-token" }, + data: { slug: SLOW }, + }); + expect(started.ok()).toBe(true); + await running(page, SLOW); + + // --- the server refuses, in the sentence every other surface says --------- + await page.getByLabel("new slug").fill("renamed-slow"); + await page.getByLabel("confirm current slug").fill(SLOW); + await page.getByRole("button", { name: "Rename channel" }).click(); + // `.first()`: the refusal is rendered by the form's own error paragraph, and + // a re-render would put the same sentence in the status above it too. + await expect(page.getByText(/before renaming it\./).first()).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/channels/${SLOW}(\\?|$)`)); + + await page.getByLabel("confirm slug to delete").fill(SLOW); + await page.getByRole("button", { name: "Delete channel" }).click(); + await expect(page.getByText(/before deleting it\./).first()).toBeVisible(); + // Nothing was deleted: the channel page still exists. + expect( + (await page.request.get(`${baseUrl}/channels/${SLOW}`)).status(), + ).toBe(200); + + // --- and a fresh render says so before either form is filled in ----------- + await page.goto(channelStage(SLOW, "danger")); + await expect(page.getByLabel("rename blocked")).toContainText( + /before renaming it\./, + ); + await expect(page.getByLabel("delete blocked")).toContainText( + /before deleting it\./, + ); + await expect( + page.getByRole("button", { name: "Rename channel" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Delete channel" }), + ).toBeDisabled(); +}); diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts @@ -3,7 +3,9 @@ import { lstat, mkdir, readdir, + rm, symlink, + utimes, writeFile, } from "node:fs/promises"; import { join } from "node:path"; @@ -104,7 +106,11 @@ test("relocate a channel's media to another root, and move it back", async ({ ).toBeVisible(); await expect(moveButton).toBeDisabled(); - await page.getByRole("button", { name: "Preview" }).click(); + // EXACT. The panel has a second preview since the clip-window card joined it + // ("Preview eviction"), and getByRole's name match is a case-insensitive + // SUBSTRING by default — so the bare name resolves to two buttons and the + // strict-mode violation reads as "the button vanished". + await page.getByRole("button", { name: "Preview", exact: true }).click(); const preview = page.getByLabel("relocation preview"); await expect(preview).toBeVisible({ timeout: 15_000 }); // The fixture is two files in one video dir. @@ -508,7 +514,11 @@ test("the Storage panel moves to a location picked by name", async ({ await destination.selectOption("cold"); const target = join(cold, SLUG, "data"); - await page.getByRole("button", { name: "Preview" }).click(); + // EXACT. The panel has a second preview since the clip-window card joined it + // ("Preview eviction"), and getByRole's name match is a case-insensitive + // SUBSTRING by default — so the bare name resolves to two buttons and the + // strict-mode violation reads as "the button vanished". + await page.getByRole("button", { name: "Preview", exact: true }).click(); const preview = page.getByLabel("relocation preview"); await expect(preview).toBeVisible({ timeout: 15_000 }); // The preview names the target the SERVER resolved from the id. @@ -656,7 +666,11 @@ test("a move to an unmounted root refuses before it creates anything", async ({ // shape an operator actually meets — and it is the shape that carries the // location id into the refusal. await page.getByLabel("destination location").selectOption("cold"); - await page.getByRole("button", { name: "Preview" }).click(); + // EXACT. The panel has a second preview since the clip-window card joined it + // ("Preview eviction"), and getByRole's name match is a case-insensitive + // SUBSTRING by default — so the bare name resolves to two buttons and the + // strict-mode violation reads as "the button vanished". + await page.getByRole("button", { name: "Preview", exact: true }).click(); // FILTERED, because Next ships its own `role="alert"` route announcer on // every page and a bare getByRole("alert") is a strict-mode violation that // reads as "the message never appeared". @@ -678,3 +692,129 @@ test("a move to an unmounted root refuses before it creates anything", async ({ // The media is still a real directory in the corpus, unmoved. expect((await lstat(dataDir())).isDirectory()).toBe(true); }); + +// EVICTING THIS CHANNEL'S FETCHED CLIP WINDOWS, from the panel that already +// says how many bytes the channel holds. +// +// The card is the SAME component /storage renders corpus-wide, mounted with a +// slug — so the gates, the wording and the by-age caveat are one definition and +// cannot drift. What this spec pins is the half that is new: the sweep is +// scoped to this channel, and the snapshot figure the panel reads follows the +// deletion (the per-channel run carries a `channelSlug`, which is what queues +// the regen; a corpus-wide run deliberately queues none). +test("the Storage panel evicts this channel's old clip windows", async ({ + page, +}) => { + test.setTimeout(120_000); + await resetData("one-youtube-channel-with-data"); + await writeSettings({ minFreeDiskGB: 0 }); + const clipsDir = resolvePath( + `test-transcripts/channels/${SLUG}/data/${VIDEO}/clips`, + ); + await mkdir(clipsDir, { recursive: true }); + const old = join(clipsDir, "10.00-40.00.mp4"); + const recent = join(clipsDir, "60.00-70.00.mp4"); + for (const [file, ageDays, size] of [ + [old, 90, 4096], + [recent, 1, 1024], + ] as const) { + await writeFile(file, "x".repeat(size)); + await writeFile( + file.replace(/\.mp4$/, ".json"), + JSON.stringify({ requestedBy: "umtool", reason: "e2e" }), + ); + const when = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000); + await utimes(file, when, when); + } + // The report is what measures `totalClipsBytes`, so it has to run AFTER the + // windows exist — and be waited out, because the card is gated on the same + // busy reason the moves are. + await generateReport(page, SLUG); + await quiet(page); + + const clipsBytesOf = async () => + ( + await readJson<{ totalClipsBytes?: number }>( + `test-transcripts/channels/${SLUG}/snapshot.json`, + ) + ).totalClipsBytes ?? 0; + const before = await clipsBytesOf(); + expect(before).toBeGreaterThanOrEqual(5120); + + await page.goto(channelStage(SLUG, "storage")); + // SCOPED, and the card says which scope it is in. "across the corpus" here + // would be a lie about what the button does. + await expect(page.getByLabel("clip windows bytes")).toContainText( + "in this channel", + ); + await expect(page.getByLabel("clip eviction caveat")).toContainText( + "by age only", + ); + + // --- the dry run lists the window and deletes nothing ------------------- + await page.getByLabel("clip eviction age").selectOption("30"); + await expect( + page.getByRole("button", { name: "Evict fetched windows" }), + ).toBeDisabled(); + await page.getByRole("button", { name: "Preview eviction" }).click(); + await expect(page.getByLabel("Preview eviction output")).toContainText( + /Would evict 1 window/, + { timeout: 60_000 }, + ); + expect(await pathExists(old)).toBe(true); + + // --- and the real run removes it ---------------------------------------- + const evict = page.getByRole("button", { name: "Evict fetched windows" }); + await expect(evict).toBeEnabled(); + await evict.click(); + await expect(page.getByLabel("Evict fetched windows output")).toContainText( + /Evicted 1 window/, + { timeout: 60_000 }, + ); + expect(await pathExists(old)).toBe(false); + expect(await pathExists(recent)).toBe(true); + + // THE PANEL'S OWN NUMBER FOLLOWS. A one-channel run carries `channelSlug`, + // so the stale report is regenerated behind it; polled because that regen is + // a queued job, not part of the eviction. + await expect.poll(clipsBytesOf, { timeout: 60_000 }).toBeLessThan(before); +}); + +// SYNC ALL SKIPS A CHANNEL WHOSE DRIVE IS NOT THERE, and says which. +// +// This is the sharpest case for the guard in AGENTS.md: every enumerator of +// `data/` swallows ENOENT as "this channel has no videos", so a sync of a +// channel on an unmounted platter reads the whole back catalogue as +// undownloaded — and hands the download lane an instruction to re-fetch it all +// onto the volume that was too full to hold it in the first place. +// +// `inspectChannelMedia` is the one module that can tell "nothing downloaded" +// from "drive not mounted". A skip is not a failure: the bulk readout reports +// both numbers and names the channel with the inspector's own sentence. +test("Sync all skips a channel whose media drive is not mounted", async ({ + page, +}, testInfo) => { + test.setTimeout(90_000); + await resetData("one-youtube-channel-with-data"); + await writeSettings({ minFreeDiskGB: 0 }); + // A relocation whose drive went away: the link and the config agree with each + // other and with nothing on disk. Deliberately NOT created — an unmounted + // mountpoint whose parent is missing too is the honest version. + const target = join(testInfo.outputPath("never-mounted"), SLUG, "data"); + await rm(dataDir(), { recursive: true, force: true }); + await symlink(target, dataDir()); + await writeChannelConfig(SLUG, { dataDir: target }); + + await page.goto("/channels"); + await page.getByRole("button", { name: "sync every channel" }).click(); + const result = page.getByLabel("sync all result"); + await expect(result).toContainText(/Queued 0 . skipped 1/, { + timeout: 15_000, + }); + // THE REASON NAMES THE DRIVE, which is the only thing the operator can act + // on — "skipped 1" alone would read as a bug in the sweep. + await expect(result).toHaveAttribute( + "title", + new RegExp(`${SLUG}: media unreachable:.*drive not mounted`), + ); +}); diff --git a/editor/e2e/lane-runner.spec.ts b/editor/e2e/lane-runner.spec.ts @@ -131,7 +131,6 @@ function laneSettings(over: Record<string, unknown> = {}) { ...((over.attribution as Record<string, unknown>) ?? {}), }, backfill: { - enabled: true, concurrency: 1, allowRedownload: false, ...((over.backfill as Record<string, unknown>) ?? {}), @@ -315,7 +314,12 @@ test("the backfill lane runner diarizes, then attributes from that diarization", await writeFile(resolvePath(dataRel(VIDEO, "audio.mp3")), "fake audio\n"); await writeSettings( laneSettings({ - autoQueue: { backfill: { enabled: true, maxWorkers: 1, root: CATCH_ALL } }, + autoQueue: { + // `held: false` because the backfill lane's gate defaults SHUT — the + // reading its inverted `backfill.enabled` always gave a file that named + // no gate, kept when S0-pause deleted the field. + backfill: { enabled: true, held: false, maxWorkers: 1, root: CATCH_ALL }, + }, }), ); await generateReport(page, CHANNEL); @@ -374,7 +378,7 @@ test("the digest and backfill lanes dispatch at the same time", async ({ laneSettings({ autoQueue: { digest: { enabled: true, maxWorkers: 1, root: CATCH_ALL }, - backfill: { enabled: true, maxWorkers: 1, root: CATCH_ALL }, + backfill: { enabled: true, held: false, maxWorkers: 1, root: CATCH_ALL }, }, }), ); diff --git a/editor/e2e/operation-settings.spec.ts b/editor/e2e/operation-settings.spec.ts @@ -127,9 +127,8 @@ test("the lane's switch survives a save of the operation form beside it", async // "Run the backfill lane" IS the lane's gate, and since slice 1.4 the gate is // `autoQueue.backfill.held` — checked means running, so held is false. It used - // to write the inverted `backfill.enabled`; that field is the migration's - // input now and nothing writes it, so asserting it here would assert a value - // no click can move. + // to write the inverted `backfill.enabled`; S0-pause deleted that field, so + // there is no second place a click could land. const saved = await readJson<{ autoQueue?: { backfill?: { held?: boolean } }; diarization?: { enabled: boolean }; diff --git a/editor/e2e/ops-api.spec.ts b/editor/e2e/ops-api.spec.ts @@ -7,12 +7,24 @@ // title-filter.spec.ts reads off the form, the busy-channel refusal the Storage // panel shows — and about the files on disk, not about the routes' own shapes. // -// THE 503 BRANCH IS NOT REACHABLE FROM HERE. The test server runs with -// WORKER_TOKEN=test-worker-token (editor/package.json, dev:test) and there is one -// server for the whole suite, so no spec can observe the endpoint disabled. 401 -// (missing and wrong) is covered below; the 503 is authorizeWorkerRequest's own -// first branch, shared with /api/worker/* and unit-tested by nothing else -// either. See plans/FACTS.md. +// THE TOKEN GATE HAS THREE ANSWERS, AND ALL THREE ARE PINNED HERE. +// +// `WORKER_TOKEN` unset => 503, wrong or missing => 401, matching => the route +// runs. The 503 is the consequential one: it is what stops an instance that +// never set the variable being an open transcription server, and 401 in its +// place would tell a scanner "there is a secret here, guess it". +// +// It used to be unreachable from a spec — the test server boots with +// WORKER_TOKEN=test-worker-token (editor/package.json, dev:test) and one server +// serves the whole suite, so nothing could observe the endpoint disabled. It is +// reachable now because /api/test/worker-token turns the variable off and back +// on INSIDE that server, which works only because `getWorkerToken()` reads +// process.env per call (common/lib/workerToken.test.ts pins that, and covers +// the branch table itself). +// +// ⚠️ THE VARIABLE IS PROCESS-WIDE AND THE SERVER OUTLIVES THE SPEC. Restoring +// it is a `finally`, never a trailing line: one failed assertion with the token +// still unset leaves every later /api/ops and /api/worker spec answering 503. import { readdir, rm } from "node:fs/promises"; import { test, expect, type APIRequestContext } from "@playwright/test"; @@ -79,6 +91,57 @@ test("the token gate answers 401 for a missing and for a wrong bearer", async ({ } }); +// UNSET IS OFF, on the ops door and on the worker door alike — one branch, +// shared, and neither surface may decide for itself that "no token configured" +// means "let them in". +test("with no token configured every guarded route answers 503", async ({ + request, +}) => { + await resetData("empty"); + const toggle = async (query: string) => { + const res = await request.get(`${baseUrl}/api/test/worker-token?${query}`); + expect(res.status(), query).toBe(200); + return (await res.json()) as { ok: boolean; enabled: boolean }; + }; + + const off = await toggle("unset=1"); + expect(off.enabled).toBe(false); + try { + // The ops door, with the RIGHT token: it is the surface being off that + // answers, not the credential being wrong. + const tags = await request.get(`${baseUrl}/api/ops/tags`, { + headers: AUTH, + }); + expect(tags.status()).toBe(503); + expect(((await tags.json()) as OpsResponse).error).toMatch( + /set WORKER_TOKEN to enable/, + ); + + // And the LAN worker door, which shares the branch. + const health = await request.get(`${baseUrl}/api/worker/health`, { + headers: AUTH, + }); + expect(health.status()).toBe(503); + expect(((await health.json()) as OpsResponse).error).toMatch( + /set WORKER_TOKEN to enable/, + ); + + // With no token configured a MISSING header is still 503, not 401: there is + // nothing to be unauthorized against. + const bare = await request.get(`${baseUrl}/api/ops/tags`); + expect(bare.status()).toBe(503); + } finally { + // NOT a trailing line. See the header: the server outlives this spec. + const on = await toggle(`set=${TOKEN}`); + expect(on.enabled).toBe(true); + } + + // Restored, and the same request now works — which is also the assertion + // that the `finally` above did what it claims. + const after = await request.get(`${baseUrl}/api/ops/tags`, { headers: AUTH }); + expect(after.status()).toBe(200); +}); + test("an unknown body key is a 400 that names the accepted keys", async ({ request, }) => { diff --git a/editor/e2e/widget.spec.ts b/editor/e2e/widget.spec.ts @@ -74,10 +74,14 @@ const BACKFILL_SETTINGS = { concurrency: 1, }, backfill: { - enabled: true, concurrency: 1, allowRedownload: false, }, + // THE LANE'S GATE, SPELLED — the lane's pause has to start OPEN for the + // widget's control to hold it. It used to be spelled by `backfill.enabled: + // true` above (the inverted retired field, deleted by S0-pause), and the + // gate now defaults shut. + autoQueue: { backfill: { held: false } }, }; const TWO_WORKERS = { @@ -798,8 +802,8 @@ test("the widget's controls hold and release the backfill lane", async ({ // Assert the KEY, not just the label: the button writes the same // `autoQueue.backfill.held` the "Run the backfill lane" checkbox does, and // the point of that choice is that the two cannot drift. (It was - // `backfill.enabled` until slice 1.4 moved the gate onto the lane; the - // polarity is no longer inverted.) + // `backfill.enabled` until slice 1.4 moved the gate onto the lane and + // S0-pause deleted the field; the polarity is no longer inverted.) const laneHeld = async () => ( await readJson<{ autoQueue?: { backfill?: { held?: boolean } } }>( diff --git a/editor/package.json b/editor/package.json @@ -5,8 +5,8 @@ "type": "module", "scripts": { "dev": "next dev --port ${EDITOR_PORT:-3001}", - "dev:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next dev --port ${PORT:-3011}", - "start:test": "WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next start --port ${PORT:-3011}", + "dev:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next dev --port ${PORT:-3011}", + "start:test": "EDITOR_TEST_ROUTES=1 WORKER_TOKEN=test-worker-token TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json EDITOR_CHANGELOG_FILE=$(pwd)/test-changelog.md YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs GALLERY_DL_BIN=$(pwd)/e2e/fixtures/bin/fake-gallery-dl.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null DIARIZE_BIN=$(pwd)/e2e/fixtures/bin/fake-diarize.mjs FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs FFPROBE_BIN=$(pwd)/e2e/fixtures/bin/fake-ffprobe.mjs OLLAMA_URL=http://127.0.0.1:${OLLAMA_STUB_PORT:-11435} CLAUDE_BIN=$(pwd)/e2e/fixtures/bin/fake-claude.mjs FINDMNT_BIN=$(pwd)/e2e/fixtures/bin/fake-findmnt.mjs UDISKSCTL_BIN=$(pwd)/e2e/fixtures/bin/fake-udisksctl.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 AUDIO_CHECK_INTERVAL_FLOOR_MS_OVERRIDE=50 AUDIO_CHECK_RECOVER_STEP_MS_OVERRIDE=100 AUDIO_CHECK_RECOVER_AFTER_OVERRIDE=2 next start --port ${PORT:-3011}", "build": "next build", "start": "next start --port ${EDITOR_PORT:-3001}", "lint": "eslint", diff --git a/plans/tools/phase1-numbers.ts b/plans/tools/phase1-numbers.ts @@ -128,16 +128,23 @@ async function main(): Promise<void> { // --- flags --------------------------------------------------------------- console.log("## flags"); + // THE FOUR RETIRED PAUSE FIELDS ARE NOT PRINTED, because they do not exist. + // `transcriptionsPaused`, `downloadsPaused`, `digest.digestsPaused` and the + // inverted `backfill.enabled` were deleted by S0-pause; the loop below prints + // the same four answers off the key that replaced them. Nothing under plans/ + // is in a tsconfig, so tsc never caught these — they would have printed + // `undefined` for every lane on every corpus, under the old names, beside the + // right answer. + // + // The two `sweepEnabled` reads stay: they come off the RAW file, are retired + // keys this script exists to report on, and are read as `=== true` rather + // than dereferenced. for (const lane of PAUSE_LANES) { console.log(`held ${lane} = ${isGateHeld(settings, lane)}`); } - console.log(`digest.digestsPaused = ${settings.digest.digestsPaused}`); console.log(`digest.sweepEnabled = ${raw.digest?.sweepEnabled === true}`); console.log(`digest.remoteEnabled = ${settings.digest.remoteEnabled}`); - console.log(`backfill.enabled = ${settings.backfill.enabled}`); console.log(`backfill.sweepEnabled = ${raw.backfill?.sweepEnabled === true}`); - console.log(`transcriptionsPaused = ${settings.transcriptionsPaused}`); - console.log(`downloadsPaused = ${settings.downloadsPaused}`); console.log(""); // --- lanes ---------------------------------------------------------------