feat(pipeline): report deployed tool versions from the tools themselves

"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 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-13 15:13:44 +02:00
parent feb1c7b3e9
commit a4f8226f3f
15 changed files with 416 additions and 4 deletions
+6
View File
@@ -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 `<Version>` 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.
+30
View File
@@ -176,6 +176,36 @@ public sealed class ApiClient
public Task<string?> UploadLogAsync(string jobId, string machineId, string logFilePath, CancellationToken ct) =>
UploadArtifactAsync(jobId, machineId, "log", logFilePath, "text/plain", ct);
/// <summary>
/// Reports the deployed worker version so the web UI can show what is
/// running where. Fire-and-forget — never worth failing a startup over.
/// </summary>
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<ExportDetail?> GetExportAsync(string exportId, CancellationToken ct)
{
@@ -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")}");
@@ -8,6 +8,14 @@
<AssemblyName>VFXReviewWorker</AssemblyName>
<!-- Single self-contained exe for render nodes: dotnet publish -r win-x64 (see README) -->
<InvariantGlobalization>true</InvariantGlobalization>
<!--
The version is a property of the build, not of the machine it lands on:
it is stamped here and read back from the assembly at runtime, so a
workstation cannot report a version it is not actually running. Bump on
every change that gets published. publish.ps1 appends the git SHA via
SourceRevisionId, giving "1.1.0+feb1c7b".
-->
<Version>1.1.0</Version>
</PropertyGroup>
<ItemGroup>
+13 -1
View File
@@ -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.
/// </summary>
public List<string> PlateSearchRoots { get; set; } = new();
public string WorkerVersion { get; set; } = "1.0.0";
/// <summary>
/// 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").
/// </summary>
public string WorkerVersion =>
typeof(WorkerOptions).Assembly
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? typeof(WorkerOptions).Assembly.GetName().Version?.ToString()
?? "unknown";
public string? AeVersion { get; set; }
public static string DataDir =>
@@ -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);
+15 -2
View File
@@ -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.
+51 -1
View File
@@ -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();
+18
View File
@@ -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
@@ -201,6 +201,77 @@ export function MachinesClient() {
})}
</div>
)}
<ToolVersions />
</div>
);
}
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 (
<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">
Deployed tool versions
{stale > 0 && (
<span className="text-xs text-amber-400">
{stale} machine{stale > 1 ? "s" : ""} behind
</span>
)}
</div>
<div className="divide-y divide-zinc-800/60">
{tools.map((t) => (
<div key={t.id} className="px-4 py-2.5 text-sm flex items-center gap-3 flex-wrap">
<span className="w-44 shrink-0 text-zinc-300">{t.toolLabel}</span>
<span className="w-40 shrink-0 text-zinc-400 text-xs font-mono">{t.machineName}</span>
<span className={cn("text-xs font-mono", t.outdated ? "text-amber-400" : "text-emerald-400")}>
v{t.version}
</span>
{t.outdated && <span className="text-xs text-amber-400/80">→ rebuild to v{t.newestVersion}</span>}
{t.buildRef && <span className="text-xs text-zinc-600 font-mono">{t.buildRef}</span>}
{t.hostUser && <span className="text-xs text-zinc-600">{t.hostUser}</span>}
<span className="ml-auto text-xs text-zinc-600">
seen {new Date(t.lastSeenAt).toLocaleString()}
</span>
</div>
))}
{tools.length === 0 && (
<div className="p-4 text-sm text-zinc-500">
Nothing has reported yet. Versions appear as each panel and worker starts up.
</div>
)}
</div>
</div>
);
}
+35
View File
@@ -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 });
}
}
+20
View File
@@ -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 });
}
}
+104
View File
@@ -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<string, string> = {
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<typeof toolPingSchema>;
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<string, string>();
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,
})),
};
}
@@ -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");
+20
View File
@@ -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 {