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:
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: "",