From a4f8226f3f92fa3b945456694f1352204d16a223 Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:13:44 +0200 Subject: [PATCH] feat(pipeline): report deployed tool versions from the tools themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "What is running where" was answerable only by asking someone. Each tool now reports its own version on startup into a ToolVersion registry, shown on Pipeline → Machines. The worker's version moves out of config.json and into the assembly: a hand-maintained string on each machine is exactly the thing that goes stale, and a machine reporting a version it is not running is worse than no version at all. publish.ps1 stamps the git SHA via SourceRevisionId, so a deployed exe traces back to a commit (1.1.0+feb1c7b). The panel gains PANEL_VERSION, shown in its title and every diagnostics report, and pings on load. Staleness is derived rather than declared: the newest version seen for a tool is the benchmark, so updating one machine is what flags the rest. There is no expected-version list to keep in sync — which would have been one more thing to forget. Co-Authored-By: Claude Opus 5 --- RenderWorker/README.md | 6 + RenderWorker/VFXReviewWorker/ApiClient.cs | 30 +++++ RenderWorker/VFXReviewWorker/Preflight.cs | 1 + .../VFXReviewWorker/VFXReviewWorker.csproj | 8 ++ RenderWorker/VFXReviewWorker/WorkerOptions.cs | 14 ++- RenderWorker/VFXReviewWorker/WorkerService.cs | 1 + RenderWorker/scripts/publish.ps1 | 17 ++- VFXReviewConnector.jsx | 52 ++++++++- VFXReviewConnector.md | 18 +++ .../pipeline/machines/MachinesClient.tsx | 71 ++++++++++++ app/api/ext/tools/ping/route.ts | 35 ++++++ app/api/tools/route.ts | 20 ++++ lib/render-pipeline/tools.ts | 104 ++++++++++++++++++ .../migration.sql | 23 ++++ prisma/schema.prisma | 20 ++++ 15 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 app/api/ext/tools/ping/route.ts create mode 100644 app/api/tools/route.ts create mode 100644 lib/render-pipeline/tools.ts create mode 100644 prisma/migrations/20260813010000_tool_versions/migration.sql diff --git a/RenderWorker/README.md b/RenderWorker/README.md index 7877253..cb06021 100644 --- a/RenderWorker/README.md +++ b/RenderWorker/README.md @@ -163,6 +163,12 @@ Create `C:\ProgramData\VFXReviewWorker\config.json` (§7.8): } ``` +There is no `workerVersion` key: the version is stamped into the exe at build time and +read back from the assembly, so a machine can never report a version it is not running. +`publish.ps1` appends the git commit, giving `1.1.0+feb1c7b`. Bump `` in +`VFXReviewWorker.csproj` when publishing a change; the worker reports it on startup and +**Pipeline → Machines** flags any machine still on an older one. + `pathMappings` translate the manifest's canonical UNC paths to this machine's drive mappings. `plateSearchRoots` is optional: it is only consulted when a manifest carries no timing plate (older panel versions), to find the shot's plates for the timecode check. diff --git a/RenderWorker/VFXReviewWorker/ApiClient.cs b/RenderWorker/VFXReviewWorker/ApiClient.cs index 6d570b8..b6a0f68 100644 --- a/RenderWorker/VFXReviewWorker/ApiClient.cs +++ b/RenderWorker/VFXReviewWorker/ApiClient.cs @@ -176,6 +176,36 @@ public sealed class ApiClient public Task UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) => UploadArtifactAsync(jobId, machineId, "log", logFilePath, "text/plain", ct); + /// + /// Reports the deployed worker version so the web UI can show what is + /// running where. Fire-and-forget — never worth failing a startup over. + /// + public async Task ReportVersionAsync(WorkerOptions o, CancellationToken ct) + { + try + { + await _http.PostAsJsonAsync("api/ext/tools/ping", new + { + tool = "RENDER_WORKER", + machineName = o.MachineName, + version = o.WorkerVersion, + hostUser = Environment.UserName, + hostApp = $"{Environment.OSVersion.VersionString} ({Dns.GetHostName()})", + details = new + { + aeVersion = o.AeVersion, + aerenderPath = o.AerenderPath, + ffmpeg = o.FfmpegPath is not null, + plateSearchRoots = o.PlateSearchRoots, + }, + }, Json, ct); + } + catch (Exception ex) + { + _log.LogDebug(ex, "Version ping failed (non-fatal)"); + } + } + // E3 — used by crash-recovery reconcile (§7.5) public async Task GetExportAsync(string exportId, CancellationToken ct) { diff --git a/RenderWorker/VFXReviewWorker/Preflight.cs b/RenderWorker/VFXReviewWorker/Preflight.cs index 9c77e0d..9279836 100644 --- a/RenderWorker/VFXReviewWorker/Preflight.cs +++ b/RenderWorker/VFXReviewWorker/Preflight.cs @@ -31,6 +31,7 @@ public static class Preflight return 1; } + Console.WriteLine($" worker version : {options.WorkerVersion}"); Console.WriteLine($" machine name : {options.MachineName}"); Console.WriteLine($" server : {options.ServerUrl}"); Console.WriteLine($" api key : {(string.IsNullOrWhiteSpace(options.ApiKey) ? "(missing)" : "set")}"); diff --git a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj index abccf80..6700b02 100644 --- a/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj +++ b/RenderWorker/VFXReviewWorker/VFXReviewWorker.csproj @@ -8,6 +8,14 @@ VFXReviewWorker true + + 1.1.0 diff --git a/RenderWorker/VFXReviewWorker/WorkerOptions.cs b/RenderWorker/VFXReviewWorker/WorkerOptions.cs index 35aead4..d0a9fe1 100644 --- a/RenderWorker/VFXReviewWorker/WorkerOptions.cs +++ b/RenderWorker/VFXReviewWorker/WorkerOptions.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; @@ -27,7 +28,18 @@ public sealed class WorkerOptions /// Leave empty and old manifests simply fall through to the API fallback. /// public List PlateSearchRoots { get; set; } = new(); - public string WorkerVersion { get; set; } = "1.0.0"; + /// + /// Read from the assembly, not from config.json: a hand-maintained version + /// string is exactly the thing that goes stale, and a machine reporting a + /// version it is not running is worse than no version at all. + /// Includes the git SHA when publish.ps1 stamped one ("1.1.0+feb1c7b"). + /// + public string WorkerVersion => + typeof(WorkerOptions).Assembly + .GetCustomAttribute()?.InformationalVersion + ?? typeof(WorkerOptions).Assembly.GetName().Version?.ToString() + ?? "unknown"; + public string? AeVersion { get; set; } public static string DataDir => diff --git a/RenderWorker/VFXReviewWorker/WorkerService.cs b/RenderWorker/VFXReviewWorker/WorkerService.cs index 7f6bccb..63e4f1c 100644 --- a/RenderWorker/VFXReviewWorker/WorkerService.cs +++ b/RenderWorker/VFXReviewWorker/WorkerService.cs @@ -40,6 +40,7 @@ public sealed class WorkerService : BackgroundService _ = Task.Run(() => _spooler.RunAsync(stoppingToken), stoppingToken); await RegisterWithRetryAsync(stoppingToken); + await _api.ReportVersionAsync(_options, stoppingToken); await _executor.ReconcileAsync(_machineId!, stoppingToken); _ = Task.Run(() => HeartbeatLoopAsync(stoppingToken), stoppingToken); diff --git a/RenderWorker/scripts/publish.ps1 b/RenderWorker/scripts/publish.ps1 index dfcb4a7..43fdfca 100644 --- a/RenderWorker/scripts/publish.ps1 +++ b/RenderWorker/scripts/publish.ps1 @@ -20,9 +20,22 @@ $ErrorActionPreference = "Stop" $root = Split-Path $PSScriptRoot -Parent if (-not $OutDir) { $OutDir = Join-Path $root "publish\win-x64" } +# Stamp the commit into the version so a deployed exe can be traced back to +# source ("1.1.0+feb1c7b"). Absent git, the plain version still publishes. +$sha = "" +try { $sha = (& git -C $root rev-parse --short HEAD 2>$null) } catch { } +if ($LASTEXITCODE -ne 0) { $sha = "" } + Write-Host "Publishing self-contained worker..." -ForegroundColor Cyan -& dotnet publish (Join-Path $root "VFXReviewWorker\VFXReviewWorker.csproj") ` - -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o $OutDir +$publishArgs = @( + (Join-Path $root "VFXReviewWorker\VFXReviewWorker.csproj") + "-c"; "Release"; "-r"; "win-x64" + "--self-contained"; "true" + "-p:PublishSingleFile=true" + "-o"; $OutDir +) +if ($sha) { $publishArgs += "-p:SourceRevisionId=$sha" } +& dotnet publish @publishArgs if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" } # The exe reads scripts\vfxr_build_preview.jsx from its own folder at runtime. diff --git a/VFXReviewConnector.jsx b/VFXReviewConnector.jsx index b409b06..8ce7bdf 100644 --- a/VFXReviewConnector.jsx +++ b/VFXReviewConnector.jsx @@ -18,6 +18,11 @@ var CONFIG = { var SETTINGS_GROUP = "VFXReviewConnector"; var SETTINGS_KEY = "cfg"; +// Bump on every change deployed to a workstation. The panel reports this to +// the server on load, so Pipeline → Machines shows which version each machine +// is actually running and flags the ones left behind. +var PANEL_VERSION = "2.1.0"; + (function VFXReviewConnector(thisObj) { var uiState = { projectDropdown: null, @@ -103,6 +108,48 @@ var SETTINGS_KEY = "cfg"; return Folder.userData.fsName + "/VFXReviewConnector/connector.log"; } + // ExtendScript exposes no hostname directly; $.getenv reaches the shell's. + function getHostName() { + var name = ""; + + try { + name = $.getenv("COMPUTERNAME") || $.getenv("HOSTNAME") || ""; + } catch (envError) { + name = ""; + } + return trimText(name) || "unknown-machine"; + } + + /** + * Reports this panel's version to the server so Pipeline → Machines shows + * what is deployed where. Fire-and-forget: a failure here must never stop + * the panel loading, so it is logged and dropped. + */ + function reportPanelVersion() { + var response; + + if (!trimText(CONFIG.baseUrl) || !trimText(CONFIG.token)) { + return; + } + + try { + response = postJSON(CONFIG.baseUrl + "/api/ext/tools/ping", { + tool: "AE_PANEL", + machineName: getHostName(), + version: PANEL_VERSION, + hostUser: CONFIG.artistEmail || "", + hostApp: "After Effects " + app.version, + details: { + os: String($.os).substring(0, 120), + project: CONFIG.lastProjectCode || "" + } + }); + diagLog("version ping: HTTP " + response.status); + } catch (pingError) { + diagLog("version ping failed: " + describeError(pingError)); + } + } + function padTwo(value) { return ("0" + value).slice(-2); } @@ -181,6 +228,7 @@ var SETTINGS_KEY = "cfg"; DIAG.lastFailure = null; diagWriteToFile(""); diagLog("=== " + action + " ==="); + diagLog("panel v" + PANEL_VERSION + " | " + getHostName()); diagLog("AE " + app.version + " | " + $.os + " | project " + (app.project && app.project.file ? app.project.file.fsName : "(unsaved)")); diagLog("config: " + describeConfig()); @@ -4052,7 +4100,7 @@ var SETTINGS_KEY = "cfg"; } } - title = scrollContent.add("statictext", undefined, "VFXReview Connector"); + title = scrollContent.add("statictext", undefined, "VFXReview Connector v" + PANEL_VERSION); title.alignment = ["fill", "top"]; shotBuilderPanel = scrollContent.add("panel", undefined, "Shot Builder"); @@ -4359,6 +4407,8 @@ var SETTINGS_KEY = "cfg"; var panel = buildUI(thisObj); refreshSelection(); populateProjectDropdown(); + diagLog("panel v" + PANEL_VERSION + " loaded on " + getHostName()); + reportPanelVersion(); if (panel instanceof Window) { panel.center(); diff --git a/VFXReviewConnector.md b/VFXReviewConnector.md index 85e6b6f..1581824 100644 --- a/VFXReviewConnector.md +++ b/VFXReviewConnector.md @@ -434,6 +434,24 @@ Below them are two buttons: --- +## Versions + +The panel's version is the `PANEL_VERSION` constant at the top of +`VFXReviewConnector.jsx`. It appears in the panel title, in every diagnostics +report, and is sent to the server when the panel loads. + +**Pipeline → Machines → Deployed tool versions** then shows which version each +workstation is running, alongside the render workers. Anything behind the newest +version seen for that tool is flagged `→ rebuild to vX` — so updating one +machine is what surfaces the ones still to do. No expected-version list to keep +in sync; the newest thing reporting in is the benchmark. + +**Bump `PANEL_VERSION` whenever you copy the .jsx to a workstation.** If two +machines report the same version but behave differently, one of them was copied +without the bump. + +--- + ## Diagnostics and logging Every button press is traced: what it tried, in what order, with the paths and diff --git a/app/(dashboard)/pipeline/machines/MachinesClient.tsx b/app/(dashboard)/pipeline/machines/MachinesClient.tsx index 92cc4fc..8a61e86 100644 --- a/app/(dashboard)/pipeline/machines/MachinesClient.tsx +++ b/app/(dashboard)/pipeline/machines/MachinesClient.tsx @@ -201,6 +201,77 @@ export function MachinesClient() { })} )} + + + + ); +} + +interface ToolRow { + id: string; + tool: string; + toolLabel: string; + machineName: string; + version: string; + buildRef: string | null; + hostUser: string | null; + hostApp: string | null; + lastSeenAt: string; + newestVersion: string; + outdated: boolean; +} + +/** + * What is deployed where. Each tool reports its own version on startup, and + * anything behind the newest version seen for that tool is flagged — so + * updating one machine is what surfaces the ones still to do. + */ +function ToolVersions() { + const { data } = useQuery({ + queryKey: ["tool-versions"], + queryFn: async () => { + const res = await fetch("/api/tools"); + if (!res.ok) throw new Error("Failed to load tool versions"); + return res.json() as Promise<{ tools: ToolRow[] }>; + }, + refetchInterval: 30000, + }); + + const tools = data?.tools ?? []; + const stale = tools.filter((t) => t.outdated).length; + + return ( +
+
+ Deployed tool versions + {stale > 0 && ( + + {stale} machine{stale > 1 ? "s" : ""} behind + + )} +
+
+ {tools.map((t) => ( +
+ {t.toolLabel} + {t.machineName} + + v{t.version} + + {t.outdated && → rebuild to v{t.newestVersion}} + {t.buildRef && {t.buildRef}} + {t.hostUser && {t.hostUser}} + + seen {new Date(t.lastSeenAt).toLocaleString()} + +
+ ))} + {tools.length === 0 && ( +
+ Nothing has reported yet. Versions appear as each panel and worker starts up. +
+ )} +
); } diff --git a/app/api/ext/tools/ping/route.ts b/app/api/ext/tools/ping/route.ts new file mode 100644 index 0000000..6a91f76 --- /dev/null +++ b/app/api/ext/tools/ping/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isExtAuthorized } from "@/lib/render-pipeline/ext-auth"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { recordToolVersion, toolPingSchema } from "@/lib/render-pipeline/tools"; + +// ── POST /api/ext/tools/ping ───────────────────────────────────────────────── +// +// A studio tool reporting its own version on startup. Fire-and-forget by +// design: callers must not fail if this errors. + +export async function POST(req: NextRequest) { + if (!isExtAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let raw: unknown; + try { + raw = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = toolPingSchema.safeParse(raw); + if (!parsed.success) { + return NextResponse.json({ error: "Invalid tool ping", details: parsed.error.issues }, { status: 422 }); + } + + try { + const result = await recordToolVersion(parsed.data); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/app/api/tools/route.ts b/app/api/tools/route.ts new file mode 100644 index 0000000..e8fafc6 --- /dev/null +++ b/app/api/tools/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { toErrorResponse } from "@/lib/render-pipeline/errors"; +import { requirePipelineUser } from "@/lib/render-pipeline/session-auth"; +import { listToolVersions } from "@/lib/render-pipeline/tools"; + +// ── GET /api/tools ─────────────────────────────────────────────────────────── +// +// What version of each studio tool is deployed where, with anything behind the +// newest seen version flagged. + +export async function GET() { + try { + await requirePipelineUser(); + const result = await listToolVersions(); + return NextResponse.json(result); + } catch (err) { + const { body, status } = toErrorResponse(err); + return NextResponse.json(body, { status }); + } +} diff --git a/lib/render-pipeline/tools.ts b/lib/render-pipeline/tools.ts new file mode 100644 index 0000000..ccded29 --- /dev/null +++ b/lib/render-pipeline/tools.ts @@ -0,0 +1,104 @@ +import { Prisma } from "@prisma/client"; +import { z } from "zod"; +import { db } from "@/lib/db"; + +/** + * Deployed-tool registry. Every studio tool reports its own version on + * startup, so "what is running where" is answered by the tools themselves + * rather than by a spreadsheet someone has to remember to update. + * + * Deliberately self-maintaining: nothing here declares what the *current* + * version ought to be. The newest version seen for a tool is treated as the + * benchmark, so updating one machine is what flags the others as stale. + */ + +export const TOOL_LABELS: Record = { + AE_PANEL: "AE connector panel", + RENDER_WORKER: "Render worker", +}; + +export const toolPingSchema = z.object({ + tool: z.enum(["AE_PANEL", "RENDER_WORKER"]), + machineName: z.string().min(1).max(200), + version: z.string().min(1).max(50), + buildRef: z.string().max(100).optional().nullable(), + hostUser: z.string().max(200).optional().nullable(), + hostApp: z.string().max(200).optional().nullable(), + details: z.record(z.string(), z.unknown()).optional(), +}); + +export type ToolPing = z.infer; + +export async function recordToolVersion(input: ToolPing) { + const now = new Date(); + const row = await db.toolVersion.upsert({ + where: { tool_machineName: { tool: input.tool, machineName: input.machineName } }, + create: { + tool: input.tool, + machineName: input.machineName, + version: input.version, + buildRef: input.buildRef ?? null, + hostUser: input.hostUser ?? null, + hostApp: input.hostApp ?? null, + details: (input.details ?? undefined) as Prisma.InputJsonValue | undefined, + lastSeenAt: now, + }, + update: { + version: input.version, + buildRef: input.buildRef ?? null, + hostUser: input.hostUser ?? null, + hostApp: input.hostApp ?? null, + details: (input.details ?? undefined) as Prisma.InputJsonValue | undefined, + lastSeenAt: now, + }, + }); + return { tool: row.tool, machineName: row.machineName, version: row.version, lastSeenAt: row.lastSeenAt }; +} + +/** + * Compares versions as dot-separated numbers, falling back to a string + * compare for anything non-numeric. Good enough to answer "is this one + * behind?", which is the only question being asked. + */ +export function compareVersions(a: string, b: string): number { + const parse = (v: string) => v.split(/[.+-]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p)); + const pa = parse(a); + const pb = parse(b); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (x === y) continue; + if (typeof x === "number" && typeof y === "number") return x < y ? -1 : 1; + return String(x) < String(y) ? -1 : 1; + } + return 0; +} + +export async function listToolVersions() { + const rows = await db.toolVersion.findMany({ orderBy: [{ tool: "asc" }, { machineName: "asc" }] }); + + // Newest version seen per tool is the benchmark — update one machine and the + // rest light up as stale, with no "expected version" to keep in sync. + const newest = new Map(); + for (const row of rows) { + const current = newest.get(row.tool); + if (!current || compareVersions(row.version, current) > 0) newest.set(row.tool, row.version); + } + + return { + tools: rows.map((row) => ({ + id: row.id, + tool: row.tool, + toolLabel: TOOL_LABELS[row.tool] ?? row.tool, + machineName: row.machineName, + version: row.version, + buildRef: row.buildRef, + hostUser: row.hostUser, + hostApp: row.hostApp, + firstSeenAt: row.firstSeenAt, + lastSeenAt: row.lastSeenAt, + newestVersion: newest.get(row.tool) ?? row.version, + outdated: compareVersions(row.version, newest.get(row.tool) ?? row.version) < 0, + })), + }; +} diff --git a/prisma/migrations/20260813010000_tool_versions/migration.sql b/prisma/migrations/20260813010000_tool_versions/migration.sql new file mode 100644 index 0000000..66903fc --- /dev/null +++ b/prisma/migrations/20260813010000_tool_versions/migration.sql @@ -0,0 +1,23 @@ +-- Deployed tool versions, reported by the tools themselves on startup. + +-- CreateTable +CREATE TABLE "tool_versions" ( + "id" TEXT NOT NULL, + "tool" TEXT NOT NULL, + "machineName" TEXT NOT NULL, + "version" TEXT NOT NULL, + "buildRef" TEXT, + "hostUser" TEXT, + "hostApp" TEXT, + "details" JSONB, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "tool_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "tool_versions_tool_idx" ON "tool_versions"("tool"); + +-- CreateIndex +CREATE UNIQUE INDEX "tool_versions_tool_machineName_key" ON "tool_versions"("tool", "machineName"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e3f457..65f66fd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -988,6 +988,26 @@ model WorkerHeartbeat { @@map("worker_heartbeats") } +/// What version of each studio tool is running where. Written by the tool +/// itself on startup, so it reflects what is *deployed* rather than what +/// someone remembered to write down. One row per (tool, machine). +model ToolVersion { + id String @id @default(cuid()) + tool String // "AE_PANEL" | "RENDER_WORKER" + machineName String + version String // "2.1.0" + buildRef String? // git sha / build stamp, when the tool can report one + hostUser String? // artist email, or the worker's service account + hostApp String? // "After Effects 24.6" / OS build + details Json? + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + + @@unique([tool, machineName]) + @@index([tool]) + @@map("tool_versions") +} + /// One row per check per validation run, so the UI shows exactly what failed /// and history survives retries (§5.4). model ValidationResult {