Archilyzer · Source

archilyzer

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

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:
Meditor/app/api/test/stuck-job/route.ts | 11++++++++++-
Meditor/e2e/channel-storage.spec.ts | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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.