Archilyzer · Source

archilyzer

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

commit 470b78d696e83fe5b9a3eff907f5fdcf0d0b4b94
parent 2b28e4f80f9171053c70f660f5d8c7ad0b2276e9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:03:08 -0400

editor: rename and delete ask the same busy question every other surface asks

Rename counted running/queued JOBS. The registry is half the truth — the
auto-queue lanes run their per-video units in-process and make no job record
(the omnimirror incident, lib/mediaBusy.ts) — so a digest unit writing a sidecar
into data/ was invisible to it, and renaming moves that directory.

Delete asked nothing at all, which is the worse of the two: an orphaned registry
entry is a nuisance, a delete racing a download for the tree loses bytes.

Both call channelMediaBusyReason now, with their own verb, and the Danger-zone
forms carry the reason so a form that cannot submit says why before it is filled
in rather than after.

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

Diffstat:
Meditor/app/channels/[slug]/page.tsx | 13++++++++++++-
Meditor/app/channels/actions.ts | 31+++++++++++++++++++------------
Meditor/app/channels/components/DeleteChannelForm.tsx | 17+++++++++++++++--
Meditor/app/channels/components/RenameChannelForm.tsx | 18++++++++++++++++--
Meditor/e2e/channel-rename.spec.ts | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 179 insertions(+), 18 deletions(-)

diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -643,11 +643,20 @@ export default async function ChannelDetailPage({ /> ); } - case "danger": + case "danger": { + // THE SAME SENTENCE THE ACTIONS REFUSE WITH, one click earlier. Both + // actions ask this themselves — a disabled button is a courtesy and the + // server is the guard — but a Danger-zone form that submits, moves + // nothing and comes back with a paragraph is the worst place to learn + // that a lane was mid-write. The verb differs per form so the reason + // reads as an instruction in each. + const renameBusy = channelMediaBusyReason(slug, "renaming it"); + const deleteBusy = channelMediaBusyReason(slug, "deleting it"); return ( <div className="flex flex-col gap-4"> <RenameChannelForm slug={slug} + busyReason={renameBusy} action={ renameChannelAction.bind(null, slug) as ( prev: ActionResult, @@ -658,6 +667,7 @@ export default async function ChannelDetailPage({ <hr className="border-border" /> <DeleteChannelForm slug={slug} + busyReason={deleteBusy} action={ deleteChannelAction.bind(null, slug) as ( prev: ActionResult, @@ -667,6 +677,7 @@ export default async function ChannelDetailPage({ /> </div> ); + } } } diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -21,6 +21,7 @@ import { writeChannelConfig, } from "yt-dlp-transcript-common/controller/channels"; import { renameChannel } from "yt-dlp-transcript-common/controller/renameChannel"; +import { channelMediaBusyReason } from "./lib/mediaBusy"; import { excludedDownloadIdSet, generateChannelSnapshot, @@ -577,6 +578,15 @@ export async function deleteChannelAction( error: `Type the channel slug "${slug}" exactly to confirm deletion`, }; } + // THE RENAME'S GUARD, AND DELETE NEEDED IT MORE. Renaming while a job runs + // orphans a registry entry keyed by the old slug; DELETING while one runs + // pulls the directory out from under a writer — a download's `.part`, a + // transcribe's sidecar, a digest unit's JSON — and the lane units make no job + // record at all, so the registry alone never saw them. `deleteChannel` then + // races the writer for the tree and whichever loses reports an ENOENT nobody + // asked about. + const busy = channelMediaBusyReason(slug, "deleting it"); + if (busy) return { error: busy }; await deleteChannel(getPaths(), slug); // Same reason as createChannelAction: the deleted channel keeps a leaf in // every compiled tree until something recompiles. A leaf matching nothing is @@ -627,18 +637,15 @@ export async function renameChannelAction( return { error: `Channel "${newSlug}" already exists` }; } - const activeJobs = getRegistry() - .list() - .filter( - (j) => - j.channelSlug === oldSlug && - (j.status === "running" || j.status === "queued"), - ); - if (activeJobs.length > 0) { - return { - error: `Finish or cancel ${activeJobs.length} running/queued job(s) for this channel before renaming.`, - }; - } + // THE REGISTRY IS HALF THE TRUTH, and this check used to be the other half's + // ancestor: it counted running/queued JOBS only. The auto-queue lanes run + // their per-video units in-process and make no job record (the omnimirror + // incident, lib/mediaBusy.ts), so a digest unit writing a sidecar into + // `data/` was invisible here — and renaming moves the directory out from + // under it. One question, one answer, the same sentence the Storage panel + // and the bulk move say. + const busy = channelMediaBusyReason(oldSlug, "renaming it"); + if (busy) return { error: busy }; let result; try { diff --git a/editor/app/channels/components/DeleteChannelForm.tsx b/editor/app/channels/components/DeleteChannelForm.tsx @@ -9,9 +9,12 @@ type Props = { prev: ActionResult, formData: FormData, ) => Promise<ActionResult>; + // Why the delete is refused right now, or null. Same shape and same reason as + // the rename form's: the action is the guard, this is the earlier warning. + busyReason?: string | null; }; -export function DeleteChannelForm({ slug, action }: Props) { +export function DeleteChannelForm({ slug, action, busyReason = null }: Props) { const [state, formAction] = useActionState<ActionResult, FormData>( action, undefined, @@ -22,6 +25,15 @@ export function DeleteChannelForm({ slug, action }: Props) { Deleting will remove <code>transcripts/channels/{slug}/</code> and all downloaded videos. Type the slug to confirm. </p> + {busyReason && ( + <p + role="status" + aria-label="delete blocked" + className="text-sm rounded border border-border bg-muted px-3 py-2" + > + {busyReason} + </p> + )} <div className="flex gap-2 items-start"> <input name="confirmSlug" @@ -32,7 +44,8 @@ export function DeleteChannelForm({ slug, action }: Props) { /> <button type="submit" - className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90" + disabled={busyReason !== null} + className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50" > Delete channel </button> diff --git a/editor/app/channels/components/RenameChannelForm.tsx b/editor/app/channels/components/RenameChannelForm.tsx @@ -6,9 +6,13 @@ import type { ActionResult } from "../actions"; type Props = { slug: string; action: (prev: ActionResult, formData: FormData) => Promise<ActionResult>; + // Why the rename is refused right now, or null. The ACTION asks the same + // question and refuses with the same sentence — this is the courtesy that + // says so before the form is filled in, not the guard. + busyReason?: string | null; }; -export function RenameChannelForm({ slug, action }: Props) { +export function RenameChannelForm({ slug, action, busyReason = null }: Props) { const [state, formAction] = useActionState<ActionResult, FormData>( action, undefined, @@ -31,6 +35,15 @@ export function RenameChannelForm({ slug, action }: Props) { className="rounded border border-border bg-card px-2 py-1 text-sm font-mono max-w-xs" /> </label> + {busyReason && ( + <p + role="status" + aria-label="rename blocked" + className="text-sm rounded border border-border bg-muted px-3 py-2" + > + {busyReason} + </p> + )} <div className="flex gap-2 items-start"> <input name="confirmSlug" @@ -41,7 +54,8 @@ export function RenameChannelForm({ slug, action }: Props) { /> <button type="submit" - className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90" + disabled={busyReason !== null} + className="px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 disabled:opacity-50" > Rename channel </button> diff --git a/editor/e2e/channel-rename.spec.ts b/editor/e2e/channel-rename.spec.ts @@ -1,6 +1,8 @@ -import { test, expect } from "@playwright/test"; +import { test, expect, type Page } from "@playwright/test"; +import { baseUrl } from "./baseUrl"; import { channelStage, + generateReport, readJson, resetData, writeSite, @@ -55,3 +57,117 @@ test("rename requires the exact current slug and then moves the channel", async ); expect(site.channels.map((c) => c.slug)).toEqual([NEW]); }); + +// RENAMING AND DELETING ARE REFUSED WHILE SOMETHING IS WRITING INTO data/. +// +// The rename always asked, but it asked the JOB REGISTRY alone — and the +// registry is half the truth: the auto-queue lanes run their per-video units +// in-process and make no job record (lib/mediaBusy.ts, the omnimirror +// incident). Delete asked nothing at all, which is the worse of the two: a +// rename that orphans a registry entry is a nuisance, a delete racing a +// download for the tree loses bytes. +// +// Both now ask `channelMediaBusyReason`, the one question the Storage panel and +// the bulk move ask, and both say the same sentence with their own verb. +// +// THE JOB IS THE HALF AN E2E CAN STAGE. A lane unit is in flight for +// milliseconds at a time and cannot be held there from a browser; the fake +// yt-dlp's `--test-slow` holds a real job for 30 s, and it is the same registry +// read either way. +const SLOW = "slow-channel"; + +async function running(page: Page, slug: string): Promise<void> { + await expect + .poll( + async () => { + const res = await page.request.get(`${baseUrl}/api/jobs/active`); + const body = await res.json(); + const jobs: { channelSlug?: string; status: string }[] = Array.isArray( + body, + ) + ? body + : (body.jobs ?? []); + return jobs.filter( + (j) => + j.channelSlug === slug && + (j.status === "running" || j.status === "queued"), + ).length; + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0); +} + +test("rename and delete refuse while a job is writing into the channel", async ({ + page, +}) => { + test.setTimeout(120_000); + await resetData("slow-pipeline-channel"); + await generateReport(page, SLOW); + // Rendered while the channel is QUIET, so both forms come back live. The + // point of the next few lines is that the SERVER refuses — a page that + // already knows would prove only that the button can be greyed out. + await expect + .poll( + async () => { + const res = await page.request.get(`${baseUrl}/api/jobs/active`); + const body = await res.json(); + const jobs: { channelSlug?: string; status: string }[] = Array.isArray( + body, + ) + ? body + : (body.jobs ?? []); + return jobs.filter( + (j) => + j.channelSlug === SLOW && + (j.status === "running" || j.status === "queued"), + ).length; + }, + { timeout: 30_000 }, + ) + .toBe(0); + await page.goto(channelStage(SLOW, "danger")); + await expect( + page.getByRole("button", { name: "Rename channel" }), + ).toBeEnabled(); + + // Out of band, so this page's forms are the ones rendered before it started. + const started = await page.request.post(`${baseUrl}/api/ops/sync`, { + headers: { authorization: "Bearer test-worker-token" }, + data: { slug: SLOW }, + }); + expect(started.ok()).toBe(true); + await running(page, SLOW); + + // --- the server refuses, in the sentence every other surface says --------- + await page.getByLabel("new slug").fill("renamed-slow"); + await page.getByLabel("confirm current slug").fill(SLOW); + await page.getByRole("button", { name: "Rename channel" }).click(); + // `.first()`: the refusal is rendered by the form's own error paragraph, and + // a re-render would put the same sentence in the status above it too. + await expect(page.getByText(/before renaming it\./).first()).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/channels/${SLOW}(\\?|$)`)); + + await page.getByLabel("confirm slug to delete").fill(SLOW); + await page.getByRole("button", { name: "Delete channel" }).click(); + await expect(page.getByText(/before deleting it\./).first()).toBeVisible(); + // Nothing was deleted: the channel page still exists. + expect( + (await page.request.get(`${baseUrl}/channels/${SLOW}`)).status(), + ).toBe(200); + + // --- and a fresh render says so before either form is filled in ----------- + await page.goto(channelStage(SLOW, "danger")); + await expect(page.getByLabel("rename blocked")).toContainText( + /before renaming it\./, + ); + await expect(page.getByLabel("delete blocked")).toContainText( + /before deleting it\./, + ); + await expect( + page.getByRole("button", { name: "Rename channel" }), + ).toBeDisabled(); + await expect( + page.getByRole("button", { name: "Delete channel" }), + ).toBeDisabled(); +});