feat(web): retry a needs-attention job from The Floor

Add POST /api/requests/[id]/retry: 404 if the request/job is missing,
400 if the job isn't in needs_attention, otherwise clears the job's
stale candidates and resets it to state=requested/currentStage=intake
(error/claimedAt cleared) plus request status=pending, in a
transaction, so the worker's requested-only claim re-picks it up.
Wire a Retry button into the attention-row note on the Floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-13 23:12:03 +02:00
parent 53c0dcc256
commit 52e74f7709
3 changed files with 127 additions and 4 deletions
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { POST } from "./route";
import { prisma } from "@/lib/db";
import type { Prisma } from "@prisma/client";
function postReq(id: string) {
return new Request(`http://localhost/api/requests/${id}/retry`, { method: "POST" });
}
async function seedRequest(jobData: Prisma.JobCreateWithoutRequestInput) {
const created = await prisma.request.create({
data: {
artist: "Failed Artist",
album: "Failed Album",
status: "needs_attention",
job: { create: jobData },
},
include: { job: true },
});
return created;
}
describe("retry API", () => {
it("resets a needs_attention job to requested and deletes candidates", async () => {
const created = await seedRequest({ state: "needs_attention", currentStage: "download", attempts: 2, error: "boom" });
await prisma.candidate.create({
data: {
jobId: created.job!.id,
source: "qobuz",
format: "flac",
qualityClass: 1,
trackCount: 12,
confidence: 0.9,
sourceRef: "ref-1",
chosen: true,
},
});
await prisma.candidate.create({
data: {
jobId: created.job!.id,
source: "slskd",
format: "mp3",
qualityClass: 2,
trackCount: 12,
confidence: 0.5,
sourceRef: "ref-2",
chosen: false,
},
});
const res = await POST(postReq(created.id), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ ok: true, status: "requested" });
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
expect(job?.state).toBe("requested");
expect(job?.currentStage).toBe("intake");
expect(job?.error).toBeNull();
expect(job?.claimedAt).toBeNull();
const candidates = await prisma.candidate.findMany({ where: { jobId: created.job!.id } });
expect(candidates).toHaveLength(0);
const request = await prisma.request.findUnique({ where: { id: created.id } });
expect(request?.status).toBe("pending");
});
it("rejects retrying a job that is not needs_attention", async () => {
const created = await seedRequest({ state: "downloading", currentStage: "download" });
const res = await POST(postReq(created.id), { params: Promise.resolve({ id: created.id }) });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("only failed jobs can be retried");
const job = await prisma.job.findUnique({ where: { id: created.job!.id } });
expect(job?.state).toBe("downloading");
});
it("404s for a missing request", async () => {
const res = await POST(postReq("does-not-exist"), { params: Promise.resolve({ id: "does-not-exist" }) });
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("not found");
});
});
@@ -0,0 +1,24 @@
import { prisma } from "@/lib/db";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const req = await prisma.request.findUnique({ where: { id }, include: { job: true } });
if (!req || !req.job) {
return Response.json({ error: "not found" }, { status: 404 });
}
if (req.job.state !== "needs_attention") {
return Response.json({ error: "only failed jobs can be retried" }, { status: 400 });
}
await prisma.$transaction([
prisma.candidate.deleteMany({ where: { jobId: req.job.id } }),
prisma.job.update({
where: { id: req.job.id },
data: { state: "requested", currentStage: "intake", error: null, claimedAt: null },
}),
prisma.request.update({ where: { id }, data: { status: "pending" } }),
]);
return Response.json({ ok: true, status: "requested" });
}
+16 -4
View File
@@ -72,6 +72,14 @@ export function Queue() {
return () => clearInterval(id);
}, []);
async function retry(id: string) {
const res = await fetch(`/api/requests/${id}/retry`, { method: "POST" });
if (res.ok) {
toast("Retrying…");
refresh();
}
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!artist.trim() || !album.trim()) return;
@@ -149,10 +157,14 @@ export function Queue() {
meta = undefined;
}
const note = attention
? j.error?.trim() ||
"No source met the quality cutoff, or the pipeline stalled. Retry the request, or lower the cutoff for this release."
: undefined;
const note = attention ? (
<>
{j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "}
<button className="btn sm" onClick={() => retry(r.id)}>
Retry
</button>
</>
) : undefined;
const stageLabel =
attention || j.state === "matching" || j.state === "matched" ? undefined : j.currentStage;