commit 270f42ec052d0805ff7811ed0eb66bd740310698
parent 2cd9a2aeb67abe6db1eb41a96224a83ea0155600
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 18 Sep 2026 17:38:19 -0400
plans: storage locations — S5 written (per-unit re-check, no tmpfs mount, auto-pause + flag)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 126 insertions(+), 0 deletions(-)
diff --git a/plans/storage-locations.md b/plans/storage-locations.md
@@ -192,3 +192,129 @@ shas, gate outputs, divergences.
- 2026-09-17 — S1 (`storage/locations-s1`, `578867f`→`68d082d`) and S2 (`storage/locations-s2`, `c078e1f`→`79c0071`) reviewed and merged as `d146c51`. Real-host probe matched the plan (available + uuid + fstab warning; mounted-elsewhere with the automount candidate). Follow-up recorded: rename/delete channel actions still check the registry alone (`channels/actions.ts` `renameChannelAction`) — `channelMediaBusyReason` is a drop-in there.
- 2026-09-17 — S3 (`storage/locations-s3`, `8824f5b`→`aed94d9`) reviewed and fast-forwarded onto main: `/storage`, controller + views, `repoint-storage-location` job, nav at twelve, boot pass, fake findmnt/udisksctl, `storage-locations.spec.ts`; common 1314/1314, e2e 6/6. Declined: a `spec` on the re-point job record (it is the Retry switch and the kind is not replayable).
- 2026-09-17 — S0 (`storage/locations-s0`, `c13c5a8`→`d4ebf1d`) merged as `093e436`: the row dim is per cell, the Tier cell hosts the popover; `channel-priority.spec.ts` proves it with two adjacent paused rows and an `elementFromPoint` owner check (passes with the fix, fails without). Main now holds S0–S3; S4 in flight.
+
+## S5 — unreachable media: re-check per unit, never materialise a mount, auto-pause + flag
+
+Operator ask 2026-09-18, after the omnimirror move landed: "Can that gracefully handle the
+case if the drive disconnects while the channel is on? Maybe an automatic disable and flag?"
+Facts verified at `main` @ `9ac8d38` (read-only Opus investigation, 2026-09-18):
+
+- **Guards at start exist, four of them**: `common/jobs/streamCommand.ts:266-292`
+ `refuseForUnreachableMedia` (first statement of `runManagedFunction`, only when
+ `opts.channelSlug` is set and the kind has `needsMedia`); `autoRunner.ts:610-630`
+ `buildChannelWork` inspects every channel on every tick (`:617`) and drops it via
+ `noteSkippedForMedia` (`:625-629`); `channelSnapshot.ts:619`; `operationBatch.ts:1593`.
+- **No per-unit re-check.** `autoRunner.ts:1673 run(picked)` and `operationBatch.ts:1723`
+ (GUARD 5, `next()`) read only the relocation marker (S1). Auto-download units re-enter
+ `runManagedFunction` per video (`autoRunner.ts:1911-1915`, kind `auto-download-unit`,
+ `needsMedia`) so they are covered; auto-transcribe (`:1840`) and both operation lanes
+ (`:1624`) run in-process with no job record. The lane runner jobs (`:2015`) pass no
+ `channelSlug`, so guard 1 no-ops for them.
+- **Writers go through the link** (`downloadOneManaged.ts:437,623,1123` mkdir of
+ `videoDir`; yt-dlp templates cwd-relative `data/%(id)s/…` at `runYtdlp.ts:258-303` with
+ `cwd = channelDir` at `:307-312`; sidecar writers do no mkdir). A recursive mkdir
+ through a DANGLING symlink fails (ENOENT on the leaf, EEXIST on `data`, then `stat`
+ ENOENT) — reasoned from the code, not executed: **S5 adds the unit test**.
+- **The one absolute-path mkdir**: `relocateChannelMedia.ts:657` (`moveOut`) and `:873`
+ (`moveBack`) `mkdir(target, { recursive: true })`. With the volume absent this creates
+ `/run/media/user/<uuid>/…/<slug>/data` on tmpfs; the link then stops dangling and every
+ writer above lands in RAM undetected.
+- **Detection is passive**: `autoRunner.ts:554-570` `noteSkippedForMedia` /
+ `noteMediaReachable` log once per transition and nothing reads them (`:576-578`);
+ snapshot regen throws and the scheduler keeps the last good `snapshot.json`; badges are
+ computed per request (`page.tsx:82`, `channels/page.tsx:204`, `[slug]/page.tsx:216`,
+ `MediaLocationBadge.tsx:51`, `/storage` rollup `storageLocations.ts:127` →
+ `views/storage.ts:198`). `/review` (`review/page.tsx:27,50`) knows nothing about media.
+ `runStorageBootPass` runs only at boot (`instrumentation.ts:100-103`); `PROBE_MEMO_MS`
+ is a request memo, not a timer.
+- **Channel-level pause mechanism**: `common/lib/channelPriority.ts:317 isChannelPaused`,
+ tier `"paused"` (`:70`, filtered at `:254,332,545`). `pauseGates.ts` is lane-level —
+ wrong granularity.
+
+### Design
+
+**(a) Per-unit reachability, same shape as the S1 marker check.** Beside the marker read
+at `autoRunner.ts:1673`: `inspectChannelMedia`; status not `ok`/`in-place` → log
+`skipping <slug>/<id>: media <status>` and `outcome: "skipped"` (same `finally` semantics
+as S1, same comment about bucket vs operation lanes). Beside GUARD 5 at
+`operationBatch.ts:1723`: unreachable → stop line + `return null`; generalise S1's
+`stoppedForRelocation: boolean` into `stopped?: "relocation" | "media-unreachable"`
+(keep the boolean as a derived getter if anything reads it; `operationJobs.ts:147,272`
+append the matching phrase). Cost: two stats and one JSON read per unit — state it.
+
+**(b) The move never materialises a mount.** New `assertRelocationRootPresent(root,
+settingsStorage, bins)` in `relocateChannelMedia.ts`, called immediately before both
+absolute-path mkdirs (`:657`, `:873`) and from `relocationRootProblem`: `stat(root)` must
+be a directory (the move creates only `<root>/<slug>` and `<root>/<slug>/data`, never the
+root); and when `locationOfDataDir(root + "/x", locations)` (or `root` equals a location
+root) resolves to a location with a `volume.uuid`, `probeLocation` must answer
+`available` with a known identity whose uuid matches — otherwise refuse
+`destination root <root> is <status> (location "<id>"); mount it or re-point first`. A
+root that is nobody's location is stat-only (documented: an unmounted fstab mountpoint
+directory is exactly the case a location protects against — add one to `/storage`). The
+umtool twin (`cues.mjs`) is unaffected; it only reads.
+
+**(c) Auto-pause + flag, hung off the transition that already exists.** `channelPriority`
+per-channel record gains `autoPaused?: { reason: "media-unreachable"; since: string;
+previousTier: Tier }` (type + parser + sanitizer, the lane-migration discipline: an older
+binary drops the field and leaves the tier as Paused — nothing lost but the automatic
+restore). Pure helpers in `common/lib/channelPriority.ts`: `autoPauseForMedia(settings,
+slug, now)` (no-op when the tier is already `paused` or `autoPaused` is set; otherwise
+records `previousTier` and sets `paused`) and `restoreAfterMedia(settings, slug)` (no-op
+unless `autoPaused` is present; restores `previousTier`, clears the field). A MANUAL tier
+change through the existing action clears `autoPaused` (the operator's word wins; a later
+return of the drive must not un-pause a channel the operator paused by hand). Wire:
+`autoRunner.ts:554-570` `noteSkippedForMedia` → `writeSettings(autoPauseForMedia(…))`;
+`noteMediaReachable` → `writeSettings(restoreAfterMedia(…))`. Both are behind the same
+once-per-transition latch that already exists there, so a flapping drive writes twice per
+flap, not per tick. Idle boot (`ARCHILYZER_IDLE_BOOT`) never writes. Flag surfaces:
+`/review` gains a "Media unreachable" section (built in `common/views/review.ts` or the
+existing review view: slug, location label, since, "auto-paused"); `MediaLocationBadge`
+gets `autoPaused?` → "auto-paused — media unreachable since <date>"; the `/storage`
+rollup's `unreachable` count links to that review section; the channel page's tier control
+shows the reason beside Paused. Restore is logged on the lane (`[auto] <slug>: media
+reachable again, tier restored to <previousTier>`).
+
+The tick is the probe: `buildChannelWork` inspects every channel per tick whether or not it
+has work. Document the one blind spot — if every lane is held by a pause gate no tick
+runs, and the flag appears on the next request-time render only.
+
+### Files
+
+`common/controller/autoRunner.ts`, `common/controller/operationBatch.ts`,
+`common/controller/operationJobs.ts`, `common/controller/relocateChannelMedia.ts`,
+`common/lib/channelPriority.ts` (+ test), `common/lib/settings.ts` (sanitizer for the new
+field), `common/views/review.ts` (or where `/review` builds), `editor/app/review/page.tsx`,
+`editor/app/channels/components/MediaLocationBadge.tsx`, the tier control, the
+`/channels` and dashboard row builders (project `autoPaused`), `editor/CHANGELOG.md`,
+`RUNNING_IN_DOCKER.md` (one paragraph: a bind mount that vanishes is the same case).
+
+### Tests
+
+- `operationBatchRelocation.test.ts` (extend): a target dir removed after the first unit
+ stops the batch with the media stop line and `stopped === "media-unreachable"`.
+- `relocateChannelMedia.test.ts`: root missing → refusal names the root and creates
+ nothing (assert the parent chain does not exist afterwards); root present but its
+ location probes `unmounted` (fake findmnt in the tmp dir, as `storageVolumes.test.ts`)
+ → refusal names the location and status.
+- New `channelMedia.test.ts` case (or `lib/danglingLink.test.ts`): `mkdir(join(link,
+ "x"), { recursive: true })` through a dangling symlink throws ENOENT and creates
+ nothing — the invariant every writer relies on.
+- `channelPriority.test.ts`: auto-pause records `previousTier`; no-op on a manual Paused;
+ restore clears; a manual tier change clears `autoPaused`; sanitizer round-trip; an older
+ shape without the field parses.
+- `views/review.test.ts` (or the view's test): the section lists auto-paused channels.
+- e2e `channel-storage.spec.ts`: seed a relocated channel and an `autoPaused` record →
+ badge text and `/review` section; Move media to a root whose location is
+ `unmounted` per `.fake-findmnt.json` → inline refusal naming the location. No e2e for
+ the lane transition itself (no harness; the unit tests own it).
+
+### Verification
+
+The S3/S4 gate list. Full suite on the final tip. Offline against the real host: `tsx -e`
+`assertRelocationRootPresent` on the platter root → passes; on `/mnt/platter/x` (absent)
+→ refuses; nothing created under `/mnt` afterwards.
+
+### Status
+
+- 2026-09-18 — written; dispatch after S4 merges (`storage/locations-s5`).