commit 30f0378ec99dccc061b9b9365f547195ca6b8414
parent 9c96c35f1aae9009bd43de239d23d8be77e92416
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 2 Oct 2026 00:09:15 -0400
plans: slice U2's record (the deliverables switch) and FACTS "umtool's deliverables switch"
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 161 insertions(+), 0 deletions(-)
diff --git a/plans/FACTS.md b/plans/FACTS.md
@@ -8307,3 +8307,24 @@ phase deletes from the destination.
`test-transcripts/` (`common/social/__fixtures__/firefoxCookieStore.ts`, imported by relative path)
and points `cookiesFromBrowser` at it as `firefox:<abs path>` — a profile PATH in the spec, so no
home directory is searched.
+
+## umtool's deliverables switch (verified 2026-10-01, branch `r17/umtool-deliverables`)
+
+- **`clips/` and `share-*/` of a report project may be links** into `UMTOOL_MEDIA_DIR` — per project,
+ when `video.manifest.json` says `"storage": { "deliverables": "media" }` (absent = local). `out/` is
+ the other directory that may be a link, and it follows `UMTOOL_MEDIA_DIR` on its own (slice U1).
+ Readers open `clips/<id>.mp4` by path through the link; a dirent `isDirectory()` filter over a
+ project hides a moved one (`listBatches` follows links for that reason).
+- **One writer for the value:** `updateStorage` in `umtool/lib/report/manifest.mjs`, called only by
+ `moveDeliverables` (`umtool/lib/report/storage.mjs`) after every directory has moved. A no-op is not
+ rewritten.
+- **A missing deliverable directory is made by `deliverableDir(project, name)`** — never a bare
+ `mkdir` (`cut.mjs`, `buildShareBatch`). Under `"media"` it is a link to a new mirror directory; the
+ media root is never created; a dangling link, a cut move's leftover (`<name>.moved-*`,
+ `<name>.incoming`), a bad value, or `"media"` in a process without `UMTOOL_MEDIA_DIR` refuse.
+- **`umtool storage deliverables <project> --to media|local`** is the move (the bench's "Move
+ deliverables" runs it as a job). `umtool check` reports `storage-unreachable` (blocking: an `out`,
+ `clips` or `share-*` link whose target is gone) and `storage-mismatch` (open).
+- The e2e app server has no media root (`UMTOOL_MEDIA_DIR=` in `playwright.config.ts`); specs give
+ their CLI one (`<fixture>-media`), so `deliverables.spec.ts` shows an app cutting through a link it
+ did not make and refusing a new batch under `"media"`.
diff --git a/plans/release-17.md b/plans/release-17.md
@@ -563,6 +563,146 @@ media tier; a dangling link still reads as "no build" there (`umtool check` lear
mix render into it lands on the media root through the link (the write check is lexical; that matches
the semantics).
+### Slice U2, as shipped — umtool's deliverables switch (2026-10-01)
+
+Branch `r17/umtool-deliverables` off `main` `bb877f93` (slice U1 merged), `main` `1d5c33bf` (slice
+XP) merged in before the gates, worktree `~/Projects/r12-source-mirror` (editor 5101, test 5111,
+export 5110), one Opus implementer. Scratch files `U2-*` in the job's `tmp`. The ruling is the plan's:
+deliverables (`clips/`, `share-*/`, final mp4s) move per project by a switch "like channels";
+manifests, `revisions/`, the caches and the cue cache stay put; final mp4s travel with `out/` (U1).
+
+**What it does.**
+- **The switch.** `video.manifest.json` gains `"storage": { "deliverables": "local" | "media" }`
+ (absent = local). Its one writer is `updateStorage` in `lib/report/manifest.mjs` (the manifest lock,
+ tmp + rename, the mtime token; any other key under `storage` kept; a no-op is not rewritten, so no
+ open bench page's token goes stale). Its one caller is `moveDeliverables`, after every directory has
+ moved.
+- **`lib/report/storage.mjs`.** `ensureOutDir`'s body is generalised to a name (`ensureProjectDir`;
+ `ensureOutDir` keeps its behaviour and sentences). New: `DELIVERABLES_MODES`, `isDeliverableName`
+ (`clips` or `share-<x>`, never a move's leftover), `deliverablesModeOf` / `deliverablesMode` (a plain
+ JSON read, so the module stays free of the writer's imports), **`deliverableDir(project, name)`** —
+ an existing directory or link is used as it is; absent and local → a directory; absent and media →
+ `mkdir -p <mirror>/<name>` + an absolute link, the media root stat'd and never created; refused,
+ creating nothing, on a dangling link, a cut move's leftover (the sentence names `umtool storage
+ deliverables <project> --to …`), a bad value, a project outside the reports root, or `"media"` in a
+ process with no `UMTOOL_MEDIA_DIR` (never silently local: a batch on the wrong drive is a split
+ nobody chose); `deliverableNames` (clips first, every `share-*` dir or link, and a name present only
+ as a leftover so a move finishes it); `deliverablesState`; `deliverablesProblems(state, name)` (the
+ sentences that stop a write: a dangling link, a leftover, and for a directory not there yet a switch
+ this process cannot honour); **`moveDeliverables(project, to, { writeMode })`** — U1's movers over
+ each name, then the switch only when none failed; idempotent; a cut move resumes; `--dry-run`
+ measures. `finishCommand` names the right command in the leftover sentence (`out` keeps U1's).
+- **The writers.** `cut.mjs` makes `clips/` through `deliverableDir` (a refusal is `reason: "storage"`,
+ before any ffmpeg; tmp and final still share the directory). `buildShareBatch` makes `share-<name>/`
+ through it, and first refuses on `deliverablesProblems` — a batch that cannot read an earlier batch
+ would ship its clips again. A batch name shaped like a leftover (`x.incoming`, `x.moved-…`) is
+ refused by `share-batch.mjs` and the route.
+- **The readers.** `listBatches` lists a `share-*` link like a directory, a dangling one as
+ `dangling` with no ids, and no leftover; `sharedIdsIn`'s walk follows a link to a directory (six
+ levels at most). `deliverStateOf` carries `storage` (the state, `cutBlocked`, `shareBlocked`). The
+ mix picker (`lib/media.ts`) follows `clips` and `share-*` links into the media root as it does `out`.
+ `kinds.mjs` needed nothing: U1's `SKIP_DIRS`/`SKIP_PREFIXES` already keep the walk out of both.
+- **`umtool check`** (through `reportDecisions`): `storage-unreachable`, **blocking** — an `out`,
+ `clips` or `share-*` link whose target is gone ("is the media drive mounted?"); `storage-mismatch`,
+ open — a cut move's leftover (with the command that finishes it), a directory while the switch says
+ media, a link while it says local, or media with no `UMTOOL_MEDIA_DIR` in this process; a value
+ that is neither is `manifest-invalid` on `storage.deliverables`, blocking.
+- **CLI.** `umtool storage deliverables <project> --to media|local [--dry-run] [--json]`: one line per
+ directory and the switch's before → after; exit 1 on any failure (the switch then stays). Refused
+ while a pipeline process works in the project — U1's `/proc` scan of the report scripts, now also the
+ report's own `apply-manifest.py` and `build.py` found by their working directory (they name no
+ path). Its blind spots, said in the code and `docs/folders.md`: the app's in-process work (the bench
+ runs the move as a job, so the app's one-job-at-a-time rule keeps cuts and batches out), a hand-run
+ command, another machine. `umtool storage [<project>]` lists each report's deliverables and switch.
+- **Bench.** The deliver panel says where the deliverables are (`data-deliverables` = `local` |
+ `media` | `invalid`; one `data-deliverable="<name>"` with `data-deliverable-state` per directory) and
+ gains **"Move deliverables to media|local"** (`data-action="deliver-move"`, `data-move-to`), the
+ `move` action of `/api/report/deliver`, a job (`driver.mjs` `moveDeliverablesSteps` runs the CLI).
+ It is disabled, the reason in `data-move-reason`, while a job of the project runs ("… is running —
+ move when it has finished"), while a deliverable link dangles, and toward media when the app has no
+ `UMTOOL_MEDIA_DIR`. Cut and share are disabled with `deliverablesProblems`' sentences
+ (`data-deliver-blocked`), and the route refuses them with 409.
+- **e2e.** `deliverables-fixture` (d01/d02 cuttable from a cached window; d03 cut and shipped in
+ `share-first`) and `deliverables.spec.ts` (5, serial): the CLI move (dry run first; links; the switch
+ set; again → already, not rewritten; `check` silent); the app (no media root) cuts THROUGH the
+ `clips/` link while the move button is disabled with "is running"; the app refuses a new batch
+ (button disabled with the reason, route 409, nothing made) and the CLI with the media root makes
+ `share-second` as a link, reading `share-first` through its own (d03 not shipped again); the root
+ unplugged → `check` exits 1 with `storage-unreachable` on all three links, the panel and the route
+ refuse, nothing made, the root not recreated; the bench's button brings everything home (the app,
+ untiered, leaves the media side and says "left in place"), the switch reads local, and the button
+ then offers media, disabled, naming `UMTOOL_MEDIA_DIR`.
+
+**Commits**
+
+| Commit | What |
+|---|---|
+| `a61b60e2` | `umtool:` the switch — `updateStorage`, `deliverableDir` and the deliverables helpers in `storage.mjs`, `cut.mjs`/`buildShareBatch` through it, `listBatches`/`sharedIdsIn` follow links, `umtool check`'s storage decisions, `umtool storage deliverables`, the busy scan's `apply-manifest.py`/`build.py`, the picker; `deliverables.test.mjs` (14) |
+| `ec7c84f3` | `umtool:` "Move deliverables" on the bench, the route's `move` action and refusals, `deliverables-fixture` + `deliverables.spec.ts`, `docs/folders.md` + `docs/cli.md`, the `[Unreleased]` bullet |
+| `eb9bb64e` | merge of `main` `1d5c33bf` (slice XP) — clean, nothing under `umtool/` |
+| `875dbff1` | `umtool:` the spec's batch count includes the id its LIST.md says was already shared (run 1's one failure) |
+| this commit | `plans:` this section; FACTS "umtool's deliverables switch" |
+
+#### Gates (logs `$T/U2-*`)
+
+- **tsc** (all workspaces) clean at `ec7c84f3` and on the merged tree.
+- **common:** 2,501/2,501 on the merged tree (no common code touched). **Editor unit:** skipped — no
+ editor code touched (only `editor/CHANGELOG.md`).
+- **test:scripts:** 408 tests: 406 passed, 1 skipped (LIVE), 1 failed — `queue-lock.test.mjs`'s
+ "QUEUE_LOCK_HELD passes straight through" (waited 1,065 ms at a load average of 16–22, the timing
+ case U1 met); `node --test scripts/queue-lock.test.mjs` alone right after: **11/11**.
+ `deliverables.test.mjs` **14/14**, `storage.test.mjs` 24/24, `next-build-trace.test.mjs` **10/10**
+ against the fresh build below.
+- **The capped umtool build with the corpus linked** (worktree `transcripts/` set aside, `ln -sT`,
+ 77 channels visible, `systemd-run --scope -p MemoryMax=5G -p MemorySwapMax=0`, `timeout -s KILL
+ 240`, the link removed and the directory put back), on the merged tree: **exit 0, 32 s, 0.82 GB**;
+ with `UMTOOL_MEDIA_DIR` set to a scratch directory: **exit 0, 35 s, 0.82 GB**. `.nft.json` entries
+ 39,809 each, **identical** (`diff` empty), none naming `transcripts`, the scratch media root or
+ `.e2e-song`.
+- **Numbers tool:** none.
+- **umtool e2e** (`SONG_DIR=~/reports/quartering-uh-song/data`, `UMTOOL_MEDIA_DIR=` in the shell,
+ `pnpm --filter umtool run e2e …` from the worktree root, queued; lists in `$T/U2-specs*.txt`):
+
+ | Run | At | Specs | Result |
+ |---|---|---|---|
+ | 1 | `eb9bb64e` | `storage`, `deliverables`, `deliver`, `clip-bench`, `report-fetch-via-editor`, `projects` | 91 passed, 1 failed, 2 did not run (serial), 7.7 min of tests (21 min with the queue and the fixture build) — `deliverables:143` expected the batch count `(2)`; the panel said `(3)`, which is right (fixed in `875dbff1`) |
+ | 2 | `875dbff1` | `deliverables` | **5 passed**, 0 failed, 2.1 min of tests (21 min with the queue) |
+
+ `projects.spec` is the one extra beyond the prompt's list: it reads the decisions inbox, which gained
+ two kinds.
+
+#### Found and left
+
+- **The app's decisions index is signed without the deliverables** (`reportSignature`: the manifest,
+ `out`, `availability.json`, `revisions`): a drive unmounted under an unchanged manifest leaves a
+ cached inbox row set stale until something else changes. `umtool check` computes afresh. Signing
+ `clips`/`share-*` would stat through the links on every index read — on a stalled drive, the
+ hang U1 left for `out`. Left with U1's note.
+- **A dangling `clips/` still reads as "nothing cut" to `deliverStateOf`'s counts** (the panel's
+ `need-cut` and per-section numbers); the panel now says why beside them and every write refuses.
+- **The app moves home without a media root of its own** (as in the e2e): the media copy is left in
+ place and named, as U1's L3 rule says. The real app runs with `UMTOOL_MEDIA_DIR` set, where the
+ project's own mirror is removed.
+- **U1's two items for this slice** are done: `listBatches`/`sharedIdsIn` follow a `share-*` link;
+ `umtool check` reports a dangling `out/` (and `clips/`, `share-*`).
+
+#### Deviations from the plan
+
+- The plan's files `report-to-video/{cut,deliver,check}.mjs` are `lib/report/cut.mjs`,
+ `lib/report/deliver.mjs` and `bin/umtool.mjs`'s `check` (through `lib/projects/report.mjs`'s
+ `reportDecisions`, so `umtool check` and the decisions inbox say the same).
+- The movers' loop and the switch are one function in `storage.mjs` (`moveDeliverables`) taking the
+ writer as `writeMode`, so `storage.mjs` — imported by the app's routes — does not import the
+ manifest writer's dependencies.
+- The bench move is the deliver route's fifth action and a job, not a route of its own: the job
+ registry is what keeps a cut or a batch from running under it.
+- `lib/media.ts` (the mix picker) and `driver.mjs` (the step builder) are touched beyond the owned
+ list, one function each.
+- A FACTS section was added ("umtool's deliverables switch"); no existing fact changed.
+
+`[Unreleased]` (`editor/CHANGELOG.md`): "umtool can move a report's cut clips and share batches to
+the media drive, one project at a time."
+
### Slice XP, as shipped — X posts are private (2026-10-01)
Branch `r17/x-posts-private` off `main` `90bd8384`, worktree `~/Projects/r13-lows-export` (editor 5501,