Archilyzer · Source

archilyzer

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

commit 0f8a0a1b38f9f64c2fc3277951efffc8005aaa7c
parent 90734588094312cfbe51122267dc11089c55a459
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 12:02:33 -0400

plans: slice RM, as shipped — a move holds the channel's writers and mirrors its copy; which writer got past the guard on 2026-09-30; FACTS; the editor changelog

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

Diffstat:
Meditor/CHANGELOG.md | 1+
Mplans/FACTS.md | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mplans/release-16.md | 165++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 254 insertions(+), 1 deletion(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -14,6 +14,7 @@ - **A job cancelled before it started now stays cancelled.** Its record on disk kept saying "queued", so a restart could put a job you had just cancelled back in its queue, and a clip fetch cancelled while waiting could be reported as still queued. Jobs still waiting when the editor shuts down are handled as before: the next start settles or re-queues them. - **A site's Charts tab gives a sixth series its own colour.** The dashboard's charts coloured their series from five colours and started again at the sixth, so a chart broken down by six or more channels drew the sixth in the first one's colour. The sixth now takes the palette's sixth colour, and each from the seventh on a hue of its own; the first five are unchanged. The published sites' charts get the same change with their next build. - **A form whose save is refused keeps what you typed.** Every editor form put its plain fields back to the stored values when its save was refused — a site's ID rejected, a page size out of range, a slug already taken — so everything typed had to be typed again. A refused save now leaves every field as you left it, beside the reason: **Settings**; a site's form (new and existing); the hub's config on `/sites`; **Cut release**; a channel's form (new and **Configure**), **Rename** and **Delete**; a video's **Delete directory**; **Drive health timing** on `/storage`; the backup config on `/saved-videos`; the sync operation's controls; the **Digest**, **Diarization**, **Speaker attribution** and **Speaker work lane** settings; and the worker list on `/workers`. A save that succeeds behaves as before, with one difference you may notice: a drop-down, and a checkbox or choice that the page tracks as you change it (a cadence, a worker's **Enabled**, a social link's **Keep in header**, a site membership, a site's accent), now shows what was saved. A form's own drop-downs used to go back to what the page had loaded with until a reload, and a second save from the same page sent that old choice again; the others went back until the page next refreshed itself (every 5 seconds by default). +- **A media move no longer starts over a job that is writing into the channel, holds the channel's writers while it runs, and makes its copy match the source before it verifies — so a transcription or a download during a move cannot fail it.** A move that has waited its turn behind other moves now checks again when it starts: if a job is running on the channel, or an auto-queue lane is working on one of its videos, it stops at once and says which ("a transcription of v50t5yt is running (Transcribe all, job …) — wait for it or cancel it"), with nothing copied; **Preview** says the same, and the Storage panel's blocked message now names the job too. While a move's marker stands, the channel is held: every lane skips it, and every job that reads or writes its media (single-video transcriptions, downloads and transcodes and the availability checks now included) refuses to start, including one that was already queued when the move began. The rack shows a **media held** chip in the channel's Tier cell and the Storage panel says "Held: its media is moving"; both go when the move finishes or its marker is cleared. The copy is now followed by a pass that makes the destination copy match the source — files the source no longer has are removed from the copy, never from the source — so a file written or deleted during the copy (a transcriber's scratch folder, say) no longer fails the check, and **Resume move** finishes a move whose copy holds such leftovers. If the source keeps changing, the move stops and lists what differs: extra on the destination, missing there, or changed. A new **Reconcile and resume** button beside **Resume move** lists those differences, makes the copy match and finishes the move, so no file has to be deleted by hand. The saved-video store's move does the same matching and the same check before it starts. Needs a rebuild and restart of the editor. ## [0.11.0] - 2026-09-30 - **Transcripts that arrived after a video was first seen are counted.** The stats behind the homepage, the hub and every site's charts were cached per video and refreshed only when the video's metadata changed, so a transcript that came later — a Whisper run days after the download, or a video downloaded after the last index build — never reached them, and a video with YouTube captions alone had no transcription date. Counts and charts were low; the homepage could show a site with 0 transcripts, 0 channels and 0 hours while it served its videos. A stat is now also redone whenever the index re-reads the video, every transcript has a date, and a captioned video is dated by when its captions arrived rather than by a later Normalize run, so its place on "Transcribed over time" can move. **After updating, rebuild and restart the editor before anything else:** until then, **Build stats dataset** runs the old code and would undo the new stats, while a site, hub or homepage build already runs the new code — and the first stats build of any kind re-reads every video once (about 10–30 minutes on a large archive; it can be stopped and picks up where it stopped). Then build the index, the stats, the homepage, the hub, and the sites. diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -8090,3 +8090,92 @@ source mirror (homepage)". Anchors are at the branch. (a read never throws). `forms-keep-input.spec.ts` `withSettingsUnwritable` restores the file in a `finally`. A background writer in that window fails the same way, so the spec keeps it to one submit. + +## A move holds the channel's writers and mirrors its copy (verified 2026-10-01, branch `r16/move-holds-writers`) + +The record is [`release-16.md`](release-16.md), "Slice RM, as shipped". Anchors are at the branch tip. +This section amends "A channel's media may be on another drive" (above): **a move is now serialized +against the channel's own writers by refusal at the job's start as well as at enqueue**, and the copy +phase deletes from the destination. + +- **Which writer got past the guard on 2026-09-30 (`realcandaceo`, from the job metas, read only).** + Not a lane: the lanes skip a channel with a marker twice over (guard 2 in `buildChannelWork`, and the + pick→run backstop that reads the marker before a unit launches), and both held. It was a manual + **Transcribe all** (`whisper-all`, job `01M3TGYDXA7WCBGN014A90J3JW`, queue `transcription`, spec + `params: {}` — the scan over the channel's 434 items). The move (`01M3TGHZSMQHHYXH44ATXXFKSQ`) was + queued 21:16:42 and passed the action's busy check (nothing running); the Transcribe all was queued + and started at 21:23:30 — no marker existed, so its `needsMedia` guard passed, correctly; the move + started at 21:36:28 after waiting behind other moves on the one relocation queue, and asked nothing + about the channel's jobs. `v50t5yt`'s transcription (749.78 s) began ≈21:25:37, its parakeet scratch + dir (`.audio.mp3.parakeet/`, meta + four windows) was copied, and its outputs landed at + 21:38:06–07, after rsync had passed the directory; the verify at 22:02:37 refused (4 differ). The + batch then **kept writing while the marker stood** — `v511otv` at 22:40:29, `v519k6c` at 23:13:25, + `v51fpcd`'s scratch from 23:21 — until it was cancelled at 23:29:44: a job's guard ran only at + enqueue, and its per-video loop never re-asks. The resume (`01M3VX740RAK1H24RZ2CRKYZ79`, 10:17) copied + the new files and refused on the counts, 1755 against 1750 — the five scratch files on the + destination, which a copy that never deletes could not settle. +- **The three closes.** (1) The move refuses over a writer at its preview, its job's FIRST STEP and a + third time right after the copy phase's marker is written (`relocateChannelMedia.ts:162` + `assertNoWriters`, `:488`, `:546`, `:667` `assertNoWritersUnderMarker` — the third removes a marker + this run created). (2) A `needsMedia` job's guard is asked again when the queue STARTS it + (`jobs/streamCommand.ts:429`, `refuseForUnreachableMedia` `:327`), so a job queued before a marker + and started after it fails before `fn` runs. (3) The per-video writers that were not in + `JOB_KINDS` at all — `whisper-video`, `transcribe-one`, `download-one-pipeline`, `transcode-audio`, + `check-availability`, `quick-availability-check`, `check-maybe-missing` (`jobs/jobKinds.ts:577` + on) — declare `needsMedia`; absent meant false, so none was ever refused. Labels stay absent + (`/jobs` unchanged). Not in the table and still not refused: `worker-transcribe`/`worker-unit` (this + box as a worker: a scratch dir or another machine's mount), `refresh-report` (asserts reachability + itself), and the corpus-wide `normalize-live-chat`/`archive-*` (no slug). +- **Who is a writer: `controller/channelWriters.ts`.** `channelWriters(slug, opts)` (`:86`): registry + jobs whose `channelSlug` is the slug, running (queued too with `includeQueued`), minus + `ignoreKinds`, then every lane's in-flight units for the slug (`getAutoRunnerStatus(lane).inFlight`). + A lane download unit is also an `auto-download-unit` job; it is named once, as the lane's. + `describeChannelWriter` (`:151`) names the first task's video ("a transcription of v50t5yt is running + (Transcribe all, job …)"); `channelWritersRefusal` (`:177`) is the move's sentence. The move's job + passes `ignoreKinds: ["relocate-channel-media"]` (it is itself running on the slug; the relocation + queue runs one at a time); the preview passes nothing. **The editor's `channelMediaBusyReason` + (`editor/app/channels/lib/mediaBusy.ts`) reads the same list with `includeQueued`**, keeps its + counts sentence (the e2e regex `running\/queued job\(s\) for this channel` still matches) and + appends `Now: <the first writer>.`. Corpus-wide jobs carry no slug and are not writers here: + `normalizeAll` and `evictClipWindows` re-ask the hold per channel, and the mirror covers a write that + lands mid-copy. +- **The hold's words: `lib/channelMediaHold.ts`.** `HELD_REASON["in-transition"]` (`:40`) leads with + "its media is moving (a move is in progress or was interrupted)" — it also reaches both builds' held + lines. `mediaHoldText(status)` (`:52`) is "held: <reason>" or null — the rack's chip + (`ChannelTierSelect`, `aria-label="media hold for <slug>"`, visible "media held", the sentence on + `title` and in sr-only text), the Storage panel's line (`aria-label="media hold"`), the refused + job's sentence, the runners' skip log. It reaches the row as `ChannelRowView.mediaHold` + (`views/channelRow.ts:114`) and the panel as a server-computed prop; `lib/storageLocations.ts` + (imported by the hold module) is never pulled into a client file by this. The runners skip on + `isMediaHeld` (`autoRunner.ts:641`); their skip log is now `[auto] skipping <slug>, held: <reason>: + media <status> — <detail>` (the old `media <status> — <detail>` is its tail). +- **The mirror: `controller/relocateDir.ts`.** `MIRROR_ARGS = ["-a", "--delete"]` (`:344`); the + verify's dry run is `VERIFY_ARGS` (`:348`, `--delete` included, so an extra on the destination is a + difference, `*deleting`). **`--delete` only ever targets the copy under construction**: + `mirrorTree` (`:487`) calls `assertMirrorDirection` (`:461`) before rsync is spawned — the + destination must equal the caller's `underConstruction` (the directory the move made to copy into: + `<root>/<slug>/data` out, `data.incoming` back, `<root>/saved-videos` for the store) and neither + tree may contain the other (realpaths). `copyMirrorVerify` (`:550`) is the copy phase for both + movers and both directions: the progress copy (`COPY_ARGS`), the mirror pass, `verifyCopy` in mirror + mode (`verifyMirrored`, `:690`): an empty itemized dry run plus equal counts; a difference gets one + more mirror pass (`retried`), a second refuses with `CopyVerificationError` (`:425`) carrying the + differences by kind (`classifyDrift`, `:386`: `*deleting` → extra on the destination, all-`+` + attributes → missing on the destination, anything else → changed). **A swap-phase re-verify + mirrors only from the live media** (`data/` still a real directory, `relocateChannelMedia.ts:863`); + against a parked `data.relocated-*` it is the strict legacy verify — the target is never mirrored + from a copy that is no longer live. Resume takes the copy phase again, so stale files on a + destination are removed. +- **Reconcile and resume** = `relocateChannelMedia({ reconcile: true })`, refused without a marker + (`:551`): the copy phase skips the progress copy, logs `diffTrees` (`relocateDir.ts:510`) by kind + ("Reconciling: the destination copy differs from the source — …"), and the mirror pass carries the + progress sink. Editor: `reconcileRelocationAction` (`storageActions.ts:136`, Resume's guards), the + Storage panel's button beside Resume move (log `"Reconcile and resume output"`), the job's done line + "(reconciled and resumed an interrupted move)". +- **The saved-video store shares the pipeline** (`relocateSavedVideos.ts` copy phases → + `copyMirrorVerify`; the swap re-verify mirrors while the store is still a real directory) and takes + `busy` (`:246`, asked first, `:259`); the editor's job passes the store check with `runningOnly` + (`editor/app/storage/lib/storeBusy.ts`). It has no reconcile button. +- **Not covered:** a lane tick that inspected the channel before the marker landed and dispatches + after the third ask (milliseconds; the pick→run backstop and the mirror cover it); a corpus-wide + writer mid-channel when a move starts; a resumed or reconciled move-out is charged the whole tree + against the destination's free space, not the remainder (move-back charges the remainder). diff --git a/plans/release-16.md b/plans/release-16.md @@ -24,7 +24,7 @@ slice's prompt carries its ruling, and this record carries what was built. Rules | CK | `r16/search-in` | A "Search in" row — Transcripts, Posts, Live chat — on the export and hub search, Transcripts and Posts on by default | `common/components/{FiltersPanel,SearchSessionContext,SearchResults,SearchBar,exportFilterStorage}.tsx/.ts`, `common/lib/searchQuery.ts` and `common/lib/search/*` as its prompt names, `export/e2e/search-in.spec.ts` (new) and the specs its prompt names, `export/e2e/helpers.ts`; records: `plans/FACTS.md` | | DX | `r16/research-setup` | The research-only setup (source → `pnpm install` → `claude mcp add archilyzer` → `/ask`) in one place, the homepage's AI and MCP doc; the sites' and the hub's Use-with-AI page removed and its links pointed at the doc; `README.md` §1/§4 and `mcp/README.md` their own copies (as amended) | `homepage/content/docs/ai-and-mcp.md`, `export/app/use-with-ai/` (removed), the Use with AI links (`export/app/components/{Header,MobileMenu,Footer}.tsx`, `export/app/(workspace)/ask/page.tsx`), `common/lib/{project,corpus}.ts` + `common/bin/compose-site.ts` (what named the page), `mcp/README.md`, `README.md` §1/§4 (wording only), `homepage/e2e/docs.spec.ts`, `export/e2e{,-hub}/use-with-ai-link.spec.ts` and the specs that visited the page | | FK | `r16/forms-keep-input` | Every editor form keeps what was typed when its action fails: actions return the submitted values with the error, the shared field helpers seed from them | new `editor/app/lib/formState.ts` + test; `editor/app/components/forms/Field.tsx` and the local `Field`s in `SiteForm.tsx`, `ChannelForm.tsx`; every action that returns `{ok:false,error}`/`{error}` (sites, settings, operations/settingsActions, scheduler, storage, homepageActions, cutReleaseAction, channels, videoActions); the 15 forms the ruling lists; e2e `forms-keep-input.spec.ts` (new) + the existing `sites-crud`, `settings`, `channels` specs; records: `plans/FACTS.md`. As shipped, also `editor/app/components/forms/Controlled.tsx` (new) and one tag swap each in `DurationField`, `SocialLinksField`, `SiteMembershipsSection`, `WorkersField` ("Slice FK, as shipped") | -| RM | `r16/move-holds-writers` | A media move holds the channel's writers and mirrors its copy, so a transcription or download during the move cannot fail it | `common/controller/relocateChannelMedia.ts` (+ the saved-video mover if it shares the code) + tests; the lane runners' per-channel skip (`common/controller/autoRunner.ts`, the backfill/digest/normalize/clip-fetch entry points, `common/lib/channelMediaHold.ts`); `common/jobs/jobKinds.ts` (`needsMedia`); the channel page's Storage panel and the rack's reason text; `editor/e2e/relocate*.spec.ts`; records: `plans/FACTS.md` | +| RM | `r16/move-holds-writers` | A media move holds the channel's writers and mirrors its copy, so a transcription or download during the move cannot fail it | `common/controller/relocateChannelMedia.ts` (+ the saved-video mover if it shares the code) + tests; the lane runners' per-channel skip (`common/controller/autoRunner.ts`, the backfill/digest/normalize/clip-fetch entry points, `common/lib/channelMediaHold.ts`); `common/jobs/jobKinds.ts` (`needsMedia`); the channel page's Storage panel and the rack's reason text; `editor/e2e/relocate*.spec.ts`; records: `plans/FACTS.md`. As shipped, also `common/controller/{channelWriters,relocateDir}.ts` (+ tests), `common/jobs/streamCommand.ts`, `common/views/channelRow.ts`, `editor/app/channels/lib/{mediaBusy,relocationJob}.ts`, `editor/app/storage/lib/{storeBusy,savedVideosJob}.ts`, `editor/app/api/test/stuck-job/route.ts`; the relocate spec is `editor/e2e/channel-storage.spec.ts` ("Slice RM, as shipped") | ## Slice CK — the ruling (2026-09-30) @@ -761,6 +761,169 @@ FK row. `new-channel-onboarding`, `social-channel`, `sites-crud`, `settings`: **70 passed**, 0 failed, 5.6 min. The full suite was not re-run, as the parent directed. +### Slice RM, as shipped — a move holds the channel's writers and mirrors its copy (2026-10-01) + +Branch `r16/move-holds-writers` off `main` `2e2f6b2a` (the ruling's remediation bullet merged in from +`9cec5be6`, a fast-forward), worktree `~/Projects/plans-export-header-first-search` (editor 4201, +test 4211, export 4210 — `pnpm wt list`'s block #12), one Opus implementer. Scratch files `rm-*` in +the job's `tmp`. The ruling is above ("Slice RM — the ruling"). + +**Which writer got past the guard on 2026-09-30** (the job metas and logs in `transcripts/.jobs/` +and `realcandaceo/data/v50t5yt/transcribe-outcome.json`, read only). Not a lane: the lanes skip a +channel with a marker twice over (the projection's media check and the pick→run backstop), and both +held. It was a manual **Transcribe all** (`whisper-all`, job `01M3TGYDXA7WCBGN014A90J3JW`, the scan +over the channel's 434 items): + +| When | What | +|---|---| +| 21:16:42 | The move (`01M3TGHZSMQHHYXH44ATXXFKSQ`) is queued; the action's busy check finds nothing running | +| 21:23:30 | Transcribe all is queued and starts. No marker exists yet, so its media guard passes — correctly | +| ≈21:25:37 | Its transcription of `v50t5yt` begins (749.78 s); parakeet's scratch dir `.audio.mp3.parakeet/` fills window by window | +| 21:36:28 | The move starts, after twenty minutes behind other moves on the one relocation queue. It asks nothing about the channel's jobs, writes the marker and copies — the scratch dir (meta + four windows) included | +| 21:38:06–07 | `v50t5yt`'s transcript, cues and outcome land, after rsync had passed the directory; the scratch dir is deleted from the source | +| 22:02:37 | The verify refuses: four paths differ. The marker stays (phase copy) | +| 22:40, 23:13, 23:21 | The batch keeps writing with the marker standing — `v511otv`, `v519k6c`, `v51fpcd`'s scratch — until cancelled at 23:29:44 | +| 2026-10-01 10:17 | The resume copies the new files and refuses on the counts, 1755 against 1750: the five scratch files on the destination, which a copy that never deletes could not settle | + +So three things let it through, and the slice closes each: the move asked about the channel's jobs +only when it was enqueued; a job's media guard ran only at enqueue, never when its queue started it +(and its per-video loop never re-asks); and seven per-video writers were not in `JOB_KINDS` at all, +so they were never refused for a moving channel. + +**What it does.** +- **A move refuses to start over a writer, and names it.** `common/controller/channelWriters.ts` + (new) reads who is writing into a channel: running registry jobs on its slug (queued ones too for the + editor's courtesy check) and every lane's in-flight units for it; a lane's download unit, which is + also a registry job, is named once. The refusal: `Cannot move the media of "realcandaceo" now: a + transcription of v50t5yt is running (Transcribe all, job 01M3…) — wait for it or cancel it. Nothing + has been touched.` (a lane unit: "wait for it, or hold the transcription lane"). Asked by + `previewRelocation`, by the job's first step, and a third time the moment the copy phase's marker is + written — after which the lanes skip the channel and a media job refuses to start, so that answer + cannot go stale; a refusal there removes a marker this run created. The editor's + `channelMediaBusyReason` (the panel's blocked message, rename, delete, the bulk move's skips) reads + the same list and appends `Now: <the first writer>.` to its counts. +- **The writers are held while the marker stands.** A media job's guard (`needsMedia`) is asked again + when its queue starts it (`jobs/streamCommand.ts`), so a job queued before a move and started during + it fails before it runs, in the hold's words: `Channel "x" is held: its media is moving (a move is in + progress or was interrupted) — …`. `whisper-video`, `transcribe-one`, `download-one-pipeline`, + `transcode-audio`, `check-availability`, `quick-availability-check` and `check-maybe-missing` now + declare `needsMedia` (labels still absent, so `/jobs` shows them as before). The lanes skip on + `isMediaHeld`, logging the hold's sentence. `HELD_REASON["in-transition"]` leads with "its media is + moving", and `mediaHoldText` is the one wording: the rack's **media held** chip in the Tier cell + (`aria-label="media hold for <slug>"`, the sentence on `title`) and the Storage panel's "Held: its + media is moving (…). The lanes skip this channel and its media jobs refuse to start until the hold + lifts — when the move completes, or its marker is cleared below." Both go with the marker. +- **The copy mirrors, toward the destination only.** `relocateDir.ts`'s `copyMirrorVerify` is the copy + phase for both movers and both directions: the progress copy, then `rsync -a --delete` source → the + copy under construction, then a verify whose dry run carries `--delete` plus equal counts. One + change seen between the mirror and the check gets one more mirror pass; a second refuses with the + paths by kind — extra on the destination, missing on the destination, changed — and "Something is + still writing into <src>: stop it, then Reconcile and resume." `mirrorTree` asserts the direction + before rsync is spawned: the destination must be the directory the move created to copy into, and + neither tree may contain the other. A resume takes the same path, so the realcandaceo leftovers are + mirrored away. A swap-phase re-verify mirrors only while `data/` is still the live directory; against + a parked `data.relocated-*` it stays the strict verify, so the target is never mirrored from a copy + that is no longer live. +- **Reconcile and resume** (the remediation bullet): a button beside **Resume move**, with its own log + ("Reconcile and resume output"), offered under Resume's condition (a marker, nothing running). One + relocation job with `reconcile`: no copy pass; it lists how the destination differs from the source + by kind ("Reconciling: the destination copy differs from the source — 2 extra on the destination + (…), 0 missing on the destination, 2 changed (…)"), mirrors (the progress bar rides the mirror + pass), verifies, swaps and reclaims; the done line says "(reconciled and resumed an interrupted + move)". Without a marker it refuses. +- **The saved-video store's move shares the pipeline** — `relocateSavedVideos` uses + `copyMirrorVerify` both ways and mirrors its swap re-verify while the store is still a real + directory — and takes a `busy` first step, which the editor's job fills with the store check + (`savedVideosStoreBusyReason(…, { runningOnly: true })`: by then a merely queued download is not + writing, and its persist is refused by the marker anyway). It has no Reconcile button; its Resume + mirrors. + +**Commits** + +| Commit | What | +|---|---| +| `50fc580c` | `common:` `channelWriters.ts` + test; the mirror, the direction assertion, `copyMirrorVerify`, the verify by kind (`relocateDir.ts` + test); both movers (`reconcile`, `writers`, `busy`) + tests; the start-time media guard + test; seven `needsMedia` kinds; the hold's words, the lanes' skip + test; `ChannelRowView.mediaHold` | +| `1c5b9739` | `editor:` the Storage panel's hold line and Reconcile and resume; the rack's chip; `mediaBusy` names the writer; the store job's first step | +| `0ee291f9` | `editor(e2e):` `channel-storage.spec.ts` — four cases (below); `/api/test/stuck-job` takes `slug` and `task` | +| this commit | `plans:` this section, the slices table's RM row; FACTS; the editor changelog | + +#### Gates (logs `$T/rm-*.log`) + +- **tsc** (all workspaces) clean at `0ee291f9`, after deleting the worktree's stale + `export/.next/{dev/,}types` (still naming the removed `/use-with-ai` page, DX's note). +- **common:** **2,432/2,432**, 78 s (`main`'s 2,404 + 28) — new: `channelWriters.test.ts` 7, `relocateDir.test.ts` +8 (the + mirror carries a file added after the copy pass and removes one deleted after it; the realcandaceo + stale dir; one change → one more pass; a second → refused by kind; reconcile lists then mirrors; + the `--delete` direction refused before rsync spawns; the itemize classifier), + `relocateChannelMedia.test.ts` 39 (two old cases re-premised, below; +9: a stray file mirrored + away, the strict parked re-verify, the refusal over a running job, the preview's, a writer seen + under the marker, a resume over a stale scratch dir, the same coming back, reconcile, reconcile + without a marker), + `relocateSavedVideos.test.ts` +2, `streamCommand.test.ts` +1 (a media job queued before the marker + refuses at its start), `autoRunner.test.ts` +1 (every lane projects nothing for a marked channel and + the hold lifts with the marker). **Editor unit:** 109/109. **test:scripts:** 302 passed, 2 skipped + (304). +- **Build:** the capped editor build with the corpus linked (`ln -sT` the primary's `transcripts`, + `systemd-run --scope -p MemoryMax=5G`, `pnpm --filter editor exec next build`, the link removed + after): exit 0, 47 s, 1.68 GB peak, at `0ee291f9`. +- **Numbers tool:** none. + + | Run | At | Specs | Result | + |---|---|---|---| + | 1 | `0ee291f9` | `channel-storage`, `storage-locations`, `channels-storage-columns`, `channels`, `channel-rename`, `ops-api`, `channel-priority`, `channels-rack-layers`, `saved-videos`, `review` (which also matched `site-publish-preview`), `fetch-window` | **88 passed**, 1 failed, 6.9 min — `channel-rename` "rename and delete refuse while a job is writing": **Delete channel** was disabled when clicked (below) | + | 2 | `0ee291f9` | `channel-rename` × 4 | **8 passed**, 0 failed, 49 s | + | 3 | `0ee291f9` | the full editor suite | **683 passed**, 3 failed, 12 skipped (the rack-audit shots), 51.8 min — `jobs-channel` "auto-refreshes the jobs list": every button on the Playlist stage still disabled after 30 s (the page never hydrated); `perf-budget` "the dashboard renders within budget": `resetData` met `EEXIST` making `test-transcripts/channels` (the fixture race helpers.ts describes); `widget` "+N more expands": `ERR_CONNECTION_REFUSED` — the log's one `[WebServer] ⚠ Server is approaching the used memory threshold, restarting...` landed on it | + | 4 | `0ee291f9` | `jobs-channel`, `perf-budget`, `widget` | **34 passed**, 0 failed, 1.4 min (after ~4 min waiting for the queue). None of the three touches what the slice changed beyond the shared job start | + + The four new `channel-storage` cases: **a move that starts while a job writes into the channel + refuses, naming the job** (the 2026-09-30 shape: a stuck job holds the relocation queue for 4 s, the + move goes in through `/api/ops/relocate`, a fake running Transcribe all on the channel's video + appears, the move starts and fails with the exact sentence; no marker, no copy; the panel's blocked + message names the writer); **the rack and the Storage panel show the hold while a marker stands, and + it lifts with it** (Clear marker); **Resume move** extended with a planted stale scratch dir on the + destination, gone after the resume; **Reconcile and resume settles an extra and a changed file on + the destination**. + +#### Found and left + +- **Run 1's `channel-rename` failure is that spec's race with the page's own refresh**, not this + slice: the spec renders the Danger zone while the channel is quiet, starts a sync out of band, and + submits Rename then Delete from the stale page. The sync's start moves the pulse token, and when the + next pulse tick (every 5 s) lands between the two submits, the refreshed page renders Delete + disabled with the busy reason (`error-context.md` shows both forms blocked, the reasons naming the + sync). 8/8 on the rerun. Not changed: the spec is not this slice's. +- **The two existing tests whose premise the ruling reversed**, re-premised: "a verify failure keeps + the source…" planted a stray file on the target and expected a refusal — now the mirror removes it + (that case is "a stray file on the destination is removed by the mirror pass"), and the refusal case + is a source that keeps changing; "out @ swap: a file the target is missing is still a refusal" is now + "… is mirrored by one more pass". +- **Corpus-wide writers carry no slug**, so the move's check cannot name them: a corpus-wide Normalize + or Evict already inside a channel when its move starts is not refused. Both re-ask the hold per + channel, and a write that lands mid-copy is carried by the mirror (or refused by kind if it keeps + landing). +- **Kinds still outside `JOB_KINDS` with a slug:** `worker-transcribe` / `worker-unit` (this box as a + worker, writing into a scratch dir or another machine's mount), `refresh-report` (it asserts + reachability itself), and the slug-less `normalize-live-chat` / `archive-*`. +- **A lane tick that inspected the channel just before the marker landed** can dispatch a unit after + the third ask (milliseconds); the pick→run backstop reads the marker, and the mirror covers the + rest. +- **A resumed or reconciled move-out is charged the whole tree against the destination's free space**, + not what is still missing (the move back charges the remainder). Unchanged; a near-full destination + can refuse a resume it has room for. +- **A server action that writes into a video's directory without a job** (the video page's edits) is + not held by the marker; the mirror carries its write. + +#### Decisions the operator could overturn + +| What I did | The alternative | +|---|---| +| The move's own check refuses over RUNNING jobs and lane units; a QUEUED media job is refused when it starts instead | Refuse the move over queued jobs too (the editor's enqueue-time check does): the operator re-queues the move after a twenty-minute wait for a job that would not have written yet | +| A third ask right after the marker is written, removing a marker the run created | The first step only; a job that starts while a big tree is measured would be copied over | +| The swap-phase re-verify mirrors while `data/` is live, never from a parked copy | Mirror from the parked copy too: it would delete from the target what was written through the link after the swap | +| The Storage panel's busy message keeps its counts and adds `Now: <writer>.` | Replace the counts with the named writer (the `ops-api` spec pins the counts' wording) | +| The rack's hold is a chip in the Tier cell, shown for every held status (unreachable, stalled and inconsistent too: the lanes skip all of them) | Only for a moving channel, as the ruling's sentence names | +| Reconcile and resume skips the progress copy and lets the mirror pass carry the bar | Run the copy pass first, as Resume does — the same bytes either way | + ## Rollout Both slices are export- and homepage-side; the editor and umtool are not rebuilt for this release.