commit 90734588094312cfbe51122267dc11089c55a459
parent 2c1c750bcea44dc0fa2b828f58cc1f3c9f1ed9ed
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 10:53:21 -0400
editor(e2e): channel-storage — a move refused at its start over a running job, the hold on the rack and the Storage panel, a resume over a stale scratch dir, Reconcile and resume
/api/test/stuck-job takes `slug` and `task`: the fake job then runs on a
channel, transcribing one video — the writer the move must name.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 245 insertions(+), 1 deletion(-)
diff --git a/editor/app/api/test/stuck-job/route.ts b/editor/app/api/test/stuck-job/route.ts
@@ -27,6 +27,12 @@ export async function GET(request: Request) {
if (denied) return denied;
const url = new URL(request.url);
const queueKey = url.searchParams.get("queue") || "stuck-queue";
+ // `slug` (+ `task`): the fake job runs ON A CHANNEL, transcribing one video —
+ // a writer the media move must refuse over and name (release 16 slice RM's
+ // relocate e2e). Without them the record is the slug-less holder it always
+ // was.
+ const slug = url.searchParams.get("slug") || undefined;
+ const task = url.searchParams.get("task") || undefined;
const registry = getRegistry();
const paths = getPaths();
@@ -36,6 +42,7 @@ export async function GET(request: Request) {
id,
kind: "whisper-all",
queueKey,
+ ...(slug ? { channelSlug: slug } : {}),
status: "queued",
queuedAt: Date.now(),
logPath,
@@ -47,7 +54,9 @@ export async function GET(request: Request) {
// and clear tasks so it reads as an idle, long-held slot → "possibly-stalled".
registry.enqueue(record, { start: () => {}, onCancel: () => {} });
record.startedAt = Date.now() - 20 * 60 * 1000;
- record.tasks = [];
+ record.tasks = task
+ ? [{ id: task, label: task, kind: "transcribe", startedAt: Date.now() }]
+ : [];
// A log line so the page's lastLogLine tail has something to show.
try {
diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts
@@ -4,6 +4,7 @@ import {
mkdir,
readdir,
rm,
+ stat,
symlink,
utimes,
writeFile,
@@ -574,6 +575,15 @@ test("Resume move finishes an interrupted move and clears its marker", async ({
),
join(target, VIDEO, "transcript.en.vtt"),
);
+ // AND A STALE SCRATCH DIR the source no longer has — the 2026-10-01 case: a
+ // transcriber's `.audio.mp3.parakeet/` copied mid-transcription, then deleted
+ // from the source when the transcription finished. The resume used to copy
+ // everything else and refuse on the counts, every time; its mirror pass
+ // (release 16 slice RM) removes it from the copy.
+ const scratch = join(target, VIDEO, ".audio.mp3.parakeet");
+ await mkdir(scratch, { recursive: true });
+ await writeFile(join(scratch, "meta.json"), "{}");
+ await writeFile(join(scratch, "win-0000.json"), "[]");
await writeFile(
resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`),
JSON.stringify(
@@ -627,6 +637,231 @@ test("Resume move finishes an interrupted move and clears its marker", async ({
`${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`,
);
expect(file.status()).toBe(200);
+ // The stale scratch dir is gone from the copy, and the copy is the source's
+ // two files and nothing else.
+ expect(await existsAbs(scratch)).toBe(false);
+ expect((await readdir(join(target, VIDEO))).sort()).toEqual([
+ "metadata.info.json",
+ "transcript.en.vtt",
+ ]);
+});
+
+// ---------------------------------------------------------------------------
+// Release 16 slice RM — a move holds the channel's writers and mirrors its copy
+// ---------------------------------------------------------------------------
+
+const OPS_AUTH = { authorization: "Bearer test-worker-token" };
+
+async function existsAbs(p: string): Promise<boolean> {
+ return stat(p).then(
+ () => true,
+ () => false,
+ );
+}
+
+// The inspect memo (5 s) and the registry, dropped the way resetData drops
+// them: a marker planted by hand after a page has rendered is otherwise read
+// as "in place" for up to five seconds.
+async function forgetCaches(): Promise<void> {
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
+}
+
+async function jobLog(
+ page: Page,
+ id: string,
+): Promise<{ status: string; content: string }> {
+ const res = await page.request.get(`${baseUrl}/api/jobs/${id}/log`);
+ return (await res.json()) as { status: string; content: string };
+}
+
+// THE 2026-09-30 SHAPE. A move asked for while the channel is quiet waits in
+// the relocation queue behind another; while it waits, a transcription starts
+// on the channel. The move used to start its copy over it. Now its first step
+// asks again and refuses, naming the job — and touches nothing.
+//
+// The holder is /api/test/stuck-job on the relocation queue, released after
+// 4 s; the writer is a fake running "Transcribe all" on this channel, on a
+// video (`?slug=&task=`). The move goes in through the ops API so the two
+// enqueues are milliseconds apart, well inside the holder's 4 s.
+test("a move that starts while a job writes into the channel refuses, naming the job", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(90_000);
+ await resetData("one-youtube-channel-with-data");
+ await generateReport(page, SLUG);
+ await quiet(page);
+ const root = testInfo.outputPath("busy-root");
+ await mkdir(root, { recursive: true });
+
+ const holder = await page.request.get(
+ `${baseUrl}/api/test/stuck-job?queue=relocate&releaseAfterMs=4000`,
+ );
+ expect(holder.ok()).toBe(true);
+ const queued = await page.request.post(`${baseUrl}/api/ops/relocate`, {
+ headers: OPS_AUTH,
+ data: { slugs: [SLUG], root },
+ });
+ expect(queued.ok()).toBe(true);
+ const { jobIds } = (await queued.json()) as { jobIds: string[] };
+ expect(jobIds).toHaveLength(1);
+ const moveId = jobIds[0];
+
+ const writer = await page.request.get(
+ `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent("rm-writer")}&slug=${SLUG}&task=${VIDEO}`,
+ );
+ expect(writer.ok()).toBe(true);
+ const writerId = ((await writer.json()) as { id: string }).id;
+
+ await expect
+ .poll(async () => (await jobLog(page, moveId)).status, { timeout: 30_000 })
+ .toBe("failed");
+ expect((await jobLog(page, moveId)).content).toContain(
+ `Cannot move the media of "${SLUG}" now: a transcription of ${VIDEO} is ` +
+ `running (Transcribe all, job ${writerId}) — wait for it or cancel it. ` +
+ `Nothing has been touched.`,
+ );
+
+ // NOTHING WAS TOUCHED: no marker, no copy, the media a real directory.
+ expect(
+ await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`),
+ ).toBe(false);
+ expect(await existsAbs(join(root, SLUG))).toBe(false);
+ expect((await lstat(dataDir())).isDirectory()).toBe(true);
+
+ // And the panel says it before anyone clicks: the move is blocked, and the
+ // sentence names the writer. `.first()`: the clip-window card on the same
+ // panel repeats the panel's blocked reason.
+ await page.goto(channelStage(SLUG, "storage"));
+ await expect(
+ page.getByText(
+ new RegExp(
+ `before moving its media\\. Now: a transcription of ${VIDEO} is running`,
+ ),
+ ).first(),
+ ).toBeVisible();
+});
+
+// THE HOLD, WHERE THE OPERATOR LOOKS. A marker stands — a move running, or one
+// interrupted — so every lane skips the channel and its media jobs refuse; the
+// rack's tier cell and the channel's Storage panel say so in one sentence, and
+// both stop saying it when the marker goes (here: Clear marker, the abandon).
+test("the rack and the Storage panel show the hold while a marker stands, and it lifts with it", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(90_000);
+ await resetData("one-youtube-channel-with-data");
+ await generateReport(page, SLUG);
+ await quiet(page);
+ const target = join(testInfo.outputPath("hold-root"), SLUG, "data");
+ await writeFile(
+ resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`),
+ JSON.stringify({
+ target,
+ direction: "out",
+ startedAt: new Date().toISOString(),
+ phase: "copy",
+ }),
+ );
+ await forgetCaches();
+
+ await page.goto("/channels");
+ const chip = page.getByLabel(`media hold for ${SLUG}`);
+ await expect(chip).toContainText("media held");
+ await expect(chip).toContainText(
+ "held: its media is moving (a move is in progress or was interrupted)",
+ );
+
+ await page.goto(channelStage(SLUG, "storage"));
+ const hold = page.getByLabel("media hold", { exact: true });
+ await expect(hold).toContainText(
+ "Held: its media is moving (a move is in progress or was interrupted)",
+ );
+ await expect(hold).toContainText(
+ "when the move completes, or its marker is cleared below",
+ );
+
+ // Abandoned: the marker goes, and the hold with it. The click is retried
+ // until the marker is gone: one that lands before hydration does nothing.
+ const clear = page.getByRole("button", { name: "Clear marker" });
+ await expect(async () => {
+ if (await clear.isVisible()) await clear.click({ timeout: 2_000 });
+ expect(
+ await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`),
+ ).toBe(false);
+ }).toPass({ timeout: 20_000 });
+ await forgetCaches();
+ await page.goto(channelStage(SLUG, "storage"));
+ await expect(page.getByLabel("media hold", { exact: true })).toHaveCount(0);
+ await page.goto("/channels");
+ await expect(page.getByLabel(`media hold for ${SLUG}`)).toHaveCount(0);
+});
+
+// RECONCILE AND RESUME — the remediation for a move whose verification failed
+// (the ruling's last bullet). The destination copy holds an extra the source
+// does not (a transcriber's scratch file) and a file that differs from the
+// source's: the job lists them by kind, makes the copy match the source, and
+// finishes the move. The operator deletes nothing by hand.
+test("Reconcile and resume settles an extra and a changed file on the destination", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(90_000);
+ await resetData("one-youtube-channel-with-data");
+ const root = testInfo.outputPath("reconcile-root");
+ const target = join(root, SLUG, "data");
+ await mkdir(join(target, VIDEO), { recursive: true });
+ // The full copy, timestamps and all, as a copy pass leaves it…
+ for (const name of ["metadata.info.json", "transcript.en.vtt"]) {
+ const src = join(dataDir(), VIDEO, name);
+ await copyFile(src, join(target, VIDEO, name));
+ const { atime, mtime } = await stat(src);
+ await utimes(join(target, VIDEO, name), atime, mtime);
+ }
+ // …then an extra, and a changed file.
+ const scratch = join(target, VIDEO, ".audio.mp3.parakeet");
+ await mkdir(scratch, { recursive: true });
+ await writeFile(join(scratch, "meta.json"), "{}");
+ await writeFile(join(target, VIDEO, "transcript.en.vtt"), "WEBVTT\n\nstale\n");
+ await writeFile(
+ resolvePath(`test-transcripts/channels/${SLUG}/.relocating.json`),
+ JSON.stringify({
+ target,
+ direction: "out",
+ startedAt: new Date().toISOString(),
+ phase: "copy",
+ }),
+ );
+
+ await quiet(page);
+ await page.goto(channelStage(SLUG, "storage"));
+ const reconcile = page.getByRole("button", { name: "Reconcile and resume" });
+ await expect(reconcile).toBeEnabled();
+ await reconcile.click();
+
+ const out = page.getByLabel("Reconcile and resume output");
+ await expect(out).toContainText("reconciled and resumed an interrupted move", {
+ timeout: 60_000,
+ });
+ // WHAT IT FOUND, BY KIND, before it changed anything.
+ await expect(out).toContainText(
+ /Reconciling: the destination copy differs from the source — \d+ extra on the destination \([^)]*\.audio\.mp3\.parakeet/,
+ );
+ await expect(out).toContainText(/changed \([^)]*transcript\.en\.vtt/);
+
+ // Finished: link, config, no marker; the copy is the source's two files.
+ expect(
+ await pathExists(`test-transcripts/channels/${SLUG}/.relocating.json`),
+ ).toBe(false);
+ expect((await lstat(dataDir())).isSymbolicLink()).toBe(true);
+ expect(await existsAbs(scratch)).toBe(false);
+ expect((await readdir(join(target, VIDEO))).sort()).toEqual([
+ "metadata.info.json",
+ "transcript.en.vtt",
+ ]);
+ const file = await page.request.get(
+ `${baseUrl}/api/channels/${SLUG}/videos/${VIDEO}/files/transcript.en.vtt`,
+ );
+ expect(file.status()).toBe(200);
+ expect(await file.text()).not.toContain("stale");
});
// A MOVE MUST NOT MATERIALISE THE MOUNTPOINT.