feat(validation): manual override for a failed run
Deploy / deploy (push) Successful in 6m13s

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>
This commit is contained in:
twotalesanimation
2026-08-14 11:19:45 +02:00
parent af810258e6
commit e66e4868ea
6 changed files with 236 additions and 3 deletions
@@ -1,8 +1,9 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, RotateCcw, XCircle } from "lucide-react";
import { ArrowLeft, RotateCcw, XCircle, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
@@ -105,6 +106,9 @@ const CHECK_STYLES: Record<string, string> = {
export function ExportDetailClient({ exportId }: { exportId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [overrideOpen, setOverrideOpen] = useState(false);
const [overrideReason, setOverrideReason] = useState("");
const [overriding, setOverriding] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["pipeline-export", exportId],
@@ -125,6 +129,31 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
}
async function overrideValidation() {
setOverriding(true);
try {
const res = await fetch(`/api/render/exports/${exportId}/override-validation`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: overrideReason }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
toast({ title: "Override failed", description: body.error ?? res.statusText, variant: "destructive" });
return;
}
toast({
title: "Validation overridden",
description: `${(body.overridden ?? []).join(", ") || "No failed checks recorded"} — continuing to preview and QC`,
});
setOverrideOpen(false);
setOverrideReason("");
queryClient.invalidateQueries({ queryKey: ["pipeline-export", exportId] });
} finally {
setOverriding(false);
}
}
if (isLoading) return <div className="p-6 text-zinc-500">Loading…</div>;
if (error || !data) {
return (
@@ -178,6 +207,11 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
<RotateCcw className="h-4 w-4 mr-2 text-amber-400" /> Retry
</Button>
)}
{e.status === "VALIDATION_FAILED" && !overrideOpen && (
<Button variant="outline" size="sm" onClick={() => setOverrideOpen(true)}>
<ShieldAlert className="h-4 w-4 mr-2 text-amber-400" /> Override validation
</Button>
)}
{ACTIVE_STATUSES.includes(e.status) && (
<Button variant="outline" size="sm" onClick={() => action("cancel")}>
<XCircle className="h-4 w-4 mr-2 text-red-400" /> Cancel
@@ -186,6 +220,48 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
</div>
</div>
{/* Override — a human overruling the checks, so the reason is required
and kept on the export's timeline. */}
{overrideOpen && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-3">
<div className="text-sm text-amber-200">
Override the failed validation for {e.shot.shotCode} {e.versionString}?
</div>
<p className="text-xs text-zinc-400">
The render continues to preview and then QC — it is not delivered directly, and the failed
checks stay on record and still show up in the QC queue. Say why, so the next person
reading this export knows what was waved through.
</p>
<textarea
value={overrideReason}
onChange={(ev) => setOverrideReason(ev.target.value)}
rows={2}
autoFocus
placeholder="e.g. plate unreachable from the render node; timecode checked by hand against the EDL"
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-200 placeholder:text-zinc-600"
/>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={overrideValidation}
disabled={overrideReason.trim().length < 3 || overriding}
>
{overriding ? "Overriding…" : "Override and continue"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setOverrideOpen(false);
setOverrideReason("");
}}
>
Cancel
</Button>
</div>
</div>
)}
{/* Manifest */}
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white">Manifest</div>
@@ -227,6 +303,13 @@ export function ExportDetailClient({ exportId }: { exportId: string }) {
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
<div className="px-4 py-2.5 border-b border-zinc-800 text-sm font-medium text-white flex items-center gap-3">
Validation
{/* A waved-through failure must stay visible long after the event
scrolls down the timeline. */}
{e.events.some((ev) => ev.note?.startsWith("Validation overridden")) && (
<span className="rounded-full border border-amber-500/30 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-400">
overridden
</span>
)}
{(() => {
const latestRun = e.validations[e.validations.length - 1].renderJobId;
const rows = e.validations.filter((v) => v.renderJobId === latestRun);
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { toErrorResponse } from "@/lib/render-pipeline/errors";
import { requirePipelineAdmin } from "@/lib/render-pipeline/session-auth";
import { overrideValidation, overrideValidationSchema } from "@/lib/render-pipeline/validation";
// ── POST /api/render/exports/{exportId}/override-validation ──────────────────
//
// A human overruling a failed validation run. Resumes the pipeline at the
// preview stage, so the export still goes through QC; the failed checks stay on
// record and keep showing up as warnings in the QC queue.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ exportId: string }> }
) {
try {
// Waving a failed check through is a supervisor's call, not any user's.
const user = await requirePipelineAdmin();
const { exportId } = await params;
let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const parsed = overrideValidationSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ error: "A reason is required", details: parsed.error.issues }, { status: 422 });
}
const result = await overrideValidation(exportId, {
reason: parsed.data.reason,
userId: user.id,
userName: user.name ?? user.email ?? null,
});
return NextResponse.json(result);
} catch (err) {
const { body, status } = toErrorResponse(err);
return NextResponse.json(body, { status });
}
}