From 2ad0e34d1c5a2be3c7f3431679f5d0680dd64da8 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:00:21 +0000 Subject: [PATCH 1/2] fix(design): never overwrite a paid design image (#1529) Every image the API returns now goes through one persistence path (design/src/persist.ts): mkdir, exclusive `wx` claim that bumps x.png -> x-2.png ... x-999.png, write, fsync, close. A failure after the bytes arrived removes the claimed file and keeps a 0600 recovery copy in the temp dir; empty image data is a failure, never a 0-byte PNG. The API call is the only thing inside each retry loop, so a local save failure never buys a second image (variants, iterate fallback, generate --retry). generate, iterate, evolve and variants (both branches) print the same run fields (requested, saved, selected, failures, recovered) on every exit, with exit 0 / 2 (nothing saved) / 3 (stopped after saving). Each generate --retry attempt is its own saved image listed in `attempts`. compare accepts --images-file and JSON-array --images. The gallery lists bumped names, orders history by mtime then board-images.json, and marks approvals by approved_path. New bin/gstack-design-claim publishes staged images without clobbering; bin/gstack-design-approved resolves an approval record (legacy letter-only records still resolve; a missing approved_path is an error, never a substitute). Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> --- bin/gstack-design-approved | 24 ++ bin/gstack-design-claim | 55 +++++ design/src/approval.ts | 56 +++++ design/src/auth.ts | 19 +- design/src/cli.ts | 65 ++--- design/src/commands.ts | 41 +++- design/src/evolve.ts | 99 ++++---- design/src/gallery.ts | 43 +++- design/src/generate.ts | 134 ++++++----- design/src/image-args.ts | 53 +++++ design/src/iterate.ts | 103 ++++---- design/src/persist.ts | 196 +++++++++++++++ design/src/variants.ts | 357 ++++++++++++++++------------ design/test/approval.test.ts | 61 +++++ design/test/cli-persistence.test.ts | 223 +++++++++++++++++ design/test/gallery.test.ts | 36 ++- design/test/image-args.test.ts | 52 ++++ design/test/models.test.ts | 16 +- design/test/persist.test.ts | 165 +++++++++++++ test/gstack-design-claim.test.ts | 118 +++++++++ 20 files changed, 1537 insertions(+), 379 deletions(-) create mode 100755 bin/gstack-design-approved create mode 100755 bin/gstack-design-claim create mode 100644 design/src/approval.ts create mode 100644 design/src/image-args.ts create mode 100644 design/src/persist.ts create mode 100644 design/test/approval.test.ts create mode 100644 design/test/cli-persistence.test.ts create mode 100644 design/test/image-args.test.ts create mode 100644 design/test/persist.test.ts create mode 100644 test/gstack-design-claim.test.ts diff --git a/bin/gstack-design-approved b/bin/gstack-design-approved new file mode 100755 index 000000000..7c3f360a2 --- /dev/null +++ b/bin/gstack-design-approved @@ -0,0 +1,24 @@ +#!/usr/bin/env bun +// gstack-design-approved — print the absolute path of the image an +// approved.json names. +// +// Usage: gstack-design-approved +// +// Prefers approved_path (relative to approved.json's directory). Legacy +// records with only approved_variant resolve to variant-.png. When the +// named image is missing, exits 1 with "approved image is missing; +// reselect from the board" and never substitutes another image. + +import { resolveApprovedImage } from '../design/src/approval'; + +const file = process.argv[2]; +if (!file) { + console.error('Usage: gstack-design-approved '); + process.exit(1); +} +const resolved = resolveApprovedImage(file); +if (!resolved.ok) { + console.error(resolved.error); + process.exit(1); +} +console.log(resolved.path); diff --git a/bin/gstack-design-claim b/bin/gstack-design-claim new file mode 100755 index 000000000..88a8c9f1f --- /dev/null +++ b/bin/gstack-design-claim @@ -0,0 +1,55 @@ +#!/bin/sh +# gstack-design-claim — publish a staged design image without overwriting. +# +# Usage: gstack-design-claim +# +# Creates with an exclusive, non-overwriting create (set -C +# noclobber, which opens with O_EXCL). When the name is taken it tries +# -2, -3 ... -999, the same rule as +# $D's claimOutputPath (x-2.png bumps to x-2-2.png). Prints the final path. +# On any failure it exits non-zero with problem/cause/fix and leaves the staged +# image in place, so the paid bytes survive. On success the staged copy is +# removed. + +set -u + +fail() { + printf 'gstack-design-claim: %s\ncause: %s\nfix: %s\n' "$1" "$2" "$3" >&2 + exit 1 +} + +[ "$#" -eq 2 ] || fail "expected 2 arguments, got $#" "usage: gstack-design-claim " "pass the staged image path, then the destination path" +src=$1 +dest=$2 + +[ -f "$src" ] && [ -s "$src" ] || fail "staged image $src is missing or empty" "the generating command did not save an image there" "use the outputPath the \$D command printed" + +dir=$(dirname "$dest") +base=$(basename "$dest") +case "$base" in + ?*.*) stem=${base%.*}; ext=".${base##*.}" ;; + *) stem=$base; ext="" ;; +esac + +err=$(mkdir -p "$dir" 2>&1) || fail "cannot create $dir; staged image kept at $src" "$err" "make $dir writable or choose another destination" + +n=1 +candidate=$dest +while :; do + if err=$( (set -C; : > "$candidate") 2>&1 ); then + if err=$(cat "$src" 2>&1 > "$candidate"); then + rm -f "$src" + printf '%s\n' "$candidate" + exit 0 + fi + rm -f "$candidate" + fail "cannot write $candidate; staged image kept at $src" "$err" "free disk space or make $dir writable, then rerun" + fi + if [ -e "$candidate" ] || [ -L "$candidate" ]; then + n=$((n + 1)) + [ "$n" -le 999 ] || fail "$dest and every name through $stem-999$ext already exist; staged image kept at $src" "999 images already use this name" "choose a new destination name or move older images out of $dir" + candidate="$dir/$stem-$n$ext" + continue + fi + fail "cannot create $candidate; staged image kept at $src" "$err" "make $dir writable or choose another destination" +done diff --git a/design/src/approval.ts b/design/src/approval.ts new file mode 100644 index 000000000..400015439 --- /dev/null +++ b/design/src/approval.ts @@ -0,0 +1,56 @@ +/** + * Resolve the image an approval record names. `approved_path` is stored + * relative to the directory holding approved.json, so a copied or relocated + * designs directory still resolves. Records without `approved_path` (written + * before never-overwrite) fall back to `variant-.png`. A + * present `approved_path` whose file is missing is an error: another image is + * never substituted. + */ + +import fs from "fs"; +import path from "path"; + +export type ApprovedImage = { ok: true; path: string; legacy: boolean } | { ok: false; error: string }; + +export interface ApprovalRecord { + approved_variant?: unknown; + approved_path?: unknown; +} + +function missing(imagePath: string): ApprovedImage { + return { ok: false, error: `approved image ${imagePath} is missing; reselect from the board` }; +} + +function readable(imagePath: string): boolean { + try { + fs.accessSync(imagePath, fs.constants.R_OK); + return fs.statSync(imagePath).isFile(); + } catch { + return false; + } +} + +/** The image path a record names, without checking that it exists. */ +export function approvedImagePath(record: ApprovalRecord, recordDir: string): { path: string; legacy: boolean } | null { + if (typeof record.approved_path === "string" && record.approved_path !== "") { + return { path: path.resolve(recordDir, record.approved_path), legacy: false }; + } + if (typeof record.approved_variant === "string" && record.approved_variant !== "") { + return { path: path.join(recordDir, `variant-${record.approved_variant}.png`), legacy: true }; + } + return null; +} + +export function resolveApprovedImage(approvedJsonPath: string): ApprovedImage { + let record: ApprovalRecord; + try { + record = JSON.parse(fs.readFileSync(approvedJsonPath, "utf-8")); + } catch (err: any) { + return { ok: false, error: `cannot read ${approvedJsonPath}: ${err.code || err.message}` }; + } + const named = approvedImagePath(record ?? {}, path.dirname(path.resolve(approvedJsonPath))); + if (!named) { + return { ok: false, error: `${approvedJsonPath} names no approved image; reselect from the board` }; + } + return readable(named.path) ? { ok: true, ...named } : missing(named.path); +} diff --git a/design/src/auth.ts b/design/src/auth.ts index 1202500a9..fc25cbcf4 100644 --- a/design/src/auth.ts +++ b/design/src/auth.ts @@ -119,19 +119,20 @@ export function saveApiKey(key: string): void { } /** - * Get API key or exit with setup instructions. + * Get API key or throw with setup instructions. */ export function requireApiKey(): string { const resolution = resolveApiKeyInfo(); if (!resolution) { - console.error("No OpenAI API key found."); - console.error(""); - console.error("Run: $D setup"); - console.error(" or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }"); - console.error(" or set OPENAI_API_KEY environment variable"); - console.error(""); - console.error("Get a key at: https://platform.openai.com/api-keys"); - process.exit(1); + throw new Error([ + "No OpenAI API key found.", + "", + "Run: $D setup", + " or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }", + " or set OPENAI_API_KEY environment variable", + "", + "Get a key at: https://platform.openai.com/api-keys", + ].join("\n")); } console.error(`Using OpenAI key from ${describeApiKeySource(resolution)}.`); if (resolution.warning) console.error(resolution.warning); diff --git a/design/src/cli.ts b/design/src/cli.ts index b266c6f38..2278b9a65 100644 --- a/design/src/cli.ts +++ b/design/src/cli.ts @@ -12,7 +12,7 @@ * 4. Print result JSON to stdout */ -import { COMMANDS } from "./commands"; +import { COMMANDS, OUTPUT_CONTRACT } from "./commands"; import { generate } from "./generate"; import { checkCommand } from "./check"; import { compare } from "./compare"; @@ -26,6 +26,7 @@ import { generateDesignToCodePrompt } from "./design-to-code"; import { serve } from "./serve"; import { gallery } from "./gallery"; import { normalizeIntFlag } from "./flag-utils"; +import { resolveImagePaths } from "./image-args"; import { daemonStatus as daemonStatusClient, ensureDaemon, @@ -33,6 +34,9 @@ import { shutdownDaemon, } from "./daemon-client"; import { spawn as nodeSpawn } from "child_process"; +import fs from "fs"; +import os from "os"; +import path from "path"; function parseArgs(argv: string[]): { command: string; @@ -75,6 +79,7 @@ function printUsage(): void { console.log(` ${name.padEnd(12)} ${info.description}`); console.log(` ${"".padEnd(12)} ${info.usage}`); } + console.log(`\n${OUTPUT_CONTRACT}`); console.log("\nAuth: ~/.gstack/openai.json, then OPENAI_API_KEY env var"); console.log("If OPENAI_API_KEY matches a current-directory .env file, the source is reported before billing."); console.log("Setup: $D setup"); @@ -107,19 +112,18 @@ async function runSetup(): Promise { // Smoke test console.log("\nRunning smoke test (generating a simple image)..."); - try { - await generate({ - brief: "A simple blue square centered on a white background. Minimal, geometric, clean.", - output: "/tmp/gstack-design-smoke-test.png", - size: "1024x1024", - quality: "low", - }); - console.log("\nSmoke test PASSED. Design generation is working."); - } catch (err: any) { - console.error(`\nSmoke test FAILED: ${err.message}`); + const smoke = await generate({ + brief: "A simple blue square centered on a white background. Minimal, geometric, clean.", + output: path.join(fs.mkdtempSync(path.join(os.tmpdir(), "gstack-design-smoke-")), "smoke-test.png"), + size: "1024x1024", + quality: "low", + }); + if (smoke.exitCode !== 0) { + console.error(`\nSmoke test FAILED: ${smoke.failures.map(f => f.reason).join("; ")}`); console.error("Check your API key and organization verification status."); process.exit(1); } + console.log("\nSmoke test PASSED. Design generation is working."); } async function main(): Promise { @@ -133,7 +137,7 @@ async function main(): Promise { switch (command) { case "generate": - await generate({ + process.exitCode = (await generate({ brief: flags.brief as string, briefFile: flags["brief-file"] as string, output: (flags.output as string) || "/tmp/gstack-mockup.png", @@ -141,7 +145,7 @@ async function main(): Promise { retry: normalizeIntFlag(flags.retry, { name: "retry", def: 0, min: 0 }), size: flags.size as string, quality: flags.quality as string, - }); + })).exitCode; break; case "check": @@ -149,9 +153,7 @@ async function main(): Promise { break; case "compare": { - // Parse --images as glob or multiple files - const imagesArg = flags.images as string; - const images = await resolveImagePaths(imagesArg); + const images = await resolveImagePaths(flags.images as string, flags["images-file"] as string | undefined); const outputPath = (flags.output as string) || "/tmp/gstack-design-board.html"; compare({ images, output: outputPath }); // If --serve flag is set, publish the board. @@ -195,7 +197,7 @@ async function main(): Promise { break; case "variants": - await variants({ + process.exitCode = await variants({ brief: flags.brief as string, briefFile: flags["brief-file"] as string, // #2032: pass the RAW flag through — variants() normalizes at its @@ -209,7 +211,7 @@ async function main(): Promise { break; case "iterate": - await iterate({ + process.exitCode = await iterate({ session: flags.session as string, feedback: flags.feedback as string, output: (flags.output as string) || "/tmp/gstack-iterate.png", @@ -261,7 +263,7 @@ async function main(): Promise { } case "evolve": - await evolve({ + process.exitCode = await evolve({ screenshot: flags.screenshot as string, brief: flags.brief as string, output: (flags.output as string) || "/tmp/gstack-evolved.png", @@ -381,31 +383,6 @@ function openBrowser(url: string): void { } } -/** - * Resolve image paths from a glob pattern or comma-separated list. - */ -async function resolveImagePaths(input: string): Promise { - if (!input) { - console.error("--images is required. Provide glob pattern or comma-separated paths."); - process.exit(1); - } - - // Check if it's a glob pattern - if (input.includes("*")) { - const glob = new Bun.Glob(input); - const paths: string[] = []; - for await (const match of glob.scan({ absolute: true })) { - if (match.endsWith(".png") || match.endsWith(".jpg") || match.endsWith(".jpeg")) { - paths.push(match); - } - } - return paths.sort(); - } - - // Comma-separated or single path - return input.split(",").map(p => p.trim()); -} - // Self-execution shortcut: when invoked with --daemon-mode, this same // binary runs as the persistent design daemon instead of the CLI. Keeps // the production install to a single executable; daemon-client.ts spawns diff --git a/design/src/commands.ts b/design/src/commands.ts index 17d5811a7..76ff9ded7 100644 --- a/design/src/commands.ts +++ b/design/src/commands.ts @@ -16,17 +16,17 @@ export const COMMANDS = new Map([ ["generate", { description: "Generate a UI mockup from a design brief", - usage: "generate --brief \"...\" --output /path.png", + usage: "generate --brief \"...\" --output /path.png [--check --retry N] (never overwrites: bumps to /path-2.png; each --retry attempt is its own saved image; prints outputPath, attempts + run fields)", flags: ["--brief", "--brief-file", "--output", "--check", "--retry", "--size", "--quality"], }], ["variants", { description: "Generate N design variants from a brief", - usage: "variants --brief \"...\" --count 3 --output-dir /path/", + usage: "variants --brief \"...\" --count 3 --output-dir /path/ [--viewports desktop,tablet,mobile] (never overwrites: variant-A.png bumps to variant-A-2.png; prints paths, errors + run fields)", flags: ["--brief", "--brief-file", "--count", "--output-dir", "--size", "--quality", "--viewports"], }], ["iterate", { description: "Iterate on an existing mockup with feedback", - usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png", + usage: "iterate --session /path/session.json --feedback \"...\" --output /path.png (never overwrites; prints outputPath + run fields)", flags: ["--session", "--feedback", "--output"], }], ["check", { @@ -36,8 +36,8 @@ export const COMMANDS = new Map x-2.png, x-2.png -> x-2-2.png), stderr prints +"note: exists; saved to (existing file kept)", and the JSON +reports the actual path. Read paths from the JSON, never from a fixed file name. + +Run fields printed by generate, iterate, evolve and variants (on every exit): + requested images requested saved every saved path, in order + selected outputPath or paths failures [{file, reason}] not saved + recovered [{path, reason}] private recovery copies in the temp dir + + command extra fields readers + generate outputPath sessionFile responseId checkResult design-review, design-shotgun, + attempts[{path, check}] (one per --retry try) plan-design-review + iterate outputPath sessionFile responseId iteration plan-design-review, design-consultation + evolve outputPath sourceScreenshot brief attempts design-shotgun + variants outputDir count succeeded failed paths errors office-hours, plan-design-review, + viewports (--viewports only) design-consultation + compare board HTML; --serve prints {id, url, sourceDir} all board flows (--images-file) + +Exit codes: 0 a selected result exists; 2 nothing was saved; 3 stopped after +saving at least one image but before its selected result (interrupted retry); +1 invalid flags rejected before any API call (no JSON).`; diff --git a/design/src/evolve.ts b/design/src/evolve.ts index a656054b0..69b486849 100644 --- a/design/src/evolve.ts +++ b/design/src/evolve.ts @@ -6,10 +6,10 @@ */ import fs from "fs"; -import path from "path"; import { requireApiKey } from "./auth"; import { receiptedFetch } from "./receipted-fetch"; import { imageRequestBody, modelRejectionHint, visionRequestBody } from "./models"; +import { emitResult, exitCodeFor, newAccounting, persistImage, recordOutcome, type ExitCode } from "./persist"; export interface EvolveOptions { screenshot: string; // Path to current site screenshot @@ -22,37 +22,68 @@ export interface EvolveOptions { * Sends the screenshot as context to the image model with image generation, * asking it to produce a new version incorporating the brief's changes. */ -export async function evolve(options: EvolveOptions): Promise { - const apiKey = requireApiKey(); - const screenshotData = fs.readFileSync(options.screenshot).toString("base64"); +export async function evolve(options: EvolveOptions): Promise { + const acct = newAccounting(1); + let outputPath: string | null = null; - console.error(`Evolving ${options.screenshot} with: "${options.brief}"`); - const startTime = Date.now(); + try { + const apiKey = requireApiKey(); + const screenshotData = fs.readFileSync(options.screenshot).toString("base64"); - // Use the Responses API with both a text prompt referencing the screenshot - // and the image_generation tool to produce the evolved version. - // Since we can't send reference images directly to image_generation, - // we describe the current state in detail first via vision, then generate. + console.error(`Evolving ${options.screenshot} with: "${options.brief}"`); + const startTime = Date.now(); - // Step 1: Analyze current screenshot - const analysis = await analyzeScreenshot(apiKey, screenshotData); - console.error(` Analyzed current design: ${analysis.slice(0, 100)}...`); + // Use the Responses API with both a text prompt referencing the screenshot + // and the image_generation tool to produce the evolved version. + // Since we can't send reference images directly to image_generation, + // we describe the current state in detail first via vision, then generate. - // Step 2: Generate evolved version using analysis + brief - const evolvedPrompt = [ - "Generate a pixel-perfect UI mockup that is an improved version of an existing design.", - "", - "CURRENT DESIGN (what exists now):", - analysis, - "", - "REQUESTED CHANGES:", - options.brief, - "", - "Generate a new mockup that keeps the existing layout structure but applies the requested changes.", - "The result should look like a real production UI. All text must be readable.", - "1536x1024 pixels.", - ].join("\n"); + // Step 1: Analyze current screenshot + const analysis = await analyzeScreenshot(apiKey, screenshotData); + console.error(` Analyzed current design: ${analysis.slice(0, 100)}...`); + // Step 2: Generate evolved version using analysis + brief + const evolvedPrompt = [ + "Generate a pixel-perfect UI mockup that is an improved version of an existing design.", + "", + "CURRENT DESIGN (what exists now):", + analysis, + "", + "REQUESTED CHANGES:", + options.brief, + "", + "Generate a new mockup that keeps the existing layout structure but applies the requested changes.", + "The result should look like a real production UI. All text must be readable.", + "1536x1024 pixels.", + ].join("\n"); + + const imageData = await requestEvolvedImage(apiKey, evolvedPrompt); + const outcome = persistImage(imageData, options.output); + outputPath = recordOutcome(acct, outcome); + if (outcome.ok) { + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.error(`Generated (${elapsed}s, ${(outcome.bytes / 1024).toFixed(0)}KB) → ${outcome.path}`); + } + } catch (err: any) { + const reason = err?.message || String(err); + console.error(reason); + acct.failures.push({ file: options.output, reason }); + } + + return emitResult({ + outputPath, + sourceScreenshot: options.screenshot, + brief: options.brief, + attempts: acct.saved.map(p => ({ path: p })), + requested: acct.requested, + saved: acct.saved, + selected: outputPath, + failures: acct.failures, + recovered: acct.recovered, + }, exitCodeFor(outputPath !== null, acct.saved.length)); +} + +async function requestEvolvedImage(apiKey: string, evolvedPrompt: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 240_000); @@ -85,19 +116,7 @@ export async function evolve(options: EvolveOptions): Promise { if (!imageItem?.result) { throw new Error("No image data in response"); } - - fs.mkdirSync(path.dirname(options.output), { recursive: true }); - const imageBuffer = Buffer.from(imageItem.result, "base64"); - fs.writeFileSync(options.output, imageBuffer); - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.error(`Generated (${elapsed}s, ${(imageBuffer.length / 1024).toFixed(0)}KB) → ${options.output}`); - - console.log(JSON.stringify({ - outputPath: options.output, - sourceScreenshot: options.screenshot, - brief: options.brief, - }, null, 2)); + return imageItem.result; } finally { clearTimeout(timeout); } diff --git a/design/src/gallery.ts b/design/src/gallery.ts index 95675559d..5ab08a486 100644 --- a/design/src/gallery.ts +++ b/design/src/gallery.ts @@ -6,6 +6,7 @@ import fs from "fs"; import path from "path"; +import { approvedImagePath } from "./approval"; export interface GalleryOptions { designsDir: string; // ~/.gstack/projects/$SLUG/designs/ @@ -20,6 +21,32 @@ interface SessionData { variants: string[]; // paths to variant PNGs } +const VARIANT_IMAGE = [/^variant-[A-Z](-\d+)?\.png$/i, /^variant-\d+(-\d+)?\.png$/i]; + +/** + * Variant images in a session directory as chronological history, not rounds: + * names not on the current board sort by mtime then name, followed by the + * board's images in board-images.json order. Never-overwrite means one + * letter can appear in several rounds (variant-A.png, variant-A-2.png). + */ +export function listVariantImages(sessionDir: string): string[] { + const found = fs.readdirSync(sessionDir) + .filter(f => VARIANT_IMAGE.some(re => re.test(f))) + .map(f => path.join(sessionDir, f)); + + let board: string[] = []; + try { + const parsed = JSON.parse(fs.readFileSync(path.join(sessionDir, "board-images.json"), "utf-8")); + if (Array.isArray(parsed)) board = parsed.filter((p): p is string => typeof p === "string").map(p => path.resolve(sessionDir, p)); + } catch {} + + const mtime = new Map(found.map(f => [f, fs.statSync(f).mtimeMs])); + const history = found + .filter(f => !board.includes(f)) + .sort((a, b) => (mtime.get(a)! - mtime.get(b)!) || a.localeCompare(b)); + return [...history, ...board.filter(f => found.includes(f))]; +} + export function generateGalleryHtml(designsDir: string): string { const sessions: SessionData[] = []; @@ -44,16 +71,9 @@ export function generateGalleryHtml(designsDir: string): string { } } - // Find variant PNGs - const variants: string[] = []; + let variants: string[] = []; try { - const files = fs.readdirSync(sessionDir); - for (const f of files) { - if (f.match(/variant-[A-Z]\.png$/i) || f.match(/variant-\d+\.png$/i)) { - variants.push(path.join(sessionDir, f)); - } - } - variants.sort(); + variants = listVariantImages(sessionDir); } catch { // Can't read directory, skip } @@ -81,12 +101,13 @@ export function generateGalleryHtml(designsDir: string): string { sessions.sort((a, b) => b.date.localeCompare(a.date)); const sessionCards = sessions.map(session => { + const approvedTarget = session.approved ? approvedImagePath(session.approved, session.dir) : null; const variantImgs = session.variants.map((vPath, i) => { try { const imgData = fs.readFileSync(vPath).toString("base64"); const ext = path.extname(vPath).slice(1) || "png"; const label = path.basename(vPath, `.${ext}`).replace("variant-", ""); - const isApproved = session.approved?.approved_variant === label; + const isApproved = approvedTarget?.path === vPath; return `