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, 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 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, })), }; }