Archilyzer · Source

archilyzer

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

commit 93d82150217ecd05d8c597ad9fba8113a37c10b5
parent 3af3d5ed17fd14679ae7b7e717bf297410e71035
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 18:26:05 -0400

storage: the drive going away is a state, so something has to look

Operator ask, 2026-09-18: "Can that gracefully handle the case if the drive
disconnects while the channel is on? Maybe an automatic disable and flag?"

Every guard the corpus has for unreachable media runs at the START of a piece of
work. None of them is a detector, so a channel on a drive that vanished sits
being refused, over and over, with nothing saying why. `storageWatch.ts` is the
thing that looks: a five-minute pass that probes every location, auto-pauses the
channels whose media it cannot reach, and restores them — to the tier they had —
when it can again.

`ChannelPriorityEntry.autoPaused` records `{reason:"storage", since,
previousTier}`. It is the machine's pause and nobody else's: the one priority
writer clears it on any manual tier change, so the operator's word always wins,
and `restoreAfterMedia` is a no-op without a record, so a returning drive can
never un-pause a channel a person paused. The rack says which — a "storage" chip
beside the tier, carrying the sentence.

Three things the pass refuses to do: write when nothing changed (a quiet pass is
silent, so no pulse bump, and a flapping drive costs two writes per flap);
auto-pause a channel mid-relocation (the marker means the relocate job is
running, and that job is what an auto-pause would be refusing); and flip a
DEFAULT priority document, which would switch the corpus off its hand-made lane
trees — it pays the same legacy seed `saveChannelPriorityAction` does, and
recompiles the lanes in the same write.

The sanitizer now keeps a rank on an auto-paused channel. It drops one from a
channel paused everywhere, which is right for a pause the operator meant and
catastrophic for a temporary one: an unplugged USB cable would otherwise destroy
the queue order and restore the channel unranked.

Armed below the idle gate in instrumentation.ts: the probe is read-only, the
WRITE is work, and a container pointed at somebody else's corpus does not
rewrite its priority document.

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

Diffstat:
Acommon/controller/storageWatch.test.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/controller/storageWatch.ts | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/channelPriority.test.ts | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/channelPriority.ts | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Meditor/app/channels/actions.ts | 20++++++++++++++++----
Meditor/app/channels/components/ChannelTierSelect.tsx | 21+++++++++++++++++++++
Meditor/app/channels/components/ChannelsTable.tsx | 3+++
Meditor/app/channels/page.tsx | 6++++++
Meditor/instrumentation.ts | 19+++++++++++++++++++
9 files changed, 929 insertions(+), 6 deletions(-)

