commit 470b64ba7bf63ca7538386a70b65952694ca8265
parent c7997a0941093ab7f3dfca65d9b3f9313ce17618
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 20 Sep 2026 19:43:09 -0400
report fetch: poll the job by id, or a failure reads as success
A bare GET /api/report/fetch answers with runningJob(), which is null the
moment the job ends. So a fetch that FAILED between two polls came back as
"nothing running" — indistinguishable from done — and the sweep button counted
it and moved on. The POST already returns the job; poll that.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/umtool/components/projects/FetchUnfetchedButton.tsx b/umtool/components/projects/FetchUnfetchedButton.tsx
@@ -44,10 +44,15 @@ export default function FetchUnfetchedButton({
const total = pending.length;
- async function poll(): Promise<JobView | null> {
+ // POLLED BY ID, not by "whatever is running". A bare GET answers with
+ // `runningJob()`, which is null the moment the job ends — so a fetch that
+ // FAILED between two polls would read as nothing running, i.e. as success.
+ async function poll(id: string): Promise<JobView | null> {
for (;;) {
await new Promise((r) => setTimeout(r, 1000));
- const r = await fetch("/api/report/fetch", { cache: "no-store" });
+ const r = await fetch(`/api/report/fetch?job=${encodeURIComponent(id)}`, {
+ cache: "no-store",
+ });
const j = (await r.json()) as { job: JobView | null };
if (!j.job) return null;
if (j.job.state !== "running") return j.job;
@@ -94,7 +99,12 @@ export default function FetchUnfetchedButton({
setMsg(body.error ?? `HTTP ${res.status}`);
break;
}
- const finished = await poll();
+ if (!body.job?.id) {
+ setFailed(true);
+ setMsg("the fetch route started no job");
+ break;
+ }
+ const finished = await poll(body.job.id);
if (finished?.state === "failed") {
setFailed(true);
setMsg(`${clip.id}: ${finished.error ?? "fetch failed"}`);
diff --git a/umtool/e2e/report-fetch-via-editor.spec.ts b/umtool/e2e/report-fetch-via-editor.spec.ts
@@ -57,15 +57,22 @@ const ytdlpCalls = (): number => {
}
};
+// BY ID. A bare GET answers with `runningJob()`, which is null the moment the
+// job ends -- so a FAILED job polled a beat late reads as "nothing running",
+// which is indistinguishable from success.
async function waitForJob(
request: import("@playwright/test").APIRequestContext,
baseURL: string,
+ jobId?: string,
): Promise<{ state: string; error: string | null }> {
let last = { state: "running", error: null as string | null };
+ const url = jobId
+ ? `${baseURL}/api/report/fetch?job=${encodeURIComponent(jobId)}`
+ : `${baseURL}/api/report/fetch`;
await expect
.poll(
async () => {
- const r = await request.get(`${baseURL}/api/report/fetch`);
+ const r = await request.get(url);
const j = (await r.json()) as { job: typeof last | null };
if (j.job) last = j.job;
return j.job?.state ?? "done";
@@ -89,8 +96,9 @@ test("the fetch route asks the editor, carries the provenance, and runs no yt-dl
data: { project: PROJECT, clip: "e01", padBefore: 2, padAfter: 2 },
});
expect(post.status(), await post.text()).toBe(202);
+ const started = (await post.json()) as { job: { id: string } };
- const job = await waitForJob(request, baseURL!);
+ const job = await waitForJob(request, baseURL!, started.job.id);
expect(job.state, job.error ?? "").toBe("done");
// WHAT CROSSED THE WIRE. A window with no requester is a window nobody can