41ee826700
Deploy / deploy (push) Successful in 6m11s
Overriding a failed validation queued the preview at priority 50 regardless. It took its rank from the export's last PREVIEW_ONLY job — but validation runs before the preview stage, so a VALIDATION_FAILED export has never had one, and the missing row fell through to a hardcoded normal priority. An urgent shot lost its urgency at exactly the moment someone was standing over it waiting. Every preview now derives its rank from the export's AE_RENDER priority rather than from a previous preview. That fixes the missing-row case and also removes a slow drift: a preview's priority is already boosted, so re-deriving from it moved an export a step up the queue on every retry. createPreviewJob takes the render priority and the attempt number, so the first build, a retry, a regenerate and an override all rank identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
import { Prisma, ValidationStatus } from "@prisma/client";
|
|
import { z } from "zod";
|
|
import { db } from "@/lib/db";
|
|
import { PipelineError } from "./errors";
|
|
import { applyTransition } from "./transitions";
|
|
import { getConfigBoolean, getConfigNumber, getConfigValue, getPreviewConfig } from "./config";
|
|
import { buildPreviewManifest, createPreviewJob, inheritPreviewMedia, renderPriorityFor } from "./preview";
|
|
|
|
/**
|
|
* Validation engine results (RenderPipeline2 §8). The worker runs the checks —
|
|
* it has the files — and reports every result in one call. The server decides
|
|
* what they mean: it is the only thing that moves an Export's state.
|
|
*/
|
|
|
|
export const validationCheckSchema = z.object({
|
|
checkName: z.string().min(1).max(64),
|
|
status: z.enum(["PASS", "FAIL", "WARN", "SKIPPED"]),
|
|
expected: z.string().max(500).optional().nullable(),
|
|
actual: z.string().max(500).optional().nullable(),
|
|
message: z.string().max(2000).optional().nullable(),
|
|
details: z.unknown().optional(),
|
|
});
|
|
|
|
export const validationReportSchema = z.object({
|
|
machineId: z.string().min(1),
|
|
checks: z.array(validationCheckSchema).min(1).max(200),
|
|
/** Sequence-level digest reused by the delivery builder for copy verification. */
|
|
checksum: z.string().max(200).optional(),
|
|
checksumAlgo: z.string().max(32).optional(),
|
|
exrFileCount: z.number().int().nonnegative().optional(),
|
|
exrTotalBytes: z.number().nonnegative().optional(),
|
|
validationSeconds: z.number().nonnegative().optional(),
|
|
});
|
|
|
|
export type ValidationReport = z.infer<typeof validationReportSchema>;
|
|
|
|
/**
|
|
* Per-check severity overrides, e.g. `{"exr_type":"warn"}` (§17.3 risk row).
|
|
* A false FAIL blocks an artist, so a check can be demoted without a redeploy.
|
|
* `timecode_plate_match` is deliberately overridable too — but demoting it
|
|
* should be a considered, temporary act: it is the check the engine exists for.
|
|
*/
|
|
async function getSeverityOverrides(): Promise<Record<string, ValidationStatus>> {
|
|
const raw = await getConfigValue("validation.overrides");
|
|
if (!raw?.trim()) return {};
|
|
try {
|
|
const parsed = JSON.parse(raw) as Record<string, string>;
|
|
const out: Record<string, ValidationStatus> = {};
|
|
for (const [check, level] of Object.entries(parsed)) {
|
|
const up = String(level).toUpperCase();
|
|
if (up === "PASS" || up === "FAIL" || up === "WARN" || up === "SKIPPED") {
|
|
out[check] = up as ValidationStatus;
|
|
}
|
|
}
|
|
return out;
|
|
} catch {
|
|
console.error("[render-pipeline] validation.overrides is not valid JSON — ignoring");
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function summarise(checks: { checkName: string; status: ValidationStatus }[]): string {
|
|
const counts = { PASS: 0, FAIL: 0, WARN: 0, SKIPPED: 0 };
|
|
for (const c of checks) counts[c.status] += 1;
|
|
return `${counts.PASS} passed, ${counts.FAIL} failed, ${counts.WARN} warned, ${counts.SKIPPED} skipped`;
|
|
}
|
|
|
|
/**
|
|
* E12 — the worker reports every check from one validation run.
|
|
*
|
|
* Any FAIL stops the pipeline (§8.2): Export → VALIDATION_FAILED with the exact
|
|
* reasons queryable as rows. Otherwise the preview stage is queued, exactly as
|
|
* a successful render used to do directly.
|
|
*/
|
|
export async function reportValidation(jobId: string, body: ValidationReport) {
|
|
const overrides = await getSeverityOverrides();
|
|
const previewEnabled = (await getPreviewConfig()).enabled;
|
|
|
|
const effective = body.checks.map((c) => ({
|
|
...c,
|
|
status: (overrides[c.checkName] ?? c.status) as ValidationStatus,
|
|
overridden: overrides[c.checkName] != null && overrides[c.checkName] !== c.status,
|
|
}));
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const job = await tx.renderJob.findUnique({
|
|
where: { id: jobId },
|
|
include: { export: true },
|
|
});
|
|
if (!job) throw new PipelineError(404, "Render job not found");
|
|
if (job.machineId !== body.machineId) {
|
|
throw new PipelineError(409, "Job is not held by this machine (lease reassigned?)");
|
|
}
|
|
const exportRow = job.export;
|
|
if (!exportRow) throw new PipelineError(422, "Job has no export to validate");
|
|
|
|
// Replayed report (worker retried the call after a network cut): the rows
|
|
// for this attempt already exist, so don't duplicate them or re-transition.
|
|
const existing = await tx.validationResult.count({
|
|
where: { exportId: exportRow.id, renderJobId: job.id },
|
|
});
|
|
if (existing > 0) {
|
|
return {
|
|
export: { id: exportRow.id, status: exportRow.status },
|
|
results: existing,
|
|
failed: exportRow.status === "VALIDATION_FAILED",
|
|
alreadyApplied: true,
|
|
};
|
|
}
|
|
|
|
if (exportRow.status !== "VALIDATING") {
|
|
throw new PipelineError(
|
|
409,
|
|
`Export is ${exportRow.status} — validation results are only accepted while VALIDATING`
|
|
);
|
|
}
|
|
|
|
// The render job was held open through validation (see completeRender) so a
|
|
// worker dying here is still reaped; the work is finished now.
|
|
await tx.renderJob.update({
|
|
where: { id: job.id },
|
|
data: { status: "COMPLETED", progress: 1, finishedAt: new Date() },
|
|
});
|
|
|
|
await tx.validationResult.createMany({
|
|
data: effective.map((c) => ({
|
|
exportId: exportRow.id,
|
|
renderJobId: job.id,
|
|
checkName: c.checkName,
|
|
status: c.status,
|
|
expected: c.expected ?? null,
|
|
actual: c.actual ?? null,
|
|
message: c.overridden
|
|
? `${c.message ?? "check reported " + c.status} [severity overridden by config]`
|
|
: (c.message ?? null),
|
|
details: (c.details ?? undefined) as Prisma.InputJsonValue | undefined,
|
|
})),
|
|
});
|
|
|
|
const failures = effective.filter((c) => c.status === "FAIL");
|
|
const stats = {
|
|
...(body.exrFileCount != null ? { exrFileCount: body.exrFileCount } : {}),
|
|
...(body.exrTotalBytes != null ? { exrTotalBytes: BigInt(Math.round(body.exrTotalBytes)) } : {}),
|
|
...(body.checksum ? { checksum: body.checksum } : {}),
|
|
};
|
|
const summary = summarise(effective);
|
|
|
|
if (failures.length > 0) {
|
|
const reasons = failures
|
|
.slice(0, 5)
|
|
.map((f) => `${f.checkName}: ${f.message ?? `expected ${f.expected}, got ${f.actual}`}`)
|
|
.join("; ");
|
|
const updated = await applyTransition(
|
|
tx,
|
|
exportRow,
|
|
"VALIDATION_FAILED",
|
|
{
|
|
type: "WORKER",
|
|
id: body.machineId,
|
|
note: `Validation failed (${summary}) — ${reasons}${failures.length > 5 ? ` (+${failures.length - 5} more)` : ""}`,
|
|
},
|
|
stats
|
|
);
|
|
return {
|
|
export: { id: updated.id, status: updated.status },
|
|
results: effective.length,
|
|
failed: true,
|
|
failures: failures.map((f) => f.checkName),
|
|
alreadyApplied: false,
|
|
};
|
|
}
|
|
|
|
// Passed: hand off to the preview stage, the same handoff completeRender
|
|
// used to make directly before validation existed.
|
|
if (!previewEnabled || exportRow.skipPreview) {
|
|
const inherited = exportRow.skipPreview
|
|
? await inheritPreviewMedia(tx, exportRow)
|
|
: { data: {}, note: "preview stage disabled" };
|
|
const updated = await applyTransition(
|
|
tx,
|
|
exportRow,
|
|
"READY_FOR_QC",
|
|
{ type: "WORKER", id: body.machineId, note: `Validation passed (${summary}); ${inherited.note}` },
|
|
{ ...stats, ...inherited.data }
|
|
);
|
|
return {
|
|
export: { id: updated.id, status: updated.status },
|
|
results: effective.length,
|
|
failed: false,
|
|
previewJob: null,
|
|
alreadyApplied: false,
|
|
};
|
|
}
|
|
|
|
const manifest = await buildPreviewManifest(exportRow);
|
|
const previewJob = await createPreviewJob(tx, exportRow, manifest, job.maxAttempts, job.priority);
|
|
const updated = await applyTransition(
|
|
tx,
|
|
exportRow,
|
|
"GENERATING_PREVIEW",
|
|
{ type: "WORKER", id: body.machineId, note: `Validation passed (${summary}) — queued preview build` },
|
|
stats
|
|
);
|
|
|
|
return {
|
|
export: { id: updated.id, status: updated.status },
|
|
results: effective.length,
|
|
failed: false,
|
|
previewJob: { id: previewJob.id, type: previewJob.type },
|
|
alreadyApplied: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Whether the validation stage runs at all (kill switch for a bad engine release). */
|
|
export async function isValidationEnabled(): Promise<boolean> {
|
|
return getConfigBoolean("validation.enabled");
|
|
}
|
|
|
|
export const overrideValidationSchema = z.object({
|
|
/**
|
|
* Required, and deliberately so: an override is a human overruling a check
|
|
* that exists to stop bad frames shipping, and "why" is the only part of
|
|
* that decision the next person cannot reconstruct.
|
|
*/
|
|
reason: z.string().trim().min(3, "A reason is required").max(2000),
|
|
/**
|
|
* Go straight to QC, inheriting the previous export's review media instead
|
|
* of building one. Correct when this export is framed differently from the
|
|
* review — unscaled or un-punched-in frames — so a preview rebuilt from it
|
|
* would be the wrong picture.
|
|
*/
|
|
skipPreview: z.boolean().optional(),
|
|
});
|
|
|
|
/**
|
|
* Manual override of a failed validation run.
|
|
*
|
|
* Some checks fail on renders that are genuinely fine — a plate the engine
|
|
* cannot reach, an expectation that does not apply to this shot. Rather than
|
|
* leaving those exports stranded, an override resumes the pipeline *at the
|
|
* point validation would have handed off*: the preview is built and the export
|
|
* lands in READY_FOR_QC. It never skips QC, so a human still looks at the
|
|
* frames before anything can be delivered.
|
|
*
|
|
* The failed ValidationResult rows are left exactly as they are — history is
|
|
* not rewritten, and the QC queue keeps surfacing them as warnings so whoever
|
|
* QCs the shot knows what was waved through.
|
|
*/
|
|
export async function overrideValidation(
|
|
exportId: string,
|
|
input: { reason: string; userId: string; userName?: string | null; skipPreview?: boolean }
|
|
) {
|
|
const previewEnabled = (await getPreviewConfig()).enabled;
|
|
const maxAttempts = await getConfigNumber("render.maxAttempts");
|
|
|
|
return db.$transaction(async (tx) => {
|
|
const exportRow = await tx.export.findUnique({ where: { id: exportId } });
|
|
if (!exportRow) throw new PipelineError(404, "Export not found");
|
|
if (exportRow.status !== "VALIDATION_FAILED") {
|
|
throw new PipelineError(
|
|
409,
|
|
`Export is ${exportRow.status} — only a VALIDATION_FAILED export can be overridden`
|
|
);
|
|
}
|
|
|
|
const failed = await tx.validationResult.findMany({
|
|
where: { exportId, status: "FAIL" },
|
|
orderBy: { createdAt: "desc" },
|
|
select: { checkName: true, renderJobId: true },
|
|
});
|
|
// Only the most recent run's failures are what is being waved through.
|
|
const latestRun = failed[0]?.renderJobId;
|
|
const waved = [...new Set(failed.filter((f) => f.renderJobId === latestRun).map((f) => f.checkName))];
|
|
|
|
const who = input.userName ?? "unknown user";
|
|
const note =
|
|
`Validation overridden by ${who}: ${input.reason.trim()}` +
|
|
(waved.length ? ` — waved: ${waved.join(", ")}` : "");
|
|
|
|
const skipPreview = input.skipPreview === true || exportRow.skipPreview;
|
|
if (!previewEnabled || skipPreview) {
|
|
const inherited = skipPreview
|
|
? await inheritPreviewMedia(tx, exportRow)
|
|
: { data: {}, note: "preview stage disabled" };
|
|
const updated = await applyTransition(
|
|
tx,
|
|
exportRow,
|
|
"READY_FOR_QC",
|
|
{ type: "USER", id: input.userId, note: `${note} — ${inherited.note}` },
|
|
inherited.data
|
|
);
|
|
return {
|
|
export: { id: updated.id, status: updated.status },
|
|
overridden: waved,
|
|
previewJob: null,
|
|
};
|
|
}
|
|
|
|
const manifest = await buildPreviewManifest(exportRow);
|
|
const last = await tx.renderJob.findFirst({
|
|
where: { exportId, type: "PREVIEW_ONLY" },
|
|
orderBy: { attempt: "desc" },
|
|
select: { attempt: true },
|
|
});
|
|
// An export that failed validation has never had a preview job — validation
|
|
// runs before the preview stage — so the rank comes from its render, which
|
|
// is what keeps an urgent export urgent through an override. Someone is
|
|
// usually standing over an urgent shot when they wave a check through.
|
|
const previewJob = await createPreviewJob(
|
|
tx,
|
|
exportRow,
|
|
manifest,
|
|
maxAttempts,
|
|
await renderPriorityFor(tx, exportId),
|
|
(last?.attempt ?? 0) + 1
|
|
);
|
|
|
|
const updated = await applyTransition(tx, exportRow, "GENERATING_PREVIEW", {
|
|
type: "USER",
|
|
id: input.userId,
|
|
note: `${note} — queued preview build`,
|
|
});
|
|
|
|
return {
|
|
export: { id: updated.id, status: updated.status },
|
|
overridden: waved,
|
|
previewJob: { id: previewJob.id, attempt: previewJob.attempt },
|
|
};
|
|
});
|
|
}
|
|
|
|
export interface ValidationBrowserFilter {
|
|
projectId?: string;
|
|
episode?: string;
|
|
checkName?: string;
|
|
status?: ValidationStatus;
|
|
page?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
/**
|
|
* Cross-export browser (§13) — the point is spotting a systemic fault
|
|
* ("every shot this week failed timecode_plate_match"), not one bad render.
|
|
*/
|
|
export async function listValidationResults(filter: ValidationBrowserFilter) {
|
|
const page = Math.max(1, filter.page ?? 1);
|
|
const limit = Math.min(200, Math.max(1, filter.limit ?? 50));
|
|
const where: Prisma.ValidationResultWhereInput = {
|
|
...(filter.checkName ? { checkName: filter.checkName } : {}),
|
|
...(filter.status ? { status: filter.status } : { status: { in: ["FAIL", "WARN"] } }),
|
|
...(filter.projectId || filter.episode
|
|
? {
|
|
export: {
|
|
...(filter.projectId ? { projectId: filter.projectId } : {}),
|
|
...(filter.episode ? { shot: { episode: filter.episode } } : {}),
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
const [total, rows] = await Promise.all([
|
|
db.validationResult.count({ where }),
|
|
db.validationResult.findMany({
|
|
where,
|
|
orderBy: { createdAt: "desc" },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
include: {
|
|
export: {
|
|
select: {
|
|
id: true,
|
|
versionString: true,
|
|
status: true,
|
|
shot: { select: { shotCode: true, episode: true } },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
return {
|
|
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
|
|
results: rows.map((r) => ({
|
|
id: r.id,
|
|
checkName: r.checkName,
|
|
status: r.status,
|
|
expected: r.expected,
|
|
actual: r.actual,
|
|
message: r.message,
|
|
details: r.details,
|
|
createdAt: r.createdAt,
|
|
exportId: r.exportId,
|
|
versionString: r.export.versionString,
|
|
exportStatus: r.export.status,
|
|
shotCode: r.export.shot.shotCode,
|
|
episode: r.export.shot.episode,
|
|
})),
|
|
};
|
|
}
|