# Folders, and the roots ## `REPORTS_ROOT` The tree every project hangs off. `REPORTS_DIR ?? ~/reports`, and in e2e it defaults to `dirname(SONG_REPORTS_DIR)` so a fixture stays confined without a new environment variable. `SONG_REPORTS` (the um-song deliverables) keeps its exact previous default and is now a *subdirectory* of `REPORTS_ROOT` rather than the widest root there is. ## `MEDIA_ROOT` Where a project's render scratch lives (release 17). `UMTOOL_MEDIA_DIR`, else `REPORTS_ROOT` — and then nothing is different: `out/` is a directory in the project. Set, a project's `out` is an absolute link to the same project-relative path under it (`/a/b/out -> /a/b/out`), made by the first writer (`lib/report/storage.mjs` `ensureOutDir`) or by `umtool storage move-out`. The manifest, `revisions/`, notes and sources stay put. - It must already exist, outside `REPORTS_ROOT`: umtool never creates it, so an unmounted drive is a loud refusal ("is the media drive mounted?"), never a new tree on the main disk. A dangling `out` link refuses the same way. - Make it a directory **inside** the drive (`/umtool`), never the mountpoint itself: a mountpoint that stays behind as an empty directory when the drive is unmounted passes the check, and the first build of a new project would make its tree on the main disk. - A cut move leaves `out.moved-` or `out.incoming` beside the project's `out`. While one exists, no writer makes a new `out/` and both moves refuse, naming it: run the move it names again to finish it. When a real `out/` exists beside the leftover too (a writer made a fresh one after the cut), the moves refuse and say so: the leftover holds the moved data — keep one, remove the other by hand, then run the move. - It is a READ root (a realpath through the link lands under it), never a write root. - The walk skips `out`, `clips` and `share-*`, so it never stats a link into a drive that is not there. - `umtool storage` lists every project's `out` (dir, link, DANGLING, none); `umtool doctor` names the roots and exits 1 when this one is set and missing. ### Deliverables: `clips/` and every `share-*/` (a switch per project) A report's deliverables do not follow `UMTOOL_MEDIA_DIR` on their own. They move per project, by a switch in `video.manifest.json`: ```json "storage": { "deliverables": "media" } ``` `"local"` (or no `storage` key) keeps them in the project; `"media"` puts them under the same project-relative path on the media root, behind a link, as `out` is. Only `lib/report/manifest.mjs`'s `updateStorage` writes the value, and only `umtool storage deliverables` (or the bench's **Move deliverables** button, which runs it as a job) calls that — after every directory has moved: - `umtool storage deliverables --to media|local [--dry-run]` moves `clips/` and each `share-*/` with the same copy-mirror-verify-swap movers, then sets the switch. Run again, it finds each one `already` there and rewrites nothing; a cut move is finished by running it again. It refuses while a pipeline process works in the project (`lib/report/busy.mjs`, Linux `/proc`): a build step whose arguments name the project's directory; a cut (`cut-from-cache.mjs`) or share batch (`share-batch.mjs`) whose `--project` is the project's id, its name, or a path that resolves to its directory — whole values, never a prefix — which is how the app starts them; and the report's own `apply-manifest.py` or `build.py`, by working directory. It cannot see a hand-run command that is none of those scripts (an `ffmpeg` into `clips/`) or another machine. From the bench the move is itself a job, so the app's one-job-at-a-time rule keeps its cuts and batches out too. - A directory that does not exist yet is made where the switch says, by the writer: a cut makes `clips/`, a batch its `share-/` (`storage.mjs` `deliverableDir`). Under `"media"` that is a link to a new directory on the media root; the root itself is never created. An existing directory or link is used as it is — a move is what changes where it lives. - Under `"media"`, a process with no `UMTOOL_MEDIA_DIR` refuses to make a new one rather than make it in the project: a batch on the wrong drive is a split nobody chose. Existing links keep working there. - A link whose drive is not there refuses every cut and batch (a batch that cannot read an earlier batch would ship its clips again), and `umtool check` reports it as `storage-unreachable`, blocking — for `out` too. A directory that disagrees with the switch, or a cut move, is `storage-mismatch`, open. - References stay relative: `clips/.mp4` resolves through the link. - Manifests, `revisions/`, the caches and the cue cache never move. Final mp4s are in `out/` and travel with it. ## `CACHE_DIR` `UMTOOL_CACHE_DIR`, else `$XDG_CACHE_HOME/archilyzer/umtool`, else `~/.cache/archilyzer/umtool`: the project index, posters, the mix bench's analyses, sliced audio. Derived, safe to delete. Until release 17 it was `/.cache/umtool`; `umtool doctor` reports a leftover one, and the first `umtool index` rebuilds the index in the new place. ## The walk Two rules do almost all the work. **A PROJECT IS A LEAF.** Detection stops the descent. That is what keeps `out/` — 1,210 files and 3.1 GB across `~/reports` — out of the walk entirely. Nothing in the index ever sees a clip, a segment, a card PNG or a variant. **A FOLDER WITH NO PROJECT BENEATH IT DOES NOT EXIST.** That silently drops the ~40 loose test directories under `quartering-uh-song` — `alarm-tests`, `chop-tests`, `run-visual-tests`, `sfx`, `pipeline` — with no denylist to maintain and nothing to update when the 41st appears. Also: dotfiles, `node_modules`, `out`, `variants`, `plan`, `thumbs` and `data` are never descended into; depth is capped at 4; symlinked directories *are* followed, but every real path is visited once so a link to an ancestor terminates instead of spinning. Measured on the real tree: **12 projects in 25 ms**, and the 3.1 GB never touched. ## Collapsing A folder with no projects and exactly one child collapses **for display**: `quartering-uh-song / videos` is one heading. **The URL is never collapsed.** `/browse/quartering-uh-song/videos/yoshi` stays the one true address. A URL has to mean the same thing in six weeks, and a display convenience does not get to decide what a link is. ## Read roots vs write roots `resolveInRoots()` guarded what may be **opened** and what may be **rendered to**. They were one list — so widening the read root to reach report videos would in the same stroke have made every report's `out/` a legal render target. A 46 MB deliverable that cost an hour of network fetches, one typo in `/api/mix/render` away from being overwritten. ``` READ_ROOTS SONG_REPORTS, REPORTS_ROOT, SONG_DATA, SONG_SCRATCH WRITE_ROOTS SONG_REPORTS, SONG_SCRATCH ``` Reports became readable and mixable. **Nothing new became writable.** A mix of a report clip still lands in `SONG_REPORTS`, and a hand-typed path outside the write set is refused exactly as before. `MIX_ROOTS` overrides the read set; `MIX_WRITE_ROOTS` overrides the write set. **`SONG_REPORTS` stays first in the read list.** It is a subdirectory of `REPORTS_ROOT`, so whichever comes first decides every relative label — and putting `REPORTS_ROOT` first would silently rewrite every existing `videos//wide.mp4` into `quartering-uh-song/videos//wide.mp4`. `labelFor` and `resolveInRoots` read the same ordered list, which is what keeps a label a round trip. ## Media listing `listMedia()` walks the **project** roots two levels deep — a report's deliverable is at `/out/.mp4` and a song's cut at `videos//.mp4`, and neither was visible before. `SONG_DATA` and `SONG_SCRATCH` stay at one level: a second level there is thousands of stats of clip fragments to find nothing anybody would load. `segments`, `clips-raw`, `cards` and `qr` are excluded by name, or reaching one level deeper would put ~260 intermediates into a picker that is already saturated. ## Discovered by getting it wrong once **Relative paths bind to the first root, without stating.** `resolveInRoots` does not touch the disk, so a relative path resolves against the first root it *could* live under whether or not it is there. Survivable only because every path that crosses the wire from a picker or a project link is **absolute** — a relative one is a display label being handed back, and those came from `labelFor` against the same ordered list. Keep it that way. **The picker's cap was saturated, and depth alone did not fix it.** Measured: four of the six report deliverables still fell off the end of a 600-entry newest-first list. Coverage had to become a property of the *enumeration* — each project is asked for its own files, with its own small cap — not of the limit.