Archilyzer · Source

archilyzer

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

commit d35e8b52ba7193518ab541914ccf339574180f68
parent 595337e5bbdc6dbc802f7ac09198d97b5103ab45
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 12 Sep 2026 12:02:38 -0400

common: the four legacy pause fields are gone, and so is the migration

S0-pause, per one-core-phase-1.md:1130-1139. `transcriptionsPaused`,
`downloadsPaused`, `digest.digestsPaused` and the inverted `backfill.enabled`
leave `SiteSettings`, their sanitizers, their defaults and `writeSettings`'
output literal; `legacyGateHeld` and `migrateHeldToLanes` leave
`lib/laneMigration.ts` (`migrateSweepsToLanes`, slice 1.3's, stays untouched).
`isGateHeld` keeps its signature and reads `autoQueue[lane].held` alone.

THE PRECONDITION HELD. The live settings.json carries all four `held` keys
(transcription/download true, digest/backfill false, checked 2026-09-12), so no
pause is dropped by the deletion. It still spells the legacy fields
(`transcriptionsPaused: true`, `downloadsPaused: false`, `digestsPaused: false`,
`backfill.enabled: true`) and loses them on its next write, because
`writeSettings` builds from only the known operational fields.

ABSENT IS NOT HELD is now the whole rule, and the comments that justified the
opposite (`AutoQueuePolicy.held`, `sanitizePolicy`, `pauseGates.ts`) say why:
there is no second field left to ask, and defaulting a missing key to `true`
would hold four lanes on every fixture that never mentioned a pause.

Tests: the four pause-migration cases in `jobs/laneMigration.test.ts` go with
the function they covered. `pauseGates.test.ts` swaps its two fallback/precedence
cases for the deletion from both ends — a settings object still spelling all four
retired fields holds nothing, and no sanitizer carries one back onto disk. The
two controller pause cases hold their lane through `withGateHeld` instead of
through a retired field. common tests 1077 -> 1073.

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

Diffstat:
Mcommon/controller/autoRunner.ts | 2+-
Mcommon/controller/laneGuards.test.ts | 13++++++++++++-
Mcommon/controller/laneGuards.ts | 2+-
Mcommon/controller/operationBatch.test.ts | 41+++++++++++++++++++++++------------------
Mcommon/jobs/autoQueuePolicy.ts | 13++++++-------
Mcommon/jobs/laneMigration.test.ts | 94-------------------------------------------------------------------------------
Mcommon/lib/autoQueueTypes.ts | 20+++++++++++---------
Mcommon/lib/laneMigration.ts | 93+++++++++++--------------------------------------------------------------------
Mcommon/lib/operations.ts | 2+-
Mcommon/lib/pauseGates.test.ts | 116++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mcommon/lib/pauseGates.ts | 45++++++++++++++++++++-------------------------
Mcommon/lib/settings.ts | 54++++++++----------------------------------------------
12 files changed, 162 insertions(+), 333 deletions(-)

diff --git a/common/controller/autoRunner.ts b/common/controller/autoRunner.ts @@ -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,13 @@ 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 the LANE'S ARM, `autoQueue.backfill.enabled`. The + // `backfill.enabled` this line used to read was the lane's inverted pause + // field, which slice 1.4 moved to `autoQueue.backfill.held` and S0-pause + // deleted; an unarmed lane dispatches nothing whatever its gate says. + assert.equal(defaultSiteSettings().autoQueue.backfill.enabled, false); 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 +252,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: [], @@ -267,7 +277,7 @@ 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", () => { const verdict = laneLimit( settingsWith({ - backfill: { ...defaultBackfill(), enabled: true, concurrency: 3 }, + backfill: { ...defaultBackfill(), concurrency: 3 }, }), { lane: "backfill", @@ -292,7 +302,7 @@ test("the GPU carve-out is keyed on contendsFor, not on laneFor existing", () => // `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 }, + backfill: { ...defaultBackfill(), concurrency: 4 }, }); const cpuOnly = laneLimit(settings, { lane: "backfill", @@ -319,7 +329,7 @@ test("the GPU carve-out is keyed on contendsFor, not on laneFor existing", () => assert.equal(gpuBound.limit, 4, "idle: nothing is transcribing in this process"); const weightless = laneLimit( settingsWith({ - backfill: { ...defaultBackfill(), enabled: true, concurrency: 4 }, + backfill: { ...defaultBackfill(), concurrency: 4 }, }), { lane: "backfill", @@ -337,7 +347,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 +362,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 +382,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/jobs/autoQueuePolicy.ts b/common/jobs/autoQueuePolicy.ts @@ -721,13 +721,12 @@ 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. + // A BOOLEAN OR NOTHING. `held` is the lane's pause gate + // (AutoQueuePolicy.held) and the only spelling of one since S0-pause + // deleted the four legacy fields it migrated from. Not defaulted: an + // absent key reads as not held anyway, and `undefined` never reaches the + // file (JSON.stringify drops it), so a lane nobody has paused stays absent + // rather than gaining a `"held": false` the operator did not write. held: typeof r.held === "boolean" ? r.held : undefined, root: r.root === undefined 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,17 @@ 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, and absent now means NOT HELD. 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 the sanitizer deliberately refused to + // default it. S0-pause deleted those four, on the precondition that the live + // settings.json already carried every `held` key, so there is nothing left to + // ask: `isGateHeld` reads this and only this. + // + // STILL NOT DEFAULTED, for a smaller reason: `undefined` never reaches the + // file (JSON.stringify drops it), so a lane that has never been paused stays + // absent rather than gaining a `"held": false` the operator did not write. 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,61 @@ 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: absent is not held, and there is no second + // opinion left to disagree with the key. + // + // The precondition that made this 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 that was paused could come 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); -}); - -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. - 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", + // `false` was HELD under the old inversion, which is the value most likely + // to be misread by anything that kept looking. + backfill: { ...base.backfill, enabled: false }, + } as unknown as SiteSettings; + for (const lane of LANES) { + assert.equal(isGateHeld(stale, lane), false, `${lane} read a retired field`); + } + // And the key still answers on the same object: the stale fields are inert, + // not consulted-and-outranked. + assert.equal(isGateHeld(withGateHeld(stale, "digest", true), "digest"), true); + assert.equal( + isGateHeld(withGateHeld(stale, "backfill", true), "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); +}); + +test("no sanitizer carries a retired pause field back onto disk", () => { + // THE DELETION, FROM THE WRITE SIDE. `writeSettings` builds its output from + // ONLY the known operational fields — `sanitizeDigest`, `sanitizeBackfill` + // and an explicit top-level literal — so a field no sanitizer names cannot + // survive a save. That is what stops a stale file resurrecting a pause on the + // next write, and it is the half a read-side test cannot see. + 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("holding a lane leaves the rest of its policy alone", () => { @@ -149,11 +162,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,28 @@ 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. +// ABSENT IS NOT HELD, 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). So a file that predates the key, or +// a hand-written one, reads as NOT held: there is no longer another field that +// could say otherwise, and inventing a default of `true` would hold four lanes +// on every fixture that never mentioned a pause. // -// `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,