Archilyzer · Source

archilyzer

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

commit 33c9ae84365c6066ab879a7a4674066520a86653
parent 78d9ef6751c8b918fff6b94f6326a880d6cb276f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 23:15:39 -0400

editor: Refresh report answers within 15 s with an error or where its job is; Update all reports answers once queued (D0 re-review R1, R2)

refreshChannelSnapshotAction waits on the job a bounded time and returns
{ error } for a failed walk (also one it found queued) or { notice } naming the
queue position and job. RefreshSnapshotButton draws both (it dropped the
result before); InlineActionButton shows the notice neutrally.
refreshAllChannelSnapshotsAction no longer waits for every walk.

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

Diffstat:
Meditor/app/channels/[slug]/components/RefreshSnapshotButton.tsx | 61++++++++++++++++++++++++++++++++++++++++++++++---------------
Meditor/app/channels/actions.ts | 92++++++++++++++++++++++++++++++++-----------------------------------------------
Meditor/app/components/actions/InlineActionButton.tsx | 7++++++-
3 files changed, 89 insertions(+), 71 deletions(-)

diff --git a/editor/app/channels/[slug]/components/RefreshSnapshotButton.tsx b/editor/app/channels/[slug]/components/RefreshSnapshotButton.tsx @@ -1,23 +1,54 @@ "use client"; -import { useTransition } from "react"; -import { refreshChannelSnapshotAction } from "../../actions"; +import { useState, useTransition } from "react"; +import { + refreshChannelSnapshotAction, + type RefreshSnapshotResult, +} from "../../actions"; +// THE CHANNEL'S "Refresh report". The regeneration runs on the serial +// refresh-report queue (release 17), so the action answers within ~15 s either +// way: nothing (the report is on disk; the page re-renders), `{ error }` (the +// walk could not run — an unmounted drive's sentence), or `{ notice }` (still +// queued: "Queued behind N report regenerations — …, job …"). Both are drawn +// under the button; they used to be dropped, so a refusal showed nothing. export function RefreshSnapshotButton({ slug }: { slug: string }) { const [pending, startTransition] = useTransition(); + const [result, setResult] = useState<RefreshSnapshotResult>(undefined); return ( - <button - type="button" - onClick={() => - startTransition(async () => { - await refreshChannelSnapshotAction(slug); - }) - } - disabled={pending} - aria-label="refresh channel report" - className="self-start px-3 py-1 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" - > - {pending ? "Refreshing…" : "Refresh report"} - </button> + <div className="flex flex-col items-start gap-1"> + <button + type="button" + onClick={() => + startTransition(async () => { + setResult(undefined); + setResult(await refreshChannelSnapshotAction(slug)); + }) + } + disabled={pending} + aria-label="refresh channel report" + className="self-start px-3 py-1 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {pending ? "Refreshing…" : "Refresh report"} + </button> + {result && "error" in result && ( + <span + role="alert" + aria-label="refresh report error" + className="text-xs text-destructive" + > + {result.error} + </span> + )} + {result && "notice" in result && ( + <span + role="status" + aria-label="refresh report notice" + className="text-xs text-muted-foreground" + > + {result.notice} + </span> + )} + </div> ); } diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -25,12 +25,12 @@ import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { channelMediaBusyReason } from "./lib/mediaBusy"; import { REFRESH_REPORT_ACTIVE, - queuedRefreshReportId, + refreshReportWaitNotice, requestChannelSnapshot, + requestRefreshReport, startRefreshReport, + waitForRefreshReport, } from "yt-dlp-transcript-common/jobs/snapshotScheduler"; -import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; -import { drainStream } from "yt-dlp-transcript-common/jobs/drainStream"; import { siteChannelIndex, type Site, @@ -349,19 +349,13 @@ export async function gotoVideoAction( redirect(`/channels/${slug}/videos/${encodeURIComponent(id)}`); } -// Poll a registry job until it is terminal (or gone from the registry). The -// registry hands out no completion promise for a job someone else started. -async function waitForJobToEnd(id: string): Promise<void> { - for (;;) { - const job = getRegistry().get(id); - if (!job || (job.status !== "queued" && job.status !== "running")) return; - await new Promise((resolve) => setTimeout(resolve, 250)); - } -} +// What a channel's Refresh report answers: nothing when the report is on disk, +// `{ error }` when it could not be made, `{ notice }` when it is still queued. +export type RefreshSnapshotResult = ActionResult | { notice: string }; export async function refreshChannelSnapshotAction( slug: string, -): Promise<ActionResult> { +): Promise<RefreshSnapshotResult> { const paths = getPaths(); if (!(await channelExists(paths, slug))) { return { error: `Channel "${slug}" not found` }; @@ -369,34 +363,27 @@ export async function refreshChannelSnapshotAction( // THROUGH THE REFRESH-REPORT QUEUE, like every other walk (release 17 slice // D0): a walk run here, in the request, beside a queued one was two walks // side by side — half of the 2026-10-01 outage — and could land an older - // read over a newer one. The action still answers when the report is on - // disk: it waits for the job it started, or for the one already queued for - // this channel (which has not started reading, so it is as fresh). + // read over a newer one. The job is the one this click starts, or the one + // already queued for the channel (it has not started reading, so it is as + // fresh). // - // generateChannelSnapshot THROWS on a channel whose media is not reachable - // (guard 3) rather than writing a snapshot that says every video is - // undownloaded. The job fails with that sentence in its log; it comes back - // here through `onError`, because an uncaught throw from a server action - // reaches the client as a digest-only "an error occurred", and the one thing - // the operator needs is the sentence naming the unmounted drive. Every - // sibling in this file returns { error }; so does this. - let failure: string | null = null; - const started = await startRefreshReport(paths, slug, { - onError: (message) => { - failure = message; - }, - }); - if (started.ok) { - const { status } = await started.done; - if (status !== "done") { - return { error: failure ?? `Refresh report ${status} (job ${started.jobId})` }; - } - } else if (!started.info) { - return { error: started.error }; - } else { - const queued = queuedRefreshReportId(slug); - if (queued) await waitForJobToEnd(queued); - } + // A BOUNDED WAIT (REFRESH_REPORT_WAIT_MS, 15 s). The queue is serial: behind + // a 3,000-video walk, or during Update all reports, this channel's report may + // be minutes away. Past the bound the action answers with where the job is + // ("Queued behind 3 report regenerations — …, job …") and the page catches + // up when it runs (the scheduler's generation moves /api/pulse). + // + // A FAILED WALK IS AN ERROR, whoever started it: generateChannelSnapshot + // throws on a channel whose media is not reachable (guard 3) rather than + // writing a snapshot that says every video is undownloaded, and that + // sentence — the job log's `[error]` line — is what comes back, not a + // digest-only "an error occurred". Every sibling in this file returns + // { error }; so does this. + const requested = await requestRefreshReport(paths, slug); + if (!requested.ok) return { error: requested.error }; + const wait = await waitForRefreshReport(paths, requested.jobId); + if (wait.state === "failed") return { error: wait.error }; + if (wait.state === "waiting") return { notice: refreshReportWaitNotice(wait) }; revalidatePath(`/channels/${slug}`); // The Report column on /channels is read off this snapshot, and the row // action sits next to the marker it flips — so revalidate the list too, not @@ -416,10 +403,8 @@ export type RefreshAllResult = { // `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.) + // the response arrived. The action answers once the jobs are QUEUED (release + // 17: they run one at a time), so the ids are what `--wait` follows. jobIds: string[]; }; @@ -429,7 +414,6 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu const queued: string[] = []; const jobIds: string[] = []; const skipped: { slug: string; reason: string }[] = []; - const streams: ReadableStream<string>[] = []; for (const c of channels) { // The scheduler's one entry point: the refresh-report queue (one // regeneration at a time — they used to run all at once, in parallel) and @@ -438,22 +422,20 @@ export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResu if (!result.ok) { skipped.push({ slug: c.slug, - reason: result.error === REFRESH_REPORT_ACTIVE ? "already running" : result.error, + reason: result.error === REFRESH_REPORT_ACTIVE ? "already queued" : result.error, }); continue; } + // Nothing reads the stream (the log is on disk). + void result.stream.cancel(); queued.push(c.slug); jobIds.push(result.jobId); - streams.push(result.stream); } - // Wait for all snapshots to finish writing before revalidating so the - // pages that read the snapshots read fresh counts. They run one at a time, - // so this is the sum of them. - await Promise.all(streams.map(drainStream)); - revalidatePath("/channels"); - revalidatePath("/operations/[id]", "page"); - revalidatePath("/cleanup"); - revalidatePath("/"); + // ANSWERS ONCE QUEUED, NOT ONCE DONE. The regenerations run one at a time, + // so waiting for them was waiting for every channel's walk added up — + // minutes on a real corpus — behind a button and an ops call that time out. + // The pages catch up as each lands: every regeneration moves the + // scheduler's generation, which /api/pulse carries. return { queued, jobIds, skipped }; } diff --git a/editor/app/components/actions/InlineActionButton.tsx b/editor/app/components/actions/InlineActionButton.tsx @@ -110,7 +110,12 @@ async function runAction(variant: Variant): Promise<StreamActionResult> { if (result && "error" in result) { return { ok: false, error: result.error }; } - // refreshReport runs no managed job, so synthesize an already-complete result. + // Still queued after the action's bounded wait: a neutral line, not a red one. + if (result && "notice" in result) { + return { ok: false, error: result.notice, info: true }; + } + // The report is on disk (the action waited for its job), so synthesize an + // already-complete result. return { ok: true, jobId: "",