Archilyzer · Source

archilyzer

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

commit 5cef97da786ba04872c24c9e8f13713231df0e3f
parent 46801ea4dca7abc8c3cfd2e7968210f104dd3514
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 17 Sep 2026 13:49:01 -0400

relocate: tests for the two halves of the omnimirror refusal

The mid-run marker guard gets its own file for the settings seam getPaths()
memoizes — the videoOperations.test.ts arrangement. The batch runs the real
backfill lane over two seeded videos whose only classification is
`missing-input`, because the guard sits BEFORE the classification and what the
classification answers only has to be deterministic.

setProgress is the seam for "a relocation started mid-run": it fires once to
seed the bar and once per resolved candidate, so the second call is exactly
the window between the first video and the second pull. A control case asserts
both videos are reached when no marker appears.

The verify pair runs at phase swap, where the re-verify runs with no `rsync -a`
ahead of it — the only shape that can tell a settled timestamp from a copied
one. A file written into the source after the copy bumps its directory mtime
TOO, so the refusal case proves one non-directory line is enough.

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

Diffstat:
Acommon/controller/operationBatchRelocation.test.ts | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/relocateChannelMedia.test.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 223 insertions(+), 0 deletions(-)

diff --git a/common/controller/operationBatchRelocation.test.ts b/common/controller/operationBatchRelocation.test.ts @@ -0,0 +1,132 @@ +// THE BATCH'S FIFTH GUARD: a relocation that starts AFTER the run did. +// +// Run with: +// node_modules/.bin/tsx --test common/controller/operationBatchRelocation.test.ts +// +// Its own file because it needs the SETTINGS SEAM — the diarization entry is +// only in the backfill lane when settings say so, and getPaths() memoizes its +// first answer at module scope, so TRANSCRIPTS_DIR and SETTINGS_FILE must be +// set before anything can import the module under test. Same arrangement as +// videoOperations.test.ts and laneForOperation.test.ts. +// +// WHAT IT PINS. The reachability guard at the top of runOperationBatch runs +// ONCE, at start. The omnimirror incident (2026-09-13) is what that misses: the +// relocate copy began while an operation batch was already running, and a unit +// writing into `data/` mid-rsync left the copy's verify refusing. The marker is +// now re-read on every candidate pull, and finding one ENDS the run. + +import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +const ROOT = mkdtempSync(path.join(os.tmpdir(), "batch-relocation-")); +process.env.TRANSCRIPTS_DIR = ROOT; +const SETTINGS_FILE = path.join(ROOT, "settings.json"); +process.env.SETTINGS_FILE = SETTINGS_FILE; + +// The backfill lane armed with ONE operation, and that operation's cheapest +// classification: every seeded video has a transcript and no audio, so +// diarization reads `missing-input` — counted, never dispatched, and (with +// re-download off) needing no engine, no model and no network. The guard under +// test lives BEFORE the classification, so what the classification answers only +// has to be deterministic, not interesting. +writeFileSync( + SETTINGS_FILE, + JSON.stringify({ + backfill: { enabled: true, allowRedownload: false, concurrency: 1 }, + diarization: { + enabled: true, + segModel: "/models/seg-1.onnx", + embModel: "/models/emb-1.onnx", + }, + attribution: { enabled: false, diarizedEnabled: false, textOnlyEnabled: false }, + }), + "utf8", +); + +const { runOperationBatch } = await import("./operationBatch"); +const { getPaths } = await import("../lib/paths"); +const { relocationMarkerPath } = await import("../lib/channelMedia"); + +after(() => rm(ROOT, { recursive: true, force: true })); + +async function seed(slug: string, ids: string[]): Promise<void> { + const channelDir = path.join(getPaths().channelsDir, slug); + await mkdir(channelDir, { recursive: true }); + await writeFile( + path.join(channelDir, "config.json"), + JSON.stringify({ handling: "transcribe", url: "https://example.com/c" }), + ); + for (const id of ids) { + const videoDir = path.join(channelDir, "data", id); + await mkdir(videoDir, { recursive: true }); + // Transcribed (so diarization is applicable) and no audio (so its input is + // missing). A non-empty transcription array keeps it out of + // `isUntranscribable`. + await writeFile( + path.join(videoDir, "transcript.json"), + JSON.stringify({ transcription: [{ text: "hello", offsets: {} }] }), + ); + } +} + +test("every candidate is classified when no relocation is in flight", async () => { + await seed("quiet", ["v1", "v2"]); + const lines: string[] = []; + const result = await runOperationBatch({ + lane: "backfill", + channelSlug: "quiet", + paths: getPaths(), + operationIds: ["diarization"], + onLog: (m) => lines.push(m), + }); + // The control for the case below: both videos are reached and counted. + assert.equal(result.missingInput, 2); + assert.equal(result.attempted, 0); + assert.ok(!lines.some((l) => l.includes("Stopping: a relocation"))); +}); + +test("a marker written mid-run ends the batch instead of racing the copy", async () => { + await seed("omni", ["v1", "v2"]); + const marker = relocationMarkerPath(getPaths(), "omni"); + + // setProgress fires once to seed the bar and then once per resolved + // candidate, so the SECOND call is the instant after the first video was + // classified and before the second is pulled — exactly the window a relocate + // job starts in. Written synchronously, because that is how the disk would + // have changed by the time the next pull reads it. + let progressCalls = 0; + const lines: string[] = []; + const result = await runOperationBatch({ + lane: "backfill", + channelSlug: "omni", + paths: getPaths(), + operationIds: ["diarization"], + onLog: (m) => lines.push(m), + setProgress: () => { + progressCalls++; + if (progressCalls !== 2) return; + writeFileSync( + marker, + JSON.stringify({ + target: "/platter/omni/data", + direction: "out", + startedAt: new Date().toISOString(), + phase: "copy", + }), + "utf8", + ); + }, + }); + + // ONE video was classified, not two: the run stopped at the next pull rather + // than working its way through the rest of the channel while rsync copied it. + assert.equal(result.missingInput, 1); + assert.equal(result.attempted, 0); + const stop = lines.find((l) => l.includes("Stopping: a relocation")); + assert.ok(stop, `expected a stop line, got:\n${lines.join("\n")}`); + assert.match(stop as string, /\(out\) of omni to \/platter\/omni\/data/); +}); diff --git a/common/controller/relocateChannelMedia.test.ts b/common/controller/relocateChannelMedia.test.ts @@ -898,3 +898,94 @@ test("a relative root is refused by the preview, not only by the job", async () ); }); }); + +// THE OMNIMIRROR REFUSAL (2026-09-13), in a tmpdir. +// +// A 131 GB copy landed byte-complete and the verify refused it. The whole of the +// drift was one directory timestamp: a sidecar written into `data/v4p31nz/` +// while rsync was already past that directory bumped the SOURCE directory's +// mtime and left the target's behind. `.d..t` is rsync's itemization for +// exactly that — a directory, and only its time differs — and it means nothing +// about the bytes. +// +// Both cases run at phase `swap`, where the re-verify runs on its own with no +// `rsync -a` ahead of it. That is the shape that can see the difference: in the +// copy phase the transfer itself would have set the timestamps. + +test("out @ swap: a directory timestamp is settled by one more pass, not refused", async () => { + await withTmp(async (paths, root) => { + const channelDir = await seed(paths, "alpha", { + v1: { "audio.m4a": "one".repeat(500), "transcript.json": "{}" }, + v2: { "audio.m4a": "two".repeat(500) }, + }); + const target = relocatedDataDir(root, "alpha"); + await copyTree(path.join(channelDir, "data"), target); + // The echo of the sidecar: the target's copy of v1/ carries a different + // mtime from the source's. Every file underneath is identical. + await utimes(path.join(target, "v1"), MTIME, MTIME); + await seedMarker(paths, "alpha", { target, direction: "out", phase: "swap" }); + + const lines: string[] = []; + const res = await relocateChannelMedia({ + paths, + slug: "alpha", + direction: "out", + root, + onLog: (m) => lines.push(m), + }); + + assert.equal(res.retried, true); + assert.equal(res.resumed, true); + assert.ok( + lines.some((l) => l.includes("directory timestamp")), + "the extra pass is announced in the job log", + ); + // And the move completed: link, config, no leftovers, no marker. + assert.equal((await inspectChannelMedia(paths, "alpha")).status, "ok"); + assert.equal((await readChannelConfig(paths, "alpha"))?.dataDir, target); + assert.deepEqual(await leftoverCopies(channelDir), []); + assert.equal(await readRelocationMarker(paths, "alpha"), null); + assert.equal( + await readFile(path.join(target, "v1", "transcript.json"), "utf8"), + "{}", + ); + }); +}); + +test("out @ swap: a file the target is missing is still a refusal", async () => { + await withTmp(async (paths, root) => { + const channelDir = await seed(paths, "alpha", { + v1: { "audio.m4a": "one".repeat(500) }, + }); + const dataDir = path.join(channelDir, "data"); + const target = relocatedDataDir(root, "alpha"); + await copyTree(dataDir, target); + // CONTENT drift, arriving the same way the timestamp did — a sidecar + // written into the source after the copy. It bumps v1/'s mtime too, so the + // drift list is a `.d..t` line AND a file line: one non-directory line is + // all it takes, and the retry must not swallow it. + await writeFile(path.join(dataDir, "v1", "diarization.json"), "{}"); + await seedMarker(paths, "alpha", { target, direction: "out", phase: "swap" }); + + await assert.rejects( + () => + relocateChannelMedia({ + paths, + slug: "alpha", + direction: "out", + root, + onLog: () => {}, + }), + /The source has NOT been touched/, + ); + + // Nothing committed: `data/` is still the real directory, holding both files. + assert.ok((await lstat(dataDir)).isDirectory()); + assert.ok(!(await lstat(dataDir)).isSymbolicLink()); + assert.equal( + await readFile(path.join(dataDir, "v1", "audio.m4a"), "utf8"), + "one".repeat(500), + ); + assert.equal((await readChannelConfig(paths, "alpha"))?.dataDir, undefined); + }); +});