diff --git a/common/controller/storageWatch.test.ts b/common/controller/storageWatch.test.ts @@ -0,0 +1,267 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import type { SiteSettings } from "../lib/settings"; +import { + compileLanes, + sanitizeChannelPriority, +} from "../lib/channelPriority"; +import { LANES } from "../lib/autoQueueTypes"; +import { runStorageWatchPass } from "./storageWatch"; + +// Run with: +// pnpm --filter yt-dlp-transcript-common exec tsx --test controller/storageWatch.test.ts +// +// NO SUBPROCESS. `bins` is passed as a Paths-shaped object whose findmnt points +// at a binary that does not exist, so `probeLocation` fails open to "identity +// unknown" — which is the container case and exactly what this pass must keep +// working under. What decides here is the ROOT's existence and the channel's +// own link, which is what the pass actually reads. + +// Lane policies whose roots the compiler already wrote. See the seed comment in +// storageWatch.ts: a corpus whose trees were NEVER compiled keeps its hand-made +// order, and the watch must not be the thing that switches it over. +const COMPILED_LANES = (() => { + const roots = compileLanes(sanitizeChannelPriority({ channels: {} }), [], []); + return Object.fromEntries( + LANES.map((lane) => [lane, { enabled: false, root: roots[lane] }]), + ); +})(); + +type H = { + paths: Paths; + root: string; + io: { read: () => SiteSettings; write: (n: SiteSettings) => Promise<void> }; + writes: number; +}; + +async function withTmp(fn: (h: H) => Promise<void>): Promise<void> { + const dir = await mkdtemp(path.join(tmpdir(), "ttb-storagewatch-")); + const transcriptsDir = path.join(dir, "corpus"); + const paths = { + transcriptsDir, + channelsDir: path.join(transcriptsDir, "channels"), + findmntBin: path.join(dir, "no-such-findmnt"), + udisksctlBin: path.join(dir, "no-such-udisksctl"), + } as Paths; + const root = path.join(dir, "platter"); + await mkdir(paths.channelsDir, { recursive: true }); + await mkdir(root, { recursive: true }); + const h: H = { + paths, + root, + writes: 0, + io: { + read: () => settings, + write: async (n) => { + settings = n; + h.writes += 1; + }, + }, + }; + let settings = { + storage: { + locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }], + defaultLocationId: "cold", + }, + channelPriority: sanitizeChannelPriority({ channels: {} }), + // The compiled-lane flag the writer's seed condition reads. Compiled here, + // so the seed path is not taken and the test is about the watch and not + // about the migration — `hasCompiledLaneRoots` is exercised by + // channelPriorityCompile.test.ts. + autoQueue: COMPILED_LANES, + } as unknown as SiteSettings; + try { + await fn(h); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +// A channel whose `data/` is a link to `<root>/<slug>/data`, with the target +// present or not. +async function seedRelocated( + h: H, + slug: string, + opts: { targetExists: boolean }, +): Promise<void> { + const channelDir = path.join(h.paths.channelsDir, slug); + await mkdir(channelDir, { recursive: true }); + const target = path.join(h.root, slug, "data"); + if (opts.targetExists) await mkdir(target, { recursive: true }); + await writeFile( + path.join(channelDir, "config.json"), + JSON.stringify({ handling: "youtube", dataDir: target }), + ); + await symlink(target, path.join(channelDir, "data")); +} + +function tierOf(h: H, slug: string): string | undefined { + return h.io.read().channelPriority.channels[slug]?.tier; +} + +test("a channel whose target is gone is auto-paused, once, in one write", async () => { + await withTmp(async (h) => { + await seedRelocated(h, "gone-a", { targetExists: false }); + await seedRelocated(h, "gone-b", { targetExists: false }); + await seedRelocated(h, "fine", { targetExists: true }); + + const first = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.deepEqual(first.paused.sort(), ["gone-a", "gone-b"]); + assert.deepEqual(first.restored, []); + assert.equal(first.wrote, true); + // TWO CHANNELS, ONE WRITE. Ten on a drive that vanished must be one pulse + // bump, not ten. + assert.equal(h.writes, 1); + assert.equal(tierOf(h, "gone-a"), "paused"); + assert.equal(tierOf(h, "fine"), undefined); + assert.equal( + h.io.read().channelPriority.channels["gone-a"].autoPaused?.reason, + "storage", + ); + + // A QUIET PASS WRITES NOTHING. The document already describes the world. + const second = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.deepEqual(second.paused, []); + assert.equal(second.wrote, false); + assert.equal(h.writes, 1); + }); +}); + +test("the drive coming back restores the tier it overwrote", async () => { + await withTmp(async (h) => { + await seedRelocated(h, "away", { targetExists: false }); + const settings = h.io.read(); + await h.io.write({ + ...settings, + channelPriority: sanitizeChannelPriority({ + channels: { away: { tier: "low", rank: 4 } }, + }), + }); + h.writes = 0; + + await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.equal(tierOf(h, "away"), "paused"); + + await mkdir(path.join(h.root, "away", "data"), { recursive: true }); + const back = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.deepEqual(back.restored, ["away"]); + assert.equal(tierOf(h, "away"), "low"); + assert.equal(h.io.read().channelPriority.channels.away.rank, 4); + assert.equal(h.io.read().channelPriority.channels.away.autoPaused, undefined); + }); +}); + +// THE OPERATOR'S WORD WINS. A channel they paused is not the machine's to +// claim, and one they resumed while the drive was still away must not be +// re-paused... by a restore. (Re-pausing it on a LATER pass is correct: the +// drive is still gone. What must not happen is the restore un-pausing a +// deliberate pause, which is what the no-record no-op guarantees.) +test("a manually paused channel is never claimed by the watch", async () => { + await withTmp(async (h) => { + await seedRelocated(h, "off", { targetExists: false }); + const settings = h.io.read(); + await h.io.write({ + ...settings, + channelPriority: sanitizeChannelPriority({ + channels: { off: { tier: "paused" } }, + }), + }); + h.writes = 0; + const r = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.deepEqual(r.paused, []); + assert.equal(r.wrote, false); + assert.equal(h.writes, 0); + assert.equal( + h.io.read().channelPriority.channels.off.autoPaused, + undefined, + ); + }); +}); + +// A MARKER MEANS A MOVE IS RUNNING OR WAS INTERRUPTED, and the relocate job is +// precisely the thing an auto-pause would then be refusing. +test("a channel mid-relocation is not auto-paused", async () => { + await withTmp(async (h) => { + await seedRelocated(h, "moving", { targetExists: false }); + await writeFile( + path.join(h.paths.channelsDir, "moving", ".relocating.json"), + JSON.stringify({ + target: path.join(h.root, "moving", "data"), + direction: "out", + startedAt: "", + phase: "copy", + }), + ); + const r = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.deepEqual(r.paused, []); + assert.equal(r.wrote, false); + }); +}); + +// A CHANNEL MOVED BACK IN PLACE CARRIES NO DRIVE TO BE AWAY, but it can carry a +// record from before — which has to come off or it stays paused for ever. +test("a record on an in-place channel is restored", async () => { + await withTmp(async (h) => { + const channelDir = path.join(h.paths.channelsDir, "home"); + await mkdir(path.join(channelDir, "data"), { recursive: true }); + await writeFile( + path.join(channelDir, "config.json"), + JSON.stringify({ handling: "youtube" }), + ); + const settings = h.io.read(); + await h.io.write({ + ...settings, + channelPriority: sanitizeChannelPriority({ + channels: { + home: { + tier: "paused", + autoPaused: { + reason: "storage", + since: "2026-09-20T00:00:00.000Z", + previousTier: "normal", + }, + }, + }, + }), + }); + h.writes = 0; + const r = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.deepEqual(r.restored, ["home"]); + assert.equal(h.io.read().channelPriority.channels.home, undefined); + }); +}); + +// `write: false` IS IDLE BOOT. It observes and reports; the write is the work, +// and idle boot refuses work. +test("write: false reports the transition and changes nothing", async () => { + await withTmp(async (h) => { + await seedRelocated(h, "gone", { targetExists: false }); + const r = await runStorageWatchPass({ + paths: h.paths, + io: h.io, + bins: h.paths, + write: false, + }); + assert.deepEqual(r.paused, ["gone"]); + assert.equal(r.wrote, false); + assert.equal(h.writes, 0); + assert.equal(tierOf(h, "gone"), undefined); + }); +}); + +test("no locations and nothing auto-paused is a free pass", async () => { + await withTmp(async (h) => { + const settings = h.io.read(); + await h.io.write({ + ...settings, + storage: { locations: [], defaultLocationId: "" }, + }); + h.writes = 0; + const r = await runStorageWatchPass({ paths: h.paths, io: h.io, bins: h.paths }); + assert.deepEqual(r, { probed: 0, paused: [], restored: [], wrote: false }); + }); +}); diff --git a/common/controller/storageWatch.ts b/common/controller/storageWatch.ts @@ -0,0 +1,260 @@ +import { getPaths, type Paths } from "../lib/paths"; +import { + getSettings, + writeSettings, + type SiteSettings, +} from "../lib/settings"; +import { + autoPausedSlugs, + autoPauseForMedia, + channelPriorityFromLegacy, + compileLanes, + hasCompiledLaneRoots, + isDefaultChannelPriority, + resolveFocusSlugs, + restoreAfterMedia, + sanitizeChannelPriority, + type ChannelPriority, +} from "../lib/channelPriority"; +import { LANES } from "../lib/autoQueueTypes"; +import { siteChannelIndex } from "../lib/site"; +import { inspectChannelMedia } from "../lib/channelMedia"; +import { locationOfDataDir } from "../lib/storageLocations"; +import type { VolumeBins } from "../lib/storageVolumes"; +import { listChannelConfigs } from "./channels"; +import { probeAllLocations } from "./storageLocations"; + +// THE DRIVE WENT AWAY WHILE THE CHANNEL WAS ON — now what. +// +// Operator ask, 2026-09-18: "Can that gracefully handle the case if the drive +// disconnects while the channel is on? Maybe an automatic disable and flag?" +// +// Every guard the corpus has for an unreachable channel runs at the START of a +// piece of work (`runManagedFunction`'s first statement, `buildChannelWork`'s +// per-tick inspect, the snapshot regen, the operation batch). None of them is a +// detector: they refuse work that is already being asked for, which means a +// channel on a drive that vanished sits there being refused, over and over, +// with nothing anywhere saying why. The flag the operator asked for is a state, +// and a state needs something that looks. +// +// SO THIS LOOKS, ON A CADENCE, AND WRITES AT MOST ONCE PER PASS. +// +// Two rules, and they are what keep it from being a settings-churn machine: +// +// 1. IT ONLY EVER WRITES ON A TRANSITION. A pass that finds the world exactly +// as the document already describes it writes nothing — no settings write, +// so no pulse revision bump, so no reader re-polls. A flapping drive costs +// two writes per flap, not one per tick. +// 2. IT RESTORES ONLY WHAT IT PAUSED. `restoreAfterMedia` is a no-op without +// an `autoPaused` record, and the one priority writer clears that record on +// any manual tier change — so a drive coming back can never un-pause a +// channel the operator paused on purpose in the meantime. +// +// IT IS A RUNNER, so `ARCHILYZER_IDLE_BOOT` must not arm it: a container +// pointed at somebody else's corpus for the first time has no business +// rewriting that corpus's priority document seconds after `docker compose up`. +// The probe is read-only and cheap; the WRITE is the work, and idle boot +// refuses work. `runStorageWatchPass({ write: false })` is the observation +// without the consequence, which is what the boot pass and a test want. + +export type StorageWatchResult = { + // Locations probed this pass. + probed: number; + // Channels newly auto-paused, and channels restored. Both empty on a quiet + // pass, which is the overwhelming majority. + paused: string[]; + restored: string[]; + // Whether settings were written. False whenever both lists are empty. + wrote: boolean; +}; + +export type StorageWatchOpts = { + paths?: Paths; + bins?: VolumeBins; + io?: { read: () => SiteSettings; write: (next: SiteSettings) => Promise<void> }; + // False observes and reports without touching settings — idle boot, and the + // unit tests. + write?: boolean; + log?: (line: string) => void; +}; + +const DEFAULT_IO = { read: getSettings, write: writeSettings }; + +export async function runStorageWatchPass( + opts: StorageWatchOpts = {}, +): Promise<StorageWatchResult> { + const io = opts.io ?? DEFAULT_IO; + const log = opts.log ?? (() => {}); + const paths = opts.paths ?? getPaths(); + const settings = io.read(); + const locations = settings.storage.locations; + const out: StorageWatchResult = { + probed: 0, + paused: [], + restored: [], + wrote: false, + }; + + // A channel can only be auto-paused for a location's sake, so with no + // locations configured there is nothing to watch — EXCEPT the records a + // previous configuration left behind, which must still be restorable. Hence + // the early return is on "no locations AND nothing auto-paused". + const already = autoPausedSlugs(settings.channelPriority); + if (locations.length === 0 && already.length === 0) return out; + + // REFRESH, NOT THE MEMO. The memo exists so a page render does not fork + // eighteen subprocesses per click; this pass runs on a cadence measured in + // minutes and its whole job is to notice a change, so an answer taken up to + // ten seconds ago is not what it is asking for. + const probes = await probeAllLocations(locations, opts.bins ?? paths, { + refresh: true, + }); + out.probed = Object.keys(probes).length; + + const configs = await listChannelConfigs(paths); + let model: ChannelPriority = settings.channelPriority; + + for (const { slug, config } of configs) { + const wasAutoPaused = Boolean(model.channels[slug]?.autoPaused); + const dataDir = config.dataDir?.trim(); + if (!dataDir) { + // In place. It cannot be on a drive that went away — but it CAN carry a + // record from before it was moved back, and that record has to come off + // or the channel stays paused for ever. + if (wasAutoPaused) { + model = restoreAfterMedia(model, slug); + out.restored.push(slug); + } + continue; + } + // THE LOCATION'S PROBE FIRST, THE CHANNEL'S OWN STAT SECOND. The probe is + // the cheap corpus-wide answer (one findmnt per location, not per channel); + // `inspectChannelMedia` is what decides, because a location can be + // available while one channel's target under it is missing — a half-done + // move, a directory deleted by hand. + const loc = locationOfDataDir(dataDir, locations); + const probe = loc ? probes[loc.id] : undefined; + const locationDown = Boolean(loc) && probe?.status !== "available"; + const media = await inspectChannelMedia(paths, slug, config); + // `in-transition` is NEVER a reason to pause: a marker means a move is + // running or was interrupted, and the relocate job is precisely the thing + // that would then be refused by the state it created. + const down = + media.status === "in-transition" + ? false + : locationDown || media.status === "unreachable"; + + if (down && !wasAutoPaused) { + const before = model; + model = autoPauseForMedia(model, slug); + // autoPauseForMedia no-ops on a channel the OPERATOR already paused — + // which is right, and means "nothing changed" is a normal outcome here. + if (model !== before) { + out.paused.push(slug); + log( + `[storage] ${slug}: media unreachable (${ + loc ? `location "${loc.id}" is ${probe?.status ?? "unprobed"}` : media.status + }) — auto-paused`, + ); + } + continue; + } + if (!down && wasAutoPaused) { + const restoredTo = model.channels[slug]?.autoPaused?.previousTier; + model = restoreAfterMedia(model, slug); + out.restored.push(slug); + log(`[storage] ${slug}: media reachable again, tier restored to ${restoredTo}`); + } + } + + if (out.paused.length === 0 && out.restored.length === 0) return out; + if (opts.write === false) return out; + // ONE WRITE PER PASS, whatever the pass found. Ten channels on one drive that + // vanished is one settings write and one pulse bump, not ten. + // + // FROM A FRESH READ, because a probe of several locations is seconds of wall + // clock during which an operator may have changed a tier in another tab — + // and clobbering that with a snapshot taken before the pass started would + // silently undo it. The edits are re-applied to whatever is current. + const latest = io.read(); + // THE LEGACY SEED, AND WHY A BACKGROUND PASS MUST PAY IT TOO. + // + // `laneDispatchRoot` is all-or-nothing on `isDefaultChannelPriority`: the + // moment the document says ANYTHING, the stored lane trees stop being + // dispatched from and compiled ones take over. So a pass that auto-paused one + // channel on a corpus whose document was empty would, as a side effect, + // replace the operator's hand-made lane order with an all-normal alphabetical + // one — silently, at 3am, because a USB cable came loose. That is the same + // trap `saveChannelPriorityAction` documents (the S2/S3 review, finding 3), + // and it is worse here because nobody clicked anything. + // + // Same remedy, same condition: when the stored document says nothing AND the + // stored trees were never compiled, derive the document the migration would + // have produced FIRST and apply the pause on top of that. The corpus's + // existing order survives. + const stored = latest.channelPriority; + const base = + isDefaultChannelPriority(stored) && !hasCompiledLaneRoots(latest.autoQueue) + ? channelPriorityFromLegacy( + configs.map((c) => ({ slug: c.slug, config: {} })), + latest.autoQueue, + ) + : stored; + let merged: ChannelPriority = base; + for (const slug of out.paused) merged = autoPauseForMedia(merged, slug); + for (const slug of out.restored) merged = restoreAfterMedia(merged, slug); + merged = sanitizeChannelPriority(merged); + // AND THE TREES, in the same write. Two writes to one settings file race each + // other, and a document that has changed tiers with trees that have not is a + // corpus dispatching from an order nobody holds any more. The condition is + // the writer's: a still-default document with never-compiled trees keeps its + // hand-made ones. + const autoQueue = { ...latest.autoQueue }; + if (!isDefaultChannelPriority(merged) || hasCompiledLaneRoots(latest.autoQueue)) { + const slugs = configs.map((c) => c.slug); + const focusSlugs = resolveFocusSlugs(merged, siteChannelIndex(paths), slugs); + const roots = compileLanes(merged, slugs, focusSlugs); + for (const lane of LANES) { + autoQueue[lane] = { ...latest.autoQueue[lane], root: roots[lane] }; + } + } + await io.write({ ...latest, channelPriority: merged, autoQueue }); + out.wrote = true; + return out; +} + +// --------------------------------------------------------------------------- +// The cadence +// --------------------------------------------------------------------------- + +// Five minutes. A drive does not come and go on a timescale a person would +// notice faster than that, and every pass is one findmnt per location plus two +// stats per relocated channel — cheap, but not free, and this runs for the life +// of the process. +export const STORAGE_WATCH_INTERVAL_MS = 5 * 60_000; + +let timer: ReturnType<typeof setInterval> | null = null; + +// ARMED ONCE PER PROCESS. `unref()` so it never holds the event loop open — a +// CLI that imports a controller must still exit. +export function startStorageWatch( + opts: StorageWatchOpts & { intervalMs?: number } = {}, +): boolean { + if (timer) return false; + const every = opts.intervalMs ?? STORAGE_WATCH_INTERVAL_MS; + timer = setInterval(() => { + void runStorageWatchPass(opts).catch((err) => { + (opts.log ?? console.warn)( + `[storage] watch pass failed: ${(err as Error).message}`, + ); + }); + }, every); + timer.unref?.(); + return true; +} + +export function stopStorageWatch(): void { + if (!timer) return; + clearInterval(timer); + timer = null; +} diff --git a/common/lib/channelPriority.test.ts b/common/lib/channelPriority.test.ts @@ -3,6 +3,11 @@ import assert from "node:assert/strict"; import { CHANNEL_TIERS, PRIORITY_OPERATIONS, + autoPauseForMedia, + autoPauseReasonOf, + autoPausedSlugs, + clearAutoPause, + restoreAfterMedia, PRIO_CATCH_ALL_ID, STORED_CHANNEL_TIERS, channelPriorityFromLegacy, @@ -1121,3 +1126,183 @@ test("a rename onto an existing entry keeps the destination's", () => { const next = renameChannelInPriority(model, "old", "taken"); assert.deepEqual(next.channels, { taken: { tier: "paused" } }); }); + +// --- Auto-pause: the machine's own pause, and its undo ---------------------- + +test("auto-pause records the tier it overwrote and forces paused", () => { + const model = sanitizeChannelPriority({ + focus: { kind: "none" }, + channels: { a: { tier: "low", rank: 3 } }, + }); + const now = new Date("2026-09-20T12:00:00.000Z"); + const next = autoPauseForMedia(model, "a", now); + assert.equal(next.channels.a.tier, "paused"); + assert.deepEqual(next.channels.a.autoPaused, { + reason: "storage", + since: "2026-09-20T12:00:00.000Z", + previousTier: "low", + }); + // Everything else on the entry survives — the pause is a tier change, not a + // reset. + assert.equal(next.channels.a.rank, 3); + // And a channel with no entry at all gets one recording the default. + assert.equal( + autoPauseForMedia(model, "fresh", now).channels.fresh.autoPaused + ?.previousTier, + "normal", + ); +}); + +// A FLAPPING DRIVE MUST NOT OVERWRITE `previousTier` WITH `paused` — that would +// make the restore restore nothing, which is the failure this no-op prevents. +test("auto-pause is a no-op on a channel it already auto-paused", () => { + const once = autoPauseForMedia( + sanitizeChannelPriority({ channels: { a: { tier: "normal" } } }), + "a", + new Date("2026-09-20T12:00:00.000Z"), + ); + const twice = autoPauseForMedia(once, "a", new Date("2026-09-21T12:00:00Z")); + assert.equal(twice, once); + assert.equal(twice.channels.a.autoPaused?.previousTier, "normal"); +}); + +// A CHANNEL THE OPERATOR PAUSED IS NOT THE MACHINE'S TO CLAIM: recording it +// would hand the next restore permission to turn it back on. +test("auto-pause is a no-op on a channel the operator already paused", () => { + const model = sanitizeChannelPriority({ channels: { a: { tier: "paused" } } }); + assert.equal(autoPauseForMedia(model, "a"), model); +}); + +test("restore puts the tier back and clears the record", () => { + const paused = autoPauseForMedia( + sanitizeChannelPriority({ channels: { a: { tier: "low", rank: 2 } } }), + "a", + ); + const back = restoreAfterMedia(paused, "a"); + assert.equal(back.channels.a.tier, "low"); + assert.equal(back.channels.a.autoPaused, undefined); + assert.equal(back.channels.a.rank, 2); +}); + +test("restore only undoes what auto-pause made", () => { + const manual = sanitizeChannelPriority({ channels: { a: { tier: "paused" } } }); + assert.equal(restoreAfterMedia(manual, "a"), manual); + assert.equal(restoreAfterMedia(manual, "nobody"), manual); +}); + +test("clearAutoPause is how a manual tier change wins", () => { + const paused = autoPauseForMedia( + sanitizeChannelPriority({ channels: { a: { tier: "normal" } } }), + "a", + ); + const manual = clearAutoPause({ ...paused.channels.a, tier: "low" }); + assert.equal(manual.autoPaused, undefined); + assert.equal(manual.tier, "low"); + // And restore then has nothing to say about it. + const model = { ...paused, channels: { a: manual } }; + assert.equal(restoreAfterMedia(model, "a"), model); +}); + +// AN AUTO-PAUSE ONLY MEANS ANYTHING ON A PAUSED CHANNEL. A record beside a +// non-paused tier is a leftover, and restoring from it would put back a tier +// from an earlier era. +test("the sanitizer drops a record whose tier is not paused, and round-trips one that is", () => { + const stray = sanitizeChannelPriority({ + channels: { + a: { + tier: "normal", + autoPaused: { reason: "storage", since: "x", previousTier: "low" }, + }, + }, + }); + assert.deepEqual(stray.channels, {}); + const kept = sanitizeChannelPriority({ + channels: { + a: { + tier: "paused", + autoPaused: { + reason: "storage", + since: "2026-09-20T00:00:00.000Z", + previousTier: "low", + }, + }, + }, + }); + assert.deepEqual(kept.channels.a.autoPaused, { + reason: "storage", + since: "2026-09-20T00:00:00.000Z", + previousTier: "low", + }); + // Idempotent. + assert.deepEqual(sanitizeChannelPriority(kept), kept); + // An unknown reason is not a reason. + assert.equal( + sanitizeChannelPriority({ + channels: { + a: { tier: "paused", autoPaused: { reason: "weather", previousTier: "low" } }, + }, + }).channels.a?.autoPaused, + undefined, + ); + // `previousTier: "paused"` would restore to paused — a no-op dressed as a + // restore. It reads as the default. + assert.equal( + sanitizeChannelPriority({ + channels: { + a: { + tier: "paused", + autoPaused: { reason: "storage", since: "", previousTier: "paused" }, + }, + }, + }).channels.a.autoPaused?.previousTier, + "normal", + ); +}); + +// AN OLDER SHAPE PARSES, which is the whole lane-migration discipline: a binary +// that drops the field leaves the channel Paused with nothing lost but the +// automatic restore. +test("an entry with no record parses exactly as it did before", () => { + const model = sanitizeChannelPriority({ + channels: { a: { tier: "paused", rank: 1 } }, + }); + assert.equal(model.channels.a.autoPaused, undefined); + assert.equal(autoPauseReasonOf(model, "a"), null); + assert.deepEqual(autoPausedSlugs(model), []); +}); + +test("the reason is one sentence, and names the tier it will restore", () => { + const model = autoPauseForMedia( + sanitizeChannelPriority({ channels: { a: { tier: "low" } } }), + "a", + new Date("2026-09-20T12:00:00.000Z"), + ); + const reason = autoPauseReasonOf(model, "a") ?? ""; + assert.match(reason, /drive that is not there since 2026-09-20/); + assert.match(reason, /returns to low/); + assert.deepEqual(autoPausedSlugs(model), ["a"]); +}); + +// AN UNPLUGGED USB CABLE MUST NOT DESTROY THE QUEUE ORDER. The sanitizer drops +// a rank from a channel that is paused everywhere — right for a pause the +// operator meant, catastrophic for a temporary one, because the restore would +// put the channel back unranked at the bottom of its tier. +test("an auto-paused channel keeps its rank across a sanitize", () => { + const paused = sanitizeChannelPriority( + autoPauseForMedia( + sanitizeChannelPriority({ channels: { a: { tier: "normal", rank: 4 } } }), + "a", + ), + ); + assert.equal(paused.channels.a.tier, "paused"); + assert.equal(paused.channels.a.rank, 4); + const back = sanitizeChannelPriority(restoreAfterMedia(paused, "a")); + assert.equal(back.channels.a.tier, "normal"); + assert.equal(back.channels.a.rank, 4); + // A pause the operator MEANT still drops its rank, unchanged. + assert.equal( + sanitizeChannelPriority({ channels: { a: { tier: "paused", rank: 4 } } }) + .channels.a.rank, + undefined, + ); +}); diff --git a/common/lib/channelPriority.ts b/common/lib/channelPriority.ts @@ -135,6 +135,30 @@ export type ChannelPriorityEntry = { // `{tier:"paused", overrides:{sync:"normal"}}`, is "sync only": keep the // playlist and metadata current, dispatch nothing. overrides?: Partial<Record<PriorityOperation, StoredChannelTier>>; + // PAUSED BY THE MACHINE, NOT BY THE OPERATOR, and what to put back. + // + // Set when the drive a channel's media is on stops being there: the watch + // pass records the tier the channel HAD and forces `paused`, so nothing in + // any lane dispatches against a `data/` nobody can read. Cleared — and the + // tier restored — when the drive comes back. + // + // WHY IT IS A FIELD AND NOT A DERIVED STATE. The lanes read `tier`; making + // them all ask a second question would be four more places to forget. And + // the tier the channel is to be RESTORED to is not derivable from anything + // once it has been overwritten — that is the whole content of this field. + // + // OPTIONAL, and an older binary that drops it leaves the channel Paused with + // nothing lost but the automatic restore. The operator's own word always + // wins: a MANUAL tier change clears it (see clearAutoPause), so a drive + // coming back can never un-pause a channel somebody paused on purpose. + autoPaused?: { + // One reason today. A union so a second one has somewhere to go, and so a + // surface can say WHICH machine decided rather than "automatic". + reason: "storage"; + // ISO, for "auto-paused — media unreachable since <date>". + since: string; + previousTier: StoredChannelTier; + }; }; export type ChannelPriority = { @@ -244,6 +268,9 @@ export function sanitizeChannelPriority(value: unknown): ChannelPriority { const entry: ChannelPriorityEntry = { tier }; const overrides = sanitizeOverrides(raw.overrides, tier); if (overrides) entry.overrides = overrides; + const autoPaused = sanitizeAutoPause(raw.autoPaused, tier); + if (autoPaused) entry.autoPaused = autoPaused; + if (typeof raw.rank === "number" && Number.isFinite(raw.rank)) { // RANK IS ONLY MEANINGFUL WHERE SOMETHING IS ORDERED. A channel that is // paused for EVERY operation is in no group anywhere, so its rank is dead @@ -253,14 +280,22 @@ export function sanitizeChannelPriority(value: unknown): ChannelPriority { const orderedSomewhere = PRIORITY_OPERATIONS.some( (op) => (overrides?.[op] ?? tier) !== "paused", ); - if (orderedSomewhere) entry.rank = Math.floor(raw.rank); + // AN AUTO-PAUSED CHANNEL KEEPS ITS RANK. The rule above is about a pause + // the operator MEANT — a channel that is off everywhere is in no group + // and its rank is dead weight in the file. An auto-pause is temporary by + // construction (the record exists precisely to undo it), so dropping the + // rank here would mean an unplugged USB cable silently destroyed the + // operator's queue order, and the restore would put the channel back + // unranked at the bottom of its tier. + if (orderedSomewhere || autoPaused) entry.rank = Math.floor(raw.rank); } // An entry that says nothing the default does not say is DROPPED, so the // document stays a list of exceptions and re-sanitizing is identity. if ( entry.tier === DEFAULT_CHANNEL_TIER && entry.rank === undefined && - entry.overrides === undefined + entry.overrides === undefined && + entry.autoPaused === undefined ) { continue; } @@ -269,6 +304,121 @@ export function sanitizeChannelPriority(value: unknown): ChannelPriority { return { focus: sanitizeFocus(r.focus), channels }; } +// AN AUTO-PAUSE ONLY MEANS ANYTHING ON A PAUSED CHANNEL. A record whose tier +// is not `paused` is a leftover — the operator changed the tier through some +// path that did not clear it, or the file was hand-edited — and keeping it +// would make the next restore put back a tier from an earlier era. Dropped. +function sanitizeAutoPause( + value: unknown, + tier: StoredChannelTier, +): ChannelPriorityEntry["autoPaused"] | null { + if (tier !== "paused") return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const r = value as Record<string, unknown>; + if (r.reason !== "storage") return null; + const previousTier: StoredChannelTier = isStoredChannelTier(r.previousTier) + ? r.previousTier + : DEFAULT_CHANNEL_TIER; + // A `previousTier` of `paused` would restore to paused, which is a no-op + // dressed as a restore. It reads as the default instead. + return { + reason: "storage", + since: typeof r.since === "string" ? r.since : "", + previousTier: previousTier === "paused" ? DEFAULT_CHANNEL_TIER : previousTier, + }; +} + +// --- Auto-pause: the machine's own pause, and its undo ---------------------- + +// PAUSE A CHANNEL BECAUSE ITS DRIVE IS NOT THERE, recording what to put back. +// +// A NO-OP IN TWO CASES, and both matter. Already auto-paused: a flapping drive +// must not overwrite `previousTier` with the `paused` it wrote last time, which +// would make the restore restore nothing. Already paused by the OPERATOR: the +// channel is off because somebody said so, and claiming the machine did it +// would hand the next restore permission to turn it back on. +export function autoPauseForMedia( + model: ChannelPriority, + slug: string, + now: Date = new Date(), +): ChannelPriority { + const key = slug.trim(); + if (!key) return model; + const entry = model.channels[key]; + if (entry?.autoPaused) return model; + const previousTier = entry?.tier ?? DEFAULT_CHANNEL_TIER; + if (previousTier === "paused") return model; + return { + ...model, + channels: { + ...model.channels, + [key]: { + ...(entry ?? { tier: DEFAULT_CHANNEL_TIER }), + tier: "paused", + autoPaused: { + reason: "storage", + since: now.toISOString(), + previousTier, + }, + }, + }, + }; +} + +// THE UNDO, and only of what this made. No record → nothing to restore, and in +// particular a channel the operator paused by hand stays paused. +export function restoreAfterMedia( + model: ChannelPriority, + slug: string, +): ChannelPriority { + const key = slug.trim(); + const entry = model.channels[key]; + if (!entry?.autoPaused) return model; + const { autoPaused, ...rest } = entry; + return { + ...model, + channels: { + ...model.channels, + [key]: { ...rest, tier: autoPaused.previousTier }, + }, + }; +} + +// THE OPERATOR'S WORD WINS. Called by the one priority writer whenever a tier +// is set by hand: the record goes, so a drive coming back later cannot undo a +// decision a person made in the meantime. +export function clearAutoPause( + entry: ChannelPriorityEntry, +): ChannelPriorityEntry { + if (!entry.autoPaused) return entry; + const { autoPaused: _dropped, ...rest } = entry; + return rest; +} + +// The sentence a surface says beside a Paused badge, or null. ONE wording, so +// the rack, the channel page and /review cannot word it three ways. +export function autoPauseReasonOf( + model: ChannelPriority, + slug: string, +): string | null { + const auto = model.channels[slug]?.autoPaused; + if (!auto) return null; + const since = auto.since ? ` since ${auto.since.slice(0, 10)}` : ""; + return ( + `Auto-paused — its media is on a drive that is not there${since}. ` + + `It returns to ${auto.previousTier} on its own when the drive is back.` + ); +} + +// Every channel this document has auto-paused, sorted. What the watch pass asks +// so it can restore the ones whose drives came back. +export function autoPausedSlugs(model: ChannelPriority): string[] { + return Object.entries(model.channels) + .filter(([, e]) => e.autoPaused) + .map(([slug]) => slug) + .sort(); +} + // --- Reading the document --------------------------------------------------- // The channel's BASE tier — what `/channels` shows in the tier column, and the diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -48,6 +48,7 @@ import { effectiveTier, hasCompiledLaneRoots, isChannelPaused, + clearAutoPause, isDefaultChannelPriority, rankOf, renameChannelInPriority, @@ -756,7 +757,11 @@ function applyPriorityEdit( ? { ...entry, rank: entry.rank + 1 } : { ...entry }; } - channels[slug] = { ...entryFor(model, slug), tier: "normal", rank }; + channels[slug] = clearAutoPause({ + ...entryFor(model, slug), + tier: "normal", + rank, + }); return { ...model, channels }; } const channels: Record<string, ChannelPriorityEntry> = { ...model.channels }; @@ -764,7 +769,14 @@ function applyPriorityEdit( const slug = raw.trim(); if (!slug) continue; if (edit.kind === "tier") { - channels[slug] = { ...entryFor(model, slug), tier: edit.tier }; + // THE OPERATOR'S WORD WINS over the machine's. Setting a tier by hand + // clears any auto-pause record, so a drive coming back later cannot + // un-pause a channel a person paused in the meantime — nor re-pause one + // they deliberately resumed while the drive was still away. + channels[slug] = clearAutoPause({ + ...entryFor(model, slug), + tier: edit.tier, + }); continue; } if (edit.kind === "operation") { @@ -782,11 +794,11 @@ function applyPriorityEdit( // "sync-only": paused everywhere, normal for sync. Its rank survives — // the sync scheduler still orders it. const entry = entryFor(model, slug); - channels[slug] = { + channels[slug] = clearAutoPause({ ...entry, tier: "paused", overrides: { sync: "normal" }, - }; + }); } return { ...model, channels }; } diff --git a/editor/app/channels/components/ChannelTierSelect.tsx b/editor/app/channels/components/ChannelTierSelect.tsx @@ -51,6 +51,11 @@ export type ChannelTierSelectProps = { focused?: boolean; // "Held — focus: <name>" for a non-focus row while a focus holds the lanes. heldReason?: string | null; + // Why the machine paused this channel, or null. Distinct from `heldReason`, + // which is the focus holding a channel that is otherwise running: this one + // says the tier ITSELF was set by something other than the operator, and + // will be set back. + autoPausedReason?: string | null; disabled?: boolean; }; @@ -79,6 +84,7 @@ export default function ChannelTierSelect({ overrides = {}, focused = false, heldReason = null, + autoPausedReason = null, disabled = false, }: ChannelTierSelectProps): React.ReactNode { const [pending, startTransition] = useTransition(); @@ -165,6 +171,21 @@ export default function ChannelTierSelect({ <span className="sr-only"> — {heldReason}</span> </span> )} + {/* WHY IT IS PAUSED, when it was not the operator who paused it. Without + this the rack shows a Paused channel and no way to tell a deliberate + pause from a drive that fell off a USB cable — which is exactly the + "automatic disable AND FLAG" the operator asked for. */} + {autoPausedReason && ( + <span + role="note" + aria-label={`auto-paused reason for ${slug}`} + title={autoPausedReason} + className="rounded-full border border-destructive/30 bg-destructive/5 px-1.5 text-[10px] uppercase tracking-wide text-destructive" + > + storage + <span className="sr-only"> — {autoPausedReason}</span> + </span> + )} {error && ( <span role="alert" diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -42,6 +42,8 @@ export type ChannelRowPriority = { overrides: Partial<Record<PriorityOperation, StoredChannelTier>>; focused: boolean; heldReason: string | null; + // Why the machine paused it, or null. See autoPauseReasonOf. + autoPausedReason: string | null; }; // A row is a stat plus its pipeline bands, in column order. The bands are @@ -771,6 +773,7 @@ function ChannelTableRow({ overrides={c.priority.overrides} focused={c.priority.focused} heldReason={c.priority.heldReason} + autoPausedReason={c.priority.autoPausedReason} /> </Td> <Td diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -15,6 +15,7 @@ import { type Site, } from "yt-dlp-transcript-common/lib/site"; import { + autoPauseReasonOf, overridesOf, rankOf, resolveFocusSlugs, @@ -284,6 +285,11 @@ export default async function ChannelsPage({ rank: rankOf(priority, stat.slug), overrides: overridesOf(priority, stat.slug), focused: focusSet.has(stat.slug), + // WHY IT IS PAUSED, when the machine paused it. Null for every channel + // the operator paused (or did not pause) themselves — the record is + // cleared by any manual tier change, so this can only ever describe a + // pause nobody chose. + autoPausedReason: autoPauseReasonOf(priority, stat.slug), // WHY THE ROW IS HELD, and only while something is actually focused. // A paused channel is not "held by the focus" — it is off, which its // own tier already says. The per-lane "and the focus still has pending diff --git a/editor/instrumentation.ts b/editor/instrumentation.ts @@ -108,6 +108,25 @@ export async function register() { // Everything past here STARTS work. On an idle boot, nothing does. if (idle) return; + // THE DRIVE WATCH. Every guard the corpus has for an unreachable channel runs + // at the start of a piece of work — none of them is a DETECTOR, so a channel + // whose drive vanished sits there being refused with nothing saying why. This + // is the thing that looks, on a five-minute cadence, and auto-pauses (and + // later restores) the channels on a location that is not there. + // + // BELOW THE IDLE GATE, deliberately, and unlike the boot probe above: this + // one WRITES settings.channelPriority, and a container pointed at somebody + // else's corpus for the first time has no business rewriting that corpus's + // priority document. See common/controller/storageWatch.ts. + try { + const { startStorageWatch } = await import( + "yt-dlp-transcript-common/controller/storageWatch" + ); + startStorageWatch({ log: (line) => console.log(line) }); + } catch { + /* a watch that fails to arm must not block server readiness */ + } + // Start the automatic priority-queue runners — all four lanes — if their // policies are enabled. Each is a self-managed registry job; this only // kicks them off and returns. Best-effort — a failure here must not stop the