import { NextResponse } from "next/server"; import { cancelJobAction, drainJobAction, forceReleaseJobAction, promoteJobAction, retryAllFailedAction, retryJobAction, } from "../../../jobs/actions"; import { OpsInputError, oneOf, ops, opsFail, reqStringArray } from "../_lib"; export const dynamic = "force-dynamic"; // POST { verb: "cancel" | "drain" | "promote" | "force-release" | "retry", // ids: [, …] } // | { verb: "retry-failed" } // // The buttons on a /jobs row, over HTTP — each verb is the row's server action // and nothing more (`editor/app/jobs/actions.ts`), so a job cancelled here and // one cancelled by a click are the same act: // // cancel a queued job leaves its queue; a running one is stopped. // drain a running batch starts no new sub-operation and ends `done` // when the ones in flight finish; a queued job is cancelled. // promote a QUEUED job jumps to the front of its queue. // force-release the wedged-slot escape hatch: frees the job's queue slot // whatever its record says, SIGKILLing any child. // retry re-runs a job from its replay descriptor, ahead of the // queue; the new job's id is returned (`jobIds`). // retry-failed every failed job still in the registry that has one — // the /jobs page's Retry all. Takes no ids. // // Every id is acted on in turn and answered in `results` ({id, ok, jobId?, // error?}); one that could not be (an unknown id, a job no longer queued for a // promote) makes the answer `ok: false` — a 400 naming them — but never stops // the others, exactly as clicking each row would not. `jobIds` lists the jobs a // retry started, so `--wait` follows them. const VERBS = [ "cancel", "drain", "promote", "force-release", "retry", "retry-failed", ] as const; type Verb = (typeof VERBS)[number]; type Result = { id: string; ok: boolean; jobId?: string; error?: string }; // What a row action's `false` means, per verb — the action itself only says no. const REFUSED: Record, string> = { cancel: "not queued or running (or unknown)", drain: "not queued or running (or unknown)", promote: "not queued, or already at the front of its queue", "force-release": "", }; const DONE: Record, string> = { cancel: "cancelled", drain: "drained", promote: "promoted", "force-release": "released", retry: "retried", }; async function act(verb: Verb, id: string): Promise { if (verb === "retry") { const res = await retryJobAction(id); if (!res.ok) return { id, ok: false, error: res.error }; void res.stream.cancel(); return { id, ok: true, jobId: res.jobId }; } const run = { cancel: cancelJobAction, drain: drainJobAction, promote: promoteJobAction, "force-release": forceReleaseJobAction, }[verb as Exclude]; const { ok } = await run(id); return ok ? { id, ok } : { id, ok, error: REFUSED[verb as keyof typeof REFUSED] }; } export async function POST(request: Request) { return ops(request, ["verb", "ids"], async (body) => { const verb = oneOf(body, "verb", VERBS); if (verb === "retry-failed") { if (body.ids !== undefined) { throw new OpsInputError('"retry-failed" takes no "ids" — it retries every failed job'); } const { count, jobIds } = await retryAllFailedAction(); return NextResponse.json({ ok: true, verb, count, jobIds }); } const ids = [...new Set(reqStringArray(body, "ids"))]; const bad = ids.filter((id) => !/^[A-Za-z0-9_-]+$/.test(id)); if (bad.length) { throw new OpsInputError(`not a job id: ${bad.join(", ")}`); } const results: Result[] = []; for (const id of ids) results.push(await act(verb, id)); const jobIds = results.flatMap((r) => (r.jobId ? [r.jobId] : [])); const failed = results.filter((r) => !r.ok); if (failed.length) { return opsFail( `${failed.length} of ${ids.length} not ${DONE[verb]}: ${failed .map((r) => `${r.id} (${r.error})`) .join("; ")}`, 400, { verb, results, jobIds }, ); } return NextResponse.json({ ok: true, verb, results, jobIds }); }); }