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);
|
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) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!artist.trim() || !album.trim()) return;
|
if (!artist.trim() || !album.trim()) return;
|
||||||
@@ -149,10 +157,14 @@ export function Queue() {
|
|||||||
meta = undefined;
|
meta = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = attention
|
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."
|
{j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "}
|
||||||
: undefined;
|
<button className="btn sm" onClick={() => retry(r.id)}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
const stageLabel =
|
const stageLabel =
|
||||||
attention || j.state === "matching" || j.state === "matched" ? undefined : j.currentStage;
|
attention || j.state === "matching" || j.state === "matched" ? undefined : j.currentStage;
|
||||||
|
|||||||
Reference in New Issue
Block a user