Files
vfxreview/lib/render-pipeline/transitions.ts
twotalesanimation e66e4868ea
Deploy / deploy (push) Successful in 6m13s
feat(validation): manual override for a failed run
Checks fail on renders that are genuinely fine — a plate the engine
cannot reach, an expectation that does not apply to this shot — and a
VALIDATION_FAILED export was a dead end, blocking delivery on a shot a
human can see is correct.

Override validation on the Export detail page resumes the pipeline where
a pass would have handed off: preview, then READY_FOR_QC. It never skips
QC and never delivers directly, so the frames are still looked at — the
override waives the automated check, not the human one.

The failed ValidationResult rows are left untouched rather than rewritten,
so the QC queue keeps listing them as warnings and whoever QCs the shot
sees what was waved through. The reason is required and recorded with the
checks it covered on the export's timeline, and the validation panel
keeps an "overridden" badge long after the event scrolls away.

Restricted to admin/producer/supervisor: waving a failed check through is
a supervisor's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 11:19:45 +02:00

86 lines
3.4 KiB
TypeScript

import { ExportStatus, Prisma } from "@prisma/client";
import { PipelineError } from "./errors";
/**
* Export lifecycle state machine (RenderPipeline2 §4).
* The server is the sole authority on state; workers *request* transitions.
*
* RENDERING → GENERATING_PREVIEW / READY_FOR_QC are the `validation.enabled=false`
* paths (the Phase 3 kill switch, §8), not interim edges: with validation on,
* a completed render always goes RENDERING → VALIDATING first.
*/
export const EXPORT_TRANSITIONS: Record<ExportStatus, ExportStatus[]> = {
QUEUED: ["RENDERING", "CANCELLED", "SUPERSEDED", "READY_FOR_QC"], // READY_FOR_QC: manual mark-done only
RENDERING: [
"VALIDATING",
"GENERATING_PREVIEW", // validation disabled
"RENDER_FAILED",
"QUEUED", // lease expiry / retryable fail with attempts remaining (T5)
"CANCELLED",
"READY_FOR_QC", // preview + validation disabled / manual mark-done
],
RENDER_FAILED: ["QUEUED", "SUPERSEDED"],
// QUEUED/RENDER_FAILED: the worker died *during* validation, so the render is
// retried — nothing was learned about the EXRs either way (reaper).
VALIDATING: ["VALIDATION_FAILED", "GENERATING_PREVIEW", "READY_FOR_QC", "QUEUED", "RENDER_FAILED", "CANCELLED"],
// GENERATING_PREVIEW / READY_FOR_QC: a human overruled the checks (§8.2).
// The override resumes where a pass would have handed off — it never skips
// QC, so the frames are still looked at before anything ships.
VALIDATION_FAILED: ["QUEUED", "SUPERSEDED", "GENERATING_PREVIEW", "READY_FOR_QC"],
GENERATING_PREVIEW: ["PREVIEW_FAILED", "READY_FOR_QC"],
PREVIEW_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"],
// GENERATING_PREVIEW: a slate/burn-in mistake is caught *after* a successful
// preview, so the preview stage must be re-runnable without re-rendering the
// validated EXRs or burning a version number (T11b).
READY_FOR_QC: ["GENERATING_PREVIEW", "QC_FAILED", "READY_FOR_DELIVERY", "SUPERSEDED"],
QC_FAILED: ["GENERATING_PREVIEW", "SUPERSEDED"],
READY_FOR_DELIVERY: ["PACKAGED", "SUPERSEDED"],
PACKAGED: ["DELIVERED"],
DELIVERED: ["ARCHIVED"],
SUPERSEDED: [],
ARCHIVED: [],
CANCELLED: [],
};
export type TransitionActor = {
type: "WORKER" | "USER" | "SYSTEM";
id?: string | null;
note?: string | null;
};
export function isTransitionAllowed(from: ExportStatus, to: ExportStatus): boolean {
return EXPORT_TRANSITIONS[from]?.includes(to) ?? false;
}
/**
* Applies a state transition inside an existing transaction: validates
* legality, stamps statusChangedAt, appends the ExportEvent audit row.
* Throws PipelineError(409) on an illegal transition (§4.2 rules).
*/
export async function applyTransition(
tx: Prisma.TransactionClient,
exportRow: { id: string; status: ExportStatus },
to: ExportStatus,
actor: TransitionActor,
extraData: Prisma.ExportUpdateInput = {}
) {
if (!isTransitionAllowed(exportRow.status, to)) {
throw new PipelineError(409, `Invalid transition ${exportRow.status} → ${to}`);
}
const updated = await tx.export.update({
where: { id: exportRow.id },
data: { status: to, statusChangedAt: new Date(), ...extraData },
});
await tx.exportEvent.create({
data: {
exportId: exportRow.id,
fromStatus: exportRow.status,
toStatus: to,
actorType: actor.type,
actorId: actor.id ?? null,
note: actor.note ?? null,
},
});
return updated;
}