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 } 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; /** * 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> { const raw = await getConfigValue("validation.overrides"); if (!raw?.trim()) return {}; try { const parsed = JSON.parse(raw) as Record; const out: Record = {}; 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) { const updated = await applyTransition( tx, exportRow, "READY_FOR_QC", { type: "WORKER", id: body.machineId, note: `Validation passed (${summary}); preview stage disabled` }, stats ); 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 { 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), }); /** * 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 } ) { 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(", ")}` : ""); if (!previewEnabled) { const updated = await applyTransition(tx, exportRow, "READY_FOR_QC", { type: "USER", id: input.userId, note: `${note} (preview stage disabled)`, }); 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" }, }); const previewJob = await tx.renderJob.create({ data: { type: "PREVIEW_ONLY", exportId, attempt: (last?.attempt ?? 0) + 1, maxAttempts, priority: last?.priority ?? 50, manifest: manifest as unknown as Prisma.InputJsonValue, }, }); 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, })), }; }