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:
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user