Archilyzer · Source

archilyzer

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

commit 425cd7102957063690c6b06e37fc07c4abda5634
parent 7207e26c33055c82c0a7edce5d7ace7af6da790e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 03:03:40 -0400

storage/ops: four things the first e2e run found

- **The memo made the page lie.** `measureTreeCached` was TTL-only, and a spec
  that seeded a container into the saved-video store then read "0 B in 0
  file(s)" off a walk taken before the seed. It is keyed by the directory's own
  mtime now, with the sixty seconds kept as a ceiling — the entry has to match
  on both. Adding or removing an entry bumps that directory's mtime, so a
  container landing, a prune, a move and an e2e reset all invalidate on the next
  render; a write deeper in the tree is what the ceiling is still there for. One
  extra `stat` per render against a recursive walk.
- **`refresh-report --all` had the `--wait` bug too.** It is a fan-out that
  starts one job per channel and its response carried no ids, so an HTTP caller
  could not tell it from an action that started nothing. `RefreshAllResult`
  gains `jobIds`, the same shape and for the same reason as `QueueOutcome`'s.
- **The move-refusal spec typed a root the panel does not offer.** With a
  location configured the Storage panel shows the select and hides the free-text
  box, so the test now picks the location BY NAME — which is the shape an
  operator meets and the one that carries the location id into the refusal.
- **The full-source spec needed transcribe handling.** `finalizeAppExtraction`
  — the step that moves `source-media.*` into the store and writes the pointer —
  runs only in app-extraction mode. The fixture channel is subtitles-first
  `youtube`, so the container stayed in the video dir and there was no pointer
  to read. Not a bug in the fetch: the persistence plan doing what it says.

And one strict-mode violation: `getByLabel("clip windows")` also matched "clip
windows bytes", which reads as a missing element rather than as two.

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

Diffstat:
Meditor/app/channels/actions.ts | 13++++++++++++-
Meditor/app/storage/lib/measureStore.ts | 37+++++++++++++++++++++++++++++--------
Meditor/e2e/channel-storage.spec.ts | 6+++++-
Meditor/e2e/fetch-window.spec.ts | 8++++++++
Meditor/e2e/storage-locations.spec.ts | 4+++-
5 files changed, 57 insertions(+), 11 deletions(-)

diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -375,6 +375,15 @@ export async function refreshChannelSnapshotAction( export type RefreshAllResult = { queued: string[]; skipped: { slug: string; reason: string }[]; + // The ids of the jobs started, parallel to `queued`. Same reason + // `QueueOutcome` grew one: without it an HTTP caller could not tell a + // fan-out that started work from an action that started none, so + // `pnpm ops refresh-report --json '{"all":true}' --wait` returned the moment + // the response arrived. (This action also AWAITS its streams, so by the time + // it answers the work is done — but the ids are what make the response + // honest about what it started, and identical in shape to every other + // fan-out's.) + jobIds: string[]; }; export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResult> { @@ -392,6 +401,7 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu .map((j) => j.channelSlug as string), ); const queued: string[] = []; + const jobIds: string[] = []; const skipped: { slug: string; reason: string }[] = []; const streams: ReadableStream<string>[] = []; for (const c of channels) { @@ -433,6 +443,7 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu continue; } queued.push(c.slug); + jobIds.push(result.jobId); streams.push(result.stream); } // Wait for all snapshots to finish writing before revalidating so the @@ -444,7 +455,7 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu revalidatePath("/operations/[id]", "page"); revalidatePath("/cleanup"); revalidatePath("/"); - return { queued, skipped }; + return { queued, jobIds, skipped }; } export async function toggleChannelBuildInclusionAction( diff --git a/editor/app/storage/lib/measureStore.ts b/editor/app/storage/lib/measureStore.ts @@ -1,3 +1,4 @@ +import { stat } from "node:fs/promises"; import { measureTree } from "yt-dlp-transcript-common/controller/relocateDir"; // THE SAVED-VIDEO STORE'S SIZE, MEASURED AT MOST ONCE EVERY 60 SECONDS. @@ -15,11 +16,22 @@ import { measureTree } from "yt-dlp-transcript-common/controller/relocateDir"; // it. A page that walks a growing directory on every refresh gets slower in // exactly the situation the operator opened it to understand. // -// SIXTY SECONDS, keyed by directory. Short enough that a move or a prune shows -// up on the next reload the operator was going to do anyway; long enough that -// an auto-refresh cycle costs one walk rather than one per tick. The store's -// SIZE is not a safety number — the location, the status and the marker are, -// and all three are read fresh every render. +// KEYED BY THE DIRECTORY'S OWN MTIME, and capped at sixty seconds. The TTL +// alone was wrong and an e2e caught it within the hour: a spec seeded a +// container into the store and the page went on reporting "0 B in 0 file(s)" +// from a walk taken before the seed. A cache that makes a page lie about a +// number the operator just changed is worse than the walk it saves. +// +// Adding or removing an entry in a directory bumps THAT directory's mtime, so +// the common cases — the first container landing, a prune, a move that replaces +// the store with a symlink, an e2e reset — all invalidate on the next render. +// A write deeper in the tree (a container added inside an existing +// `<slug>/<video>/`) does not, which is what the sixty-second ceiling is still +// there for: the entry has to match on BOTH. +// +// One extra `stat` per render against a recursive walk is the trade. The +// store's SIZE is the only thing cached; its location, status and marker are +// read fresh every render, because those are the safety facts. // // `maxEntries` caps the map because the key is a path and a re-point changes // it: without a cap a long-lived process that re-pointed repeatedly would keep @@ -36,7 +48,7 @@ export type MeasuredTree = { bytes: number; files: number }; const TTL_MS = 60_000; const MAX_ENTRIES = 32; -type Entry = { at: number; value: MeasuredTree }; +type Entry = { at: number; mtimeMs: number; value: MeasuredTree }; const cache = new Map<string, Entry>(); // Test seam, and the escape hatch for a process that has just moved the store. @@ -49,16 +61,25 @@ export async function measureTreeCached( opts: { now?: number; refresh?: boolean } = {}, ): Promise<MeasuredTree> { const now = opts.now ?? Date.now(); + // -1 when the directory is gone or unreadable: a value that can never equal a + // real mtime, so a vanished store is always re-walked rather than answered + // from a stale entry. + const mtimeMs = await stat(dir).then( + (st) => st.mtimeMs, + () => -1, + ); if (!opts.refresh) { const hit = cache.get(dir); - if (hit && now - hit.at < TTL_MS) return hit.value; + if (hit && hit.mtimeMs === mtimeMs && now - hit.at < TTL_MS) { + return hit.value; + } } const value = await measureTree(dir); // Delete before set so a refreshed key moves to the END of the insertion // order; otherwise the entry the page uses every minute would be the first // one evicted. cache.delete(dir); - cache.set(dir, { at: now, value }); + cache.set(dir, { at: now, mtimeMs, value }); while (cache.size > MAX_ENTRIES) { const oldest = cache.keys().next(); if (oldest.done) break; diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts @@ -651,7 +651,11 @@ test("a move to an unmounted root refuses before it creates anything", async ({ }); await page.goto(channelStage(SLUG, "storage")); - await page.getByLabel("destination root").fill(root); + // BY NAME, not as a typed root: with a location configured the panel offers + // the select and hides the free-text box (`custom` is false), which is the + // shape an operator actually meets — and it is the shape that carries the + // location id into the refusal. + await page.getByLabel("destination location").selectOption("cold"); await page.getByRole("button", { name: "Preview" }).click(); const alert = page.getByRole("alert"); await expect(alert).toContainText(/does not exist or is not a directory/, { diff --git a/editor/e2e/fetch-window.spec.ts b/editor/e2e/fetch-window.spec.ts @@ -336,6 +336,14 @@ test("a full-source fetch lands in the saved-video store and is cached on a seco request, }) => { test.setTimeout(90_000); + // TRANSCRIBE HANDLING, because that is what makes the container persist. + // `finalizeAppExtraction` — the step that moves `source-media.*` into the + // saved-video store and writes the pointer — runs only in app-extraction + // mode, which is `handling: "transcribe"`. The fixture channel is a + // subtitles-first `youtube` channel, so a full-source fetch there leaves the + // container in the video dir and never reaches the store. That is not a bug + // in the fetch; it is the persistence plan doing what it says. + await writeChannelConfig(SLUG, { handling: "transcribe" }); const ask = () => request.post(`${baseUrl}/api/media/fetch-window`, { headers: AUTH, diff --git a/editor/e2e/storage-locations.spec.ts b/editor/e2e/storage-locations.spec.ts @@ -594,7 +594,9 @@ test("Evict fetched windows removes an old one and leaves a recent one", async ( await writeSettings({ adminTitle: "Test Admin", minFreeDiskGB: 0 }); await page.goto("/storage"); - const card = page.getByLabel("clip windows"); + // EXACT: "clip windows bytes" is a substring match away, and a strict-mode + // violation reads as a missing element rather than as two. + const card = page.getByLabel("clip windows", { exact: true }); await expect(card).toBeVisible(); // THE LIMITATION IS ON THE PAGE, not only in a comment. An operator about to // delete a cache they cannot reference-count is entitled to read why.