From 15e5302115a40f0137e8d53c8e7a5f69f8fa4b27 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 20 Aug 2026 00:41:40 -0400 Subject: [PATCH 001/110] feat(create-images): define shared workflow contracts Add strict graph, template, provider, run, archive, import, retry, execution, and IPC schemas shared across renderer and main boundaries. Cover hostile inputs, deterministic planning, consent, recovery, and path-free asset authorization with focused tests. --- renderer/shared/create-images/archive.test.ts | 412 +++++ renderer/shared/create-images/archive.ts | 757 ++++++++ .../shared/create-images/execution.test.ts | 571 ++++++ renderer/shared/create-images/execution.ts | 578 ++++++ renderer/shared/create-images/ipc.test.ts | 580 ++++++ renderer/shared/create-images/ipc.ts | 1273 +++++++++++++ .../create-images/node-banana-import.test.ts | 148 ++ .../create-images/node-banana-import.ts | 441 +++++ renderer/shared/create-images/ports.ts | 356 ++++ .../shared/create-images/providers.test.ts | 136 ++ renderer/shared/create-images/providers.ts | 204 ++ renderer/shared/create-images/retry-policy.ts | 37 + .../shared/create-images/run-contract.test.ts | 711 +++++++ renderer/shared/create-images/run-contract.ts | 1634 +++++++++++++++++ renderer/shared/create-images/schema.test.ts | 262 +++ renderer/shared/create-images/schema.ts | 802 ++++++++ .../shared/create-images/templates.test.ts | 55 + renderer/shared/create-images/templates.ts | 104 ++ 18 files changed, 9061 insertions(+) create mode 100644 renderer/shared/create-images/archive.test.ts create mode 100644 renderer/shared/create-images/archive.ts create mode 100644 renderer/shared/create-images/execution.test.ts create mode 100644 renderer/shared/create-images/execution.ts create mode 100644 renderer/shared/create-images/ipc.test.ts create mode 100644 renderer/shared/create-images/ipc.ts create mode 100644 renderer/shared/create-images/node-banana-import.test.ts create mode 100644 renderer/shared/create-images/node-banana-import.ts create mode 100644 renderer/shared/create-images/ports.ts create mode 100644 renderer/shared/create-images/providers.test.ts create mode 100644 renderer/shared/create-images/providers.ts create mode 100644 renderer/shared/create-images/retry-policy.ts create mode 100644 renderer/shared/create-images/run-contract.test.ts create mode 100644 renderer/shared/create-images/run-contract.ts create mode 100644 renderer/shared/create-images/schema.test.ts create mode 100644 renderer/shared/create-images/schema.ts create mode 100644 renderer/shared/create-images/templates.test.ts create mode 100644 renderer/shared/create-images/templates.ts diff --git a/renderer/shared/create-images/archive.test.ts b/renderer/shared/create-images/archive.test.ts new file mode 100644 index 00000000..578cf4e6 --- /dev/null +++ b/renderer/shared/create-images/archive.test.ts @@ -0,0 +1,412 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CREATE_IMAGES_ARCHIVE_MAX_ASSET_BYTES, + CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES, + CREATE_IMAGES_ARCHIVE_FORMAT, + CREATE_IMAGES_ARCHIVE_MAX_ENTRIES, + CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + CREATE_IMAGES_ARCHIVE_VERSION, + CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + parseCreateImagesArchiveManifestBytes, + parseCreateImagesArchiveManifest, + validateCreateImagesArchiveBootstrap, + validateCreateImagesArchiveExtractedEntries, + validateCreateImagesArchiveInventory, + validateCreateImagesArchiveWorkflowAssets, + type CreateImagesArchiveManifestV1, +} from "./archive.js"; +import { createStarterWorkflow } from "./schema.js"; + +const digest = "a".repeat(64); + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} +const manifest: CreateImagesArchiveManifestV1 = { + format: CREATE_IMAGES_ARCHIVE_FORMAT, + version: CREATE_IMAGES_ARCHIVE_VERSION, + exportedAt: "2026-08-11T12:00:00.000Z", + workflow: { path: CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, sha256: "b".repeat(64), byteLength: 240 }, + assets: [ + { + assetId: digest, + sha256: digest, + path: `assets/${digest}.png`, + mediaType: "image/png", + byteLength: 1_024, + width: 32, + height: 32, + }, + ], +}; + +test("native Create Images archive manifest is strict and content addressed", () => { + assert.deepEqual(parseCreateImagesArchiveManifest(manifest), { success: true, value: manifest }); + assert.equal(parseCreateImagesArchiveManifest({ ...manifest, future: true }).success, false); + assert.equal( + parseCreateImagesArchiveManifest({ + ...manifest, + assets: [{ ...manifest.assets[0], assetId: "c".repeat(64) }], + }).success, + false, + ); +}); + +test("native archive accepts the asset-store byte ceiling and rejects one byte beyond it", () => { + const boundaryAsset = { + ...manifest.assets[0]!, + byteLength: CREATE_IMAGES_ARCHIVE_MAX_ASSET_BYTES, + }; + assert.equal( + parseCreateImagesArchiveManifest({ ...manifest, assets: [boundaryAsset] }).success, + true, + ); + assert.equal( + parseCreateImagesArchiveManifest({ + ...manifest, + assets: [{ ...boundaryAsset, byteLength: CREATE_IMAGES_ARCHIVE_MAX_ASSET_BYTES + 1 }], + }).success, + false, + ); +}); + +test("archive bootstrap validates the sole manifest before any member is read", () => { + const manifestBytes = new TextEncoder().encode(JSON.stringify(manifest)); + const manifestEntry = { + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + kind: "file" as const, + encrypted: false, + compressionMethod: 0, + compressedBytes: manifestBytes.byteLength, + uncompressedBytes: manifestBytes.byteLength, + crc32: crc32(manifestBytes), + }; + assert.deepEqual(validateCreateImagesArchiveBootstrap([manifestEntry]), []); + assert.deepEqual(parseCreateImagesArchiveManifestBytes(manifestBytes, manifestEntry), { + success: true, + value: manifest, + }); + + const hostile = validateCreateImagesArchiveBootstrap([ + { + ...manifestEntry, + kind: "symlink", + encrypted: true, + compressionMethod: 99, + compressedBytes: 1, + uncompressedBytes: CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES + 1, + }, + { ...manifestEntry }, + { + ...manifestEntry, + compressedBytes: 1, + uncompressedBytes: CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES, + }, + ]); + for (const code of [ + "unsupported_entry", + "encrypted_entry", + "compression_method", + "size_limit", + "duplicate_entry", + "compression_limit", + ]) { + assert.ok( + hostile.some((issue) => issue.code === code), + `missing ${code}`, + ); + } + assert.ok( + validateCreateImagesArchiveBootstrap([ + { ...manifestEntry, path: CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH }, + ]).some((issue) => issue.code === "missing_entry"), + ); + assert.equal( + parseCreateImagesArchiveManifestBytes(manifestBytes, { + ...manifestEntry, + crc32: manifestEntry.crc32 ^ 1, + }).success, + false, + ); +}); + +test("archive inventory blocks zip-slip, links, duplicates, extras, and bombs", () => { + const validEntries = [ + { + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + kind: "file" as const, + encrypted: false, + compressionMethod: 8, + compressedBytes: 100, + uncompressedBytes: 200, + crc32: 1, + }, + { + path: CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + kind: "file" as const, + encrypted: false, + compressionMethod: 8, + compressedBytes: 120, + uncompressedBytes: 240, + crc32: 2, + }, + { + path: manifest.assets[0]!.path, + kind: "file" as const, + encrypted: false, + compressionMethod: 0, + compressedBytes: 1_024, + uncompressedBytes: 1_024, + crc32: 3, + }, + ]; + assert.deepEqual(validateCreateImagesArchiveInventory(manifest, validEntries), []); + + const issues = validateCreateImagesArchiveInventory(manifest, [ + ...validEntries, + { + path: "../escape", + kind: "file", + encrypted: false, + compressionMethod: 8, + compressedBytes: 1, + uncompressedBytes: 2, + crc32: 4, + }, + { + path: "assets/link", + kind: "symlink", + encrypted: false, + compressionMethod: 8, + compressedBytes: 1, + uncompressedBytes: 2, + crc32: 5, + }, + { ...validEntries[0]!, compressedBytes: 0, uncompressedBytes: 20_000 }, + { + path: "unexpected.json", + kind: "file", + encrypted: true, + compressionMethod: 99, + compressedBytes: 1, + uncompressedBytes: 2, + crc32: 6, + }, + ]); + assert.ok(issues.some((issue) => issue.code === "unsafe_path")); + assert.ok(issues.some((issue) => issue.code === "unsupported_entry")); + assert.ok(issues.some((issue) => issue.code === "duplicate_entry")); + assert.ok(issues.some((issue) => issue.code === "unexpected_entry")); + assert.ok(issues.some((issue) => issue.code === "encrypted_entry")); + assert.ok(issues.some((issue) => issue.code === "compression_method")); +}); + +test("archive validation bounds central-directory entries before per-entry work", () => { + const oversized = Array.from({ length: CREATE_IMAGES_ARCHIVE_MAX_ENTRIES + 1 }, (_, index) => ({ + path: `unexpected/${index}`, + kind: "file" as const, + encrypted: false, + compressionMethod: 8, + compressedBytes: 1, + uncompressedBytes: 1, + crc32: 0, + })); + assert.deepEqual(validateCreateImagesArchiveInventory(manifest, oversized), [ + { + path: "entries", + code: "entry_count", + message: `Archive contains more than ${CREATE_IMAGES_ARCHIVE_MAX_ENTRIES} entries.`, + }, + ]); +}); + +test("quarantine measurements must match declared sizes, CRCs, and content digests", () => { + const inventory = [ + { + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + kind: "file" as const, + encrypted: false, + compressionMethod: 8, + compressedBytes: 100, + uncompressedBytes: 200, + crc32: 1, + }, + { + path: CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + kind: "file" as const, + encrypted: false, + compressionMethod: 8, + compressedBytes: 120, + uncompressedBytes: 240, + crc32: 2, + }, + { + path: manifest.assets[0]!.path, + kind: "file" as const, + encrypted: false, + compressionMethod: 0, + compressedBytes: 1_024, + uncompressedBytes: 1_024, + crc32: 3, + }, + ]; + const extracted = [ + { + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + byteLength: 200, + crc32: 1, + sha256: "c".repeat(64), + }, + { + path: CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + byteLength: 240, + crc32: 2, + sha256: manifest.workflow.sha256, + }, + { + path: manifest.assets[0]!.path, + byteLength: 1_024, + crc32: 3, + sha256: manifest.assets[0]!.sha256, + }, + ]; + assert.deepEqual(validateCreateImagesArchiveExtractedEntries(manifest, inventory, extracted), []); + const tampered = extracted.map((entry) => ({ ...entry })); + tampered[1] = { ...tampered[1]!, byteLength: 239, crc32: 9, sha256: "d".repeat(64) }; + const issues = validateCreateImagesArchiveExtractedEntries(manifest, inventory, tampered); + assert.ok(issues.some((issue) => issue.code === "actual_size_mismatch")); + assert.ok(issues.some((issue) => issue.code === "checksum_mismatch")); + assert.ok(issues.some((issue) => issue.code === "digest_mismatch")); +}); + +test("native manifest byte lengths must match inventory and extracted bytes", () => { + const inventory = [ + { + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + kind: "file" as const, + encrypted: false, + compressionMethod: 8, + compressedBytes: 100, + uncompressedBytes: 200, + crc32: 1, + }, + { + path: CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + kind: "file" as const, + encrypted: false, + compressionMethod: 8, + compressedBytes: 120, + uncompressedBytes: manifest.workflow.byteLength + 1, + crc32: 2, + }, + { + path: manifest.assets[0]!.path, + kind: "file" as const, + encrypted: false, + compressionMethod: 0, + compressedBytes: manifest.assets[0]!.byteLength - 1, + uncompressedBytes: manifest.assets[0]!.byteLength - 1, + crc32: 3, + }, + ]; + const inventoryIssues = validateCreateImagesArchiveInventory(manifest, inventory); + assert.equal(inventoryIssues.filter((issue) => issue.code === "actual_size_mismatch").length, 2); + + const extracted = inventory.map((entry) => ({ + path: entry.path, + byteLength: entry.uncompressedBytes, + crc32: entry.crc32, + sha256: + entry.path === manifest.workflow.path + ? manifest.workflow.sha256 + : entry.path === manifest.assets[0]!.path + ? manifest.assets[0]!.sha256 + : "c".repeat(64), + })); + const extractedIssues = validateCreateImagesArchiveExtractedEntries( + manifest, + inventory, + extracted, + ); + assert.equal(extractedIssues.filter((issue) => issue.code === "actual_size_mismatch").length, 2); +}); + +test("native manifest accepts the full asset quota plus workflow envelope and rejects one extra asset", () => { + const asset = manifest.assets[0]!; + const atAssetQuota = { + ...manifest, + assets: Array.from({ length: 160 }, (_, index) => { + const assetId = index.toString(16).padStart(64, "0"); + return { + ...asset, + assetId, + sha256: assetId, + path: `assets/${assetId}.png`, + byteLength: CREATE_IMAGES_ARCHIVE_MAX_ASSET_BYTES, + }; + }), + }; + assert.equal(parseCreateImagesArchiveManifest(atAssetQuota).success, true); + + const extraAssetId = "f".repeat(64); + assert.equal( + parseCreateImagesArchiveManifest({ + ...atAssetQuota, + assets: [ + ...atAssetQuota.assets, + { + ...asset, + assetId: extraAssetId, + sha256: extraAssetId, + path: `assets/${extraAssetId}.png`, + byteLength: 1, + }, + ], + }).success, + false, + ); +}); + +test("workflow, native manifest, and deeply validated asset descriptors must agree", () => { + const workflow = createStarterWorkflow({ + workflowId: "archive-workflow", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); + workflow.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 0, y: 0 }, + data: { assetId: digest }, + }); + workflow.assetRefs = [digest]; + const validated = [ + { + assetId: digest, + mediaType: "image/png" as const, + byteLength: 1_024, + width: 32, + height: 32, + }, + ]; + assert.deepEqual(validateCreateImagesArchiveWorkflowAssets(manifest, workflow, validated), []); + assert.ok( + validateCreateImagesArchiveWorkflowAssets( + manifest, + { ...workflow, nodes: workflow.nodes.filter((node) => node.id !== "image-1"), assetRefs: [] }, + [{ ...validated[0]!, width: 31, mediaType: "image/jpeg" }], + ).some((issue) => issue.code === "asset_contract_mismatch"), + ); +}); diff --git a/renderer/shared/create-images/archive.ts b/renderer/shared/create-images/archive.ts new file mode 100644 index 00000000..f1bf8714 --- /dev/null +++ b/renderer/shared/create-images/archive.ts @@ -0,0 +1,757 @@ +import { + CREATE_IMAGES_MAX_ASSET_REFS, + CREATE_IMAGES_MAX_TOTAL_ASSET_BYTES, + CREATE_IMAGES_MAX_WORKFLOW_BYTES, + type WorkflowDocumentV1, +} from "./schema"; + +export const CREATE_IMAGES_ARCHIVE_EXTENSION = ".aiden-images" as const; +export const CREATE_IMAGES_ARCHIVE_FORMAT = "aiden-images-workflow" as const; +export const CREATE_IMAGES_ARCHIVE_VERSION = 1 as const; +export const CREATE_IMAGES_ARCHIVE_MANIFEST_PATH = "manifest.json" as const; +export const CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH = "workflow.json" as const; +export const CREATE_IMAGES_ARCHIVE_MAX_WORKFLOW_BYTES = CREATE_IMAGES_MAX_WORKFLOW_BYTES; +export const CREATE_IMAGES_ARCHIVE_MAX_ASSET_BYTES = 64 * 1024 * 1024; +export const CREATE_IMAGES_ARCHIVE_MAX_ENTRY_BYTES = CREATE_IMAGES_ARCHIVE_MAX_ASSET_BYTES; +export const CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES = 1024 * 1024; +export const CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES = + CREATE_IMAGES_MAX_TOTAL_ASSET_BYTES + + CREATE_IMAGES_ARCHIVE_MAX_WORKFLOW_BYTES + + CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES; +export const CREATE_IMAGES_ARCHIVE_MAX_COMPRESSION_RATIO = 100; +export const CREATE_IMAGES_ARCHIVE_MAX_ENTRIES = CREATE_IMAGES_MAX_ASSET_REFS + 2; + +const ZIP_COMPRESSION_STORED = 0; +const ZIP_COMPRESSION_DEFLATE = 8; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const MEDIA_TYPES = new Set(["image/jpeg", "image/png"]); + +export interface CreateImagesArchiveAssetV1 { + assetId: string; + sha256: string; + path: string; + mediaType: "image/jpeg" | "image/png"; + byteLength: number; + width: number; + height: number; +} + +export interface CreateImagesArchiveManifestV1 { + format: typeof CREATE_IMAGES_ARCHIVE_FORMAT; + version: typeof CREATE_IMAGES_ARCHIVE_VERSION; + exportedAt: string; + workflow: { + path: typeof CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH; + sha256: string; + byteLength: number; + }; + assets: CreateImagesArchiveAssetV1[]; +} + +export interface CreateImagesArchiveInventoryEntry { + path: string; + kind: "file" | "directory" | "symlink"; + encrypted: boolean; + compressionMethod: number; + compressedBytes: number; + uncompressedBytes: number; + crc32: number; +} + +/** + * Result of consuming one quarantined entry through a bounded streaming + * reader. Importers must produce these measurements from bytes actually read; + * central-directory declarations are not sufficient. + */ +export interface CreateImagesArchiveExtractedEntry { + path: string; + byteLength: number; + crc32: number; + sha256: string; +} + +export interface CreateImagesArchiveIssue { + path: string; + code: + | "invalid_manifest" + | "unsafe_path" + | "unsupported_entry" + | "duplicate_entry" + | "missing_entry" + | "unexpected_entry" + | "entry_count" + | "encrypted_entry" + | "compression_method" + | "size_limit" + | "compression_limit" + | "actual_size_mismatch" + | "checksum_mismatch" + | "digest_mismatch" + | "asset_contract_mismatch"; + message: string; +} + +export type CreateImagesArchiveManifestResult = + | { success: true; value: CreateImagesArchiveManifestV1 } + | { success: false; issues: CreateImagesArchiveIssue[] }; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function ownKeysExactly(record: Record, expected: readonly string[]): boolean { + const keys = Object.keys(record).sort(); + if (keys.length !== expected.length) return false; + const sortedExpected = [...expected].sort(); + return keys.every((key, index) => key === sortedExpected[index]); +} + +function isSafeArchivePath(value: string): boolean { + if ( + value.length === 0 || + value.length > 512 || + value.startsWith("/") || + value.startsWith("\\") || + /^[A-Za-z]:/u.test(value) || + value.includes("\\") || + value.includes("\0") + ) { + return false; + } + const segments = value.split("/"); + return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); +} + +function safeInteger(value: unknown, minimum: number, maximum: number): value is number { + return ( + typeof value === "number" && Number.isSafeInteger(value) && value >= minimum && value <= maximum + ); +} + +function manifestIssue(path: string, message: string): CreateImagesArchiveIssue { + return { path, code: "invalid_manifest", message }; +} + +function isPotentialNativeArchivePath(value: string): boolean { + return ( + value === CREATE_IMAGES_ARCHIVE_MANIFEST_PATH || + value === CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH || + /^assets\/[a-f0-9]{64}\.(?:jpg|png)$/u.test(value) + ); +} + +/** + * Manifest-independent ZIP central-directory gate. An importer must run this + * before selecting or reading any member, then bounded-read the sole canonical + * manifest with `parseCreateImagesArchiveManifestBytes`. + */ +export function validateCreateImagesArchiveBootstrap( + entries: readonly CreateImagesArchiveInventoryEntry[], +): CreateImagesArchiveIssue[] { + if (entries.length > CREATE_IMAGES_ARCHIVE_MAX_ENTRIES) { + return [ + { + path: "entries", + code: "entry_count", + message: `Archive contains more than ${CREATE_IMAGES_ARCHIVE_MAX_ENTRIES} entries.`, + }, + ]; + } + const issues: CreateImagesArchiveIssue[] = []; + const observed = new Set(); + let manifestCount = 0; + let totalBytes = 0; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const at = `entries[${index}]`; + if (!isSafeArchivePath(entry.path)) { + issues.push({ path: at, code: "unsafe_path", message: "Archive entry path is unsafe." }); + continue; + } + if (entry.path === CREATE_IMAGES_ARCHIVE_MANIFEST_PATH) manifestCount += 1; + if (observed.has(entry.path)) { + issues.push({ path: at, code: "duplicate_entry", message: "Duplicate archive entry." }); + } + observed.add(entry.path); + if (!isPotentialNativeArchivePath(entry.path)) { + issues.push({ path: at, code: "unexpected_entry", message: "Unexpected archive entry." }); + } + if (entry.kind !== "file") { + issues.push({ + path: at, + code: "unsupported_entry", + message: "Only regular files are supported.", + }); + } + if (entry.encrypted) { + issues.push({ + path: at, + code: "encrypted_entry", + message: "Encrypted archive entries are unsupported.", + }); + } + if ( + !Number.isSafeInteger(entry.compressionMethod) || + (entry.compressionMethod !== ZIP_COMPRESSION_STORED && + entry.compressionMethod !== ZIP_COMPRESSION_DEFLATE) + ) { + issues.push({ + path: at, + code: "compression_method", + message: "Archive entry uses an unsupported compression method.", + }); + } + if (!safeInteger(entry.crc32, 0, 0xffff_ffff)) { + issues.push({ + path: at, + code: "checksum_mismatch", + message: "Archive entry CRC-32 is invalid.", + }); + } + const entryLimit = + entry.path === CREATE_IMAGES_ARCHIVE_MANIFEST_PATH + ? CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES + : CREATE_IMAGES_ARCHIVE_MAX_ENTRY_BYTES; + if ( + !safeInteger(entry.compressedBytes, 0, CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES) || + !safeInteger(entry.uncompressedBytes, 1, entryLimit) + ) { + issues.push({ + path: at, + code: "size_limit", + message: "Archive entry exceeds its byte limit.", + }); + continue; + } + if (entry.uncompressedBytes > CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES - totalBytes) { + totalBytes = CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES + 1; + } else { + totalBytes += entry.uncompressedBytes; + } + if ( + entry.uncompressedBytes / Math.max(1, entry.compressedBytes) > + CREATE_IMAGES_ARCHIVE_MAX_COMPRESSION_RATIO + ) { + issues.push({ + path: at, + code: "compression_limit", + message: "Archive entry exceeds the compression-ratio limit.", + }); + } + } + if (manifestCount === 0) { + issues.push({ + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + code: "missing_entry", + message: "Archive manifest is missing.", + }); + } else if (manifestCount > 1) { + issues.push({ + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + code: "duplicate_entry", + message: "Archive must contain exactly one manifest.", + }); + } + if (totalBytes > CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES) { + issues.push({ + path: "entries", + code: "size_limit", + message: "Archive exceeds its total byte limit.", + }); + } + return issues; +} + +export function parseCreateImagesArchiveManifestBytes( + bytes: Uint8Array, + inventoryEntry: CreateImagesArchiveInventoryEntry, +): CreateImagesArchiveManifestResult { + const bootstrapIssues = validateCreateImagesArchiveBootstrap([inventoryEntry]); + if (bootstrapIssues.length > 0) return { success: false, issues: bootstrapIssues }; + if ( + bytes.byteLength !== inventoryEntry.uncompressedBytes || + bytes.byteLength > CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES + ) { + return { + success: false, + issues: [ + { + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + code: "actual_size_mismatch", + message: "Manifest bytes do not match the bounded inventory entry.", + }, + ], + }; + } + if (crc32(bytes) !== inventoryEntry.crc32) { + return { + success: false, + issues: [ + { + path: CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + code: "checksum_mismatch", + message: "Manifest CRC-32 does not match the archive inventory.", + }, + ], + }; + } + try { + return parseCreateImagesArchiveManifest( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown, + ); + } catch { + return { + success: false, + issues: [manifestIssue(CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, "Manifest JSON is invalid.")], + }; + } +} + +/** + * Parse the small JSON manifest before an importer extracts any archive entry. + * The workflow itself is parsed separately with `parseWorkflowDocument` after + * its digest and byte length have been verified. + */ +export function parseCreateImagesArchiveManifest( + value: unknown, +): CreateImagesArchiveManifestResult { + const issues: CreateImagesArchiveIssue[] = []; + if ( + !isRecord(value) || + !ownKeysExactly(value, ["format", "version", "exportedAt", "workflow", "assets"]) + ) { + return { success: false, issues: [manifestIssue("$", "Archive manifest fields are invalid.")] }; + } + if (value.format !== CREATE_IMAGES_ARCHIVE_FORMAT) { + issues.push(manifestIssue("$.format", "Unsupported Create Images archive format.")); + } + if (value.version !== CREATE_IMAGES_ARCHIVE_VERSION) { + issues.push(manifestIssue("$.version", "Unsupported Create Images archive version.")); + } + if ( + typeof value.exportedAt !== "string" || + value.exportedAt.length > 64 || + !Number.isFinite(Date.parse(value.exportedAt)) + ) { + issues.push(manifestIssue("$.exportedAt", "Expected an ISO-8601 export timestamp.")); + } + + const workflow = value.workflow; + if (!isRecord(workflow) || !ownKeysExactly(workflow, ["path", "sha256", "byteLength"])) { + issues.push(manifestIssue("$.workflow", "Workflow entry metadata is invalid.")); + } else { + if (workflow.path !== CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH) { + issues.push( + manifestIssue("$.workflow.path", "Workflow must use the canonical archive path."), + ); + } + if (typeof workflow.sha256 !== "string" || !SHA256_PATTERN.test(workflow.sha256)) { + issues.push(manifestIssue("$.workflow.sha256", "Workflow digest must be lowercase SHA-256.")); + } + if (!safeInteger(workflow.byteLength, 1, CREATE_IMAGES_ARCHIVE_MAX_WORKFLOW_BYTES)) { + issues.push(manifestIssue("$.workflow.byteLength", "Workflow byte length is invalid.")); + } + } + + const assetValues = value.assets; + if (!Array.isArray(assetValues) || assetValues.length > CREATE_IMAGES_MAX_ASSET_REFS) { + issues.push(manifestIssue("$.assets", "Archive asset inventory is invalid or too large.")); + } + const assets: CreateImagesArchiveAssetV1[] = []; + const ids = new Set(); + const paths = new Set(); + if (Array.isArray(assetValues) && assetValues.length <= CREATE_IMAGES_MAX_ASSET_REFS) { + for (let index = 0; index < assetValues.length; index += 1) { + const asset = assetValues[index]; + const at = `$.assets[${index}]`; + if ( + !isRecord(asset) || + !ownKeysExactly(asset, [ + "assetId", + "sha256", + "path", + "mediaType", + "byteLength", + "width", + "height", + ]) + ) { + issues.push(manifestIssue(at, "Asset entry metadata is invalid.")); + continue; + } + const validIdentity = + typeof asset.assetId === "string" && + SHA256_PATTERN.test(asset.assetId) && + typeof asset.sha256 === "string" && + asset.sha256 === asset.assetId; + if (!validIdentity) { + issues.push( + manifestIssue(`${at}.assetId`, "Asset ID must equal its lowercase SHA-256 digest."), + ); + } + const expectedPath = + validIdentity && asset.mediaType === "image/png" + ? `assets/${asset.assetId}.png` + : validIdentity && asset.mediaType === "image/jpeg" + ? `assets/${asset.assetId}.jpg` + : undefined; + if ( + typeof asset.path !== "string" || + !isSafeArchivePath(asset.path) || + !expectedPath || + asset.path !== expectedPath + ) { + issues.push( + manifestIssue(`${at}.path`, "Asset path is unsafe or is not content addressed."), + ); + } + if (typeof asset.mediaType !== "string" || !MEDIA_TYPES.has(asset.mediaType)) { + issues.push(manifestIssue(`${at}.mediaType`, "Unsupported image media type.")); + } + if (!safeInteger(asset.byteLength, 1, CREATE_IMAGES_ARCHIVE_MAX_ENTRY_BYTES)) { + issues.push(manifestIssue(`${at}.byteLength`, "Asset byte length is invalid.")); + } + if (!safeInteger(asset.width, 1, 100_000) || !safeInteger(asset.height, 1, 100_000)) { + issues.push(manifestIssue(`${at}.width`, "Asset dimensions are invalid.")); + } + if (typeof asset.assetId === "string" && ids.has(asset.assetId)) { + issues.push(manifestIssue(`${at}.assetId`, "Duplicate asset ID.")); + } + if (typeof asset.path === "string" && paths.has(asset.path)) { + issues.push(manifestIssue(`${at}.path`, "Duplicate asset path.")); + } + if (typeof asset.assetId === "string") ids.add(asset.assetId); + if (typeof asset.path === "string") paths.add(asset.path); + if ( + validIdentity && + typeof asset.path === "string" && + isSafeArchivePath(asset.path) && + typeof asset.mediaType === "string" && + MEDIA_TYPES.has(asset.mediaType) && + safeInteger(asset.byteLength, 1, CREATE_IMAGES_ARCHIVE_MAX_ENTRY_BYTES) && + safeInteger(asset.width, 1, 100_000) && + safeInteger(asset.height, 1, 100_000) + ) { + assets.push(asset as unknown as CreateImagesArchiveAssetV1); + } + } + } + + const declaredAssetBytes = assets.reduce((total, asset) => total + asset.byteLength, 0); + if (declaredAssetBytes > CREATE_IMAGES_MAX_TOTAL_ASSET_BYTES) { + issues.push(manifestIssue("$.assets", "Archive assets exceed the storage byte limit.")); + } + const declaredPayloadBytes = + (isRecord(workflow) && + safeInteger(workflow.byteLength, 1, CREATE_IMAGES_ARCHIVE_MAX_WORKFLOW_BYTES) + ? workflow.byteLength + : 0) + declaredAssetBytes; + if (declaredPayloadBytes > CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES) { + issues.push(manifestIssue("$", "Archive manifest exceeds its total byte limit.")); + } + + if (issues.length > 0 || !isRecord(workflow)) return { success: false, issues }; + return { + success: true, + value: { + format: CREATE_IMAGES_ARCHIVE_FORMAT, + version: CREATE_IMAGES_ARCHIVE_VERSION, + exportedAt: value.exportedAt as string, + workflow: workflow as unknown as CreateImagesArchiveManifestV1["workflow"], + assets, + }, + }; +} + +/** Validate the ZIP inventory before extraction to block zip-slip, symlinks, + * duplicate names, unexpected payloads, and decompression bombs. */ +export function validateCreateImagesArchiveInventory( + manifest: CreateImagesArchiveManifestV1, + entries: readonly CreateImagesArchiveInventoryEntry[], +): CreateImagesArchiveIssue[] { + const bootstrapIssues = validateCreateImagesArchiveBootstrap(entries); + if (bootstrapIssues.length > 0) return bootstrapIssues; + const issues: CreateImagesArchiveIssue[] = []; + const expected = new Set([ + CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + ...manifest.assets.map((asset) => asset.path), + ]); + const observed = new Set(); + const manifestByteLengths = new Map([ + [manifest.workflow.path, manifest.workflow.byteLength], + ...manifest.assets.map((asset) => [asset.path, asset.byteLength] as const), + ]); + let totalBytes = 0; + + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const at = `entries[${index}]`; + if (!isSafeArchivePath(entry.path)) { + issues.push({ path: at, code: "unsafe_path", message: "Archive entry path is unsafe." }); + continue; + } + if (entry.kind !== "file") { + issues.push({ + path: at, + code: "unsupported_entry", + message: "Only regular files are supported.", + }); + continue; + } + if (entry.encrypted) { + issues.push({ + path: at, + code: "encrypted_entry", + message: "Encrypted archive entries are unsupported.", + }); + } + if ( + !Number.isSafeInteger(entry.compressionMethod) || + (entry.compressionMethod !== ZIP_COMPRESSION_STORED && + entry.compressionMethod !== ZIP_COMPRESSION_DEFLATE) + ) { + issues.push({ + path: at, + code: "compression_method", + message: "Archive entry uses an unsupported compression method.", + }); + } + if (!safeInteger(entry.crc32, 0, 0xffff_ffff)) { + issues.push({ + path: at, + code: "checksum_mismatch", + message: "Archive entry CRC-32 is invalid.", + }); + } + if (observed.has(entry.path)) { + issues.push({ path: at, code: "duplicate_entry", message: "Duplicate archive entry." }); + continue; + } + observed.add(entry.path); + if (!expected.has(entry.path)) { + issues.push({ path: at, code: "unexpected_entry", message: "Unexpected archive entry." }); + } + if ( + !safeInteger(entry.compressedBytes, 0, CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES) || + !safeInteger(entry.uncompressedBytes, 1, CREATE_IMAGES_ARCHIVE_MAX_ENTRY_BYTES) + ) { + issues.push({ + path: at, + code: "size_limit", + message: "Archive entry exceeds its byte limit.", + }); + continue; + } + totalBytes += entry.uncompressedBytes; + const manifestByteLength = manifestByteLengths.get(entry.path); + if (manifestByteLength !== undefined && entry.uncompressedBytes !== manifestByteLength) { + issues.push({ + path: at, + code: "actual_size_mismatch", + message: "Archive entry byte length does not match the native manifest.", + }); + } + const ratio = entry.uncompressedBytes / Math.max(1, entry.compressedBytes); + if (ratio > CREATE_IMAGES_ARCHIVE_MAX_COMPRESSION_RATIO) { + issues.push({ + path: at, + code: "compression_limit", + message: "Archive entry exceeds the compression-ratio limit.", + }); + } + } + + if (totalBytes > CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES) { + issues.push({ + path: "entries", + code: "size_limit", + message: "Archive exceeds its total byte limit.", + }); + } + for (const path of expected) { + if (!observed.has(path)) { + issues.push({ path, code: "missing_entry", message: "Required archive entry is missing." }); + } + } + return issues; +} + +/** + * Compare bounded, streamed quarantine measurements with both the ZIP + * inventory and the signed-by-content native manifest. This is deliberately + * separate from inventory validation so a future importer cannot accidentally + * treat attacker-controlled declared sizes/checksums as observed facts. + */ +export function validateCreateImagesArchiveExtractedEntries( + manifest: CreateImagesArchiveManifestV1, + inventory: readonly CreateImagesArchiveInventoryEntry[], + extracted: readonly CreateImagesArchiveExtractedEntry[], +): CreateImagesArchiveIssue[] { + if ( + inventory.length > CREATE_IMAGES_ARCHIVE_MAX_ENTRIES || + extracted.length > CREATE_IMAGES_ARCHIVE_MAX_ENTRIES + ) { + return [ + { path: "entries", code: "entry_count", message: "Archive entry count exceeds its limit." }, + ]; + } + const issues: CreateImagesArchiveIssue[] = []; + const declared = new Map(inventory.map((entry) => [entry.path, entry] as const)); + const expectedDigests = new Map([ + [manifest.workflow.path, manifest.workflow.sha256], + ...manifest.assets.map((asset) => [asset.path, asset.sha256] as const), + ]); + const expectedByteLengths = new Map([ + [manifest.workflow.path, manifest.workflow.byteLength], + ...manifest.assets.map((asset) => [asset.path, asset.byteLength] as const), + ]); + const observed = new Set(); + for (let index = 0; index < extracted.length; index += 1) { + const entry = extracted[index]; + const at = `extracted[${index}]`; + if (!isSafeArchivePath(entry.path) || observed.has(entry.path)) { + issues.push({ + path: at, + code: observed.has(entry.path) ? "duplicate_entry" : "unsafe_path", + message: "Extracted archive entry identity is unsafe or duplicated.", + }); + continue; + } + observed.add(entry.path); + const inventoryEntry = declared.get(entry.path); + if (!inventoryEntry) { + issues.push({ + path: at, + code: "unexpected_entry", + message: "Extracted entry was not declared.", + }); + continue; + } + if (entry.byteLength !== inventoryEntry.uncompressedBytes) { + issues.push({ + path: at, + code: "actual_size_mismatch", + message: "Extracted byte length does not match the archive inventory.", + }); + } + const expectedByteLength = expectedByteLengths.get(entry.path); + if (expectedByteLength !== undefined && entry.byteLength !== expectedByteLength) { + issues.push({ + path: at, + code: "actual_size_mismatch", + message: "Extracted byte length does not match the native manifest.", + }); + } + if (entry.crc32 !== inventoryEntry.crc32) { + issues.push({ + path: at, + code: "checksum_mismatch", + message: "Extracted CRC-32 does not match the archive inventory.", + }); + } + const expectedDigest = expectedDigests.get(entry.path); + if (expectedDigest && entry.sha256 !== expectedDigest) { + issues.push({ + path: at, + code: "digest_mismatch", + message: "Extracted SHA-256 does not match the native manifest.", + }); + } + } + for (const entry of inventory) { + if (!observed.has(entry.path)) { + issues.push({ + path: entry.path, + code: "missing_entry", + message: "Declared entry was not extracted.", + }); + } + } + return issues; +} + +export interface CreateImagesArchiveValidatedAsset { + assetId: string; + mediaType: "image/jpeg" | "image/png"; + byteLength: number; + width: number; + height: number; +} + +/** + * Final pre-publication referential-integrity gate. Call only after the + * workflow has passed `parseWorkflowDocument` and every image has passed the + * main-owned structural/deep decoder boundary. + */ +export function validateCreateImagesArchiveWorkflowAssets( + manifest: CreateImagesArchiveManifestV1, + workflow: WorkflowDocumentV1, + assets: readonly CreateImagesArchiveValidatedAsset[], +): CreateImagesArchiveIssue[] { + const issues: CreateImagesArchiveIssue[] = []; + const manifestIds = new Set(manifest.assets.map((asset) => asset.assetId)); + const workflowIds = new Set(workflow.assetRefs); + if ( + manifestIds.size !== workflowIds.size || + [...manifestIds].some((assetId) => !workflowIds.has(assetId)) + ) { + issues.push({ + path: "$.workflow.assetRefs", + code: "asset_contract_mismatch", + message: "Workflow asset references must exactly match the native archive manifest.", + }); + } + const validatedById = new Map(); + for (const [index, asset] of assets.entries()) { + if (validatedById.has(asset.assetId)) { + issues.push({ + path: `validatedAssets[${index}]`, + code: "duplicate_entry", + message: "A validated archive asset was duplicated.", + }); + } + validatedById.set(asset.assetId, asset); + } + if ( + validatedById.size !== manifestIds.size || + [...validatedById.keys()].some((assetId) => !manifestIds.has(assetId)) + ) { + issues.push({ + path: "validatedAssets", + code: "asset_contract_mismatch", + message: "Validated image assets must exactly match the native archive manifest.", + }); + } + for (const [index, expected] of manifest.assets.entries()) { + const actual = validatedById.get(expected.assetId); + if ( + !actual || + actual.mediaType !== expected.mediaType || + actual.byteLength !== expected.byteLength || + actual.width !== expected.width || + actual.height !== expected.height + ) { + issues.push({ + path: `$.assets[${index}]`, + code: "asset_contract_mismatch", + message: "Validated image metadata does not match the native archive manifest.", + }); + } + } + return issues; +} diff --git a/renderer/shared/create-images/execution.test.ts b/renderer/shared/create-images/execution.test.ts new file mode 100644 index 00000000..2ed6651c --- /dev/null +++ b/renderer/shared/create-images/execution.test.ts @@ -0,0 +1,571 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CREATE_IMAGES_MAX_DOWNSTREAM_PATH_CHOICES, + CREATE_IMAGES_MAX_DOWNSTREAM_PATH_SEARCH_STEPS, + enumerateWorkflowDownstreamPaths, + isWorkflowDownstreamPathExplicit, + isWorkflowRunScopeExecutable, + planWorkflowExecution, + reduceWorkflowRunTransition, + runWorkflowPlan, + type WorkflowExecutionPlan, + type WorkflowNodeRunTransition, +} from "./execution.js"; +import type { WorkflowDocumentV1, WorkflowNodeV1 } from "./schema.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; + +function documentWith(nodes: WorkflowNodeV1[]): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "workflow-1", + title: "Execution test", + revision: 1, + createdAt: NOW, + updatedAt: NOW, + nodes, + edges: [], + assetRefs: [], + settings: { concurrency: 1 }, + }; +} + +function prompt(id: string): WorkflowNodeV1 { + return { id, type: "prompt", position: { x: 0, y: 0 }, data: { text: id } }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +test("scheduler respects concurrency and stable plan order", async () => { + const document = documentWith([prompt("node-a"), prompt("node-b"), prompt("node-c")]); + const plan = planWorkflowExecution(document, { kind: "all" }); + const gates = new Map(plan.orderedNodeIds.map((nodeId) => [nodeId, deferred()])); + let active = 0; + let maximumActive = 0; + const started: string[] = []; + const run = runWorkflowPlan(document, plan, { + runId: "run-concurrency", + concurrency: 2, + executeNode: async ({ node }) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + started.push(node.id); + await gates.get(node.id)?.promise; + active -= 1; + return node.id; + }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(started, ["node-a", "node-b"]); + gates.get("node-a")?.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(started, ["node-a", "node-b", "node-c"]); + gates.get("node-b")?.resolve(); + gates.get("node-c")?.resolve(); + const result = await run; + assert.equal(maximumActive, 2); + assert.deepEqual(result.statuses, { + "node-a": "succeeded", + "node-b": "succeeded", + "node-c": "succeeded", + }); +}); + +test("failed nodes block descendants while independent work completes", async () => { + const document = documentWith([ + prompt("prompt-1"), + { + id: "generate-1", + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { id: "output-1", type: "output", position: { x: 200, y: 0 }, data: {} }, + prompt("independent"), + ]); + document.edges = [ + { + id: "edge-1", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "edge-2", + source: "generate-1", + sourcePort: "images", + target: "output-1", + targetPort: "images", + }, + ]; + const plan = planWorkflowExecution(document, { kind: "all" }); + const result = await runWorkflowPlan(document, plan, { + runId: "run-failure", + concurrency: 2, + executeNode: async ({ node }) => { + if (node.id === "generate-1") throw new Error("provider failed"); + return node.id; + }, + }); + assert.equal(result.statuses["prompt-1"], "succeeded"); + assert.equal(result.statuses["generate-1"], "failed"); + assert.equal(result.statuses["output-1"], "blocked"); + assert.equal(result.statuses.independent, "succeeded"); +}); + +test("cancellation rejects late completion instead of publishing it", async () => { + const document = documentWith([prompt("node-a")]); + const plan = planWorkflowExecution(document, { kind: "all" }); + const gate = deferred(); + const controller = new AbortController(); + const run = runWorkflowPlan(document, plan, { + runId: "run-cancel", + concurrency: 1, + signal: controller.signal, + executeNode: () => gate.promise, + }); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(new Error("cancelled by test")); + gate.resolve("late output"); + const result = await run; + assert.equal(result.statuses["node-a"], "cancelled"); + assert.equal(result.outputs.has("node-a"), false); +}); + +test("scheduler normalizes synchronous executor failures", async () => { + const document = documentWith([prompt("node-a")]); + const plan = planWorkflowExecution(document, { kind: "all" }); + const result = await runWorkflowPlan(document, plan, { + runId: "run-sync-failure", + concurrency: 1, + executeNode: () => { + throw new Error("synchronous failure"); + }, + }); + assert.equal(result.statuses["node-a"], "failed"); + assert.match( + result.transitions[result.transitions.length - 1]?.error ?? "", + /synchronous failure/u, + ); +}); + +test("run-from-node includes required ancestors and optional downstream nodes", () => { + const document = documentWith([ + prompt("prompt-1"), + { + id: "generate-1", + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { id: "output-1", type: "output", position: { x: 200, y: 0 }, data: {} }, + ]); + document.edges = [ + { + id: "edge-1", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "edge-2", + source: "generate-1", + sourcePort: "images", + target: "output-1", + targetPort: "images", + }, + ]; + const selected: WorkflowExecutionPlan = planWorkflowExecution(document, { + kind: "from-node", + nodeId: "generate-1", + }); + assert.deepEqual(selected.orderedNodeIds, ["prompt-1", "generate-1"]); + const downstream = planWorkflowExecution(document, { + kind: "from-node", + nodeId: "generate-1", + downstreamPath: ["output-1"], + }); + assert.deepEqual(downstream.orderedNodeIds, ["prompt-1", "generate-1", "output-1"]); + assert.equal( + isWorkflowRunScopeExecutable(document, { + kind: "from-node", + nodeId: "generate-1", + downstreamPath: ["output-1"], + }), + true, + ); + assert.equal( + isWorkflowRunScopeExecutable(document, { + kind: "from-node", + nodeId: "generate-1", + downstreamPath: ["missing-output"], + }), + false, + ); + assert.equal(isWorkflowDownstreamPathExplicit(document, "generate-1", ["output-1"]), true); +}); + +test("downstream path choices are deterministic, connected, and deduplicate parallel edges", () => { + const document = documentWith([ + prompt("start"), + prompt("branch-b"), + prompt("branch-a"), + prompt("sink-b"), + prompt("sink-a"), + ]); + document.edges = [ + { id: "z", source: "start", sourcePort: "text", target: "branch-a", targetPort: "text" }, + { id: "a", source: "start", sourcePort: "text", target: "branch-b", targetPort: "text" }, + { + id: "parallel", + source: "start", + sourcePort: "text", + target: "branch-b", + targetPort: "text", + }, + { + id: "sink-a", + source: "branch-a", + sourcePort: "text", + target: "sink-a", + targetPort: "text", + }, + { + id: "sink-b", + source: "branch-b", + sourcePort: "text", + target: "sink-b", + targetPort: "text", + }, + ]; + const result = enumerateWorkflowDownstreamPaths(document, "start"); + assert.equal(result.truncated, false); + assert.deepEqual( + result.choices.map((choice) => choice.downstreamPath), + [ + ["branch-b", "sink-b"], + ["branch-a", "sink-a"], + ], + ); + assert.deepEqual( + result.choices.map((choice) => choice.id), + ["path:1", "path:2"], + ); +}); + +test("a downstream choice is rejected when a rejoining branch would run invisibly", () => { + const generation = (id: string): WorkflowNodeV1 => ({ + id, + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }); + const document = documentWith([ + prompt("start"), + generation("generation-a"), + generation("generation-b"), + { + id: "gallery", + type: "output-gallery", + position: { x: 200, y: 0 }, + data: {}, + }, + ]); + document.edges = [ + { + id: "prompt-a", + source: "start", + sourcePort: "text", + target: "generation-a", + targetPort: "prompt", + }, + { + id: "prompt-b", + source: "start", + sourcePort: "text", + target: "generation-b", + targetPort: "prompt", + }, + { + id: "images-a", + source: "generation-a", + sourcePort: "images", + target: "gallery", + targetPort: "images", + }, + { + id: "images-b", + source: "generation-b", + sourcePort: "images", + target: "gallery", + targetPort: "images", + }, + ]; + const choices = enumerateWorkflowDownstreamPaths(document, "start").choices; + assert.equal(choices.length, 2); + assert.equal( + isWorkflowDownstreamPathExplicit(document, "start", choices[0]!.downstreamPath), + false, + ); + assert.equal( + isWorkflowDownstreamPathExplicit(document, "start", choices[1]!.downstreamPath), + false, + ); + assert.throws( + () => + planWorkflowExecution(document, { + kind: "from-node", + nodeId: "start", + downstreamPath: choices[0]!.downstreamPath, + }), + /additional branch work/u, + ); +}); + +test("downstream enumeration caps exponential fan-out and explains overflow", () => { + const layers = Array.from({ length: 7 }, (_, layer) => + layer === 0 ? ["start"] : [`layer-${layer}-a`, `layer-${layer}-b`], + ); + const document = documentWith(layers.flat().map(prompt)); + document.edges = layers.slice(0, -1).flatMap((layer, layerIndex) => + layer.flatMap((source) => + layers[layerIndex + 1]!.map((target) => ({ + id: `${source}-${target}`, + source, + sourcePort: "text", + target, + targetPort: "text", + })), + ), + ); + const result = enumerateWorkflowDownstreamPaths(document, "start"); + assert.equal(result.choices.length, CREATE_IMAGES_MAX_DOWNSTREAM_PATH_CHOICES); + assert.equal(result.truncated, true); + assert.equal(result.overflowReason, "choice-limit"); + assert.ok(result.searchSteps <= CREATE_IMAGES_MAX_DOWNSTREAM_PATH_SEARCH_STEPS); + assert.equal( + new Set(result.choices.map((choice) => choice.downstreamPath.join(">"))).size, + CREATE_IMAGES_MAX_DOWNSTREAM_PATH_CHOICES, + ); +}); + +test("downstream enumeration handles the maximum path depth without recursion", () => { + const nodes = Array.from({ length: 500 }, (_, index) => prompt(`node-${index}`)); + const document = documentWith(nodes); + document.edges = nodes.slice(0, -1).map((node, index) => ({ + id: `edge-${index}`, + source: node.id, + sourcePort: "text", + target: nodes[index + 1]!.id, + targetPort: "text", + })); + const result = enumerateWorkflowDownstreamPaths(document, "node-0"); + assert.equal(result.truncated, false); + assert.equal(result.choices.length, 1); + assert.equal(result.choices[0]?.downstreamPath.length, 499); + assert.ok(result.searchSteps <= CREATE_IMAGES_MAX_DOWNSTREAM_PATH_SEARCH_STEPS); +}); + +test("cancellation terminalizes without waiting for a non-cooperative executor", async () => { + const document = documentWith([prompt("node-a")]); + const plan = planWorkflowExecution(document, { kind: "all" }); + const controller = new AbortController(); + const never = new Promise(() => undefined); + const run = runWorkflowPlan(document, plan, { + runId: "run-non-cooperative", + concurrency: 1, + signal: controller.signal, + executeNode: () => never, + }); + await new Promise((resolve) => setImmediate(resolve)); + controller.abort(); + const result = await Promise.race([ + run, + new Promise((_, reject) => + setTimeout(() => reject(new Error("cancelled run did not terminalize")), 100), + ), + ]); + assert.equal(result.statuses["node-a"], "cancelled"); + assert.equal(result.outputs.size, 0); +}); + +test("a rejection without a reason is still a failure", async () => { + const document = documentWith([prompt("node-a")]); + const plan = planWorkflowExecution(document, { kind: "all" }); + const result = await runWorkflowPlan(document, plan, { + runId: "run-undefined-rejection", + concurrency: 1, + executeNode: () => Promise.reject(), + }); + assert.equal(result.statuses["node-a"], "failed"); + assert.equal(result.outputs.has("node-a"), false); +}); + +test("execution uses the immutable plan snapshot after the live document mutates", async () => { + const document = documentWith([prompt("node-a")]); + const plan = planWorkflowExecution(document, { kind: "all" }); + const liveNode = document.nodes[0]; + assert.ok(liveNode?.type === "prompt"); + liveNode.data.text = "MUTATED"; + const observed: string[] = []; + await runWorkflowPlan(document, plan, { + runId: "run-snapshot", + concurrency: 1, + executeNode: async ({ node }) => { + if (node.type === "prompt") observed.push(node.data.text); + return node.id; + }, + }); + assert.deepEqual(observed, ["node-a"]); + assert.equal(Object.isFrozen(plan.snapshot.nodes[0]?.data), true); +}); + +test("explicit downstream paths reject target ancestors that were not selected", () => { + const document = documentWith([ + prompt("prompt-1"), + prompt("prompt-2"), + { + id: "generate-1", + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { + id: "generate-2", + type: "generate-image", + position: { x: 200, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { id: "output-selected", type: "output", position: { x: 300, y: 0 }, data: {} }, + { id: "output-other", type: "output", position: { x: 300, y: 100 }, data: {} }, + ]); + document.edges = [ + { + id: "e1", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "e2", + source: "prompt-2", + sourcePort: "text", + target: "generate-2", + targetPort: "prompt", + }, + { + id: "e3", + source: "generate-1", + sourcePort: "images", + target: "generate-2", + targetPort: "references", + }, + { + id: "e4", + source: "generate-2", + sourcePort: "images", + target: "output-selected", + targetPort: "images", + }, + { + id: "e5", + source: "generate-1", + sourcePort: "images", + target: "output-other", + targetPort: "images", + }, + ]; + assert.throws( + () => + planWorkflowExecution(document, { + kind: "from-node", + nodeId: "generate-1", + downstreamPath: ["generate-2", "output-selected"], + }), + /additional branch work/u, + ); + assert.throws( + () => + planWorkflowExecution(document, { + kind: "from-node", + nodeId: "generate-1", + downstreamPath: ["output-selected"], + }), + /not connected/u, + ); +}); + +test("transition reduction rejects cross-run, duplicate, stale, and out-of-order events", () => { + const cursor = { + workflowId: "workflow-1", + workflowRevision: 1, + runId: "run-current", + lastSequence: 0, + }; + const transition = (overrides: Partial = {}) => ({ + workflowId: "workflow-1", + workflowRevision: 1, + runId: "run-current", + nodeId: "node-a", + status: "running" as const, + sequence: 1, + ...overrides, + }); + const accepted = reduceWorkflowRunTransition(cursor, transition()); + assert.equal(accepted.lastSequence, 1); + assert.equal(reduceWorkflowRunTransition(accepted, transition()), accepted); + assert.equal(reduceWorkflowRunTransition(accepted, transition({ sequence: 3 })), accepted); + assert.equal( + reduceWorkflowRunTransition(accepted, transition({ runId: "run-stale", sequence: 2 })), + accepted, + ); + assert.equal( + reduceWorkflowRunTransition(accepted, transition({ workflowRevision: 2, sequence: 2 })), + accepted, + ); +}); diff --git a/renderer/shared/create-images/execution.ts b/renderer/shared/create-images/execution.ts new file mode 100644 index 00000000..f1e069dd --- /dev/null +++ b/renderer/shared/create-images/execution.ts @@ -0,0 +1,578 @@ +import { + topologicalWorkflowOrder, + validateWorkflowGraph, + type WorkflowGraphIssue, +} from "./ports.js"; +import { parseWorkflowDocument, type WorkflowDocumentV1, type WorkflowNodeV1 } from "./schema.js"; + +export type WorkflowRunScope = + | { kind: "all" } + | { kind: "from-node"; nodeId: string; downstreamPath?: readonly string[] }; + +export interface WorkflowExecutionPlan { + workflowId: string; + workflowRevision: number; + scope: WorkflowRunScope; + snapshot: WorkflowDocumentV1; + orderedNodeIds: string[]; + dependencies: Readonly>; +} + +export const CREATE_IMAGES_MAX_DOWNSTREAM_PATH_CHOICES = 24; +export const CREATE_IMAGES_MAX_DOWNSTREAM_PATH_SEARCH_STEPS = 25_000; + +export interface WorkflowDownstreamPathChoice { + id: string; + downstreamPath: readonly string[]; + terminalNodeId: string; +} + +export interface WorkflowDownstreamPathChoices { + choices: readonly WorkflowDownstreamPathChoice[]; + truncated: boolean; + overflowReason?: "choice-limit" | "search-budget"; + searchSteps: number; +} + +export class WorkflowPlanError extends Error { + constructor(readonly issues: readonly WorkflowGraphIssue[]) { + super(issues[0]?.message ?? "The workflow cannot run."); + this.name = "WorkflowPlanError"; + } +} + +function adjacency(document: WorkflowDocumentV1): { + incoming: Map; + outgoing: Map; +} { + const incoming = new Map(document.nodes.map((node) => [node.id, [] as string[]])); + const outgoing = new Map(document.nodes.map((node) => [node.id, [] as string[]])); + for (const edge of document.edges) { + incoming.get(edge.target)?.push(edge.source); + outgoing.get(edge.source)?.push(edge.target); + } + return { incoming, outgoing }; +} + +/** + * Enumerate complete, connected paths from one node to downstream sinks. + * + * The traversal is iterative and has both a result cap and a hard search-step + * budget. Outgoing nodes use workflow node order, then opaque node ID, so an + * identical immutable revision always presents choices in the same order. + * Parallel edges are intentionally deduplicated because they produce the same + * executable node path. + */ +export function enumerateWorkflowDownstreamPaths( + document: WorkflowDocumentV1, + startNodeId: string, +): WorkflowDownstreamPathChoices { + if (!document.nodes.some((node) => node.id === startNodeId)) { + throw new WorkflowPlanError([ + { + code: "unknown_node", + nodeId: startNodeId, + message: "The selected run node is not in this workflow revision.", + }, + ]); + } + const nodeOrder = new Map(document.nodes.map((node, index) => [node.id, index])); + const outgoingSets = new Map(document.nodes.map((node) => [node.id, new Set()])); + for (const edge of document.edges) { + if (nodeOrder.has(edge.source) && nodeOrder.has(edge.target)) { + outgoingSets.get(edge.source)?.add(edge.target); + } + } + const outgoing = new Map(); + for (const [nodeId, targets] of outgoingSets) { + outgoing.set( + nodeId, + [...targets].sort( + (left, right) => + (nodeOrder.get(left) ?? Number.MAX_SAFE_INTEGER) - + (nodeOrder.get(right) ?? Number.MAX_SAFE_INTEGER) || left.localeCompare(right), + ), + ); + } + + const collected: WorkflowDownstreamPathChoice[] = []; + const path = [startNodeId]; + const inPath = new Set(path); + const stack: Array<{ nodeId: string; nextTargetIndex: number }> = [ + { nodeId: startNodeId, nextTargetIndex: 0 }, + ]; + let searchSteps = 0; + let overflowReason: WorkflowDownstreamPathChoices["overflowReason"]; + + while (stack.length > 0) { + if (searchSteps >= CREATE_IMAGES_MAX_DOWNSTREAM_PATH_SEARCH_STEPS) { + overflowReason = "search-budget"; + break; + } + const frame = stack[stack.length - 1]!; + const targets = outgoing.get(frame.nodeId) ?? []; + if (targets.length === 0 && path.length > 1) { + const downstreamPath = path.slice(1); + collected.push({ + id: `path:${collected.length + 1}`, + downstreamPath, + terminalNodeId: downstreamPath[downstreamPath.length - 1]!, + }); + if (collected.length > CREATE_IMAGES_MAX_DOWNSTREAM_PATH_CHOICES) { + overflowReason = "choice-limit"; + break; + } + const removed = path.pop(); + if (removed) inPath.delete(removed); + stack.pop(); + continue; + } + const target = targets[frame.nextTargetIndex]; + if (target === undefined) { + const removed = path.pop(); + if (removed) inPath.delete(removed); + stack.pop(); + continue; + } + frame.nextTargetIndex += 1; + searchSteps += 1; + if (inPath.has(target)) continue; + path.push(target); + inPath.add(target); + stack.push({ nodeId: target, nextTargetIndex: 0 }); + } + + return { + choices: collected.slice(0, CREATE_IMAGES_MAX_DOWNSTREAM_PATH_CHOICES), + truncated: overflowReason !== undefined, + ...(overflowReason ? { overflowReason } : {}), + searchSteps, + }; +} + +function traverseMany( + starts: readonly string[], + edges: ReadonlyMap, +): Set { + const visited = new Set(); + const pending = [...starts]; + while (pending.length > 0) { + const current = pending.pop(); + if (!current || visited.has(current)) continue; + visited.add(current); + for (const next of edges.get(current) ?? []) pending.push(next); + } + return visited; +} + +function traverse(start: string, edges: ReadonlyMap): Set { + return traverseMany([start], edges); +} + +function scopedDocument(document: WorkflowDocumentV1, scope: WorkflowRunScope): WorkflowDocumentV1 { + if (scope.kind === "all") return document; + if (!document.nodes.some((node) => node.id === scope.nodeId)) { + throw new WorkflowPlanError([ + { + code: "unknown_node", + nodeId: scope.nodeId, + message: "The selected run node is not in this workflow revision.", + }, + ]); + } + const { incoming, outgoing } = adjacency(document); + const included = new Set(); + const selectedPath = [scope.nodeId, ...(scope.downstreamPath ?? [])]; + const reachable = traverse(scope.nodeId, outgoing); + for (let index = 1; index < selectedPath.length; index += 1) { + const previous = selectedPath[index - 1]; + const current = selectedPath[index]; + const connected = document.edges.some( + (edge) => edge.source === previous && edge.target === current, + ); + if (!current || !reachable.has(current) || !connected) { + throw new WorkflowPlanError([ + { + code: "invalid_run_scope", + nodeId: current, + message: "The selected downstream run path is not connected in this workflow revision.", + }, + ]); + } + } + if (new Set(selectedPath).size !== selectedPath.length) { + throw new WorkflowPlanError([ + { + code: "invalid_run_scope", + nodeId: scope.nodeId, + message: "The selected downstream run path contains the same node more than once.", + }, + ]); + } + for (const nodeId of traverseMany(selectedPath, incoming)) included.add(nodeId); + if (scope.downstreamPath !== undefined) { + const selectedOnlyNodeIds = traverse(scope.nodeId, incoming); + const explicitPathNodeIds = new Set(selectedPath); + const implicitDownstreamNodeId = [...included].find( + (nodeId) => !selectedOnlyNodeIds.has(nodeId) && !explicitPathNodeIds.has(nodeId), + ); + if (implicitDownstreamNodeId) { + throw new WorkflowPlanError([ + { + code: "invalid_run_scope", + nodeId: implicitDownstreamNodeId, + message: + "The selected downstream path requires additional branch work that was not explicitly selected.", + }, + ]); + } + } + return { + ...document, + nodes: document.nodes.filter((node) => included.has(node.id)), + edges: document.edges.filter((edge) => included.has(edge.source) && included.has(edge.target)), + }; +} + +export interface WorkflowRunScopeAnalysis { + executable: boolean; + orderedNodeIds: readonly string[]; +} + +export function analyzeWorkflowRunScope( + document: WorkflowDocumentV1, + scope: WorkflowRunScope, +): WorkflowRunScopeAnalysis { + try { + const scoped = scopedDocument(document, scope); + if (validateWorkflowGraph(scoped, { forRun: true }).length > 0) { + return { executable: false, orderedNodeIds: [] }; + } + const topological = topologicalWorkflowOrder(scoped); + return topological.issues.length === 0 + ? { executable: true, orderedNodeIds: topological.order } + : { executable: false, orderedNodeIds: [] }; + } catch (error) { + if (error instanceof WorkflowPlanError) return { executable: false, orderedNodeIds: [] }; + throw error; + } +} + +export function isWorkflowRunScopeExecutable( + document: WorkflowDocumentV1, + scope: WorkflowRunScope, +): boolean { + return analyzeWorkflowRunScope(document, scope).executable; +} + +export function isWorkflowDownstreamPathExplicit( + document: WorkflowDocumentV1, + startNodeId: string, + downstreamPath: readonly string[], +): boolean { + if (downstreamPath.length === 0) return false; + const selectedOnly = analyzeWorkflowRunScope(document, { + kind: "from-node", + nodeId: startNodeId, + }); + if (!selectedOnly.executable) return false; + const candidate = analyzeWorkflowRunScope(document, { + kind: "from-node", + nodeId: startNodeId, + downstreamPath, + }); + if (!candidate.executable) return false; + const selectedOnlyNodeIds = new Set(selectedOnly.orderedNodeIds); + const explicitNodeIds = new Set(downstreamPath); + return candidate.orderedNodeIds.every( + (nodeId) => selectedOnlyNodeIds.has(nodeId) || explicitNodeIds.has(nodeId), + ); +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +function immutableWorkflowSnapshot(document: WorkflowDocumentV1): WorkflowDocumentV1 { + const parsed = parseWorkflowDocument(document); + if (!parsed.success) { + throw new Error(parsed.issues[0]?.message ?? "The workflow snapshot is invalid."); + } + return deepFreeze(parsed.value); +} + +export function planWorkflowExecution( + document: WorkflowDocumentV1, + scope: WorkflowRunScope, +): WorkflowExecutionPlan { + const snapshot = immutableWorkflowSnapshot(document); + const snapshotScope: WorkflowRunScope = + scope.kind === "all" + ? { kind: "all" } + : { + kind: "from-node", + nodeId: scope.nodeId, + ...(scope.downstreamPath ? { downstreamPath: [...scope.downstreamPath] } : {}), + }; + const scoped = scopedDocument(snapshot, snapshotScope); + const issues = validateWorkflowGraph(scoped, { forRun: true }); + if (issues.length > 0) throw new WorkflowPlanError(issues); + const topological = topologicalWorkflowOrder(scoped); + if (topological.issues.length > 0) throw new WorkflowPlanError(topological.issues); + const included = new Set(topological.order); + const dependencies: Record = Object.fromEntries( + topological.order.map((nodeId) => [nodeId, []]), + ); + for (const edge of scoped.edges) { + if (included.has(edge.source) && included.has(edge.target)) { + dependencies[edge.target]?.push(edge.source); + } + } + const order = new Map(topological.order.map((nodeId, index) => [nodeId, index])); + for (const values of Object.values(dependencies)) { + values.sort((left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0)); + } + return deepFreeze({ + workflowId: snapshot.id, + workflowRevision: snapshot.revision, + scope: snapshotScope, + snapshot, + orderedNodeIds: topological.order, + dependencies, + }); +} + +export type WorkflowNodeRunStatus = + | "queued" + | "running" + | "succeeded" + | "failed" + | "cancelled" + | "blocked"; + +export interface WorkflowNodeRunTransition { + workflowId: string; + workflowRevision: number; + runId: string; + nodeId: string; + status: WorkflowNodeRunStatus; + sequence: number; + error?: string; +} + +export interface WorkflowExecutionResult { + workflowId: string; + workflowRevision: number; + runId: string; + statuses: Readonly>; + outputs: ReadonlyMap; + transitions: readonly WorkflowNodeRunTransition[]; +} + +export interface WorkflowNodeExecutionContext { + node: WorkflowNodeV1; + workflowId: string; + workflowRevision: number; + signal: AbortSignal; + dependencyOutputs: ReadonlyMap; +} + +export interface RunWorkflowPlanOptions { + runId: string; + concurrency: 1 | 2 | 3 | 4; + signal?: AbortSignal; + executeNode(context: WorkflowNodeExecutionContext): Promise; + onTransition?(transition: WorkflowNodeRunTransition): void; +} + +type SettledExecution = + | { nodeId: string; ok: true; output: unknown } + | { nodeId: string; ok: false; error: unknown }; + +export interface WorkflowRunTransitionCursor { + workflowId: string; + workflowRevision: number; + runId: string; + lastSequence: number; +} + +/** Reject cross-run, stale, duplicate, and out-of-order notifications. */ +export function reduceWorkflowRunTransition( + cursor: WorkflowRunTransitionCursor, + transition: WorkflowNodeRunTransition, +): WorkflowRunTransitionCursor { + if ( + cursor.workflowId !== transition.workflowId || + cursor.workflowRevision !== transition.workflowRevision || + cursor.runId !== transition.runId || + transition.sequence !== cursor.lastSequence + 1 + ) { + return cursor; + } + return { ...cursor, lastSequence: transition.sequence }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim() ? error.message : "Node execution failed."; +} + +function hasNonSuccessDependency( + dependencies: readonly string[], + statuses: ReadonlyMap, +): boolean { + return dependencies.some((dependency) => { + const status = statuses.get(dependency); + return status === "failed" || status === "cancelled" || status === "blocked"; + }); +} + +function allDependenciesSucceeded( + dependencies: readonly string[], + statuses: ReadonlyMap, +): boolean { + return dependencies.every((dependency) => statuses.get(dependency) === "succeeded"); +} + +export async function runWorkflowPlan( + document: WorkflowDocumentV1, + plan: WorkflowExecutionPlan, + options: RunWorkflowPlanOptions, +): Promise { + if (document.id !== plan.workflowId || document.revision !== plan.workflowRevision) { + throw new Error("The execution plan does not match this workflow revision."); + } + if ( + !Number.isInteger(options.concurrency) || + options.concurrency < 1 || + options.concurrency > 4 + ) { + throw new Error("Create Images concurrency must be between 1 and 4."); + } + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(options.runId)) { + throw new Error("Create Images runs require an opaque run ID."); + } + const nodes = new Map(plan.snapshot.nodes.map((node) => [node.id, node])); + for (const nodeId of plan.orderedNodeIds) { + if (!nodes.has(nodeId)) throw new Error(`Execution plan references missing node "${nodeId}".`); + } + + const controller = new AbortController(); + const abortFromParent = (): void => controller.abort(options.signal?.reason); + if (options.signal?.aborted) abortFromParent(); + else options.signal?.addEventListener("abort", abortFromParent, { once: true }); + + const statuses = new Map( + plan.orderedNodeIds.map((nodeId) => [nodeId, "queued"]), + ); + const outputs = new Map(); + const transitions: WorkflowNodeRunTransition[] = []; + const active = new Map>(); + const cancelled = new Promise((resolve) => { + const cancel = (): void => resolve(CANCELLED_RACE); + if (controller.signal.aborted) cancel(); + else controller.signal.addEventListener("abort", cancel, { once: true }); + }); + let sequence = 0; + const transition = (nodeId: string, status: WorkflowNodeRunStatus, error?: string): void => { + statuses.set(nodeId, status); + sequence += 1; + const event: WorkflowNodeRunTransition = { + workflowId: plan.workflowId, + workflowRevision: plan.workflowRevision, + runId: options.runId, + nodeId, + status, + sequence, + ...(error ? { error } : {}), + }; + transitions.push(event); + options.onTransition?.(event); + }; + + try { + while ([...statuses.values()].some((status) => status === "queued" || status === "running")) { + if (controller.signal.aborted) { + for (const nodeId of plan.orderedNodeIds) { + const status = statuses.get(nodeId); + if (status === "queued" || status === "running") { + transition(nodeId, "cancelled", "The workflow run was cancelled."); + } + } + // Executor promises already normalize both fulfillment and rejection. + // Detach them: a provider that ignores AbortSignal cannot hold the run open, + // and no late settlement has a path back into outputs or transitions. + active.clear(); + break; + } + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + if (hasNonSuccessDependency(dependencies, statuses)) { + transition(nodeId, "blocked", "A required upstream node did not succeed."); + } + } + + for (const nodeId of plan.orderedNodeIds) { + if (active.size >= options.concurrency) break; + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + if (!allDependenciesSucceeded(dependencies, statuses)) continue; + const node = nodes.get(nodeId); + if (!node) continue; + transition(nodeId, "running"); + const dependencyOutputs = new Map(); + for (const dependency of dependencies) { + if (outputs.has(dependency)) dependencyOutputs.set(dependency, outputs.get(dependency)); + } + const context = { + node, + workflowId: plan.workflowId, + workflowRevision: plan.workflowRevision, + signal: controller.signal, + dependencyOutputs, + }; + const execution = Promise.resolve() + .then(() => options.executeNode(context)) + .then( + (output): SettledExecution => ({ nodeId, ok: true, output }), + (error): SettledExecution => ({ nodeId, ok: false, error }), + ); + active.set(nodeId, execution); + } + + if (active.size === 0) { + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) === "queued") { + transition(nodeId, "blocked", "The node's dependencies could not be scheduled."); + } + } + continue; + } + + const settled = await Promise.race([...active.values(), cancelled]); + if ("cancelled" in settled) continue; + active.delete(settled.nodeId); + if (!settled.ok) { + transition(settled.nodeId, "failed", errorMessage(settled.error)); + } else { + outputs.set(settled.nodeId, settled.output); + transition(settled.nodeId, "succeeded"); + } + } + } finally { + options.signal?.removeEventListener("abort", abortFromParent); + } + + return { + workflowId: plan.workflowId, + workflowRevision: plan.workflowRevision, + runId: options.runId, + statuses: Object.fromEntries(statuses), + outputs, + transitions, + }; +} + +const CANCELLED_RACE = { cancelled: true } as const; diff --git a/renderer/shared/create-images/ipc.test.ts b/renderer/shared/create-images/ipc.test.ts new file mode 100644 index 00000000..ad4d0a93 --- /dev/null +++ b/renderer/shared/create-images/ipc.test.ts @@ -0,0 +1,580 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createStarterWorkflow, parseWorkflowDocument } from "./schema.js"; +import { + CREATE_IMAGES_MAX_DROPPED_FILES, + createImagesAssetGrantUrl, + parseCreateImagesCreateWorkflowRequest, + parseCreateImagesApplyAssetCleanupRequest, + parseCreateImagesDeleteWorkflowRequest, + parseCreateImagesDiscardDegradedRunRequest, + parseCreateImagesDroppedAssetImportRequest, + parseCreateImagesDownloadRunAssetRequest, + parseCreateImagesExportArchiveRequest, + parseCreateImagesGrantAssetRequest, + parseCreateImagesGrantRunAssetRequest, + parseCreateImagesGetRunRequest, + parseCreateImagesImportArchiveRequest, + parseCreateImagesImportNodeBananaRequest, + parseCreateImagesPlanRunHistoryPruneRequest, + parseCreateImagesPlanAssetCleanupRequest, + parseCreateImagesPasteImageRequest, + parseCreateImagesPrepareRunRequest, + parseCreateImagesPlanDegradedRunDiscardRequest, + parseCreateImagesPruneRunHistoryRequest, + parseCreateImagesRecoverRunRequest, + parseCreateImagesResolveRunAmbiguityRequest, + parseCreateImagesSaveWorkflowRequest, + parseCreateImagesStartRunRequest, + parseCreateImagesStopRunRequest, + parseCreateImagesUnsubscribeRunsRequest, + parseCreateImagesWorkspaceRequest, +} from "./ipc.js"; + +function starter() { + return createStarterWorkflow({ + workflowId: "workflow-1", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); +} + +test("workflow save requests require strict CAS revision advancement", () => { + const workflow = { ...starter(), revision: 2 }; + assert.deepEqual(parseCreateImagesSaveWorkflowRequest({ expectedRevision: 1, workflow }), { + expectedRevision: 1, + workflow, + }); + assert.throws(() => parseCreateImagesSaveWorkflowRequest({ expectedRevision: 2, workflow })); + assert.throws(() => + parseCreateImagesSaveWorkflowRequest({ + expectedRevision: 1, + workflow, + future: true, + }), + ); +}); + +test("schema, IPC, and persistence share the same workflow byte ceiling", () => { + const underLimit = { ...starter(), revision: 2 }; + underLimit.nodes.push( + ...Array.from({ length: 140 }, (_, index) => ({ + id: `large-prompt-${index}`, + type: "prompt" as const, + position: { x: index, y: index }, + data: { text: "x".repeat(30_000) }, + })), + ); + assert.equal(parseWorkflowDocument(underLimit).success, true); + assert.equal( + parseCreateImagesSaveWorkflowRequest({ + expectedRevision: 1, + workflow: underLimit, + }).workflow.nodes.length, + underLimit.nodes.length, + ); + + const overLimit = structuredClone(underLimit); + overLimit.nodes.push( + ...Array.from({ length: 140 }, (_, index) => ({ + id: `overflow-prompt-${index}`, + type: "prompt" as const, + position: { x: index, y: index + 200 }, + data: { text: "y".repeat(30_000) }, + })), + ); + assert.equal(parseWorkflowDocument(overLimit).success, false); + assert.throws(() => + parseCreateImagesSaveWorkflowRequest({ + expectedRevision: 1, + workflow: overLimit, + }), + ); +}); + +test("workflow and asset IPC requests reject hostile object keys and identifiers", () => { + assert.deepEqual( + parseCreateImagesCreateWorkflowRequest({ + template: "blank", + title: " New ", + }), + { + template: "blank", + title: "New", + }, + ); + assert.deepEqual(parseCreateImagesCreateWorkflowRequest({ template: "reference-edit" }), { + template: "reference-edit", + }); + assert.deepEqual(parseCreateImagesCreateWorkflowRequest({ template: "variant-set" }), { + template: "variant-set", + }); + assert.throws(() => + parseCreateImagesCreateWorkflowRequest({ + template: "blank", + __proto__: {}, + }), + ); + assert.throws(() => + parseCreateImagesGrantAssetRequest({ + workflowId: "constructor", + assetId: "a".repeat(63), + }), + ); +}); + +test("workflow delete requests accept only the exact CAS contract", () => { + assert.deepEqual( + parseCreateImagesDeleteWorkflowRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + }), + { workflowId: "workflow-1", expectedRevision: 3 }, + ); + assert.throws(() => + parseCreateImagesDeleteWorkflowRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + future: true, + }), + ); + assert.throws(() => + parseCreateImagesDeleteWorkflowRequest({ + workflowId: "../workflow", + expectedRevision: 3, + }), + ); + assert.throws(() => + parseCreateImagesDeleteWorkflowRequest({ + workflowId: "workflow-1", + expectedRevision: 0, + }), + ); + assert.throws(() => + parseCreateImagesDeleteWorkflowRequest( + Object.assign(Object.create({ inherited: true }), { + workflowId: "workflow-1", + expectedRevision: 3, + }), + ), + ); +}); + +test("native archive requests expose no renderer-controlled file paths", () => { + assert.deepEqual( + parseCreateImagesExportArchiveRequest({ workflowId: "workflow-1", expectedRevision: 3 }), + { workflowId: "workflow-1", expectedRevision: 3 }, + ); + assert.deepEqual(parseCreateImagesImportArchiveRequest({}), {}); + assert.deepEqual(parseCreateImagesImportNodeBananaRequest({}), {}); + assert.deepEqual(parseCreateImagesWorkspaceRequest({}), {}); + assert.throws(() => + parseCreateImagesExportArchiveRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + destination: "/tmp/stolen.aiden-images", + }), + ); + assert.throws(() => + parseCreateImagesImportArchiveRequest({ source: "/tmp/hostile.aiden-images" }), + ); + assert.throws(() => + parseCreateImagesImportNodeBananaRequest({ source: "/tmp/node-banana.json" }), + ); + assert.throws(() => parseCreateImagesWorkspaceRequest({ path: "/tmp/hostile" })); +}); + +test("asset cleanup is an exact two-step confirmation with no asset IDs from renderer", () => { + assert.deepEqual(parseCreateImagesPlanAssetCleanupRequest({}), {}); + assert.deepEqual( + parseCreateImagesApplyAssetCleanupRequest({ planId: "a".repeat(32), confirmed: true }), + { planId: "a".repeat(32), confirmed: true }, + ); + assert.throws(() => parseCreateImagesPlanAssetCleanupRequest({ graceMs: 0 })); + assert.throws(() => + parseCreateImagesApplyAssetCleanupRequest({ + planId: "a".repeat(32), + confirmed: false, + }), + ); + assert.throws(() => + parseCreateImagesApplyAssetCleanupRequest({ + planId: "a".repeat(32), + confirmed: true, + assetIds: ["b".repeat(64)], + }), + ); +}); + +test("retained image download identifies only an authorized run asset and never a path", () => { + const request = { + workflowId: "workflow-1", + runId: "run-1", + assetId: "a".repeat(64), + }; + assert.deepEqual(parseCreateImagesDownloadRunAssetRequest(request), request); + assert.throws(() => + parseCreateImagesDownloadRunAssetRequest({ ...request, destination: "/tmp/output.png" }), + ); + assert.throws(() => parseCreateImagesDownloadRunAssetRequest({ ...request, assetId: "bad" })); +}); + +test("asset delivery URLs contain only opaque grant tokens", () => { + const token = "A".repeat(43); + assert.equal(createImagesAssetGrantUrl(token), `aiden-asset://asset/${token}`); + assert.throws(() => createImagesAssetGrantUrl("../../etc/passwd")); +}); + +test("clipboard image paste requests carry only the workflow identifier", () => { + assert.deepEqual(parseCreateImagesPasteImageRequest({ workflowId: "workflow-1" }), { + workflowId: "workflow-1", + }); + assert.throws(() => + parseCreateImagesPasteImageRequest({ + workflowId: "workflow-1", + bytes: "data:image/png;base64,not-allowed", + }), + ); + assert.throws(() => + parseCreateImagesPasteImageRequest({ + workflowId: "workflow-1", + filePath: "/tmp/clipboard.png", + }), + ); + assert.throws(() => parseCreateImagesPasteImageRequest({ workflowId: "../workflow" })); +}); + +test("dropped asset imports accept only a bounded exact preload-owned path batch", () => { + assert.deepEqual( + parseCreateImagesDroppedAssetImportRequest({ + workflowId: "workflow-1", + filePaths: ["/private/tmp/photo.webp", "/private/tmp/reference.heic"], + }), + { + workflowId: "workflow-1", + filePaths: ["/private/tmp/photo.webp", "/private/tmp/reference.heic"], + }, + ); + assert.throws(() => + parseCreateImagesDroppedAssetImportRequest({ workflowId: "workflow-1", filePaths: [] }), + ); + assert.throws(() => + parseCreateImagesDroppedAssetImportRequest({ + workflowId: "workflow-1", + filePaths: Array.from( + { length: CREATE_IMAGES_MAX_DROPPED_FILES + 1 }, + (_, index) => `/private/tmp/${index}.png`, + ), + }), + ); + assert.throws(() => + parseCreateImagesDroppedAssetImportRequest({ + workflowId: "workflow-1", + filePaths: ["/private/tmp/photo.png\0.jpg"], + }), + ); + assert.throws(() => + parseCreateImagesDroppedAssetImportRequest({ + workflowId: "workflow-1", + filePaths: ["/private/tmp/photo.png"], + arbitraryPath: "/etc/passwd", + }), + ); +}); + +test("run preparation and start accept only exact bounded local or main-minted Gemini consent", () => { + assert.deepEqual( + parseCreateImagesStartRunRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + scope: { + kind: "from-node", + nodeId: "generate-1", + downstreamPath: ["output-1"], + }, + consent: { executionMode: "local-mock", reviewed: true }, + }), + { + workflowId: "workflow-1", + expectedRevision: 3, + scope: { + kind: "from-node", + nodeId: "generate-1", + downstreamPath: ["output-1"], + }, + consent: { executionMode: "local-mock", reviewed: true }, + }, + ); + assert.throws(() => + parseCreateImagesStartRunRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + scope: { kind: "all" }, + consent: { executionMode: "cloud", reviewed: true }, + }), + ); + assert.deepEqual( + parseCreateImagesPrepareRunRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + scope: { kind: "all" }, + executionMode: "gemini", + }), + { + workflowId: "workflow-1", + expectedRevision: 3, + scope: { kind: "all" }, + executionMode: "gemini", + }, + ); + const fingerprint = "a".repeat(64); + const token = "b".repeat(64); + assert.deepEqual( + parseCreateImagesStartRunRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + scope: { kind: "all" }, + consent: { + executionMode: "gemini", + version: 1, + authorizationId: "authorization-1", + consentFingerprint: fingerprint, + token, + reviewed: true, + }, + }).consent, + { + executionMode: "gemini", + version: 1, + authorizationId: "authorization-1", + consentFingerprint: fingerprint, + token, + reviewed: true, + }, + ); + assert.throws(() => + parseCreateImagesStartRunRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + scope: { kind: "all" }, + consent: { + executionMode: "gemini", + version: 1, + authorizationId: "authorization-1", + consentFingerprint: fingerprint, + token, + reviewed: true, + apiKey: "must-never-cross-ipc", + }, + }), + ); + assert.throws(() => + parseCreateImagesStartRunRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + scope: { + kind: "from-node", + nodeId: "generate-1", + downstreamPath: ["output-1", "output-1"], + }, + consent: { executionMode: "local-mock", reviewed: true }, + }), + ); + assert.throws(() => + parseCreateImagesStartRunRequest({ + workflowId: "workflow-1", + expectedRevision: 3, + scope: { kind: "all" }, + consent: { executionMode: "local-mock", reviewed: true }, + endpoint: "https://example.invalid", + }), + ); +}); + +test("run stop, subscription, and output grants accept only opaque identifiers", () => { + assert.deepEqual( + parseCreateImagesStopRunRequest({ + workflowId: "workflow-1", + runId: "run-1", + }), + { + workflowId: "workflow-1", + runId: "run-1", + }, + ); + assert.deepEqual( + parseCreateImagesGrantRunAssetRequest({ + workflowId: "workflow-1", + runId: "run-1", + assetId: "a".repeat(64), + }), + { workflowId: "workflow-1", runId: "run-1", assetId: "a".repeat(64) }, + ); + assert.deepEqual( + parseCreateImagesUnsubscribeRunsRequest({ + subscriptionId: "subscription_1234", + }), + { subscriptionId: "subscription_1234" }, + ); + assert.throws(() => + parseCreateImagesGrantRunAssetRequest({ + workflowId: "workflow-1", + runId: "../run", + assetId: "a".repeat(64), + }), + ); + assert.throws(() => parseCreateImagesUnsubscribeRunsRequest({ subscriptionId: "short" })); +}); + +test("run detail and recovery requests are exact, opaque, and CAS guarded", () => { + assert.deepEqual( + parseCreateImagesGetRunRequest({ + workflowId: "workflow-1", + runId: "run-1", + }), + { + workflowId: "workflow-1", + runId: "run-1", + }, + ); + assert.deepEqual( + parseCreateImagesRecoverRunRequest({ + workflowId: "workflow-1", + runId: "run-1", + source: "current", + expectedCandidateJournalRevision: 9, + }), + { + workflowId: "workflow-1", + runId: "run-1", + source: "current", + expectedCandidateJournalRevision: 9, + }, + ); + assert.throws(() => + parseCreateImagesGetRunRequest({ + workflowId: "workflow-1", + runId: "../run-1", + }), + ); + assert.throws(() => + parseCreateImagesRecoverRunRequest({ + workflowId: "workflow-1", + runId: "run-1", + source: "last-known-good", + expectedCandidateJournalRevision: 0, + }), + ); + assert.throws(() => + parseCreateImagesRecoverRunRequest({ + workflowId: "workflow-1", + runId: "run-1", + source: "last-known-good", + expectedCandidateJournalRevision: 9, + path: "/private/run.json", + }), + ); + assert.throws(() => + parseCreateImagesRecoverRunRequest({ + workflowId: "workflow-1", + runId: "run-1", + source: "other", + expectedCandidateJournalRevision: 9, + }), + ); +}); + +test("ambiguity acknowledgement is an exact CAS-bound resolution request", () => { + const request = { + workflowId: "workflow-1", + runId: "run-1", + expectedJournalRevision: 9, + resolution: "acknowledge-unresolved-submission" as const, + }; + assert.deepEqual(parseCreateImagesResolveRunAmbiguityRequest(request), request); + assert.throws(() => + parseCreateImagesResolveRunAmbiguityRequest({ + ...request, + expectedJournalRevision: 0, + }), + ); + assert.throws(() => + parseCreateImagesResolveRunAmbiguityRequest({ + ...request, + resolution: "retry", + }), + ); + assert.throws(() => + parseCreateImagesResolveRunAmbiguityRequest({ + ...request, + providerJobId: "secret-provider-state", + }), + ); +}); + +test("run history prune requests require bounded retention and explicit CAS confirmation", () => { + assert.deepEqual(parseCreateImagesPlanRunHistoryPruneRequest({ keepLatest: 100 }), { + keepLatest: 100, + }); + const authorizationToken = "a".repeat(64); + assert.deepEqual( + parseCreateImagesPruneRunHistoryRequest({ + keepLatest: 500, + authorizationToken, + confirmed: true, + }), + { keepLatest: 500, authorizationToken, confirmed: true }, + ); + assert.throws(() => parseCreateImagesPlanRunHistoryPruneRequest({ keepLatest: 99 })); + assert.throws(() => + parseCreateImagesPruneRunHistoryRequest({ + keepLatest: 100, + authorizationToken, + confirmed: false, + }), + ); + assert.throws(() => + parseCreateImagesPruneRunHistoryRequest({ + keepLatest: 100, + authorizationToken: "../token", + confirmed: true, + }), + ); +}); + +test("degraded run discard requires an exact two-step CAS token and never accepts paths", () => { + assert.deepEqual(parseCreateImagesPlanDegradedRunDiscardRequest({ runId: "run-1" }), { + runId: "run-1", + }); + const request = { + runId: "run-1", + expectedCurrentJournalRevision: 7, + expectedLastKnownGoodJournalRevision: 6, + authorizationToken: "b".repeat(64), + confirmed: true as const, + }; + assert.deepEqual(parseCreateImagesDiscardDegradedRunRequest(request), request); + assert.throws(() => parseCreateImagesDiscardDegradedRunRequest({ ...request, confirmed: false })); + assert.throws(() => + parseCreateImagesDiscardDegradedRunRequest({ + ...request, + expectedCurrentJournalRevision: 0, + }), + ); + assert.throws(() => + parseCreateImagesDiscardDegradedRunRequest({ + ...request, + authorizationToken: "../discard", + }), + ); + assert.throws(() => + parseCreateImagesDiscardDegradedRunRequest({ + ...request, + path: "/private/run-journal.json", + }), + ); +}); diff --git a/renderer/shared/create-images/ipc.ts b/renderer/shared/create-images/ipc.ts new file mode 100644 index 00000000..7d107329 --- /dev/null +++ b/renderer/shared/create-images/ipc.ts @@ -0,0 +1,1273 @@ +import type { WorkflowRunScope } from "./execution"; +import type { CreateImagesNodeRunStatus, CreateImagesRunStatus } from "./run-contract"; +import type { WorkflowDocumentV1 } from "./schema"; +import type { CreateImagesWorkflowTemplateId } from "./templates"; +import type { CreateImagesNodeBananaImportReport } from "./node-banana-import"; +import { + CREATE_IMAGES_MAX_WORKFLOW_BYTES, + createImagesWorkflowSerializedBytes, + parseWorkflowDocument, +} from "./schema"; + +export const CREATE_IMAGES_MAX_IPC_DOCUMENT_BYTES = CREATE_IMAGES_MAX_WORKFLOW_BYTES; +export const CREATE_IMAGES_MAX_TITLE_LENGTH = 120; +export const CREATE_IMAGES_ASSET_PROTOCOL = "aiden-asset:" as const; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const ASSET_ID_PATTERN = /^[a-f0-9]{64}$/u; +const GRANT_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/u; +const SUBSCRIPTION_ID_PATTERN = /^[A-Za-z0-9_-]{16,128}$/u; +const RETENTION_TOKEN_PATTERN = /^[a-f0-9]{64}$/u; +const CONSENT_FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/u; + +export interface CreateImagesWorkflowSummary { + id: string; + title: string; + revision: number; + createdAt: string; + updatedAt: string; + nodeCount: number; + edgeCount: number; + assetCount: number; + missingAssetCount: number; + health: "healthy" | "recovery-required" | "unsafe"; +} + +export type CreateImagesWorkflowRecoveryView = + | { status: "missing"; workflowId: string } + | { + status: "healthy"; + workflowId: string; + revision: number; + lastKnownGoodAvailable: boolean; + autosave: "none" | "pending"; + autosaveTargetRevision?: number; + } + | { + status: "recovery-required"; + workflowId: string; + reason: + | "current-corrupt" + | "current-missing" + | "last-known-good-corrupt" + | "journal-corrupt" + | "journal-pending" + | "journal-conflict"; + currentRevision?: number; + lastKnownGoodAvailable: boolean; + lastKnownGoodRevision?: number; + autosave: "none" | "pending" | "corrupt"; + autosaveTargetRevision?: number; + } + | { + status: "unsafe"; + workflowId: string; + reason: "current-future-schema" | "last-known-good-future-schema" | "journal-future-schema"; + lastKnownGoodAvailable: boolean; + autosave: "none" | "pending" | "unsafe"; + }; + +export type CreateImagesWorkflowListResult = + | { + status: "ready"; + workflows: CreateImagesWorkflowSummary[]; + recoveries: CreateImagesWorkflowRecoveryView[]; + } + | { status: "unavailable"; message: string }; + +export type CreateImagesWorkflowLoadResult = + | { + status: "ready"; + workflow: WorkflowDocumentV1; + missingAssetIds: string[]; + } + | { status: "recovery-required"; recovery: CreateImagesWorkflowRecoveryView } + | { + status: "unsafe"; + recovery: CreateImagesWorkflowRecoveryView; + message: string; + } + | { status: "not-found" } + | { status: "unavailable"; message: string }; + +export type CreateImagesWorkflowMutationResult = + | { status: "saved"; workflow: WorkflowDocumentV1 } + | { status: "deleted" } + | { + status: "conflict"; + expectedRevision: number; + currentRevision: number; + current: WorkflowDocumentV1; + } + | { status: "not-found" } + | { status: "unavailable"; message: string }; + +export interface CreateImagesExportArchiveRequest { + workflowId: string; + expectedRevision: number; +} + +export type CreateImagesExportArchiveResult = + | { status: "canceled" } + | { + status: "exported"; + workflowId: string; + revision: number; + fileName: string; + assetCount: number; + } + | { status: "conflict"; currentRevision?: number } + | { status: "not-found" } + | { status: "unavailable"; message: string }; + +export type CreateImagesImportArchiveResult = + | { status: "canceled" } + | { + status: "imported"; + workflow: WorkflowDocumentV1; + sourceFileName: string; + importedAssetCount: number; + } + | { status: "unavailable"; message: string }; + +export type CreateImagesImportNodeBananaResult = + | { status: "canceled" } + | { + status: "imported"; + workflow: WorkflowDocumentV1; + sourceFileName: string; + importedAssetCount: number; + report: CreateImagesNodeBananaImportReport; + } + | { status: "unavailable"; message: string }; + +export type CreateImagesWorkspaceStatus = + | { status: "unconfigured" } + | { + status: "ready"; + displayName: string; + importedAssetCount: number; + generatedAssetCount: number; + conflictCount: number; + lastSyncedAt?: string; + } + | { + status: "unavailable"; + reason: "missing" | "permission-denied" | "changed" | "unsafe" | "sync-failed"; + displayName?: string; + message: string; + }; + +export type CreateImagesChooseWorkspaceResult = + | { status: "canceled" } + | { status: "ready"; workspace: Extract } + | { status: "unavailable"; message: string }; + +export type CreateImagesOpenWorkspaceResult = + | { status: "opened" } + | { status: "unconfigured" } + | { status: "unavailable"; message: string }; + +export type CreateImagesSyncWorkspaceResult = + | { status: "synced"; workspace: Extract } + | { status: "unconfigured" } + | { status: "unavailable"; message: string }; + +export interface CreateImagesAssetView { + assetId: string; + mediaType: "image/jpeg" | "image/png"; + byteLength: number; + width: number; + height: number; + importedAt: string; + originalName?: string; +} + +export interface CreateImagesAssetGrantView { + token: string; + url: string; + expiresAt: number; + asset: CreateImagesAssetView; +} + +export type CreateImagesAssetPickResult = + | { status: "canceled" } + | { status: "imported"; grant: CreateImagesAssetGrantView } + | { status: "unavailable"; message: string }; + +export interface CreateImagesPasteImageRequest { + workflowId: string; +} + +export type CreateImagesPasteImageResult = + | { status: "no-image" } + | { status: "imported"; grant: CreateImagesAssetGrantView } + | { status: "unavailable"; message: string }; + +export const CREATE_IMAGES_MAX_DROPPED_FILES = 24; + +export type CreateImagesDroppedAssetImportItem = + | { status: "imported"; grant: CreateImagesAssetGrantView } + | { status: "unavailable"; fileName: string; message: string }; + +export type CreateImagesDroppedAssetImportResult = + | { status: "completed"; items: CreateImagesDroppedAssetImportItem[] } + | { status: "unavailable"; message: string }; + +export type CreateImagesAssetGrantResult = + | { status: "ready"; grant: CreateImagesAssetGrantView } + | { status: "not-found" | "forbidden" } + | { status: "unavailable"; message: string }; + +export interface CreateImagesDownloadRunAssetRequest { + workflowId: string; + runId: string; + assetId: string; +} + +export type CreateImagesDownloadRunAssetResult = + | { status: "canceled" } + | { status: "saved"; fileName: string } + | { status: "not-found" | "forbidden" } + | { status: "unavailable"; message: string }; + +export interface CreateImagesStorageHealthView { + workflowCount: number; + assetCount: number; + assetBytes: number; + recoverableWorkflowCount: number; + orphanAssetCount: number; + missingAssetCount: number; + runIndex: { + status: "healthy" | "recovered" | "needs-attention" | "unsafe"; + entryCount?: number; + quarantinedIndexCount?: number; + degradedRecordCount: number; + degradedRecordsTruncated: boolean; + degradedRecords: CreateImagesDegradedRunRecordView[]; + }; +} + +export type CreateImagesAssetCleanupPlanResult = + | { status: "empty" } + | { + status: "ready"; + planId: string; + candidateCount: number; + reclaimableBytes: number; + expiresAt: number; + } + | { status: "unavailable"; message: string }; + +export interface CreateImagesApplyAssetCleanupRequest { + planId: string; + confirmed: true; +} + +export type CreateImagesAssetCleanupResult = + | { + status: "cleaned"; + deletedCount: number; + reclaimedBytes: number; + skippedCount: number; + } + | { status: "stale" } + | { status: "unavailable"; message: string }; + +export interface CreateImagesRunNodeView { + nodeId: string; + label: string; + status: CreateImagesNodeRunStatus; + attempt: number; + outputAssetIds: string[]; + errorCode?: string; + retrySafety?: "confirmed-not-submitted" | "same-idempotency-key"; +} + +export interface CreateImagesRunAmbiguityResolutionView { + kind: "acknowledged-unresolved-submission"; + acknowledgedAt: string; + acknowledgedAtJournalRevision: number; +} + +export interface CreateImagesRunView { + runId: string; + workflowId: string; + workflowRevision: number; + journalRevision: number; + status: CreateImagesRunStatus; + lastSequence: number; + scope: WorkflowRunScope; + createdAt: string; + updatedAt: string; + executionMode?: "local-mock" | "gemini"; + ambiguityResolution?: CreateImagesRunAmbiguityResolutionView; + nodes: CreateImagesRunNodeView[]; +} + +export interface CreateImagesTerminalRunView { + runId: string; + workflowRevision: number; + status: "succeeded" | "failed" | "cancelled" | "interrupted" | "needs_attention"; + scope: WorkflowRunScope; + createdAt: string; + updatedAt: string; + executionMode?: "local-mock" | "gemini"; + providerLabel?: string; + modelLabel?: string; + costLabel?: string; + ambiguityResolution?: CreateImagesRunAmbiguityResolutionView; + requestCount: number; + outputCount: number; + completedNodeCount: number; + totalNodeCount: number; +} + +export interface CreateImagesRunRecoveryRequiredView { + status: "recovery-required"; + workflowId: string; + runId: string; + reason: + | "current-corrupt" + | "current-missing" + | "last-known-good-corrupt" + | "last-known-good-missing" + | "last-known-good-mismatch" + | "pending-corrupt" + | "pending-conflict"; + currentJournalRevision?: number; + lastKnownGoodJournalRevision?: number; + recoverySource?: "last-known-good" | "current"; + expectedCandidateJournalRevision?: number; +} + +export interface CreateImagesRunUnsafeRecoveryView { + status: "unsafe"; + workflowId: string; + runId: string; + reason: + | "current-future-schema" + | "last-known-good-future-schema" + | "pending-future-schema" + | "unsafe-storage"; +} + +export type CreateImagesRunRecoveryView = + | CreateImagesRunRecoveryRequiredView + | CreateImagesRunUnsafeRecoveryView; + +export type CreateImagesRunListResult = + | { + status: "ready"; + authoritative: true; + activeRun?: CreateImagesRunView; + latestTerminalRun?: CreateImagesRunView; + history: CreateImagesTerminalRunView[]; + recoveries: CreateImagesRunRecoveryView[]; + } + | { status: "not-found" } + | { status: "unavailable"; message: string; retryAfterMs?: number }; + +export type CreateImagesRunDetailResult = + | { status: "ready"; run: CreateImagesRunView } + | { + status: "recovery-required"; + recovery: CreateImagesRunRecoveryRequiredView; + } + | { + status: "unsafe"; + recovery: CreateImagesRunUnsafeRecoveryView; + message: string; + } + | { status: "not-found" } + | { status: "unavailable"; message: string }; + +export type CreateImagesRunRecoveryMutationResult = + | { status: "recovered"; run: CreateImagesRunView } + | { + status: "conflict"; + source: "last-known-good" | "current"; + expectedCandidateJournalRevision: number; + currentCandidateJournalRevision?: number; + } + | { + status: "recovery-required"; + recovery: CreateImagesRunRecoveryRequiredView; + } + | { + status: "unsafe"; + recovery: CreateImagesRunUnsafeRecoveryView; + message: string; + } + | { status: "not-found" } + | { status: "unavailable"; message: string; retryAfterMs?: number }; + +export type CreateImagesRunAmbiguityResolutionResult = + | { + status: "resolved" | "already-resolved"; + run: CreateImagesRunView; + authoritativeList: Extract; + } + | { + status: "conflict"; + expectedJournalRevision: number; + currentJournalRevision: number; + } + | { status: "not-ambiguous" } + | { status: "not-found" } + | { status: "unavailable"; message: string; retryAfterMs?: number }; + +export type CreateImagesRunMutationResult = + | { status: "started" | "stopping"; run: CreateImagesRunView } + | { status: "already-running"; run: CreateImagesRunView } + | { status: "conflict"; expectedRevision: number; currentRevision: number } + | { status: "invalid" | "not-found" | "unavailable"; message: string }; + +export interface CreateImagesProviderConsentAccountingView { + initialRequestCount: number; + expectedOutputCount: number; + maximumAttempts: number; + promptBytes: number; + referenceImageCount: number; + referenceImageBytes: number; + initialProviderInputBytes: number; + dataLeavesDevice: true; + retryPolicy: "manual-new-consent"; +} + +export interface CreateImagesProviderConsentPlanView { + version: 1; + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: "gemini"; + providerId: "gemini"; + providerLabel: "Google Gemini"; + modelId: string; + modelLabel: string; + accounting: CreateImagesProviderConsentAccountingView; + estimate: { + kind: "best-effort" | "unavailable"; + amountMicros?: number; + currency?: string; + estimatedAt: string; + sourceFingerprint: string; + }; + createdAt: string; + expiresAt: string; + consentFingerprint: string; + token: string; +} + +export type CreateImagesPrepareRunResult = + | { status: "ready"; plan: CreateImagesProviderConsentPlanView } + | { status: "conflict"; expectedRevision: number; currentRevision: number } + | { status: "invalid" | "not-found" | "unavailable"; message: string }; + +export type CreateImagesRunHistoryPrunePlanResult = + | { + status: "ready"; + scope: "all-workflows"; + mayReleaseUniqueOutputs: true; + authorizationToken: string; + keepLatest: number; + candidateRunCount: number; + releasedAssetCount: number; + } + | { status: "nothing-to-prune" } + | { status: "unavailable"; message: string; retryAfterMs?: number }; + +export type CreateImagesRunHistoryPruneResult = + | { + status: "pruned"; + removedRunCount: number; + releasedAssetCount: number; + } + | { status: "nothing-to-prune" } + | { status: "conflict"; message: string } + | { status: "unavailable"; message: string; retryAfterMs?: number }; + +export type CreateImagesDegradedRunReason = + | CreateImagesRunRecoveryRequiredView["reason"] + | CreateImagesRunUnsafeRecoveryView["reason"]; + +export interface CreateImagesDegradedRunRecordView { + runId: string; + association: "workflow" | "unassociated"; + workflowId?: string; + status: "recovery-required" | "unsafe"; + reason: CreateImagesDegradedRunReason; + discardEligible: boolean; +} + +export type CreateImagesDegradedRunDiscardPlanResult = + | { + status: "ready"; + runId: string; + reason: CreateImagesDegradedRunReason; + association: "workflow" | "unassociated"; + workflowId?: string; + expectedCurrentJournalRevision?: number; + expectedLastKnownGoodJournalRevision?: number; + authorizationToken: string; + mayLoseOutputs: true; + mayDuplicateProviderWork: true; + } + | { status: "not-found" } + | { status: "not-degraded" } + | { status: "recoverable" } + | { status: "unavailable"; message: string }; + +export type CreateImagesDegradedRunDiscardResult = + | { + status: "discarded"; + runId: string; + releasedAssetCount: number; + authoritativeList?: CreateImagesRunListResult; + } + | { status: "conflict" } + | { status: "not-found" } + | { status: "not-degraded" } + | { status: "recoverable" } + | { status: "unavailable"; message: string }; + +export type CreateImagesRunSubscriptionResult = + | { + status: "ready"; + subscriptionId: string; + streamSequence: number; + snapshot: CreateImagesRunListResult; + } + | { status: "not-found" } + | { status: "unavailable"; message: string; retryAfterMs?: number }; + +export interface CreateImagesRunChangedNotification { + subscriptionId: string; + streamSequence: number; + snapshot: CreateImagesRunListResult; +} + +export interface CreateImagesGetWorkflowRequest { + workflowId: string; +} + +export interface CreateImagesCreateWorkflowRequest { + template: CreateImagesWorkflowTemplateId; + title?: string; +} + +export interface CreateImagesSaveWorkflowRequest { + expectedRevision: number; + workflow: WorkflowDocumentV1; +} + +export interface CreateImagesRenameWorkflowRequest { + workflowId: string; + expectedRevision: number; + title: string; +} + +export interface CreateImagesDuplicateWorkflowRequest { + workflowId: string; + expectedRevision: number; + title?: string; +} + +export interface CreateImagesDeleteWorkflowRequest { + workflowId: string; + expectedRevision: number; +} + +export interface CreateImagesPickAssetRequest { + workflowId: string; +} + +export interface CreateImagesDroppedAssetImportRequest { + workflowId: string; + /** Main-process-only paths resolved by Electron's trusted preload bridge. */ + filePaths: string[]; +} + +export interface CreateImagesGrantAssetRequest { + workflowId: string; + assetId: string; +} + +export interface CreateImagesRevokeAssetGrantRequest { + token: string; +} + +export interface CreateImagesRecoverWorkflowRequest { + workflowId: string; + source: "last-known-good" | "autosave"; + expectedCandidateRevision: number; +} + +export interface CreateImagesRepairWorkflowRequest { + workflowId: string; + expectedRevision: number; +} + +export interface CreateImagesDiscardAutosaveRequest { + workflowId: string; + expectedTargetRevision: number; +} + +export interface CreateImagesStartRunRequest { + workflowId: string; + expectedRevision: number; + scope: WorkflowRunScope; + consent: + | { executionMode: "local-mock"; reviewed: true } + | { + executionMode: "gemini"; + version: 1; + authorizationId: string; + consentFingerprint: string; + token: string; + reviewed: true; + }; +} + +export interface CreateImagesPrepareRunRequest { + workflowId: string; + expectedRevision: number; + scope: WorkflowRunScope; + executionMode: "gemini"; +} + +export interface CreateImagesStopRunRequest { + workflowId: string; + runId: string; +} + +export interface CreateImagesListRunsRequest { + workflowId: string; +} + +export interface CreateImagesSubscribeRunsRequest { + workflowId: string; +} + +export interface CreateImagesUnsubscribeRunsRequest { + subscriptionId: string; +} + +export interface CreateImagesGrantRunAssetRequest { + workflowId: string; + runId: string; + assetId: string; +} + +export interface CreateImagesGetRunRequest { + workflowId: string; + runId: string; +} + +export interface CreateImagesRecoverRunRequest { + workflowId: string; + runId: string; + source: "last-known-good" | "current"; + expectedCandidateJournalRevision: number; +} + +export interface CreateImagesResolveRunAmbiguityRequest { + workflowId: string; + runId: string; + expectedJournalRevision: number; + resolution: "acknowledge-unresolved-submission"; +} + +export interface CreateImagesPlanRunHistoryPruneRequest { + keepLatest: number; +} + +export interface CreateImagesPruneRunHistoryRequest { + keepLatest: number; + authorizationToken: string; + confirmed: true; +} + +export interface CreateImagesPlanDegradedRunDiscardRequest { + runId: string; +} + +export interface CreateImagesDiscardDegradedRunRequest { + runId: string; + expectedCurrentJournalRevision?: number; + expectedLastKnownGoodJournalRevision?: number; + authorizationToken: string; + confirmed: true; +} + +function isRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function exactKeys( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean { + const allowed = new Set([...required, ...optional]); + return ( + required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) && + Object.keys(value).every((key) => allowed.has(key)) + ); +} + +function opaqueId(value: unknown): string | undefined { + return typeof value === "string" && OPAQUE_ID_PATTERN.test(value) ? value : undefined; +} + +function title(value: unknown): string | undefined { + if (typeof value !== "string" || value.length > CREATE_IMAGES_MAX_TITLE_LENGTH) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function revision(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined; +} + +function runScope(value: unknown): WorkflowRunScope | undefined { + if (!isRecord(value)) return undefined; + if (value.kind === "all" && exactKeys(value, ["kind"])) return { kind: "all" }; + if (value.kind !== "from-node" || !exactKeys(value, ["kind", "nodeId"], ["downstreamPath"])) { + return undefined; + } + const nodeId = opaqueId(value.nodeId); + if (!nodeId) return undefined; + if (value.downstreamPath === undefined) return { kind: "from-node", nodeId }; + if (!Array.isArray(value.downstreamPath) || value.downstreamPath.length > 500) return undefined; + const downstreamPath = value.downstreamPath.map(opaqueId); + if ( + downstreamPath.some((node): node is undefined => node === undefined) || + new Set(downstreamPath).size !== downstreamPath.length + ) { + return undefined; + } + return { + kind: "from-node", + nodeId, + downstreamPath: downstreamPath as string[], + }; +} + +function invalidRequest(): never { + throw new Error("Invalid Create Images request."); +} + +export function parseCreateImagesGetWorkflowRequest( + value: unknown, +): CreateImagesGetWorkflowRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId"])) invalidRequest(); + const workflowId = opaqueId(value.workflowId); + if (!workflowId) invalidRequest(); + return { workflowId }; +} + +export function parseCreateImagesCreateWorkflowRequest( + value: unknown, +): CreateImagesCreateWorkflowRequest { + if (!isRecord(value) || !exactKeys(value, ["template"], ["title"])) invalidRequest(); + if ( + value.template !== "blank" && + value.template !== "starter" && + value.template !== "reference-edit" && + value.template !== "variant-set" + ) { + invalidRequest(); + } + const parsedTitle = value.title === undefined ? undefined : title(value.title); + if (value.title !== undefined && !parsedTitle) invalidRequest(); + return { + template: value.template, + ...(parsedTitle ? { title: parsedTitle } : {}), + }; +} + +export function parseCreateImagesSaveWorkflowRequest( + value: unknown, +): CreateImagesSaveWorkflowRequest { + if (!isRecord(value) || !exactKeys(value, ["expectedRevision", "workflow"])) invalidRequest(); + const expectedRevision = revision(value.expectedRevision); + if (!expectedRevision) invalidRequest(); + const serializedBytes = createImagesWorkflowSerializedBytes(value.workflow); + if (serializedBytes === undefined) invalidRequest(); + if (serializedBytes! > CREATE_IMAGES_MAX_IPC_DOCUMENT_BYTES) invalidRequest(); + const parsed = parseWorkflowDocument(value.workflow); + if (!parsed.success || parsed.value.revision !== expectedRevision + 1) invalidRequest(); + return { expectedRevision, workflow: parsed.value }; +} + +export function parseCreateImagesRenameWorkflowRequest( + value: unknown, +): CreateImagesRenameWorkflowRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "expectedRevision", "title"])) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const expectedRevision = revision(value.expectedRevision); + const parsedTitle = title(value.title); + if (!workflowId || !expectedRevision || !parsedTitle) invalidRequest(); + return { workflowId, expectedRevision, title: parsedTitle }; +} + +export function parseCreateImagesDuplicateWorkflowRequest( + value: unknown, +): CreateImagesDuplicateWorkflowRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "expectedRevision"], ["title"])) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const expectedRevision = revision(value.expectedRevision); + const parsedTitle = value.title === undefined ? undefined : title(value.title); + if (!workflowId || !expectedRevision || (value.title !== undefined && !parsedTitle)) { + invalidRequest(); + } + return { + workflowId, + expectedRevision, + ...(parsedTitle ? { title: parsedTitle } : {}), + }; +} + +export function parseCreateImagesDeleteWorkflowRequest( + value: unknown, +): CreateImagesDeleteWorkflowRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "expectedRevision"])) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const expectedRevision = revision(value.expectedRevision); + if (!workflowId || !expectedRevision) invalidRequest(); + return { workflowId, expectedRevision }; +} + +export function parseCreateImagesExportArchiveRequest( + value: unknown, +): CreateImagesExportArchiveRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "expectedRevision"])) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const expectedRevision = revision(value.expectedRevision); + if (!workflowId || !expectedRevision) invalidRequest(); + return { workflowId, expectedRevision }; +} + +export function parseCreateImagesImportArchiveRequest(value: unknown): Record { + if (!isRecord(value) || !exactKeys(value, [])) invalidRequest(); + return {}; +} + +export function parseCreateImagesImportNodeBananaRequest(value: unknown): Record { + if (!isRecord(value) || !exactKeys(value, [])) invalidRequest(); + return {}; +} + +export function parseCreateImagesWorkspaceRequest(value: unknown): Record { + if (!isRecord(value) || !exactKeys(value, [])) invalidRequest(); + return {}; +} + +export function parseCreateImagesPlanAssetCleanupRequest(value: unknown): Record { + if (!isRecord(value) || !exactKeys(value, [])) invalidRequest(); + return {}; +} + +export function parseCreateImagesApplyAssetCleanupRequest( + value: unknown, +): CreateImagesApplyAssetCleanupRequest { + if (!isRecord(value) || !exactKeys(value, ["planId", "confirmed"])) invalidRequest(); + const planId = + typeof value.planId === "string" && GRANT_TOKEN_PATTERN.test(value.planId) + ? value.planId + : undefined; + if (!planId || value.confirmed !== true) invalidRequest(); + return { planId, confirmed: true }; +} + +export function parseCreateImagesPickAssetRequest(value: unknown): CreateImagesPickAssetRequest { + return parseCreateImagesGetWorkflowRequest(value); +} + +export function parseCreateImagesPasteImageRequest(value: unknown): CreateImagesPasteImageRequest { + return parseCreateImagesGetWorkflowRequest(value); +} + +export function parseCreateImagesDroppedAssetImportRequest( + value: unknown, +): CreateImagesDroppedAssetImportRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "filePaths"])) invalidRequest(); + const workflowId = opaqueId(value.workflowId); + if ( + !workflowId || + !Array.isArray(value.filePaths) || + value.filePaths.length < 1 || + value.filePaths.length > CREATE_IMAGES_MAX_DROPPED_FILES || + !value.filePaths.every( + (filePath) => + typeof filePath === "string" && + filePath.length >= 1 && + filePath.length <= 4_096 && + !filePath.includes("\0"), + ) + ) { + invalidRequest(); + } + return { workflowId, filePaths: [...value.filePaths] }; +} + +export function parseCreateImagesGrantAssetRequest(value: unknown): CreateImagesGrantAssetRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "assetId"])) invalidRequest(); + const workflowId = opaqueId(value.workflowId); + const assetId = + typeof value.assetId === "string" && ASSET_ID_PATTERN.test(value.assetId) + ? value.assetId + : undefined; + if (!workflowId || !assetId) invalidRequest(); + return { workflowId, assetId }; +} + +export function parseCreateImagesDownloadRunAssetRequest( + value: unknown, +): CreateImagesDownloadRunAssetRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "runId", "assetId"])) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const runId = opaqueId(value.runId); + const assetId = + typeof value.assetId === "string" && ASSET_ID_PATTERN.test(value.assetId) + ? value.assetId + : undefined; + if (!workflowId || !runId || !assetId) invalidRequest(); + return { workflowId, runId, assetId }; +} + +export function parseCreateImagesRevokeAssetGrantRequest( + value: unknown, +): CreateImagesRevokeAssetGrantRequest { + if (!isRecord(value) || !exactKeys(value, ["token"])) invalidRequest(); + const token = + typeof value.token === "string" && GRANT_TOKEN_PATTERN.test(value.token) + ? value.token + : undefined; + if (!token) invalidRequest(); + return { token }; +} + +export function parseCreateImagesRecoverWorkflowRequest( + value: unknown, +): CreateImagesRecoverWorkflowRequest { + if ( + !isRecord(value) || + !exactKeys(value, ["workflowId", "source", "expectedCandidateRevision"]) + ) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const expectedCandidateRevision = revision(value.expectedCandidateRevision); + if ( + !workflowId || + !expectedCandidateRevision || + (value.source !== "last-known-good" && value.source !== "autosave") + ) { + invalidRequest(); + } + return { workflowId, source: value.source, expectedCandidateRevision }; +} + +export function parseCreateImagesRepairWorkflowRequest( + value: unknown, +): CreateImagesRepairWorkflowRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "expectedRevision"])) invalidRequest(); + const workflowId = opaqueId(value.workflowId); + const expectedRevision = revision(value.expectedRevision); + if (!workflowId || !expectedRevision) invalidRequest(); + return { workflowId, expectedRevision }; +} + +export function parseCreateImagesDiscardAutosaveRequest( + value: unknown, +): CreateImagesDiscardAutosaveRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "expectedTargetRevision"])) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const expectedTargetRevision = revision(value.expectedTargetRevision); + if (!workflowId || !expectedTargetRevision) invalidRequest(); + return { workflowId, expectedTargetRevision }; +} + +export function parseCreateImagesStartRunRequest(value: unknown): CreateImagesStartRunRequest { + if ( + !isRecord(value) || + !exactKeys(value, ["workflowId", "expectedRevision", "scope", "consent"]) || + !isRecord(value.consent) + ) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const expectedRevision = revision(value.expectedRevision); + const scope = runScope(value.scope); + if (!workflowId || !expectedRevision || !scope) invalidRequest(); + if (value.consent.executionMode === "local-mock") { + if ( + !exactKeys(value.consent, ["executionMode", "reviewed"]) || + value.consent.reviewed !== true + ) { + invalidRequest(); + } + return { + workflowId, + expectedRevision, + scope, + consent: { executionMode: "local-mock", reviewed: true }, + }; + } + const authorizationId = opaqueId(value.consent.authorizationId); + if ( + value.consent.executionMode !== "gemini" || + !exactKeys(value.consent, [ + "executionMode", + "version", + "authorizationId", + "consentFingerprint", + "token", + "reviewed", + ]) || + value.consent.version !== 1 || + !authorizationId || + typeof value.consent.consentFingerprint !== "string" || + !CONSENT_FINGERPRINT_PATTERN.test(value.consent.consentFingerprint) || + typeof value.consent.token !== "string" || + !CONSENT_FINGERPRINT_PATTERN.test(value.consent.token) || + value.consent.reviewed !== true + ) + invalidRequest(); + return { + workflowId, + expectedRevision, + scope, + consent: { + executionMode: "gemini", + version: 1, + authorizationId, + consentFingerprint: value.consent.consentFingerprint, + token: value.consent.token, + reviewed: true, + }, + }; +} + +export function parseCreateImagesPrepareRunRequest(value: unknown): CreateImagesPrepareRunRequest { + if ( + !isRecord(value) || + !exactKeys(value, ["workflowId", "expectedRevision", "scope", "executionMode"]) + ) + invalidRequest(); + const workflowId = opaqueId(value.workflowId); + const expectedRevision = revision(value.expectedRevision); + const scope = runScope(value.scope); + if (!workflowId || !expectedRevision || !scope || value.executionMode !== "gemini") { + invalidRequest(); + } + return { workflowId, expectedRevision, scope, executionMode: "gemini" }; +} + +export function parseCreateImagesStopRunRequest(value: unknown): CreateImagesStopRunRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "runId"])) invalidRequest(); + const workflowId = opaqueId(value.workflowId); + const runId = opaqueId(value.runId); + if (!workflowId || !runId) invalidRequest(); + return { workflowId, runId }; +} + +export function parseCreateImagesGetRunRequest(value: unknown): CreateImagesGetRunRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "runId"])) invalidRequest(); + const workflowId = opaqueId(value.workflowId); + const runId = opaqueId(value.runId); + if (!workflowId || !runId) invalidRequest(); + return { workflowId, runId }; +} + +export function parseCreateImagesRecoverRunRequest(value: unknown): CreateImagesRecoverRunRequest { + if ( + !isRecord(value) || + !exactKeys(value, ["workflowId", "runId", "source", "expectedCandidateJournalRevision"]) + ) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const runId = opaqueId(value.runId); + const source = + value.source === "last-known-good" || value.source === "current" ? value.source : undefined; + const expectedCandidateJournalRevision = revision(value.expectedCandidateJournalRevision); + if (!workflowId || !runId || !source || !expectedCandidateJournalRevision) invalidRequest(); + return { workflowId, runId, source, expectedCandidateJournalRevision }; +} + +export function parseCreateImagesResolveRunAmbiguityRequest( + value: unknown, +): CreateImagesResolveRunAmbiguityRequest { + if ( + !isRecord(value) || + !exactKeys(value, ["workflowId", "runId", "expectedJournalRevision", "resolution"]) + ) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const runId = opaqueId(value.runId); + const expectedJournalRevision = revision(value.expectedJournalRevision); + if ( + !workflowId || + !runId || + !expectedJournalRevision || + value.resolution !== "acknowledge-unresolved-submission" + ) { + invalidRequest(); + } + return { + workflowId, + runId, + expectedJournalRevision, + resolution: "acknowledge-unresolved-submission", + }; +} + +function retentionKeepLatest(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 100 && value <= 900 + ? value + : undefined; +} + +export function parseCreateImagesPlanRunHistoryPruneRequest( + value: unknown, +): CreateImagesPlanRunHistoryPruneRequest { + if (!isRecord(value) || !exactKeys(value, ["keepLatest"])) invalidRequest(); + const keepLatest = retentionKeepLatest(value.keepLatest); + if (!keepLatest) invalidRequest(); + return { keepLatest }; +} + +export function parseCreateImagesPruneRunHistoryRequest( + value: unknown, +): CreateImagesPruneRunHistoryRequest { + if (!isRecord(value) || !exactKeys(value, ["keepLatest", "authorizationToken", "confirmed"])) { + invalidRequest(); + } + const keepLatest = retentionKeepLatest(value.keepLatest); + const authorizationToken = + typeof value.authorizationToken === "string" && + RETENTION_TOKEN_PATTERN.test(value.authorizationToken) + ? value.authorizationToken + : undefined; + if (!keepLatest || !authorizationToken || value.confirmed !== true) invalidRequest(); + return { keepLatest, authorizationToken, confirmed: true }; +} + +export function parseCreateImagesPlanDegradedRunDiscardRequest( + value: unknown, +): CreateImagesPlanDegradedRunDiscardRequest { + if (!isRecord(value) || !exactKeys(value, ["runId"])) invalidRequest(); + const runId = opaqueId(value.runId); + if (!runId) invalidRequest(); + return { runId }; +} + +export function parseCreateImagesDiscardDegradedRunRequest( + value: unknown, +): CreateImagesDiscardDegradedRunRequest { + if ( + !isRecord(value) || + !exactKeys( + value, + ["runId", "authorizationToken", "confirmed"], + ["expectedCurrentJournalRevision", "expectedLastKnownGoodJournalRevision"], + ) + ) { + invalidRequest(); + } + const runId = opaqueId(value.runId); + const expectedCurrentJournalRevision = + value.expectedCurrentJournalRevision === undefined + ? undefined + : revision(value.expectedCurrentJournalRevision); + const expectedLastKnownGoodJournalRevision = + value.expectedLastKnownGoodJournalRevision === undefined + ? undefined + : revision(value.expectedLastKnownGoodJournalRevision); + const authorizationToken = + typeof value.authorizationToken === "string" && + RETENTION_TOKEN_PATTERN.test(value.authorizationToken) + ? value.authorizationToken + : undefined; + if ( + !runId || + (value.expectedCurrentJournalRevision !== undefined && + expectedCurrentJournalRevision === undefined) || + (value.expectedLastKnownGoodJournalRevision !== undefined && + expectedLastKnownGoodJournalRevision === undefined) || + !authorizationToken || + value.confirmed !== true + ) { + invalidRequest(); + } + return { + runId, + ...(expectedCurrentJournalRevision === undefined ? {} : { expectedCurrentJournalRevision }), + ...(expectedLastKnownGoodJournalRevision === undefined + ? {} + : { expectedLastKnownGoodJournalRevision }), + authorizationToken, + confirmed: true, + }; +} + +export function parseCreateImagesListRunsRequest(value: unknown): CreateImagesListRunsRequest { + return parseCreateImagesGetWorkflowRequest(value); +} + +export function parseCreateImagesSubscribeRunsRequest( + value: unknown, +): CreateImagesSubscribeRunsRequest { + return parseCreateImagesGetWorkflowRequest(value); +} + +export function parseCreateImagesUnsubscribeRunsRequest( + value: unknown, +): CreateImagesUnsubscribeRunsRequest { + if (!isRecord(value) || !exactKeys(value, ["subscriptionId"])) invalidRequest(); + const subscriptionId = + typeof value.subscriptionId === "string" && SUBSCRIPTION_ID_PATTERN.test(value.subscriptionId) + ? value.subscriptionId + : undefined; + if (!subscriptionId) invalidRequest(); + return { subscriptionId }; +} + +export function parseCreateImagesGrantRunAssetRequest( + value: unknown, +): CreateImagesGrantRunAssetRequest { + if (!isRecord(value) || !exactKeys(value, ["workflowId", "runId", "assetId"])) { + invalidRequest(); + } + const workflowId = opaqueId(value.workflowId); + const runId = opaqueId(value.runId); + const assetId = + typeof value.assetId === "string" && ASSET_ID_PATTERN.test(value.assetId) + ? value.assetId + : undefined; + if (!workflowId || !runId || !assetId) invalidRequest(); + return { workflowId, runId, assetId }; +} + +export function createImagesAssetGrantUrl(token: string): string { + if (!GRANT_TOKEN_PATTERN.test(token)) throw new Error("Invalid Create Images asset grant."); + return `${CREATE_IMAGES_ASSET_PROTOCOL}//asset/${token}`; +} diff --git a/renderer/shared/create-images/node-banana-import.test.ts b/renderer/shared/create-images/node-banana-import.test.ts new file mode 100644 index 00000000..691dd3eb --- /dev/null +++ b/renderer/shared/create-images/node-banana-import.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateWorkflowGraph } from "./ports.js"; +import { parseWorkflowDocument } from "./schema.js"; +import { + CreateImagesNodeBananaImportError, + convertNodeBananaWorkflow, +} from "./node-banana-import.js"; + +function ids(): () => string { + let next = 0; + return () => `imported-${++next}`; +} + +test("Node Banana conversion maps the supported image graph and externalizes inline media", () => { + const converted = convertNodeBananaWorkflow( + { + version: 1, + name: "Node Banana edit", + directoryPath: "/private/source", + providerSettings: { apiKey: "must-not-survive" }, + nodes: [ + { + id: "image-1", + type: "imageInput", + position: { x: 10, y: 20 }, + data: { + filename: "reference.webp", + image: "data:image/webp;base64,UklGRgAAAAA=", + imageRef: "/private/reference.webp", + }, + }, + { + id: "prompt-1", + type: "prompt", + position: { x: 10, y: 200 }, + data: { prompt: "Turn it yellow" }, + }, + { + id: "generation-1", + type: "nanoBanana", + position: { x: 380, y: 100 }, + data: { + aspectRatio: "16:9", + resolution: "2K", + selectedModel: { + provider: "gemini", + modelId: "gemini-3.1-flash-image-preview", + apiKey: "must-not-survive", + }, + outputImage: "data:image/png;base64,AAAA", + parameters: { secret: "must-not-survive" }, + }, + }, + { + id: "output-1", + type: "outputGallery", + position: { x: 760, y: 100 }, + data: { images: ["data:image/png;base64,AAAA"] }, + }, + { + id: "audio-1", + type: "generateAudio", + position: { x: 900, y: 300 }, + data: { credential: "must-not-survive" }, + }, + ], + edges: [ + { + id: "image-edge", + source: "image-1", + sourceHandle: "image", + target: "generation-1", + targetHandle: "image", + }, + { + id: "prompt-edge", + source: "prompt-1", + sourceHandle: "text", + target: "generation-1", + targetHandle: "text", + }, + { + id: "output-edge", + source: "generation-1", + sourceHandle: "image", + target: "output-1", + targetHandle: "image", + }, + { id: "unsupported-edge", source: "audio-1", target: "output-1" }, + ], + }, + { + workflowId: "workflow-imported", + now: "2026-08-19T12:00:00.000Z", + nextId: ids(), + }, + ); + + assert.equal(parseWorkflowDocument(converted.workflow).success, true); + assert.deepEqual( + converted.workflow.nodes.map((node) => node.type), + ["image-input", "prompt", "generate-image", "output-gallery"], + ); + assert.equal(converted.workflow.edges.length, 3); + assert.deepEqual(converted.workflow.assetRefs, []); + assert.equal(converted.inlineImages.length, 1); + assert.equal(converted.inlineImages[0]?.mediaType, "image/webp"); + assert.equal(converted.report.skippedNodeCount, 1); + assert.equal(converted.report.skippedEdgeCount, 1); + assert.equal(converted.report.entries.length, 5); + assert.equal( + converted.report.entries.some( + (entry) => entry.sourceType === "generateAudio" && entry.action === "skipped", + ), + true, + ); + const serialized = JSON.stringify(converted.workflow); + assert.equal(serialized.includes("must-not-survive"), false); + assert.equal(serialized.includes("/private/"), false); + assert.equal(serialized.includes("data:image"), false); + assert.deepEqual( + validateWorkflowGraph(converted.workflow).filter( + (issue) => !["missing_asset", "missing_provider", "missing_model"].includes(issue.code), + ), + [], + ); +}); + +test("Node Banana conversion rejects unsupported versions and bounded graph overflow", () => { + const input = { + workflowId: "workflow-imported", + now: "2026-08-19T12:00:00.000Z", + nextId: ids(), + }; + assert.throws( + () => convertNodeBananaWorkflow({ version: 2, nodes: [], edges: [] }, input), + CreateImagesNodeBananaImportError, + ); + assert.throws( + () => + convertNodeBananaWorkflow( + { version: 1, nodes: Array.from({ length: 501 }, () => ({})), edges: [] }, + input, + ), + /graph limits/u, + ); +}); diff --git a/renderer/shared/create-images/node-banana-import.ts b/renderer/shared/create-images/node-banana-import.ts new file mode 100644 index 00000000..ef3690b9 --- /dev/null +++ b/renderer/shared/create-images/node-banana-import.ts @@ -0,0 +1,441 @@ +import { + CREATE_IMAGES_MAX_EDGES, + CREATE_IMAGES_MAX_NODES, + CREATE_IMAGES_MAX_PROMPT_LENGTH, + CREATE_IMAGES_POSITION_LIMIT, + CREATE_IMAGES_SCHEMA_VERSION, + parseWorkflowDocument, + type CreateImagesAspectRatio, + type CreateImagesImageSize, + type WorkflowDocumentV1, + type WorkflowEdgeV1, + type WorkflowNodeV1, +} from "./schema.js"; + +const SOURCE_ID_MAX_LENGTH = 512; +const LABEL_MAX_LENGTH = 120; +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,191}$/u; +const DATA_IMAGE_PATTERN = /^data:(image\/[A-Za-z0-9.+-]{1,64});base64,([A-Za-z0-9+/]*={0,2})$/u; +const ASPECT_RATIOS: ReadonlySet = new Set([ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]); +const IMAGE_SIZES: ReadonlySet = new Set(["1K", "2K", "4K"]); + +export interface CreateImagesNodeBananaInlineImage { + sourceNodeIndex: number; + targetNodeId: string; + mediaType: string; + base64: string; + displayName: string; +} + +export interface CreateImagesNodeBananaImportEntry { + sourceNodeIndex: number; + sourceType: string; + action: "rewritten" | "skipped"; + message: string; +} + +export interface CreateImagesNodeBananaImportReport { + sourceNodeCount: number; + importedNodeCount: number; + skippedNodeCount: number; + importedEdgeCount: number; + skippedEdgeCount: number; + embeddedImageCount: number; + importedEmbeddedImageCount: number; + skippedEmbeddedImageCount: number; + entries: CreateImagesNodeBananaImportEntry[]; + securityNote: string; +} + +export interface CreateImagesNodeBananaConversion { + workflow: WorkflowDocumentV1; + inlineImages: CreateImagesNodeBananaInlineImage[]; + report: CreateImagesNodeBananaImportReport; +} + +export class CreateImagesNodeBananaImportError extends Error { + constructor(message: string) { + super(message); + this.name = "CreateImagesNodeBananaImportError"; + } +} + +interface SourceNode { + id: string; + type: string; + data: Record; + position: { x: number; y: number }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function sourceType(value: unknown): string { + return typeof value === "string" && /^[A-Za-z0-9_-]{1,64}$/u.test(value) ? value : "unknown"; +} + +function sourceNode(value: unknown): SourceNode | undefined { + if (!isRecord(value)) return undefined; + if ( + typeof value.id !== "string" || + value.id.length < 1 || + value.id.length > SOURCE_ID_MAX_LENGTH + ) { + return undefined; + } + const type = sourceType(value.type); + const rawPosition = isRecord(value.position) ? value.position : {}; + const coordinate = (candidate: unknown): number => + typeof candidate === "number" && Number.isFinite(candidate) + ? Math.max(-CREATE_IMAGES_POSITION_LIMIT, Math.min(CREATE_IMAGES_POSITION_LIMIT, candidate)) + : 0; + return { + id: value.id, + type, + data: isRecord(value.data) ? value.data : {}, + position: { x: coordinate(rawPosition.x), y: coordinate(rawPosition.y) }, + }; +} + +function label(data: Record, fallback: string): string { + for (const candidate of [data.customTitle, data.label, data.filename]) { + if (typeof candidate === "string") { + const normalized = candidate.normalize("NFKC").replace(/\s+/gu, " ").trim(); + if (normalized) return normalized.slice(0, LABEL_MAX_LENGTH); + } + } + return fallback; +} + +function title(value: unknown): string { + if (typeof value !== "string") return "Imported Node Banana workflow"; + const normalized = value.normalize("NFKC").replace(/\s+/gu, " ").trim(); + return normalized ? normalized.slice(0, LABEL_MAX_LENGTH) : "Imported Node Banana workflow"; +} + +function validDataImage(value: unknown): { mediaType: string; base64: string } | undefined { + if (typeof value !== "string") return undefined; + const match = DATA_IMAGE_PATTERN.exec(value); + if (!match || match[2]!.length < 4 || match[2]!.length % 4 !== 0) return undefined; + if (match[1]!.toLowerCase() === "image/svg+xml") return undefined; + return { mediaType: match[1]!.toLowerCase(), base64: match[2]! }; +} + +function importedModel(data: Record): { providerId?: "gemini"; modelId?: string } { + const selected = isRecord(data.selectedModel) ? data.selectedModel : undefined; + const selectedModelId = selected?.modelId; + if ( + selected?.provider === "gemini" && + typeof selectedModelId === "string" && + MODEL_ID_PATTERN.test(selectedModelId) + ) { + return { providerId: "gemini", modelId: selectedModelId }; + } + if (typeof data.model === "string" && MODEL_ID_PATTERN.test(data.model)) { + return { providerId: "gemini", modelId: data.model }; + } + return {}; +} + +function sourcePort(type: string): string | undefined { + if (type === "prompt") return "text"; + if (type === "imageInput") return "image"; + if (type === "nanoBanana") return "images"; + return undefined; +} + +function targetPort( + source: string, + target: string, + sourceHandle: unknown, + targetHandle: unknown, +): string | undefined { + if (target === "nanoBanana") { + if ( + source === "prompt" || + (typeof targetHandle === "string" && targetHandle.startsWith("text")) || + sourceHandle === "text" + ) { + return "prompt"; + } + if ( + source === "imageInput" || + source === "nanoBanana" || + (typeof targetHandle === "string" && targetHandle.startsWith("image")) + ) { + return "references"; + } + } + if ( + (target === "output" || target === "outputGallery") && + (source === "imageInput" || source === "nanoBanana") + ) { + return "images"; + } + return undefined; +} + +function createsCycle(edges: readonly WorkflowEdgeV1[], source: string, target: string): boolean { + const outgoing = new Map(); + for (const edge of edges) { + const targets = outgoing.get(edge.source) ?? []; + targets.push(edge.target); + outgoing.set(edge.source, targets); + } + const pending = [target]; + const visited = new Set(); + while (pending.length > 0) { + const current = pending.pop()!; + if (current === source) return true; + if (visited.has(current)) continue; + visited.add(current); + pending.push(...(outgoing.get(current) ?? [])); + } + return false; +} + +function connectionLimit(targetType: string, port: string): number { + if (targetType === "nanoBanana") return port === "prompt" ? 1 : 14; + if (targetType === "output") return 1; + if (targetType === "outputGallery") return 64; + return 0; +} + +export function convertNodeBananaWorkflow( + value: unknown, + input: { workflowId: string; now: string; nextId(): string }, +): CreateImagesNodeBananaConversion { + if (!isRecord(value) || value.version !== 1) { + throw new CreateImagesNodeBananaImportError( + "Only Node Banana workflow version 1 is supported.", + ); + } + if (!Array.isArray(value.nodes) || !Array.isArray(value.edges)) { + throw new CreateImagesNodeBananaImportError("The Node Banana workflow graph is missing."); + } + if ( + value.nodes.length > CREATE_IMAGES_MAX_NODES || + value.edges.length > CREATE_IMAGES_MAX_EDGES + ) { + throw new CreateImagesNodeBananaImportError( + "The Node Banana workflow exceeds Aiden's graph limits.", + ); + } + + const nodes: WorkflowNodeV1[] = []; + const inlineImages: CreateImagesNodeBananaInlineImage[] = []; + const entries: CreateImagesNodeBananaImportEntry[] = []; + const sourceById = new Map(); + let skippedNodeCount = 0; + + for (let index = 0; index < value.nodes.length; index += 1) { + const parsed = sourceNode(value.nodes[index]); + const type = + parsed?.type ?? + sourceType(isRecord(value.nodes[index]) ? value.nodes[index].type : undefined); + if (!parsed || sourceById.has(parsed.id)) { + skippedNodeCount += 1; + entries.push({ + sourceNodeIndex: index, + sourceType: type, + action: "skipped", + message: parsed ? "Duplicate source node ID." : "Invalid source node shape or ID.", + }); + continue; + } + const targetId = input.nextId(); + let targetNode: WorkflowNodeV1 | undefined; + let message = ""; + if (parsed.type === "imageInput") { + const image = validDataImage(parsed.data.image); + const displayName = label(parsed.data, `Imported image ${index + 1}`); + targetNode = { + id: targetId, + type: "image-input", + position: parsed.position, + data: { label: displayName }, + }; + if (image) { + inlineImages.push({ + sourceNodeIndex: index, + targetNodeId: targetId, + mediaType: image.mediaType, + base64: image.base64, + displayName, + }); + message = "Mapped to Image Input; embedded bytes will be validated and externalized."; + } else if (parsed.data.image !== null && parsed.data.image !== undefined) { + message = + "Mapped to Image Input, but its non-portable or unsupported image was not imported."; + } else if (parsed.data.imageRef !== undefined) { + message = "Mapped to Image Input without its device-specific image reference."; + } else { + message = "Mapped to an empty Image Input."; + } + } else if (parsed.type === "prompt") { + const text = typeof parsed.data.prompt === "string" ? parsed.data.prompt : ""; + targetNode = { + id: targetId, + type: "prompt", + position: parsed.position, + data: { text: text.slice(0, CREATE_IMAGES_MAX_PROMPT_LENGTH) }, + }; + message = + text.length > CREATE_IMAGES_MAX_PROMPT_LENGTH + ? "Mapped to Prompt and truncated to Aiden's prompt limit." + : "Mapped to Prompt."; + } else if (parsed.type === "nanoBanana") { + const model = importedModel(parsed.data); + const aspectRatio = + typeof parsed.data.aspectRatio === "string" && ASPECT_RATIOS.has(parsed.data.aspectRatio) + ? (parsed.data.aspectRatio as CreateImagesAspectRatio) + : "1:1"; + const imageSize = + typeof parsed.data.resolution === "string" && IMAGE_SIZES.has(parsed.data.resolution) + ? (parsed.data.resolution as CreateImagesImageSize) + : "1K"; + targetNode = { + id: targetId, + type: "generate-image", + position: parsed.position, + data: { + ...model, + aspectRatio, + imageSize, + outputMime: "image/png", + count: 1, + }, + }; + message = model.modelId + ? "Mapped to Generate Image; runtime state, outputs, search, fallback, parameters, and credentials were removed." + : "Mapped to Generate Image without an unverified provider model; choose a current Aiden model before running."; + } else if (parsed.type === "output" || parsed.type === "outputGallery") { + targetNode = { + id: targetId, + type: parsed.type === "output" ? "output" : "output-gallery", + position: parsed.position, + data: { label: label(parsed.data, parsed.type === "output" ? "Output" : "Output gallery") }, + }; + message = `Mapped to ${parsed.type === "output" ? "Output" : "Output Gallery"}; cached media was removed.`; + } + if (!targetNode) { + skippedNodeCount += 1; + entries.push({ + sourceNodeIndex: index, + sourceType: parsed.type, + action: "skipped", + message: "This Node Banana node type is not supported by the Create Images MVP.", + }); + continue; + } + nodes.push(targetNode); + sourceById.set(parsed.id, { node: parsed, targetId }); + entries.push({ + sourceNodeIndex: index, + sourceType: parsed.type, + action: "rewritten", + message, + }); + } + + const edges: WorkflowEdgeV1[] = []; + const connections = new Set(); + const incoming = new Map(); + let skippedEdgeCount = 0; + for (const rawEdge of value.edges) { + if ( + !isRecord(rawEdge) || + typeof rawEdge.source !== "string" || + typeof rawEdge.target !== "string" + ) { + skippedEdgeCount += 1; + continue; + } + const source = sourceById.get(rawEdge.source); + const target = sourceById.get(rawEdge.target); + if (!source || !target) { + skippedEdgeCount += 1; + continue; + } + const mappedSourcePort = sourcePort(source.node.type); + const mappedTargetPort = targetPort( + source.node.type, + target.node.type, + rawEdge.sourceHandle, + rawEdge.targetHandle, + ); + if (!mappedSourcePort || !mappedTargetPort) { + skippedEdgeCount += 1; + continue; + } + const connection = `${source.targetId}\u0000${mappedSourcePort}\u0000${target.targetId}\u0000${mappedTargetPort}`; + const incomingKey = `${target.targetId}\u0000${mappedTargetPort}`; + const nextIncoming = (incoming.get(incomingKey) ?? 0) + 1; + if ( + connections.has(connection) || + nextIncoming > connectionLimit(target.node.type, mappedTargetPort) || + createsCycle(edges, source.targetId, target.targetId) + ) { + skippedEdgeCount += 1; + continue; + } + connections.add(connection); + incoming.set(incomingKey, nextIncoming); + edges.push({ + id: input.nextId(), + source: source.targetId, + sourcePort: mappedSourcePort, + target: target.targetId, + targetPort: mappedTargetPort, + }); + } + + const workflow: WorkflowDocumentV1 = { + schemaVersion: CREATE_IMAGES_SCHEMA_VERSION, + id: input.workflowId, + title: title(value.name), + revision: 1, + createdAt: input.now, + updatedAt: input.now, + viewport: { x: 0, y: 0, zoom: 1 }, + nodes, + edges, + assetRefs: [], + settings: { concurrency: 1 }, + }; + const parsedWorkflow = parseWorkflowDocument(workflow); + if (!parsedWorkflow.success) { + throw new CreateImagesNodeBananaImportError( + "The converted workflow did not pass Aiden's schema.", + ); + } + return { + workflow: parsedWorkflow.value, + inlineImages, + report: { + sourceNodeCount: value.nodes.length, + importedNodeCount: nodes.length, + skippedNodeCount, + importedEdgeCount: edges.length, + skippedEdgeCount, + embeddedImageCount: inlineImages.length, + importedEmbeddedImageCount: 0, + skippedEmbeddedImageCount: 0, + entries, + securityNote: + "Provider credentials, provider settings, absolute paths, external media references, cached outputs, and runtime state were never imported.", + }, + }; +} diff --git a/renderer/shared/create-images/ports.ts b/renderer/shared/create-images/ports.ts new file mode 100644 index 00000000..99a945e1 --- /dev/null +++ b/renderer/shared/create-images/ports.ts @@ -0,0 +1,356 @@ +import type { + CreateImagesNodeType, + WorkflowDocumentV1, + WorkflowEdgeV1, + WorkflowNodeV1, +} from "./schema.js"; + +export type CreateImagesPortKind = "text" | "image" | "image-list" | "metadata"; + +export interface CreateImagesPortDefinition { + id: string; + kind: CreateImagesPortKind; + label: string; + required?: boolean; + maxConnections?: number; +} + +export interface CreateImagesNodeDefinition { + type: CreateImagesNodeType; + title: string; + category: "input" | "prompt" | "generation" | "output"; + inputs: readonly CreateImagesPortDefinition[]; + outputs: readonly CreateImagesPortDefinition[]; + execution: "local" | "remote"; +} + +export const CREATE_IMAGES_NODE_DEFINITIONS: Readonly< + Record +> = Object.freeze({ + "image-input": { + type: "image-input", + title: "Image Input", + category: "input", + inputs: [], + outputs: [{ id: "image", kind: "image", label: "Image" }], + execution: "local", + }, + prompt: { + type: "prompt", + title: "Prompt", + category: "prompt", + inputs: [], + outputs: [{ id: "text", kind: "text", label: "Prompt" }], + execution: "local", + }, + "generate-image": { + type: "generate-image", + title: "Generate Image", + category: "generation", + inputs: [ + { id: "prompt", kind: "text", label: "Prompt", required: true, maxConnections: 1 }, + { id: "references", kind: "image-list", label: "References", maxConnections: 14 }, + ], + outputs: [ + { id: "images", kind: "image-list", label: "Images" }, + { id: "metadata", kind: "metadata", label: "Generation metadata" }, + ], + execution: "remote", + }, + output: { + type: "output", + title: "Output", + category: "output", + inputs: [ + { id: "images", kind: "image-list", label: "Images", required: true, maxConnections: 1 }, + ], + outputs: [], + execution: "local", + }, + "output-gallery": { + type: "output-gallery", + title: "Output Gallery", + category: "output", + inputs: [ + { id: "images", kind: "image-list", label: "Images", required: true, maxConnections: 64 }, + ], + outputs: [], + execution: "local", + }, +}); + +export type WorkflowGraphIssueCode = + | "unknown_node" + | "unknown_port" + | "invalid_direction" + | "incompatible_port" + | "duplicate_connection" + | "connection_limit" + | "self_loop" + | "cycle" + | "missing_required_input" + | "missing_asset" + | "missing_prompt" + | "missing_provider" + | "missing_model" + | "invalid_run_scope"; + +export interface WorkflowGraphIssue { + code: WorkflowGraphIssueCode; + message: string; + nodeId?: string; + edgeId?: string; + portId?: string; +} + +function nodeMap(document: WorkflowDocumentV1): Map { + return new Map(document.nodes.map((node) => [node.id, node])); +} + +function port( + node: WorkflowNodeV1, + direction: "inputs" | "outputs", + portId: string, +): CreateImagesPortDefinition | undefined { + return CREATE_IMAGES_NODE_DEFINITIONS[node.type][direction].find( + (candidate) => candidate.id === portId, + ); +} + +export function isCreateImagesPortCompatible( + source: CreateImagesPortKind, + target: CreateImagesPortKind, +): boolean { + return source === target || (source === "image" && target === "image-list"); +} + +function connectionKey(edge: WorkflowEdgeV1): string { + return [edge.source, edge.sourcePort, edge.target, edge.targetPort].join("\u0000"); +} + +function structurallyValidEdges( + document: WorkflowDocumentV1, + issues: WorkflowGraphIssue[], +): WorkflowEdgeV1[] { + const nodes = nodeMap(document); + const seenConnections = new Set(); + const incomingCount = new Map(); + const valid: WorkflowEdgeV1[] = []; + + for (const edge of document.edges) { + const source = nodes.get(edge.source); + const target = nodes.get(edge.target); + if (!source || !target) { + issues.push({ + code: "unknown_node", + edgeId: edge.id, + message: `Edge "${edge.id}" references a node that is not in the workflow.`, + }); + continue; + } + if (source.id === target.id) { + issues.push({ + code: "self_loop", + edgeId: edge.id, + nodeId: source.id, + message: "A node cannot connect to itself.", + }); + continue; + } + const sourcePort = port(source, "outputs", edge.sourcePort); + const targetPort = port(target, "inputs", edge.targetPort); + if (!sourcePort) { + const reverse = port(source, "inputs", edge.sourcePort); + issues.push({ + code: reverse ? "invalid_direction" : "unknown_port", + edgeId: edge.id, + nodeId: source.id, + portId: edge.sourcePort, + message: reverse + ? `Port "${edge.sourcePort}" is an input and cannot start a connection.` + : `Source port "${edge.sourcePort}" does not exist on ${source.type}.`, + }); + continue; + } + if (!targetPort) { + const reverse = port(target, "outputs", edge.targetPort); + issues.push({ + code: reverse ? "invalid_direction" : "unknown_port", + edgeId: edge.id, + nodeId: target.id, + portId: edge.targetPort, + message: reverse + ? `Port "${edge.targetPort}" is an output and cannot end a connection.` + : `Target port "${edge.targetPort}" does not exist on ${target.type}.`, + }); + continue; + } + if (!isCreateImagesPortCompatible(sourcePort.kind, targetPort.kind)) { + issues.push({ + code: "incompatible_port", + edgeId: edge.id, + message: `${sourcePort.label} (${sourcePort.kind}) cannot connect to ${targetPort.label} (${targetPort.kind}).`, + }); + continue; + } + const key = connectionKey(edge); + if (seenConnections.has(key)) { + issues.push({ + code: "duplicate_connection", + edgeId: edge.id, + message: "This connection already exists.", + }); + continue; + } + seenConnections.add(key); + + const targetKey = `${target.id}\u0000${targetPort.id}`; + const nextCount = (incomingCount.get(targetKey) ?? 0) + 1; + incomingCount.set(targetKey, nextCount); + if (targetPort.maxConnections !== undefined && nextCount > targetPort.maxConnections) { + issues.push({ + code: "connection_limit", + edgeId: edge.id, + nodeId: target.id, + portId: targetPort.id, + message: `${targetPort.label} accepts at most ${targetPort.maxConnections} connection${targetPort.maxConnections === 1 ? "" : "s"}.`, + }); + continue; + } + valid.push(edge); + } + + return valid; +} + +function cycleIssues( + document: WorkflowDocumentV1, + edges: readonly WorkflowEdgeV1[], +): WorkflowGraphIssue[] { + const order = new Map(document.nodes.map((node, index) => [node.id, index])); + const indegree = new Map(document.nodes.map((node) => [node.id, 0])); + const outgoing = new Map(document.nodes.map((node) => [node.id, [] as string[]])); + for (const edge of edges) { + indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1); + outgoing.get(edge.source)?.push(edge.target); + } + const ready: string[] = []; + for (const node of document.nodes) { + if (indegree.get(node.id) === 0) ready.push(node.id); + } + let visited = 0; + while (ready.length > 0) { + ready.sort((left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0)); + const current = ready.shift(); + if (!current) break; + visited += 1; + for (const target of outgoing.get(current) ?? []) { + const next = (indegree.get(target) ?? 0) - 1; + indegree.set(target, next); + if (next === 0) ready.push(target); + } + } + if (visited === document.nodes.length) return []; + const issues: WorkflowGraphIssue[] = []; + for (const node of document.nodes) { + if ((indegree.get(node.id) ?? 0) > 0) { + issues.push({ + code: "cycle" as const, + nodeId: node.id, + message: "Cycles are not supported in Create Images workflows.", + }); + } + } + return issues; +} + +export function validateWorkflowGraph( + document: WorkflowDocumentV1, + options: { forRun?: boolean } = {}, +): WorkflowGraphIssue[] { + const issues: WorkflowGraphIssue[] = []; + const validEdges = structurallyValidEdges(document, issues); + issues.push(...cycleIssues(document, validEdges)); + + if (!options.forRun) return issues; + + const incoming = new Set(validEdges.map((edge) => `${edge.target}\u0000${edge.targetPort}`)); + for (const node of document.nodes) { + const definition = CREATE_IMAGES_NODE_DEFINITIONS[node.type]; + for (const input of definition.inputs) { + if (input.required && !incoming.has(`${node.id}\u0000${input.id}`)) { + issues.push({ + code: "missing_required_input", + nodeId: node.id, + portId: input.id, + message: `${definition.title} requires ${input.label}.`, + }); + } + } + if (node.type === "image-input" && !node.data.assetId) { + issues.push({ + code: "missing_asset", + nodeId: node.id, + message: "Choose an image before running this node.", + }); + } else if (node.type === "prompt" && node.data.text.trim().length === 0) { + issues.push({ + code: "missing_prompt", + nodeId: node.id, + message: "Enter a prompt before running this node.", + }); + } else if (node.type === "generate-image") { + if (!node.data.providerId) { + issues.push({ + code: "missing_provider", + nodeId: node.id, + message: "Choose a connected image provider.", + }); + } + if (!node.data.modelId) { + issues.push({ + code: "missing_model", + nodeId: node.id, + message: "Choose a supported image model.", + }); + } + } + } + return issues; +} + +export function topologicalWorkflowOrder(document: WorkflowDocumentV1): { + order: string[]; + issues: WorkflowGraphIssue[]; +} { + const issues: WorkflowGraphIssue[] = []; + const edges = structurallyValidEdges(document, issues); + const cycles = cycleIssues(document, edges); + issues.push(...cycles); + if (issues.length > 0) return { order: [], issues }; + + const index = new Map(document.nodes.map((node, nodeIndex) => [node.id, nodeIndex])); + const indegree = new Map(document.nodes.map((node) => [node.id, 0])); + const outgoing = new Map(document.nodes.map((node) => [node.id, [] as string[]])); + for (const edge of edges) { + indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1); + outgoing.get(edge.source)?.push(edge.target); + } + const ready: string[] = []; + for (const node of document.nodes) { + if (indegree.get(node.id) === 0) ready.push(node.id); + } + const order: string[] = []; + while (ready.length > 0) { + ready.sort((left, right) => (index.get(left) ?? 0) - (index.get(right) ?? 0)); + const current = ready.shift(); + if (!current) break; + order.push(current); + for (const target of outgoing.get(current) ?? []) { + const next = (indegree.get(target) ?? 0) - 1; + indegree.set(target, next); + if (next === 0) ready.push(target); + } + } + return { order, issues }; +} diff --git a/renderer/shared/create-images/providers.test.ts b/renderer/shared/create-images/providers.test.ts new file mode 100644 index 00000000..43fd6990 --- /dev/null +++ b/renderer/shared/create-images/providers.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + createImagesCuratedGeminiModels, + evaluateCreateImagesProviderBinding, + type CreateImagesProviderStatus, +} from "./providers.js"; +import type { GenerateImageNodeV1 } from "./schema.js"; + +const generationNode: GenerateImageNodeV1 = { + id: "generate-1", + type: "generate-image", + position: { x: 0, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, +}; + +function connectedStatus( + overrides: Partial = {}, +): CreateImagesProviderStatus { + return { + schemaVersion: 1, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "connected", + credentialKind: "google-api-key", + capabilitySnapshot: structuredClone(CREATE_IMAGES_GEMINI_RELEASE_CATALOG), + ...overrides, + }; +} + +test("Gemini release catalog is curated, bounded, and contains no credential or endpoint fields", () => { + assert.deepEqual( + CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models.map((model) => model.id), + ["gemini-3.1-flash-lite-image", "gemini-3.1-flash-image", "gemini-3-pro-image"], + ); + for (const model of CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models) { + assert.equal(model.maxOutputs, 1); + assert.equal(model.supportsCancellation, false); + assert.ok(model.aspectRatios.length <= 10); + assert.ok(model.imageSizes.length <= 3); + } + const serialized = JSON.stringify(CREATE_IMAGES_GEMINI_RELEASE_CATALOG); + assert.doesNotMatch(serialized, /api[_-]?key|credential|authorization|endpoint|baseUrl/iu); +}); + +test("provider binding accepts only a connected current snapshot with compatible exact options", () => { + const ready = evaluateCreateImagesProviderBinding(generationNode, connectedStatus()); + assert.equal(ready.status, "ready"); + + for (const [status, issue] of [ + [connectedStatus({ connectionState: "disconnected" }), "connection-not-ready"], + [connectedStatus({ capabilitySnapshot: undefined }), "capabilities-unavailable"], + [ + connectedStatus({ + capabilitySnapshot: { ...CREATE_IMAGES_GEMINI_RELEASE_CATALOG, state: "stale" }, + }), + "capabilities-stale", + ], + ] as const) { + assert.deepEqual(evaluateCreateImagesProviderBinding(generationNode, status), { + status: "blocked", + issue, + }); + } +}); + +test("provider binding rejects model and option drift including output fan-out", () => { + const currentModel = CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models[1]!; + const cases: Array<[GenerateImageNodeV1, CreateImagesProviderStatus, string]> = [ + [ + { ...generationNode, data: { ...generationNode.data, modelId: "attacker/model" } }, + connectedStatus(), + "model-not-curated", + ], + [ + generationNode, + connectedStatus({ + capabilitySnapshot: { + ...CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + models: CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models.filter( + (model) => model.id !== generationNode.data.modelId, + ), + }, + }), + "model-no-longer-available", + ], + [ + { ...generationNode, data: { ...generationNode.data, imageSize: "4K" } }, + connectedStatus({ + capabilitySnapshot: { + ...CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + models: [{ ...currentModel, imageSizes: ["1K"] }], + }, + }), + "image-size-no-longer-supported", + ], + [ + { ...generationNode, data: { ...generationNode.data, count: 2 } }, + connectedStatus(), + "output-count-no-longer-supported", + ], + ]; + for (const [node, status, issue] of cases) { + assert.deepEqual(evaluateCreateImagesProviderBinding(node, status), { + status: "blocked", + issue, + }); + } +}); + +test("connected capabilities are intersected with the release catalog", () => { + const status = connectedStatus({ + capabilitySnapshot: { + ...CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + models: [ + CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models[0]!, + { + ...CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models[0]!, + id: "provider-added-unreviewed-model", + }, + ], + }, + }); + assert.deepEqual( + createImagesCuratedGeminiModels(status).map((model) => model.id), + ["gemini-3.1-flash-lite-image"], + ); +}); diff --git a/renderer/shared/create-images/providers.ts b/renderer/shared/create-images/providers.ts new file mode 100644 index 00000000..1c21e25f --- /dev/null +++ b/renderer/shared/create-images/providers.ts @@ -0,0 +1,204 @@ +import type { + CreateImagesAspectRatio, + CreateImagesImageSize, + CreateImagesOutputMime, + GenerateImageNodeV1, +} from "./schema"; + +export const CREATE_IMAGES_PROVIDER_STATUS_VERSION = 1 as const; +export const CREATE_IMAGES_GEMINI_PROVIDER_ID = "gemini" as const; + +export type CreateImagesExecutionMode = "local-mock" | typeof CREATE_IMAGES_GEMINI_PROVIDER_ID; + +export type CreateImagesProviderConnectionState = + | "disconnected" + | "connecting" + | "connected" + | "invalid" + | "unavailable"; + +export type CreateImagesCapabilityState = "current" | "stale" | "unknown"; + +export type CreateImagesProviderSafeErrorCode = + | "credential-missing" + | "credential-invalid" + | "credential-scope-unverified" + | "capability-check-failed" + | "provider-unreachable" + | "rate-limited" + | "feature-unavailable"; + +export interface CreateImagesProviderModelCapability { + id: string; + label: string; + aspectRatios: readonly CreateImagesAspectRatio[]; + imageSizes: readonly CreateImagesImageSize[]; + outputMimes: readonly CreateImagesOutputMime[]; + maxReferenceImages: number; + maxOutputs: number; + supportsEditing: boolean; + supportsCancellation: boolean; +} + +export interface CreateImagesProviderCapabilitySnapshot { + catalogId: string; + verifiedAt: string; + state: CreateImagesCapabilityState; + models: readonly CreateImagesProviderModelCapability[]; +} + +/** + * Renderer-safe connection DTO. It intentionally contains no key, credential + * value, endpoint, absolute path, request body, prompt, or reference image. + */ +export interface CreateImagesProviderStatus { + schemaVersion: typeof CREATE_IMAGES_PROVIDER_STATUS_VERSION; + providerId: typeof CREATE_IMAGES_GEMINI_PROVIDER_ID; + displayName: "Google Gemini"; + connectionState: CreateImagesProviderConnectionState; + credentialKind?: "google-api-key"; + capabilitySnapshot?: CreateImagesProviderCapabilitySnapshot; + safeErrorCode?: CreateImagesProviderSafeErrorCode; + retryAfterMs?: number; +} + +const COMMON_GEMINI_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +] as const satisfies readonly CreateImagesAspectRatio[]; + +/** Release-pinned renderer catalog matching the Phase 0 Gemini contract. */ +export const CREATE_IMAGES_GEMINI_RELEASE_CATALOG: CreateImagesProviderCapabilitySnapshot = + Object.freeze({ + catalogId: "gemini-interactions-2026-08-11", + verifiedAt: "2026-08-11T00:00:00.000Z", + state: "current", + models: Object.freeze([ + Object.freeze({ + id: "gemini-3.1-flash-lite-image", + label: "Nano Banana 2 Lite", + aspectRatios: COMMON_GEMINI_ASPECT_RATIOS, + imageSizes: ["1K"] as const, + outputMimes: ["image/png", "image/jpeg"] as const, + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }), + Object.freeze({ + id: "gemini-3.1-flash-image", + label: "Nano Banana 2", + aspectRatios: COMMON_GEMINI_ASPECT_RATIOS, + imageSizes: ["1K", "2K", "4K"] as const, + outputMimes: ["image/png", "image/jpeg"] as const, + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }), + Object.freeze({ + id: "gemini-3-pro-image", + label: "Nano Banana Pro", + aspectRatios: COMMON_GEMINI_ASPECT_RATIOS, + imageSizes: ["1K", "2K", "4K"] as const, + outputMimes: ["image/png", "image/jpeg"] as const, + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }), + ]), + }); + +export type CreateImagesProviderBindingIssue = + | "connection-not-ready" + | "capabilities-unavailable" + | "capabilities-stale" + | "model-unselected" + | "model-not-curated" + | "model-no-longer-available" + | "aspect-ratio-no-longer-supported" + | "image-size-no-longer-supported" + | "output-format-no-longer-supported" + | "output-count-no-longer-supported"; + +export type CreateImagesProviderBindingResult = + | { status: "ready"; model: CreateImagesProviderModelCapability } + | { status: "blocked"; issue: CreateImagesProviderBindingIssue }; + +function releaseModel( + modelId: string | undefined, +): CreateImagesProviderModelCapability | undefined { + return CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models.find((model) => model.id === modelId); +} + +/** + * Capability drift is fail-closed: a connected provider must return a current + * main-owned snapshot and every selected option must still be present. + */ +export function evaluateCreateImagesProviderBinding( + node: GenerateImageNodeV1, + provider: CreateImagesProviderStatus, +): CreateImagesProviderBindingResult { + if (!node.data.modelId) return { status: "blocked", issue: "model-unselected" }; + if (!releaseModel(node.data.modelId)) { + return { status: "blocked", issue: "model-not-curated" }; + } + if (provider.connectionState !== "connected") { + return { status: "blocked", issue: "connection-not-ready" }; + } + const snapshot = provider.capabilitySnapshot; + if (!snapshot) return { status: "blocked", issue: "capabilities-unavailable" }; + if (snapshot.state !== "current") { + return { status: "blocked", issue: "capabilities-stale" }; + } + const model = snapshot.models.find((candidate) => candidate.id === node.data.modelId); + if (!model) return { status: "blocked", issue: "model-no-longer-available" }; + if (!model.aspectRatios.includes(node.data.aspectRatio)) { + return { status: "blocked", issue: "aspect-ratio-no-longer-supported" }; + } + if (!model.imageSizes.includes(node.data.imageSize)) { + return { status: "blocked", issue: "image-size-no-longer-supported" }; + } + if (!model.outputMimes.includes(node.data.outputMime)) { + return { status: "blocked", issue: "output-format-no-longer-supported" }; + } + if (node.data.count > model.maxOutputs) { + return { status: "blocked", issue: "output-count-no-longer-supported" }; + } + return { status: "ready", model }; +} + +export function createImagesCuratedGeminiModels( + provider: CreateImagesProviderStatus, +): readonly CreateImagesProviderModelCapability[] { + const snapshot = provider.capabilitySnapshot; + if (provider.connectionState !== "connected" || snapshot?.state !== "current") { + return CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models; + } + const availableIds = new Set(snapshot.models.map((model) => model.id)); + return CREATE_IMAGES_GEMINI_RELEASE_CATALOG.models.filter((model) => availableIds.has(model.id)); +} + +export function createImagesProviderModelLabel(modelId: string | undefined): string { + if (!modelId) return "Choose a supported model"; + return releaseModel(modelId)?.label ?? "Unsupported model"; +} + +export function disconnectedCreateImagesProviderStatus(): CreateImagesProviderStatus { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: CREATE_IMAGES_GEMINI_PROVIDER_ID, + displayName: "Google Gemini", + connectionState: "disconnected", + safeErrorCode: "credential-missing", + }; +} diff --git a/renderer/shared/create-images/retry-policy.ts b/renderer/shared/create-images/retry-policy.ts new file mode 100644 index 00000000..628a200c --- /dev/null +++ b/renderer/shared/create-images/retry-policy.ts @@ -0,0 +1,37 @@ +export const CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY = Object.freeze({ + maxRetriesPerNode: 2, + baseDelayMs: 250, + maxDelayMs: 2_000, + maxTotalDelayMs: 5_000, + jitterRatio: 0, + retryRemoteNotSubmitted: true, + retryRemoteIdempotent: true, +} as const); + +export interface CreateImagesRunAttemptBudget { + initialGenerationRequests: number; + maximumAutomaticRetryAttempts: number; + maximumTotalAttempts: number; +} + +export function createImagesLocalMockAttemptBudget( + initialGenerationRequests: number, +): CreateImagesRunAttemptBudget { + if (!Number.isSafeInteger(initialGenerationRequests) || initialGenerationRequests < 0) { + throw new Error("Initial generation request count must be a non-negative safe integer."); + } + const maximumAutomaticRetryAttempts = + initialGenerationRequests * CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY.maxRetriesPerNode; + const maximumTotalAttempts = initialGenerationRequests + maximumAutomaticRetryAttempts; + if ( + !Number.isSafeInteger(maximumAutomaticRetryAttempts) || + !Number.isSafeInteger(maximumTotalAttempts) + ) { + throw new Error("The local mock attempt budget exceeds the safe integer range."); + } + return Object.freeze({ + initialGenerationRequests, + maximumAutomaticRetryAttempts, + maximumTotalAttempts, + }); +} diff --git a/renderer/shared/create-images/run-contract.test.ts b/renderer/shared/create-images/run-contract.test.ts new file mode 100644 index 00000000..99e51901 --- /dev/null +++ b/renderer/shared/create-images/run-contract.test.ts @@ -0,0 +1,711 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { planWorkflowExecution } from "./execution.js"; +import { + appendCreateImagesRunEvent, + CREATE_IMAGES_MAX_RUN_EVENTS, + createCreateImagesRunJournal, + hasUnresolvedCreateImagesRunAmbiguity, + isFutureCreateImagesRunJournal, + parseCreateImagesRunJournal, + projectCreateImagesRun, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, +} from "./run-contract.js"; +import type { WorkflowDocumentV1 } from "./schema.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; +const LATER = "2026-08-11T12:00:01.000Z"; +const FINGERPRINT = "a".repeat(64); +const ASSET_ID = "b".repeat(64); + +function workflow(): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "workflow-1", + title: "Run contract", + revision: 7, + createdAt: NOW, + updatedAt: NOW, + nodes: [ + { + id: "prompt-1", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "A durable prompt" }, + }, + { + id: "generate-1", + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { id: "output-1", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + edges: [ + { + id: "edge-prompt", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "edge-output", + source: "generate-1", + sourcePort: "images", + target: "output-1", + targetPort: "images", + }, + ], + assetRefs: [], + settings: { concurrency: 1 }, + }; +} + +function scopedWorkflow(): WorkflowDocumentV1 { + const snapshot = workflow(); + snapshot.nodes = [ + ...snapshot.nodes, + { + id: "unrelated-prompt", + type: "prompt", + position: { x: 0, y: 200 }, + data: { text: "Not selected" }, + }, + ]; + return snapshot; +} + +function scopedJournal(): CreateImagesRunJournalV1 { + const snapshot = scopedWorkflow(); + const scope = { + kind: "from-node" as const, + nodeId: "generate-1", + downstreamPath: ["output-1"], + }; + const plan = planWorkflowExecution(snapshot, scope); + return createCreateImagesRunJournal({ + runId: "run-scoped", + workflowSnapshot: snapshot, + workflowFingerprint: FINGERPRINT, + plan: { + scope, + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + plan.orderedNodeIds.map((nodeId) => [nodeId, [...(plan.dependencies[nodeId] ?? [])]]), + ), + }, + createdAt: NOW, + }); +} + +function initial(): CreateImagesRunJournalV1 { + return createCreateImagesRunJournal({ + runId: "run-1", + workflowSnapshot: workflow(), + workflowFingerprint: FINGERPRINT, + plan: { + scope: { kind: "all" }, + orderedNodeIds: ["prompt-1", "generate-1", "output-1"], + dependencies: { + "prompt-1": [], + "generate-1": ["prompt-1"], + "output-1": ["generate-1"], + }, + }, + createdAt: NOW, + }); +} + +function event( + journal: CreateImagesRunJournalV1, + type: T, + fields: Omit< + Extract, + "type" | "workflowId" | "workflowRevision" | "runId" | "sequence" | "at" + >, + at = LATER, +): Extract { + return { + type, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at, + ...fields, + } as Extract; +} + +function append( + journal: CreateImagesRunJournalV1, + type: T, + fields: Omit< + Extract, + "type" | "workflowId" | "workflowRevision" | "runId" | "sequence" | "at" + >, +): CreateImagesRunJournalV1 { + return appendCreateImagesRunEvent(journal, event(journal, type, fields)); +} + +function startGenerateNode(journal: CreateImagesRunJournalV1): CreateImagesRunJournalV1 { + let next = append(journal, "node-started", { nodeId: "prompt-1" }); + next = append(next, "node-output-published", { nodeId: "prompt-1", outputAssetIds: [] }); + next = append(next, "node-succeeded", { nodeId: "prompt-1", outputAssetIds: [] }); + return append(next, "node-started", { nodeId: "generate-1" }); +} + +function startedGenerateJournal(): CreateImagesRunJournalV1 { + return startGenerateNode(append(initial(), "run-started", {})); +} + +test("creates a strict immutable path-free and credential-free journal", () => { + const journal = initial(); + assert.equal(journal.journalRevision, 1); + assert.equal(journal.workflowRevision, 7); + assert.equal(journal.workflowFingerprint, FINGERPRINT); + assert.equal(Object.isFrozen(journal), true); + assert.equal(Object.isFrozen(journal.workflowSnapshot.nodes[0]?.data), true); + assert.equal(JSON.stringify(journal).includes("credential"), false); + assert.equal(JSON.stringify(journal).includes("Path"), false); + + const withCredential = { ...structuredClone(journal), credential: "secret" }; + const parsedCredential = parseCreateImagesRunJournal(withCredential); + assert.equal(parsedCredential.success, false); + if (!parsedCredential.success) { + assert.ok(parsedCredential.issues.some((candidate) => candidate.code === "unknown_field")); + } + assert.equal( + parseCreateImagesRunJournal({ + ...structuredClone(journal), + workflowFingerprint: "/tmp/key", + }).success, + false, + ); +}); + +test("persists ambiguous attempts and forbids a retry until not-found reconciliation", () => { + let journal = startedGenerateJournal(); + journal = append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = append(journal, "node-submission-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + assert.throws( + () => + append(journal, "node-failed", { + nodeId: "generate-1", + errorCode: "interrupted", + }), + /ambiguous submission/u, + ); + assert.throws( + () => + append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0002", + providerId: "mock", + modelId: "mock-image-v1", + }), + /safely sealed/u, + ); + journal = append(journal, "node-submission-reconciled", { + nodeId: "generate-1", + attempt: 1, + outcome: "not-found", + }); + journal = append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0002", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = append(journal, "node-submission-accepted", { + nodeId: "generate-1", + attempt: 2, + providerJobId: "mock-job-2", + }); + const projection = projectCreateImagesRun(journal); + assert.deepEqual( + projection.nodes["generate-1"]?.attempts.map((attempt) => attempt.submission), + ["reconciled-not-found", "accepted"], + ); +}); + +test("accepted reconciliation requires a durable job ID and rejects duplicate idempotency keys", () => { + let journal = startedGenerateJournal(); + journal = append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = append(journal, "node-submission-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + assert.throws( + () => + append(journal, "node-submission-reconciled", { + nodeId: "generate-1", + attempt: 1, + outcome: "accepted", + }), + /provider job ID/u, + ); + journal = append(journal, "node-submission-reconciled", { + nodeId: "generate-1", + attempt: 1, + outcome: "not-found", + }); + assert.throws( + () => + append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }), + /unique per run/u, + ); +}); + +test("safe retry scheduling seals an attempt and makes idempotency relation explicit", () => { + let journal = startedGenerateJournal(); + journal = append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = append(journal, "node-submission-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = append(journal, "node-retry-scheduled", { + nodeId: "generate-1", + attempt: 1, + errorCode: "transport-timeout", + delayMs: 1_000, + retrySafety: "same-idempotency-key", + }); + assert.throws( + () => + append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0002", + providerId: "mock", + modelId: "mock-image-v1", + }), + /reuse the prior/u, + ); + journal = append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + const first = projectCreateImagesRun(journal).nodes["generate-1"]?.attempts[0]; + assert.deepEqual(first?.retry, { + errorCode: "transport-timeout", + delayMs: 1_000, + safety: "same-idempotency-key", + }); +}); + +test("same-idempotency retry exception never authorizes cross-node key reuse", () => { + const snapshot = workflow(); + snapshot.nodes.splice(2, 0, { + ...structuredClone(snapshot.nodes[1]!), + id: "generate-2", + position: { x: 100, y: 100 }, + }); + snapshot.edges.splice(1, 0, { + id: "edge-prompt-2", + source: "prompt-1", + sourcePort: "text", + target: "generate-2", + targetPort: "prompt", + }); + const canonical = planWorkflowExecution(snapshot, { kind: "all" }); + let journal = createCreateImagesRunJournal({ + runId: "run-cross-node", + workflowSnapshot: snapshot, + workflowFingerprint: FINGERPRINT, + plan: { + scope: { kind: "all" }, + orderedNodeIds: [...canonical.orderedNodeIds], + dependencies: Object.fromEntries( + canonical.orderedNodeIds.map((nodeId) => [ + nodeId, + [...(canonical.dependencies[nodeId] ?? [])], + ]), + ), + }, + createdAt: NOW, + }); + journal = append(journal, "run-started", {}); + journal = append(journal, "node-started", { nodeId: "prompt-1" }); + journal = append(journal, "node-output-published", { + nodeId: "prompt-1", + outputAssetIds: [], + }); + journal = append(journal, "node-succeeded", { nodeId: "prompt-1", outputAssetIds: [] }); + journal = append(journal, "node-started", { nodeId: "generate-1" }); + journal = append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-shared-node-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = append(journal, "node-started", { nodeId: "generate-2" }); + assert.throws( + () => + append(journal, "node-submission-prepared", { + nodeId: "generate-2", + attempt: 1, + idempotencyKey: "idem-shared-node-0001", + providerId: "mock", + modelId: "mock-image-v1", + }), + /unique per run/u, + ); +}); + +test("unresolved ambiguity is explicit terminal history and cannot silently resubmit", () => { + let journal = startedGenerateJournal(); + journal = append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = append(journal, "node-submission-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = append(journal, "node-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = append(journal, "node-blocked", { + nodeId: "output-1", + upstreamNodeIds: ["generate-1"], + }); + assert.throws(() => append(journal, "run-terminal", { status: "failed" }), /needs-attention/u); + journal = append(journal, "run-terminal", { status: "needs_attention" }); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal(hasUnresolvedCreateImagesRunAmbiguity(projection), true); + assert.throws( + () => + append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }), + /Terminal runs/u, + ); + assert.throws( + () => + append(journal, "run-ambiguity-acknowledged", { + expectedNeedsAttentionJournalRevision: journal.journalRevision - 1, + }), + /exact needs-attention journal revision/u, + ); + const needsAttentionJournalRevision = journal.journalRevision; + journal = append(journal, "run-ambiguity-acknowledged", { + expectedNeedsAttentionJournalRevision: needsAttentionJournalRevision, + }); + const acknowledged = projectCreateImagesRun(journal); + assert.equal(acknowledged.status, "needs_attention"); + assert.equal(acknowledged.nodes["generate-1"]?.status, "ambiguous"); + assert.equal(hasUnresolvedCreateImagesRunAmbiguity(acknowledged), false); + assert.deepEqual(acknowledged.ambiguityResolution, { + kind: "acknowledged-unresolved-submission", + acknowledgedAt: LATER, + acknowledgedAtJournalRevision: needsAttentionJournalRevision + 1, + }); + assert.throws( + () => + append(journal, "run-ambiguity-acknowledged", { + expectedNeedsAttentionJournalRevision: journal.journalRevision, + }), + /only once/u, + ); +}); + +test("monotonic identity-bound events reject gaps, duplicates, stale revisions, and time reversal", () => { + const journal = initial(); + const started = event(journal, "run-started", {}); + for (const mutation of [ + { ...started, sequence: 2 }, + { ...started, runId: "run-old" }, + { ...started, workflowRevision: 8 }, + { ...started, at: "2026-08-11T11:59:59.000Z" }, + ]) { + assert.throws(() => appendCreateImagesRunEvent(journal, mutation), /Run|Event|time/u); + } + const next = appendCreateImagesRunEvent(journal, started); + assert.throws(() => appendCreateImagesRunEvent(next, started), /monotonic/u); +}); + +test("durable cancellation prevents new starts and requires all nodes terminal before the run", () => { + let journal = startedGenerateJournal(); + journal = append(journal, "run-cancel-requested", { reason: "user" }); + assert.throws(() => append(journal, "node-started", { nodeId: "output-1" }), /non-cancelled/u); + assert.throws( + () => append(journal, "node-failed", { nodeId: "output-1", errorCode: "interrupted" }), + /queued interruption/u, + ); + assert.throws( + () => append(journal, "run-terminal", { status: "cancelled" }), + /Every planned node/u, + ); + journal = append(journal, "node-cancelled", { nodeId: "generate-1" }); + journal = append(journal, "node-cancelled", { nodeId: "output-1" }); + journal = append(journal, "run-terminal", { status: "cancelled" }); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "cancelled"); + assert.deepEqual(projection.cancellation, { + reason: "user", + requestedAt: LATER, + }); + assert.throws( + () => append(journal, "run-cancel-requested", { reason: "user" }), + /Terminal runs/u, + ); +}); + +test("queued interruption can terminalize a never-started run without fabricated provenance", () => { + const snapshot: WorkflowDocumentV1 = { + ...workflow(), + nodes: [ + { + id: "prompt-only", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "Interrupted before launch" }, + }, + ], + edges: [], + }; + const queued = createCreateImagesRunJournal({ + runId: "run-interrupted-before-start", + workflowSnapshot: snapshot, + workflowFingerprint: FINGERPRINT, + plan: { + scope: { kind: "all" }, + orderedNodeIds: ["prompt-only"], + dependencies: { "prompt-only": [] }, + }, + createdAt: NOW, + }); + let interrupted = append(queued, "node-failed", { + nodeId: "prompt-only", + errorCode: "interrupted", + }); + interrupted = append(interrupted, "run-terminal", { status: "interrupted" }); + const projection = projectCreateImagesRun(interrupted); + assert.equal(projection.status, "interrupted"); + assert.equal(projection.nodes["prompt-only"]?.status, "failed"); + assert.equal(projection.cancellation, undefined); + assert.equal( + interrupted.events.some((candidate) => candidate.type === "run-started"), + false, + ); + assert.equal( + interrupted.events.some((candidate) => candidate.type === "node-started"), + false, + ); + + assert.throws( + () => append(queued, "node-failed", { nodeId: "prompt-only", errorCode: "provider-error" }), + /queued interruption/u, + ); +}); + +test("terminal history retains durable asset outputs and enforces dependency order", () => { + let journal = append(initial(), "run-started", {}); + assert.throws(() => append(journal, "node-started", { nodeId: "output-1" }), /dependencies/u); + journal = startGenerateNode(journal); + journal = append(journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = append(journal, "node-submission-accepted", { + nodeId: "generate-1", + attempt: 1, + providerJobId: "mock-job-1", + }); + assert.throws( + () => + append(journal, "node-succeeded", { + nodeId: "generate-1", + outputAssetIds: [ASSET_ID], + }), + /published durable output positions/u, + ); + journal = append(journal, "node-output-published", { + nodeId: "generate-1", + outputAssetIds: [ASSET_ID, ASSET_ID], + }); + journal = append(journal, "node-succeeded", { + nodeId: "generate-1", + outputAssetIds: [ASSET_ID, ASSET_ID], + }); + journal = append(journal, "node-started", { nodeId: "output-1" }); + journal = append(journal, "node-output-published", { + nodeId: "output-1", + outputAssetIds: [], + }); + journal = append(journal, "node-succeeded", { + nodeId: "output-1", + outputAssetIds: [], + }); + journal = append(journal, "run-terminal", { status: "succeeded" }); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "succeeded"); + assert.deepEqual(projection.nodes["generate-1"]?.durableOutputAssetIds, [ASSET_ID, ASSET_ID]); + assert.deepEqual(projection.nodes["generate-1"]?.outputAssetIds, [ASSET_ID, ASSET_ID]); + assert.equal(projection.terminal?.status, "succeeded"); +}); + +test("strict plan validation rejects forged dependencies and unknown plan fields", () => { + const journal = structuredClone(initial()); + journal.plan.dependencies["output-1"] = []; + const forged = parseCreateImagesRunJournal(journal); + assert.equal(forged.success, false); + if (!forged.success) + assert.ok(forged.issues.some((candidate) => candidate.path.includes("dependencies"))); + const unknown = structuredClone(initial()) as CreateImagesRunJournalV1 & { + plan: CreateImagesRunJournalV1["plan"] & { credentialPath: string }; + }; + unknown.plan.credentialPath = "/tmp/key"; + assert.equal(parseCreateImagesRunJournal(unknown).success, false); + const omittedRunAllNode = structuredClone(initial()); + omittedRunAllNode.plan.orderedNodeIds = ["generate-1"]; + omittedRunAllNode.plan.dependencies = { "generate-1": [] }; + assert.equal(parseCreateImagesRunJournal(omittedRunAllNode).success, false); +}); + +test("strict scoped plans cannot omit required ancestors or selected downstream nodes", () => { + const omittedAncestor = structuredClone(scopedJournal()); + omittedAncestor.plan.orderedNodeIds = ["generate-1", "output-1"]; + omittedAncestor.plan.dependencies = { + "generate-1": [], + "output-1": ["generate-1"], + }; + const parsedAncestor = parseCreateImagesRunJournal(omittedAncestor); + assert.equal(parsedAncestor.success, false); + if (!parsedAncestor.success) { + assert.ok( + parsedAncestor.issues.some( + (candidate) => + candidate.path === "plan.orderedNodeIds" && candidate.message.includes("run scope"), + ), + ); + } + + const omittedDownstream = structuredClone(scopedJournal()); + omittedDownstream.plan.orderedNodeIds = ["prompt-1", "generate-1"]; + omittedDownstream.plan.dependencies = { + "prompt-1": [], + "generate-1": ["prompt-1"], + }; + const parsedDownstream = parseCreateImagesRunJournal(omittedDownstream); + assert.equal(parsedDownstream.success, false); + if (!parsedDownstream.success) { + assert.ok(parsedDownstream.issues.some((candidate) => candidate.path === "plan.scope")); + } +}); + +test("strict scoped plans cannot add unrelated work or change deterministic order", () => { + const unrelated = structuredClone(scopedJournal()); + unrelated.plan.orderedNodeIds.push("unrelated-prompt"); + unrelated.plan.dependencies["unrelated-prompt"] = []; + const parsedUnrelated = parseCreateImagesRunJournal(unrelated); + assert.equal(parsedUnrelated.success, false); + if (!parsedUnrelated.success) { + assert.ok(parsedUnrelated.issues.some((candidate) => candidate.path === "plan.orderedNodeIds")); + } + + const snapshot: WorkflowDocumentV1 = { + ...workflow(), + nodes: [ + { id: "prompt-a", type: "prompt", position: { x: 0, y: 0 }, data: { text: "A" } }, + { id: "prompt-b", type: "prompt", position: { x: 100, y: 0 }, data: { text: "B" } }, + ], + edges: [], + }; + const reordered = createCreateImagesRunJournal({ + runId: "run-stable-order", + workflowSnapshot: snapshot, + workflowFingerprint: FINGERPRINT, + plan: { + scope: { kind: "all" }, + orderedNodeIds: ["prompt-a", "prompt-b"], + dependencies: { "prompt-a": [], "prompt-b": [] }, + }, + createdAt: NOW, + }); + const forgedOrder = structuredClone(reordered); + forgedOrder.plan.orderedNodeIds = ["prompt-b", "prompt-a"]; + forgedOrder.plan.dependencies = { "prompt-b": [], "prompt-a": [] }; + const parsedOrder = parseCreateImagesRunJournal(forgedOrder); + assert.equal(parsedOrder.success, false); + if (!parsedOrder.success) { + assert.ok(parsedOrder.issues.some((candidate) => candidate.path === "plan.orderedNodeIds")); + } +}); + +test("future schemas and bounded event/byte histories fail closed", () => { + assert.equal(isFutureCreateImagesRunJournal({ version: 2 }), true); + assert.equal( + parseCreateImagesRunJournal({ ...structuredClone(initial()), version: 2 }).success, + false, + ); + const tooMany = { + ...structuredClone(initial()), + journalRevision: CREATE_IMAGES_MAX_RUN_EVENTS + 2, + events: new Array(CREATE_IMAGES_MAX_RUN_EVENTS + 1).fill({}), + }; + const parsedCount = parseCreateImagesRunJournal(tooMany); + assert.equal(parsedCount.success, false); + if (!parsedCount.success) + assert.ok(parsedCount.issues.some((candidate) => candidate.code === "too_large")); + const oversized = structuredClone(initial()) as unknown as Record; + oversized.padding = "x".repeat(16 * 1024 * 1024); + const parsedBytes = parseCreateImagesRunJournal(oversized); + assert.equal(parsedBytes.success, false); + if (!parsedBytes.success) assert.equal(parsedBytes.issues[0]?.code, "too_large"); +}); diff --git a/renderer/shared/create-images/run-contract.ts b/renderer/shared/create-images/run-contract.ts new file mode 100644 index 00000000..deb4c39a --- /dev/null +++ b/renderer/shared/create-images/run-contract.ts @@ -0,0 +1,1634 @@ +import { planWorkflowExecution, type WorkflowRunScope } from "./execution.js"; +import { + CREATE_IMAGES_ASSET_ID_PATTERN, + parseWorkflowDocument, + type WorkflowDocumentV1, +} from "./schema.js"; + +export const CREATE_IMAGES_RUN_JOURNAL_VERSION = 1 as const; +export const CREATE_IMAGES_MAX_RUN_EVENTS = 10_000; +export const CREATE_IMAGES_MAX_RUN_ATTEMPTS_PER_NODE = 16; +export const CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES = 16 * 1024 * 1024; +export const CREATE_IMAGES_MAX_RUN_ERROR_CODE_LENGTH = 96; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,191}$/u; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u; +const PROVIDER_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; +const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/u; +const ERROR_CODE_PATTERN = /^[a-z][a-z0-9-]{0,95}$/u; +const TIMESTAMP_MAX_LENGTH = 64; + +export type CreateImagesRunStatus = + | "queued" + | "running" + | "cancel_requested" + | "needs_attention" + | "succeeded" + | "failed" + | "cancelled" + | "interrupted"; + +export type CreateImagesNodeRunStatus = + | "queued" + | "running" + | "succeeded" + | "failed" + | "cancelled" + | "blocked" + | "ambiguous"; + +export type CreateImagesCancellationReason = "user" | "renderer-disconnected" | "app-quit"; + +export type CreateImagesRunTerminalStatus = Extract< + CreateImagesRunStatus, + "succeeded" | "failed" | "cancelled" | "interrupted" | "needs_attention" +>; + +interface RunEventBaseV1 { + type: TType; + workflowId: string; + workflowRevision: number; + runId: string; + sequence: number; + at: string; +} + +export type CreateImagesRunStartedEventV1 = RunEventBaseV1<"run-started">; + +export type CreateImagesNodeStartedEventV1 = RunEventBaseV1<"node-started"> & { + nodeId: string; +}; + +export type CreateImagesNodeSubmissionPreparedEventV1 = + RunEventBaseV1<"node-submission-prepared"> & { + nodeId: string; + attempt: number; + idempotencyKey: string; + providerId: string; + modelId: string; + }; + +export type CreateImagesNodeSubmissionAcceptedEventV1 = + RunEventBaseV1<"node-submission-accepted"> & { + nodeId: string; + attempt: number; + providerJobId?: string; + }; + +export type CreateImagesNodeSubmissionAmbiguousEventV1 = + RunEventBaseV1<"node-submission-ambiguous"> & { + nodeId: string; + attempt: number; + }; + +export type CreateImagesNodeSubmissionReconciledEventV1 = + RunEventBaseV1<"node-submission-reconciled"> & { + nodeId: string; + attempt: number; + outcome: "accepted" | "not-found"; + providerJobId?: string; + }; + +export type CreateImagesNodeOutputPublishedEventV1 = RunEventBaseV1<"node-output-published"> & { + nodeId: string; + /** Ordered output positions. Byte-identical images may intentionally repeat an asset ID. */ + outputAssetIds: string[]; +}; + +export type CreateImagesNodeRetryScheduledEventV1 = RunEventBaseV1<"node-retry-scheduled"> & { + nodeId: string; + attempt: number; + errorCode: string; + delayMs: number; + retrySafety: "confirmed-not-submitted" | "same-idempotency-key"; +}; + +export type CreateImagesNodeAmbiguousEventV1 = RunEventBaseV1<"node-ambiguous"> & { + nodeId: string; + attempt: number; +}; + +export type CreateImagesNodeSucceededEventV1 = RunEventBaseV1<"node-succeeded"> & { + nodeId: string; + outputAssetIds: string[]; +}; + +export type CreateImagesNodeFailedEventV1 = RunEventBaseV1<"node-failed"> & { + nodeId: string; + errorCode: string; +}; + +export type CreateImagesNodeCancelledEventV1 = RunEventBaseV1<"node-cancelled"> & { + nodeId: string; +}; + +export type CreateImagesNodeBlockedEventV1 = RunEventBaseV1<"node-blocked"> & { + nodeId: string; + upstreamNodeIds: string[]; +}; + +export type CreateImagesRunCancelRequestedEventV1 = RunEventBaseV1<"run-cancel-requested"> & { + reason: CreateImagesCancellationReason; +}; + +export type CreateImagesRunTerminalEventV1 = RunEventBaseV1<"run-terminal"> & { + status: CreateImagesRunTerminalStatus; +}; + +export type CreateImagesRunAmbiguityAcknowledgedEventV1 = + RunEventBaseV1<"run-ambiguity-acknowledged"> & { + expectedNeedsAttentionJournalRevision: number; + }; + +export type CreateImagesRunEventV1 = + | CreateImagesRunStartedEventV1 + | CreateImagesNodeStartedEventV1 + | CreateImagesNodeSubmissionPreparedEventV1 + | CreateImagesNodeSubmissionAcceptedEventV1 + | CreateImagesNodeSubmissionAmbiguousEventV1 + | CreateImagesNodeSubmissionReconciledEventV1 + | CreateImagesNodeOutputPublishedEventV1 + | CreateImagesNodeRetryScheduledEventV1 + | CreateImagesNodeAmbiguousEventV1 + | CreateImagesNodeSucceededEventV1 + | CreateImagesNodeFailedEventV1 + | CreateImagesNodeCancelledEventV1 + | CreateImagesNodeBlockedEventV1 + | CreateImagesRunCancelRequestedEventV1 + | CreateImagesRunTerminalEventV1 + | CreateImagesRunAmbiguityAcknowledgedEventV1; + +export interface CreateImagesRunPlanV1 { + scope: WorkflowRunScope; + orderedNodeIds: string[]; + dependencies: Record; +} + +export interface CreateImagesRunProviderAuthorizationV1 { + version: 1; + executionMode: "gemini"; + authorizationId: string; + consentFingerprint: string; + capabilityFingerprint: string; + credentialRecordId: string; + credentialRevision: number; + initialRequestCount: number; + expectedOutputCount: number; + maximumAttempts: number; + createdAt: string; + expiresAt: string; +} + +export interface CreateImagesRunJournalV1 { + version: typeof CREATE_IMAGES_RUN_JOURNAL_VERSION; + journalRevision: number; + runId: string; + workflowId: string; + workflowRevision: number; + workflowFingerprint: string; + workflowSnapshot: WorkflowDocumentV1; + plan: CreateImagesRunPlanV1; + providerAuthorization?: CreateImagesRunProviderAuthorizationV1; + createdAt: string; + updatedAt: string; + events: CreateImagesRunEventV1[]; +} + +export interface CreateImagesRunJournalCreationInput { + runId: string; + workflowSnapshot: WorkflowDocumentV1; + workflowFingerprint: string; + plan: CreateImagesRunPlanV1; + providerAuthorization?: CreateImagesRunProviderAuthorizationV1; + createdAt: string; +} + +export interface CreateImagesRunContractIssue { + path: string; + code: + | "invalid_type" + | "invalid_value" + | "unknown_field" + | "too_large" + | "duplicate" + | "invalid_transition"; + message: string; +} + +export type CreateImagesRunJournalParseResult = + | { success: true; value: CreateImagesRunJournalV1 } + | { success: false; issues: CreateImagesRunContractIssue[] }; + +export interface CreateImagesRunAttemptProjection { + attempt: number; + idempotencyKey: string; + providerId: string; + modelId: string; + submission: "prepared" | "accepted" | "ambiguous" | "reconciled-not-found" | "retry-scheduled"; + providerJobId?: string; + retry?: { + errorCode: string; + delayMs: number; + safety: "confirmed-not-submitted" | "same-idempotency-key"; + }; +} + +export interface CreateImagesNodeRunProjection { + status: CreateImagesNodeRunStatus; + attempts: CreateImagesRunAttemptProjection[]; + durableOutputAssetIds?: string[]; + outputAssetIds: string[]; + errorCode?: string; + terminalAt?: string; +} + +export interface CreateImagesRunProjection { + status: CreateImagesRunStatus; + lastSequence: number; + cancellation?: { + reason: CreateImagesCancellationReason; + requestedAt: string; + }; + terminal?: { status: CreateImagesRunTerminalStatus; at: string }; + ambiguityResolution?: { + kind: "acknowledged-unresolved-submission"; + acknowledgedAt: string; + acknowledgedAtJournalRevision: number; + }; + nodes: Record; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(value: object, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function issue( + issues: CreateImagesRunContractIssue[], + path: string, + code: CreateImagesRunContractIssue["code"], + message: string, +): void { + issues.push({ path, code, message }); +} + +function rejectUnknown( + record: Record, + allowed: readonly string[], + path: string, + issues: CreateImagesRunContractIssue[], +): void { + const allowedSet = new Set(allowed); + for (const key of Object.keys(record)) { + if (!allowedSet.has(key)) issue(issues, `${path}.${key}`, "unknown_field", "Unknown field."); + } +} + +function boundedString( + value: unknown, + path: string, + issues: CreateImagesRunContractIssue[], + pattern: RegExp, + maxLength: number, +): string | undefined { + if (typeof value !== "string") { + issue(issues, path, "invalid_type", "Expected a string."); + return undefined; + } + if (value.length < 1 || value.length > maxLength) { + issue( + issues, + path, + value.length > maxLength ? "too_large" : "invalid_value", + "Invalid length.", + ); + return undefined; + } + if (!pattern.test(value)) { + issue(issues, path, "invalid_value", "Invalid value."); + return undefined; + } + return value; +} + +function opaqueId( + value: unknown, + path: string, + issues: CreateImagesRunContractIssue[], +): string | undefined { + return boundedString(value, path, issues, OPAQUE_ID_PATTERN, 128); +} + +function positiveInteger( + value: unknown, + path: string, + issues: CreateImagesRunContractIssue[], + max = Number.MAX_SAFE_INTEGER, +): number | undefined { + if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > max) { + issue(issues, path, "invalid_value", `Expected an integer from 1 through ${max}.`); + return undefined; + } + return value as number; +} + +function nonnegativeInteger( + value: unknown, + path: string, + issues: CreateImagesRunContractIssue[], + max: number, +): number | undefined { + if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) > max) { + issue(issues, path, "invalid_value", `Expected an integer from 0 through ${max}.`); + return undefined; + } + return value as number; +} + +function timestamp( + value: unknown, + path: string, + issues: CreateImagesRunContractIssue[], +): string | undefined { + let canonical = false; + if (typeof value === "string" && value.length >= 1 && value.length <= TIMESTAMP_MAX_LENGTH) { + try { + canonical = new Date(value).toISOString() === value; + } catch { + canonical = false; + } + } + if (!canonical) { + issue(issues, path, "invalid_value", "Expected a bounded ISO-8601 timestamp."); + return undefined; + } + return value as string; +} + +function parseScope( + value: unknown, + path: string, + issues: CreateImagesRunContractIssue[], +): WorkflowRunScope | undefined { + if (!isRecord(value)) { + issue(issues, path, "invalid_type", "Expected an object."); + return undefined; + } + if (value.kind === "all") { + rejectUnknown(value, ["kind"], path, issues); + return { kind: "all" }; + } + if (value.kind !== "from-node") { + issue(issues, `${path}.kind`, "invalid_value", "Unknown run scope."); + return undefined; + } + rejectUnknown(value, ["kind", "nodeId", "downstreamPath"], path, issues); + const nodeId = opaqueId(value.nodeId, `${path}.nodeId`, issues); + let downstreamPath: string[] | undefined; + if (value.downstreamPath !== undefined) { + if (!Array.isArray(value.downstreamPath)) { + issue(issues, `${path}.downstreamPath`, "invalid_type", "Expected an array."); + } else if (value.downstreamPath.length > 500) { + issue(issues, `${path}.downstreamPath`, "too_large", "Run path is too large."); + } else { + downstreamPath = value.downstreamPath.flatMap((candidate, index) => { + const parsed = opaqueId(candidate, `${path}.downstreamPath[${index}]`, issues); + return parsed ? [parsed] : []; + }); + if (new Set(downstreamPath).size !== downstreamPath.length) { + issue(issues, `${path}.downstreamPath`, "duplicate", "Run path contains duplicates."); + } + } + } + if (!nodeId) return undefined; + return downstreamPath + ? { kind: "from-node", nodeId, downstreamPath } + : { kind: "from-node", nodeId }; +} + +function parsePlan( + value: unknown, + snapshot: WorkflowDocumentV1, + issues: CreateImagesRunContractIssue[], +): CreateImagesRunPlanV1 | undefined { + const path = "plan"; + if (!isRecord(value)) { + issue(issues, path, "invalid_type", "Expected an object."); + return undefined; + } + rejectUnknown(value, ["scope", "orderedNodeIds", "dependencies"], path, issues); + const scope = parseScope(value.scope, `${path}.scope`, issues); + if (!Array.isArray(value.orderedNodeIds)) { + issue(issues, `${path}.orderedNodeIds`, "invalid_type", "Expected an array."); + return undefined; + } + if (value.orderedNodeIds.length < 1 || value.orderedNodeIds.length > snapshot.nodes.length) { + issue(issues, `${path}.orderedNodeIds`, "invalid_value", "Invalid planned node count."); + } + const orderedNodeIds = value.orderedNodeIds.flatMap((candidate, index) => { + const parsed = opaqueId(candidate, `${path}.orderedNodeIds[${index}]`, issues); + return parsed ? [parsed] : []; + }); + if (new Set(orderedNodeIds).size !== orderedNodeIds.length) { + issue(issues, `${path}.orderedNodeIds`, "duplicate", "Planned nodes must be unique."); + } + const snapshotNodeIds = new Set(snapshot.nodes.map((node) => node.id)); + for (const nodeId of orderedNodeIds) { + if (!snapshotNodeIds.has(nodeId)) { + issue(issues, `${path}.orderedNodeIds`, "invalid_value", "Plan references an unknown node."); + } + } + if (!isRecord(value.dependencies)) { + issue(issues, `${path}.dependencies`, "invalid_type", "Expected an object."); + return undefined; + } + const planned = new Set(orderedNodeIds); + const order = new Map(orderedNodeIds.map((nodeId, index) => [nodeId, index])); + const dependencyKeys = Object.keys(value.dependencies); + if ( + dependencyKeys.length !== orderedNodeIds.length || + dependencyKeys.some((nodeId) => !planned.has(nodeId)) + ) { + issue( + issues, + `${path}.dependencies`, + "invalid_value", + "Dependencies must exactly cover the plan.", + ); + } + const dependencies: Record = {}; + for (const nodeId of dependencyKeys) { + const candidate = value.dependencies[nodeId]; + if (!Array.isArray(candidate) || candidate.length > orderedNodeIds.length) { + issue(issues, `${path}.dependencies.${nodeId}`, "invalid_type", "Expected a bounded array."); + continue; + } + const values = candidate.flatMap((dependency, index) => { + const parsed = opaqueId(dependency, `${path}.dependencies.${nodeId}[${index}]`, issues); + return parsed ? [parsed] : []; + }); + if (new Set(values).size !== values.length) { + issue(issues, `${path}.dependencies.${nodeId}`, "duplicate", "Dependencies must be unique."); + } + for (const dependency of values) { + if ( + !planned.has(dependency) || + (order.get(dependency) ?? Infinity) >= (order.get(nodeId) ?? -1) + ) { + issue( + issues, + `${path}.dependencies.${nodeId}`, + "invalid_value", + "Dependencies must precede their node.", + ); + } + } + dependencies[nodeId] = values; + } + for (const nodeId of orderedNodeIds) { + const expected = snapshot.edges + .filter((edge) => planned.has(edge.source) && edge.target === nodeId) + .map((edge) => edge.source) + .sort((left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0)); + if (JSON.stringify(dependencies[nodeId] ?? []) !== JSON.stringify(expected)) { + issue( + issues, + `${path}.dependencies.${nodeId}`, + "invalid_value", + "Dependencies must exactly match edges in the immutable snapshot.", + ); + } + } + if (scope?.kind === "from-node") { + const selected = [scope.nodeId, ...(scope.downstreamPath ?? [])]; + if (selected.some((nodeId) => !planned.has(nodeId))) { + issue(issues, `${path}.scope`, "invalid_value", "Run scope is outside the immutable plan."); + } + } else if (scope?.kind === "all" && planned.size !== snapshot.nodes.length) { + issue( + issues, + `${path}.orderedNodeIds`, + "invalid_value", + "Run-all must include every snapshot node.", + ); + } + if (scope) { + try { + const expected = planWorkflowExecution(snapshot, scope); + if (JSON.stringify(orderedNodeIds) !== JSON.stringify(expected.orderedNodeIds)) { + issue( + issues, + `${path}.orderedNodeIds`, + "invalid_value", + "Planned nodes and order must exactly match the immutable snapshot and run scope.", + ); + } + const expectedDependencyKeys = expected.orderedNodeIds; + if (JSON.stringify(dependencyKeys) !== JSON.stringify(expectedDependencyKeys)) { + issue( + issues, + `${path}.dependencies`, + "invalid_value", + "Dependencies must use the deterministic planned-node order.", + ); + } + for (const nodeId of expectedDependencyKeys) { + if ( + JSON.stringify(dependencies[nodeId] ?? []) !== + JSON.stringify(expected.dependencies[nodeId] ?? []) + ) { + issue( + issues, + `${path}.dependencies.${nodeId}`, + "invalid_value", + "Dependencies must exactly match the deterministic immutable plan.", + ); + } + } + } catch { + issue( + issues, + `${path}.scope`, + "invalid_value", + "Run scope cannot produce a valid deterministic plan from the immutable snapshot.", + ); + } + } + return scope ? { scope, orderedNodeIds, dependencies } : undefined; +} + +function parseEvent( + value: unknown, + index: number, + identity: { runId: string; workflowId: string; workflowRevision: number }, + issues: CreateImagesRunContractIssue[], +): CreateImagesRunEventV1 | undefined { + const path = `events[${index}]`; + if (!isRecord(value)) { + issue(issues, path, "invalid_type", "Expected an object."); + return undefined; + } + const baseFields = ["type", "workflowId", "workflowRevision", "runId", "sequence", "at"]; + const type = value.type; + const extraFields: Record = { + "run-started": [], + "node-started": ["nodeId"], + "node-submission-prepared": ["nodeId", "attempt", "idempotencyKey", "providerId", "modelId"], + "node-submission-accepted": ["nodeId", "attempt", "providerJobId"], + "node-submission-ambiguous": ["nodeId", "attempt"], + "node-submission-reconciled": ["nodeId", "attempt", "outcome", "providerJobId"], + "node-output-published": ["nodeId", "outputAssetIds"], + "node-retry-scheduled": ["nodeId", "attempt", "errorCode", "delayMs", "retrySafety"], + "node-ambiguous": ["nodeId", "attempt"], + "node-succeeded": ["nodeId", "outputAssetIds"], + "node-failed": ["nodeId", "errorCode"], + "node-cancelled": ["nodeId"], + "node-blocked": ["nodeId", "upstreamNodeIds"], + "run-cancel-requested": ["reason"], + "run-terminal": ["status"], + "run-ambiguity-acknowledged": ["expectedNeedsAttentionJournalRevision"], + }; + if (typeof type !== "string" || !hasOwn(extraFields, type)) { + issue(issues, `${path}.type`, "invalid_value", "Unknown run event type."); + return undefined; + } + rejectUnknown(value, [...baseFields, ...(extraFields[type] ?? [])], path, issues); + const workflowId = opaqueId(value.workflowId, `${path}.workflowId`, issues); + const runId = opaqueId(value.runId, `${path}.runId`, issues); + const workflowRevision = positiveInteger( + value.workflowRevision, + `${path}.workflowRevision`, + issues, + ); + const sequence = positiveInteger( + value.sequence, + `${path}.sequence`, + issues, + CREATE_IMAGES_MAX_RUN_EVENTS, + ); + const at = timestamp(value.at, `${path}.at`, issues); + if ( + workflowId !== identity.workflowId || + runId !== identity.runId || + workflowRevision !== identity.workflowRevision + ) { + issue( + issues, + path, + "invalid_value", + "Event identity does not match the immutable run snapshot.", + ); + } + if (!workflowId || !runId || !workflowRevision || !sequence || !at) return undefined; + const base = { type, workflowId, workflowRevision, runId, sequence, at }; + if (type === "run-started") return base as CreateImagesRunStartedEventV1; + if (type === "run-cancel-requested") { + if ( + !(["user", "renderer-disconnected", "app-quit"] as const).includes( + value.reason as CreateImagesCancellationReason, + ) + ) { + issue(issues, `${path}.reason`, "invalid_value", "Unknown cancellation reason."); + return undefined; + } + return { + ...base, + type, + reason: value.reason as CreateImagesCancellationReason, + }; + } + if (type === "run-terminal") { + if ( + !(["succeeded", "failed", "cancelled", "interrupted", "needs_attention"] as const).includes( + value.status as CreateImagesRunTerminalStatus, + ) + ) { + issue(issues, `${path}.status`, "invalid_value", "Unknown terminal status."); + return undefined; + } + return { + ...base, + type, + status: value.status as CreateImagesRunTerminalStatus, + }; + } + if (type === "run-ambiguity-acknowledged") { + const expectedNeedsAttentionJournalRevision = positiveInteger( + value.expectedNeedsAttentionJournalRevision, + `${path}.expectedNeedsAttentionJournalRevision`, + issues, + CREATE_IMAGES_MAX_RUN_EVENTS, + ); + return expectedNeedsAttentionJournalRevision + ? { ...base, type, expectedNeedsAttentionJournalRevision } + : undefined; + } + const nodeId = opaqueId(value.nodeId, `${path}.nodeId`, issues); + if (!nodeId) return undefined; + if (type === "node-started" || type === "node-cancelled") return { ...base, type, nodeId }; + if (type === "node-submission-prepared") { + const attempt = positiveInteger( + value.attempt, + `${path}.attempt`, + issues, + CREATE_IMAGES_MAX_RUN_ATTEMPTS_PER_NODE, + ); + const idempotencyKey = boundedString( + value.idempotencyKey, + `${path}.idempotencyKey`, + issues, + IDEMPOTENCY_KEY_PATTERN, + 192, + ); + const providerId = boundedString( + value.providerId, + `${path}.providerId`, + issues, + PROVIDER_ID_PATTERN, + 128, + ); + const modelId = boundedString(value.modelId, `${path}.modelId`, issues, MODEL_ID_PATTERN, 192); + return attempt && idempotencyKey && providerId && modelId + ? { ...base, type, nodeId, attempt, idempotencyKey, providerId, modelId } + : undefined; + } + if (type === "node-submission-accepted") { + const attempt = positiveInteger( + value.attempt, + `${path}.attempt`, + issues, + CREATE_IMAGES_MAX_RUN_ATTEMPTS_PER_NODE, + ); + const providerJobId = + value.providerJobId === undefined + ? undefined + : boundedString( + value.providerJobId, + `${path}.providerJobId`, + issues, + PROVIDER_JOB_ID_PATTERN, + 256, + ); + return attempt && (value.providerJobId === undefined || providerJobId) + ? { + ...base, + type, + nodeId, + attempt, + ...(providerJobId ? { providerJobId } : {}), + } + : undefined; + } + if (type === "node-submission-ambiguous") { + const attempt = positiveInteger( + value.attempt, + `${path}.attempt`, + issues, + CREATE_IMAGES_MAX_RUN_ATTEMPTS_PER_NODE, + ); + return attempt ? { ...base, type, nodeId, attempt } : undefined; + } + if (type === "node-retry-scheduled") { + const attempt = positiveInteger( + value.attempt, + `${path}.attempt`, + issues, + CREATE_IMAGES_MAX_RUN_ATTEMPTS_PER_NODE, + ); + const errorCode = boundedString( + value.errorCode, + `${path}.errorCode`, + issues, + ERROR_CODE_PATTERN, + CREATE_IMAGES_MAX_RUN_ERROR_CODE_LENGTH, + ); + const delayMs = nonnegativeInteger(value.delayMs, `${path}.delayMs`, issues, 5 * 60 * 1_000); + const retrySafety = + value.retrySafety === "confirmed-not-submitted" || + value.retrySafety === "same-idempotency-key" + ? value.retrySafety + : undefined; + if (!retrySafety) + issue(issues, `${path}.retrySafety`, "invalid_value", "Unknown retry safety contract."); + return attempt && errorCode && delayMs !== undefined && retrySafety + ? { ...base, type, nodeId, attempt, errorCode, delayMs, retrySafety } + : undefined; + } + if (type === "node-ambiguous") { + const attempt = positiveInteger( + value.attempt, + `${path}.attempt`, + issues, + CREATE_IMAGES_MAX_RUN_ATTEMPTS_PER_NODE, + ); + return attempt ? { ...base, type, nodeId, attempt } : undefined; + } + if (type === "node-submission-reconciled") { + const attempt = positiveInteger( + value.attempt, + `${path}.attempt`, + issues, + CREATE_IMAGES_MAX_RUN_ATTEMPTS_PER_NODE, + ); + const outcome = + value.outcome === "accepted" || value.outcome === "not-found" ? value.outcome : undefined; + if (!outcome) + issue(issues, `${path}.outcome`, "invalid_value", "Unknown reconciliation outcome."); + const providerJobId = + value.providerJobId === undefined + ? undefined + : boundedString( + value.providerJobId, + `${path}.providerJobId`, + issues, + PROVIDER_JOB_ID_PATTERN, + 256, + ); + if (outcome === "accepted" && !providerJobId) { + issue( + issues, + `${path}.providerJobId`, + "invalid_value", + "Accepted reconciliation requires a durable provider job ID.", + ); + } + if (outcome === "not-found" && value.providerJobId !== undefined) { + issue( + issues, + `${path}.providerJobId`, + "invalid_value", + "A not-found reconciliation cannot carry a provider job ID.", + ); + } + return attempt && outcome && (outcome !== "accepted" || providerJobId) + ? { + ...base, + type, + nodeId, + attempt, + outcome, + ...(providerJobId ? { providerJobId } : {}), + } + : undefined; + } + if (type === "node-output-published" || type === "node-succeeded") { + if (!Array.isArray(value.outputAssetIds) || value.outputAssetIds.length > 2_000) { + issue(issues, `${path}.outputAssetIds`, "invalid_type", "Expected a bounded asset ID array."); + return undefined; + } + const outputAssetIds = value.outputAssetIds.flatMap((candidate, assetIndex) => { + const parsed = boundedString( + candidate, + `${path}.outputAssetIds[${assetIndex}]`, + issues, + CREATE_IMAGES_ASSET_ID_PATTERN, + 64, + ); + return parsed ? [parsed] : []; + }); + return type === "node-output-published" + ? { ...base, type, nodeId, outputAssetIds } + : { ...base, type, nodeId, outputAssetIds }; + } + if (type === "node-failed") { + const errorCode = boundedString( + value.errorCode, + `${path}.errorCode`, + issues, + ERROR_CODE_PATTERN, + CREATE_IMAGES_MAX_RUN_ERROR_CODE_LENGTH, + ); + return errorCode ? { ...base, type, nodeId, errorCode } : undefined; + } + if ( + !Array.isArray(value.upstreamNodeIds) || + value.upstreamNodeIds.length < 1 || + value.upstreamNodeIds.length > 500 + ) { + issue( + issues, + `${path}.upstreamNodeIds`, + "invalid_value", + "Expected bounded upstream node IDs.", + ); + return undefined; + } + const upstreamNodeIds = value.upstreamNodeIds.flatMap((candidate, upstreamIndex) => { + const parsed = opaqueId(candidate, `${path}.upstreamNodeIds[${upstreamIndex}]`, issues); + return parsed ? [parsed] : []; + }); + if (new Set(upstreamNodeIds).size !== upstreamNodeIds.length) { + issue(issues, `${path}.upstreamNodeIds`, "duplicate", "Upstream nodes must be unique."); + } + return { ...base, type: "node-blocked", nodeId, upstreamNodeIds }; +} + +function initialProjection(plan: CreateImagesRunPlanV1): CreateImagesRunProjection { + return { + status: "queued", + lastSequence: 0, + nodes: Object.fromEntries( + plan.orderedNodeIds.map((nodeId) => [ + nodeId, + { status: "queued", attempts: [], outputAssetIds: [] }, + ]), + ), + }; +} + +function terminalNode(status: CreateImagesNodeRunStatus): boolean { + return ( + status === "succeeded" || + status === "failed" || + status === "cancelled" || + status === "blocked" || + status === "ambiguous" + ); +} + +function invalidTransition(path: string, message: string): never { + const error = new Error(message); + Object.assign(error, { runContractPath: path }); + throw error; +} + +function applyEvent( + projection: CreateImagesRunProjection, + plan: CreateImagesRunPlanV1, + generateNodeIds: ReadonlySet, + event: CreateImagesRunEventV1, + eventIndex: number, + usedIdempotencyKeys: Set, +): void { + const path = `events[${eventIndex}]`; + if (event.sequence !== projection.lastSequence + 1) { + invalidTransition(`${path}.sequence`, "Run events must be strictly monotonic and gap-free."); + } + if (event.type === "run-ambiguity-acknowledged") { + if ( + projection.terminal?.status !== "needs_attention" || + projection.status !== "needs_attention" || + !Object.values(projection.nodes).some((node) => node.status === "ambiguous") + ) { + invalidTransition( + path, + "Only a needs-attention run with an ambiguous node can be acknowledged.", + ); + } + if (projection.ambiguityResolution) { + invalidTransition(path, "An unresolved submission can be acknowledged only once."); + } + if (event.expectedNeedsAttentionJournalRevision !== event.sequence) { + invalidTransition( + `${path}.expectedNeedsAttentionJournalRevision`, + "The acknowledgement must name the exact needs-attention journal revision.", + ); + } + projection.ambiguityResolution = { + kind: "acknowledged-unresolved-submission", + acknowledgedAt: event.at, + acknowledgedAtJournalRevision: event.expectedNeedsAttentionJournalRevision + 1, + }; + } else if (projection.terminal) { + invalidTransition(path, "Terminal runs cannot accept more events."); + } else if (event.type === "run-started") { + if (projection.status !== "queued") invalidTransition(path, "Run can only start once."); + projection.status = "running"; + } else if (event.type === "run-cancel-requested") { + if (projection.cancellation) invalidTransition(path, "Cancellation intent is immutable."); + projection.cancellation = { reason: event.reason, requestedAt: event.at }; + projection.status = "cancel_requested"; + } else if (event.type === "run-terminal") { + const nodes = Object.values(projection.nodes); + if (nodes.some((node) => !terminalNode(node.status))) { + invalidTransition(path, "Every planned node must be terminal before the run."); + } + if (event.status === "succeeded" && nodes.some((node) => node.status !== "succeeded")) { + invalidTransition(path, "A succeeded run requires every node to succeed."); + } + if ( + event.status === "failed" && + !nodes.some((node) => node.status === "failed" || node.status === "blocked") + ) { + invalidTransition(path, "A failed run requires a failed or blocked node."); + } + if (event.status === "cancelled" && !projection.cancellation) { + invalidTransition(path, "A cancelled run requires durable cancellation intent."); + } + if (nodes.some((node) => node.status === "ambiguous") && event.status !== "needs_attention") { + invalidTransition(path, "An ambiguous node requires a needs-attention run terminal."); + } + if (event.status === "needs_attention" && !nodes.some((node) => node.status === "ambiguous")) { + invalidTransition(path, "A needs-attention run requires an ambiguous node."); + } + projection.status = event.status; + projection.terminal = { status: event.status, at: event.at }; + } else { + const node = projection.nodes[event.nodeId]; + if (!node) + invalidTransition(`${path}.nodeId`, "Event references a node outside the immutable plan."); + if (event.type === "node-started") { + if (projection.status !== "running" || node.status !== "queued") { + invalidTransition(path, "Only a queued node in a running, non-cancelled run can start."); + } + const dependencies = plan.dependencies[event.nodeId] ?? []; + if (dependencies.some((dependency) => projection.nodes[dependency]?.status !== "succeeded")) { + invalidTransition(path, "Node dependencies must succeed before it starts."); + } + node.status = "running"; + } else if (event.type === "node-blocked") { + if (node.status !== "queued") invalidTransition(path, "Only a queued node can be blocked."); + const declaredDependencies = new Set(plan.dependencies[event.nodeId] ?? []); + if ( + event.upstreamNodeIds.some( + (dependency) => + !declaredDependencies.has(dependency) || + !["failed", "cancelled", "blocked", "ambiguous"].includes( + projection.nodes[dependency]?.status ?? "", + ), + ) + ) { + invalidTransition(path, "Blocked nodes must identify failed direct dependencies."); + } + node.status = "blocked"; + node.terminalAt = event.at; + } else if (event.type === "node-cancelled") { + if (!projection.cancellation || (node.status !== "queued" && node.status !== "running")) { + invalidTransition(path, "Cancellation requires durable intent and a nonterminal node."); + } + if (node.attempts[node.attempts.length - 1]?.submission === "ambiguous") { + invalidTransition( + path, + "An ambiguous submission must be reconciled or terminalized as ambiguous.", + ); + } + node.status = "cancelled"; + node.terminalAt = event.at; + } else if (event.type === "node-ambiguous") { + if (node.status !== "running") + invalidTransition(path, "Only a running node can become ambiguous."); + const attempt = node.attempts[node.attempts.length - 1]; + if ( + !attempt || + attempt.attempt !== event.attempt || + (attempt.submission !== "ambiguous" && attempt.submission !== "accepted") + ) { + invalidTransition( + path, + "Ambiguous terminalization requires the matching ambiguous or accepted submission.", + ); + } + node.status = "ambiguous"; + node.terminalAt = event.at; + } else if (event.type === "node-output-published") { + if (node.status !== "running") { + invalidTransition(path, "Only a running node can publish durable output."); + } + if (node.durableOutputAssetIds !== undefined) { + invalidTransition(path, "A node can publish its durable output only once."); + } + if ( + generateNodeIds.has(event.nodeId) && + node.attempts[node.attempts.length - 1]?.submission !== "accepted" + ) { + invalidTransition( + path, + "Generate Image output publication requires a durably accepted submission.", + ); + } + node.durableOutputAssetIds = [...event.outputAssetIds]; + } else if (event.type === "node-succeeded") { + if (node.status !== "running") invalidTransition(path, "Only a running node can succeed."); + if (node.attempts[node.attempts.length - 1]?.submission === "ambiguous") { + invalidTransition(path, "An ambiguous submission must be reconciled before success."); + } + if ( + generateNodeIds.has(event.nodeId) && + node.attempts[node.attempts.length - 1]?.submission !== "accepted" + ) { + invalidTransition(path, "Generate Image success requires a durably accepted submission."); + } + if ( + node.durableOutputAssetIds === undefined || + JSON.stringify(node.durableOutputAssetIds) !== JSON.stringify(event.outputAssetIds) + ) { + invalidTransition( + path, + "Node success must match its previously published durable output positions.", + ); + } + node.status = "succeeded"; + node.outputAssetIds = [...event.outputAssetIds]; + node.terminalAt = event.at; + } else if (event.type === "node-failed") { + const interruptedBeforeStart = + node.status === "queued" && + event.errorCode === "interrupted" && + (projection.status === "queued" || projection.status === "running"); + if (node.status !== "running" && !interruptedBeforeStart) { + invalidTransition(path, "Only a running node can fail, except for a queued interruption."); + } + if (node.attempts[node.attempts.length - 1]?.submission === "ambiguous") { + invalidTransition( + path, + "An ambiguous submission must be reconciled or terminalized as ambiguous.", + ); + } + node.status = "failed"; + node.errorCode = event.errorCode; + node.terminalAt = event.at; + } else { + if (node.status !== "running") + invalidTransition(path, "Submission state requires a running node."); + if (event.type === "node-submission-prepared") { + const previous = node.attempts[node.attempts.length - 1]; + if ( + event.attempt !== node.attempts.length + 1 || + (previous && + previous.submission !== "reconciled-not-found" && + previous.submission !== "retry-scheduled") + ) { + invalidTransition(path, "A new attempt requires a safely sealed predecessor."); + } + const reusesRequiredKey = + previous?.submission === "retry-scheduled" && + previous.retry?.safety === "same-idempotency-key"; + if (reusesRequiredKey && event.idempotencyKey !== previous.idempotencyKey) { + invalidTransition( + `${path}.idempotencyKey`, + "This safe retry must reuse the prior idempotency key.", + ); + } + if ( + previous?.submission === "retry-scheduled" && + previous.retry?.safety === "confirmed-not-submitted" && + event.idempotencyKey === previous.idempotencyKey + ) { + invalidTransition( + `${path}.idempotencyKey`, + "A confirmed-not-submitted retry requires a fresh idempotency key.", + ); + } + if (usedIdempotencyKeys.has(event.idempotencyKey) && !reusesRequiredKey) { + invalidTransition(`${path}.idempotencyKey`, "Idempotency keys must be unique per run."); + } + usedIdempotencyKeys.add(event.idempotencyKey); + node.attempts.push({ + attempt: event.attempt, + idempotencyKey: event.idempotencyKey, + providerId: event.providerId, + modelId: event.modelId, + submission: "prepared", + }); + } else { + const attempt = node.attempts[node.attempts.length - 1]; + if (!attempt || attempt.attempt !== event.attempt) { + invalidTransition(path, "Submission event does not match the active attempt."); + } + if (event.type === "node-retry-scheduled") { + if ( + (attempt.submission !== "prepared" && attempt.submission !== "ambiguous") || + (attempt.submission === "ambiguous" && event.retrySafety !== "same-idempotency-key") + ) { + invalidTransition( + path, + "A retry requires a prepared submission or an ambiguous submission with the same idempotency key.", + ); + } + attempt.submission = "retry-scheduled"; + attempt.retry = { + errorCode: event.errorCode, + delayMs: event.delayMs, + safety: event.retrySafety, + }; + } else if (event.type === "node-submission-accepted") { + if (attempt.submission !== "prepared") + invalidTransition(path, "Only a prepared submission can be accepted."); + attempt.submission = "accepted"; + if (event.providerJobId) attempt.providerJobId = event.providerJobId; + } else if (event.type === "node-submission-ambiguous") { + if (attempt.submission !== "prepared") + invalidTransition(path, "Only a prepared submission can become ambiguous."); + attempt.submission = "ambiguous"; + } else { + if (attempt.submission !== "ambiguous") + invalidTransition(path, "Only an ambiguous submission can be reconciled."); + attempt.submission = event.outcome === "accepted" ? "accepted" : "reconciled-not-found"; + if (event.providerJobId) attempt.providerJobId = event.providerJobId; + } + } + } + } + projection.lastSequence = event.sequence; +} + +export function hasUnresolvedCreateImagesRunAmbiguity( + projection: CreateImagesRunProjection, +): boolean { + return ( + projection.terminal?.status === "needs_attention" && + projection.ambiguityResolution === undefined && + Object.values(projection.nodes).some((node) => node.status === "ambiguous") + ); +} + +function replay( + plan: CreateImagesRunPlanV1, + events: readonly CreateImagesRunEventV1[], + generateNodeIds: ReadonlySet, +): CreateImagesRunProjection { + const projection = initialProjection(plan); + const usedIdempotencyKeys = new Set(); + for (const [index, event] of events.entries()) { + applyEvent(projection, plan, generateNodeIds, event, index, usedIdempotencyKeys); + } + return projection; +} + +function deepFreeze(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +export function createImagesRunJournalSerializedBytes(value: unknown): number | undefined { + try { + const serialized = JSON.stringify(value, null, 2); + if (typeof serialized !== "string") return undefined; + return new TextEncoder().encode(`${serialized}\n`).byteLength; + } catch { + return undefined; + } +} + +/** Canonical, path-free snapshot material used by main to calculate SHA-256. */ +export function createImagesWorkflowSnapshotFingerprintMaterial( + snapshot: WorkflowDocumentV1, +): string { + const parsed = parseWorkflowDocument(snapshot); + if (!parsed.success) throw new Error(parsed.issues[0]?.message ?? "Invalid workflow snapshot."); + return `${JSON.stringify(parsed.value)}\n`; +} + +export function isFutureCreateImagesRunJournal(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.version === "number" && + value.version > CREATE_IMAGES_RUN_JOURNAL_VERSION + ); +} + +export function parseCreateImagesRunJournal(value: unknown): CreateImagesRunJournalParseResult { + const issues: CreateImagesRunContractIssue[] = []; + const bytes = createImagesRunJournalSerializedBytes(value); + if (bytes === undefined || bytes > CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES) { + issue(issues, "$", "too_large", "Run journal exceeds its byte limit."); + return { success: false, issues }; + } + if (!isRecord(value)) { + issue(issues, "$", "invalid_type", "Expected an object."); + return { success: false, issues }; + } + rejectUnknown( + value, + [ + "version", + "journalRevision", + "runId", + "workflowId", + "workflowRevision", + "workflowFingerprint", + "workflowSnapshot", + "plan", + "providerAuthorization", + "createdAt", + "updatedAt", + "events", + ], + "$", + issues, + ); + if (value.version !== CREATE_IMAGES_RUN_JOURNAL_VERSION) { + issue(issues, "version", "invalid_value", "Unsupported run journal version."); + } + const journalRevision = positiveInteger( + value.journalRevision, + "journalRevision", + issues, + CREATE_IMAGES_MAX_RUN_EVENTS + 1, + ); + const runId = opaqueId(value.runId, "runId", issues); + const workflowId = opaqueId(value.workflowId, "workflowId", issues); + const workflowRevision = positiveInteger(value.workflowRevision, "workflowRevision", issues); + const workflowFingerprint = boundedString( + value.workflowFingerprint, + "workflowFingerprint", + issues, + FINGERPRINT_PATTERN, + 64, + ); + const createdAt = timestamp(value.createdAt, "createdAt", issues); + const updatedAt = timestamp(value.updatedAt, "updatedAt", issues); + const parsedSnapshot = parseWorkflowDocument(value.workflowSnapshot); + if (!parsedSnapshot.success) { + for (const snapshotIssue of parsedSnapshot.issues) { + issue( + issues, + `workflowSnapshot.${snapshotIssue.path}`, + snapshotIssue.code === "too_large" ? "too_large" : "invalid_value", + snapshotIssue.message, + ); + } + } + const snapshot = parsedSnapshot.success ? parsedSnapshot.value : undefined; + if (snapshot && (snapshot.id !== workflowId || snapshot.revision !== workflowRevision)) { + issue(issues, "workflowSnapshot", "invalid_value", "Snapshot identity does not match the run."); + } + const plan = snapshot ? parsePlan(value.plan, snapshot, issues) : undefined; + let providerAuthorization: CreateImagesRunProviderAuthorizationV1 | undefined; + if (value.providerAuthorization !== undefined) { + const candidate = value.providerAuthorization; + if (!isRecord(candidate)) { + issue(issues, "providerAuthorization", "invalid_type", "Expected an object."); + } else { + rejectUnknown( + candidate, + [ + "version", + "executionMode", + "authorizationId", + "consentFingerprint", + "capabilityFingerprint", + "credentialRecordId", + "credentialRevision", + "initialRequestCount", + "expectedOutputCount", + "maximumAttempts", + "createdAt", + "expiresAt", + ], + "providerAuthorization", + issues, + ); + const authorizationId = opaqueId( + candidate.authorizationId, + "providerAuthorization.authorizationId", + issues, + ); + const credentialRecordId = opaqueId( + candidate.credentialRecordId, + "providerAuthorization.credentialRecordId", + issues, + ); + const consentFingerprint = boundedString( + candidate.consentFingerprint, + "providerAuthorization.consentFingerprint", + issues, + FINGERPRINT_PATTERN, + 64, + ); + const capabilityFingerprint = boundedString( + candidate.capabilityFingerprint, + "providerAuthorization.capabilityFingerprint", + issues, + FINGERPRINT_PATTERN, + 64, + ); + const credentialRevision = positiveInteger( + candidate.credentialRevision, + "providerAuthorization.credentialRevision", + issues, + ); + const initialRequestCount = positiveInteger( + candidate.initialRequestCount, + "providerAuthorization.initialRequestCount", + issues, + 500, + ); + const expectedOutputCount = positiveInteger( + candidate.expectedOutputCount, + "providerAuthorization.expectedOutputCount", + issues, + 2_000, + ); + const maximumAttempts = positiveInteger( + candidate.maximumAttempts, + "providerAuthorization.maximumAttempts", + issues, + 1_500, + ); + const authorizationCreatedAt = timestamp( + candidate.createdAt, + "providerAuthorization.createdAt", + issues, + ); + const authorizationExpiresAt = timestamp( + candidate.expiresAt, + "providerAuthorization.expiresAt", + issues, + ); + if (candidate.version !== 1 || candidate.executionMode !== "gemini") { + issue( + issues, + "providerAuthorization", + "invalid_value", + "Unsupported provider authorization.", + ); + } + if ( + authorizationId && + credentialRecordId && + consentFingerprint && + capabilityFingerprint && + credentialRevision && + initialRequestCount && + expectedOutputCount && + maximumAttempts && + authorizationCreatedAt && + authorizationExpiresAt && + candidate.version === 1 && + candidate.executionMode === "gemini" + ) { + if ( + maximumAttempts !== initialRequestCount || + Date.parse(authorizationExpiresAt) <= Date.parse(authorizationCreatedAt) + ) { + issue( + issues, + "providerAuthorization", + "invalid_value", + "Provider authorization accounting or expiry is invalid.", + ); + } else { + providerAuthorization = { + version: 1, + executionMode: "gemini", + authorizationId, + consentFingerprint, + capabilityFingerprint, + credentialRecordId, + credentialRevision, + initialRequestCount, + expectedOutputCount, + maximumAttempts, + createdAt: authorizationCreatedAt, + expiresAt: authorizationExpiresAt, + }; + } + } + } + } + if (!Array.isArray(value.events)) { + issue(issues, "events", "invalid_type", "Expected an array."); + } else if (value.events.length > CREATE_IMAGES_MAX_RUN_EVENTS) { + issue(issues, "events", "too_large", "Run event history exceeds its limit."); + } + const events: CreateImagesRunEventV1[] = []; + if ( + Array.isArray(value.events) && + value.events.length <= CREATE_IMAGES_MAX_RUN_EVENTS && + runId && + workflowId && + workflowRevision + ) { + for (const [index, candidate] of value.events.entries()) { + const parsed = parseEvent(candidate, index, { runId, workflowId, workflowRevision }, issues); + if (parsed) events.push(parsed); + } + } + if (snapshot) { + const nodeTypes = new Map(snapshot.nodes.map((node) => [node.id, node.type])); + const nodes = new Map(snapshot.nodes.map((node) => [node.id, node])); + for (const [index, event] of events.entries()) { + if ( + [ + "node-submission-prepared", + "node-submission-accepted", + "node-submission-ambiguous", + "node-submission-reconciled", + "node-retry-scheduled", + "node-ambiguous", + ].includes(event.type) && + "nodeId" in event && + nodeTypes.get(event.nodeId) !== "generate-image" + ) { + issue( + issues, + `events[${index}].nodeId`, + "invalid_value", + "Submission events are only valid for Generate Image nodes.", + ); + } + if ( + event.type === "node-succeeded" && + nodeTypes.get(event.nodeId) === "generate-image" && + events + .slice(0, index) + .filter( + (candidate): candidate is CreateImagesNodeSubmissionPreparedEventV1 => + candidate.type === "node-submission-prepared" && candidate.nodeId === event.nodeId, + ).length === 0 + ) { + issue( + issues, + `events[${index}]`, + "invalid_transition", + "Generate Image success requires a durable submission attempt.", + ); + } + if (event.type === "node-submission-prepared" && providerAuthorization) { + const node = nodes.get(event.nodeId); + if ( + event.providerId !== "gemini" || + node?.type !== "generate-image" || + node.data.modelId !== event.modelId + ) { + issue( + issues, + `events[${index}]`, + "invalid_value", + "Submission metadata does not match the durable provider authorization.", + ); + } + } + } + } + if (journalRevision !== undefined && journalRevision !== events.length + 1) { + issue( + issues, + "journalRevision", + "invalid_value", + "Journal revision must match its append-only history.", + ); + } + if (createdAt && updatedAt) { + const expectedUpdatedAt = events[events.length - 1]?.at ?? createdAt; + if (updatedAt !== expectedUpdatedAt || Date.parse(updatedAt) < Date.parse(createdAt)) { + issue( + issues, + "updatedAt", + "invalid_value", + "Updated time must match the latest journal event.", + ); + } + let previous = Date.parse(createdAt); + for (const [index, event] of events.entries()) { + const current = Date.parse(event.at); + if (current < previous) + issue(issues, `events[${index}].at`, "invalid_value", "Event time moved backwards."); + previous = current; + } + } + if (plan && events.length === (Array.isArray(value.events) ? value.events.length : -1)) { + try { + replay( + plan, + events, + new Set( + snapshot?.nodes.filter((node) => node.type === "generate-image").map((node) => node.id), + ), + ); + } catch (error) { + const path = (error as { runContractPath?: string }).runContractPath ?? "events"; + issue( + issues, + path, + "invalid_transition", + error instanceof Error ? error.message : "Invalid run transition.", + ); + } + } + if ( + issues.length > 0 || + !journalRevision || + !runId || + !workflowId || + !workflowRevision || + !workflowFingerprint || + !snapshot || + !plan || + !createdAt || + !updatedAt + ) { + return { success: false, issues }; + } + return { + success: true, + value: deepFreeze({ + version: CREATE_IMAGES_RUN_JOURNAL_VERSION, + journalRevision, + runId, + workflowId, + workflowRevision, + workflowFingerprint, + workflowSnapshot: snapshot, + plan, + ...(providerAuthorization ? { providerAuthorization } : {}), + createdAt, + updatedAt, + events, + }), + }; +} + +export function createCreateImagesRunJournal( + input: CreateImagesRunJournalCreationInput, +): CreateImagesRunJournalV1 { + const candidate = { + version: CREATE_IMAGES_RUN_JOURNAL_VERSION, + journalRevision: 1, + runId: input.runId, + workflowId: input.workflowSnapshot.id, + workflowRevision: input.workflowSnapshot.revision, + workflowFingerprint: input.workflowFingerprint, + workflowSnapshot: input.workflowSnapshot, + plan: input.plan, + ...(input.providerAuthorization ? { providerAuthorization: input.providerAuthorization } : {}), + createdAt: input.createdAt, + updatedAt: input.createdAt, + events: [], + }; + const parsed = parseCreateImagesRunJournal(candidate); + if (!parsed.success) throw new Error(parsed.issues[0]?.message ?? "Invalid Create Images run."); + return parsed.value; +} + +export function appendCreateImagesRunEvent( + journal: CreateImagesRunJournalV1, + event: CreateImagesRunEventV1, +): CreateImagesRunJournalV1 { + const candidate = { + ...journal, + journalRevision: journal.journalRevision + 1, + updatedAt: event.at, + events: [...journal.events, event], + }; + const parsed = parseCreateImagesRunJournal(candidate); + if (!parsed.success) + throw new Error(parsed.issues[0]?.message ?? "Invalid Create Images run event."); + return parsed.value; +} + +export function projectCreateImagesRun( + journal: CreateImagesRunJournalV1, +): CreateImagesRunProjection { + const parsed = parseCreateImagesRunJournal(journal); + if (!parsed.success) + throw new Error(parsed.issues[0]?.message ?? "Invalid Create Images run journal."); + return deepFreeze( + replay( + parsed.value.plan, + parsed.value.events, + new Set( + parsed.value.workflowSnapshot.nodes + .filter((node) => node.type === "generate-image") + .map((node) => node.id), + ), + ), + ); +} diff --git a/renderer/shared/create-images/schema.test.ts b/renderer/shared/create-images/schema.test.ts new file mode 100644 index 00000000..d77b386c --- /dev/null +++ b/renderer/shared/create-images/schema.test.ts @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CREATE_IMAGES_MAX_ZOOM, + CREATE_IMAGES_MAX_NODES, + CREATE_IMAGES_MIN_ZOOM, + createStarterWorkflow, + parseWorkflowDocument, + type WorkflowDocumentV1, +} from "./schema.js"; +import { topologicalWorkflowOrder, validateWorkflowGraph } from "./ports.js"; + +function starter(): WorkflowDocumentV1 { + return createStarterWorkflow({ + workflowId: "workflow-1", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: "2026-08-11T12:00:00.000Z", + }); +} + +test("starter workflow is structurally valid and deterministic", () => { + const workflow = starter(); + const parsed = parseWorkflowDocument(JSON.parse(JSON.stringify(workflow))); + assert.deepEqual(parsed, { success: true, value: workflow }); + assert.deepEqual(topologicalWorkflowOrder(workflow), { + order: ["prompt-1", "generate-1", "output-1"], + issues: [], + }); +}); + +test("schema rejects unknown credential fields and inline image data", () => { + const workflow = starter(); + const generation = workflow.nodes.find((node) => node.type === "generate-image"); + assert.ok(generation); + const withSecret = structuredClone(workflow) as unknown as { + nodes: Array<{ type: string; data: Record }>; + }; + const generationRecord = withSecret.nodes.find((node) => node.type === "generate-image"); + assert.ok(generationRecord); + generationRecord.data.apiKey = "must-not-cross-the-boundary"; + const secretResult = parseWorkflowDocument(withSecret); + assert.equal(secretResult.success, false); + if (!secretResult.success) { + assert.ok(secretResult.issues.some((issue) => issue.path.endsWith(".apiKey"))); + } + + const withInlineImage = structuredClone(workflow); + withInlineImage.nodes.unshift({ + id: "input-1", + type: "image-input", + position: { x: 0, y: 0 }, + data: { assetId: "data:image/png;base64,AAAA" }, + }); + assert.equal(parseWorkflowDocument(withInlineImage).success, false); +}); + +test("schema fails closed on future versions, unknown fields, and duplicate IDs", () => { + const workflow = starter() as unknown as Record; + workflow.schemaVersion = 2; + workflow.credentials = { apiKey: "secret" }; + const nodes = workflow.nodes as Array>; + nodes.push(structuredClone(nodes[0])); + const result = parseWorkflowDocument(workflow); + assert.equal(result.success, false); + if (result.success) return; + assert.ok(result.issues.some((issue) => issue.path === "$.schemaVersion")); + assert.ok(result.issues.some((issue) => issue.path === "$.credentials")); + assert.ok(result.issues.some((issue) => issue.code === "duplicate")); +}); + +test("graph validation reports broken ports without repairing them", () => { + const workflow = starter(); + workflow.edges.push({ + id: "edge-cycle", + source: "output-1", + sourcePort: "images", + target: "prompt-1", + targetPort: "text", + }); + const invalidDirection = validateWorkflowGraph(workflow); + assert.ok(invalidDirection.some((issue) => issue.code === "invalid_direction")); + + workflow.edges.pop(); + workflow.edges.push( + { + id: "edge-cycle-a", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "edge-invalid-reverse", + source: "generate-1", + sourcePort: "images", + target: "output-1", + targetPort: "images", + }, + ); + assert.ok(validateWorkflowGraph(workflow).some((issue) => issue.code === "duplicate_connection")); +}); + +test("graph validation detects a cycle made from otherwise compatible ports", () => { + const workflow = starter(); + const generation = workflow.nodes.find((node) => node.type === "generate-image"); + assert.ok(generation); + workflow.nodes.push( + { + id: "prompt-2", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "second" }, + }, + { + ...structuredClone(generation), + id: "generate-2", + position: { x: 320, y: 320 }, + }, + ); + workflow.edges.push( + { + id: "edge-prompt-2", + source: "prompt-2", + sourcePort: "text", + target: "generate-2", + targetPort: "prompt", + }, + { + id: "edge-cycle-a", + source: "generate-1", + sourcePort: "images", + target: "generate-2", + targetPort: "references", + }, + { + id: "edge-cycle-b", + source: "generate-2", + sourcePort: "images", + target: "generate-1", + targetPort: "references", + }, + ); + + const issues = validateWorkflowGraph(workflow); + assert.ok(issues.some((issue) => issue.code === "cycle" && issue.nodeId === "generate-1")); + assert.ok(issues.some((issue) => issue.code === "cycle" && issue.nodeId === "generate-2")); + assert.deepEqual(topologicalWorkflowOrder(workflow).order, []); +}); + +test("schema rejects oversized collections without walking their entries", () => { + const workflow = starter() as unknown as Record; + const trap = Object.defineProperty({}, "id", { + get() { + throw new Error("oversized collection entries must not be inspected"); + }, + }); + workflow.nodes = Array.from({ length: CREATE_IMAGES_MAX_NODES + 1 }, () => trap); + + const result = parseWorkflowDocument(workflow); + assert.equal(result.success, false); + if (result.success) return; + assert.ok(result.issues.some((issue) => issue.path === "$.nodes" && issue.code === "too_large")); +}); + +test("schema rejects sparse workflow arrays", () => { + const workflow = starter() as unknown as Record; + workflow.nodes = new Array(1); + workflow.edges = new Array(1); + workflow.assetRefs = new Array(1); + const result = parseWorkflowDocument(workflow); + assert.equal(result.success, false); + if (result.success) return; + assert.ok(result.issues.some((issue) => issue.path === "$.nodes[0]")); + assert.ok(result.issues.some((issue) => issue.path === "$.edges[0]")); + assert.ok(result.issues.some((issue) => issue.path === "$.assetRefs[0]")); +}); + +test("schema requires node-held assets in the workflow asset manifest", () => { + const workflow = starter(); + const assetId = "a".repeat(64); + workflow.nodes.push({ + id: "input-1", + type: "image-input", + position: { x: 0, y: 0 }, + data: { assetId }, + }); + const missing = parseWorkflowDocument(workflow); + assert.equal(missing.success, false); + if (!missing.success) { + assert.ok(missing.issues.some((issue) => issue.path.endsWith(".data.assetId"))); + } + workflow.assetRefs.push(assetId); + assert.equal(parseWorkflowDocument(workflow).success, true); +}); + +test("schema requires lowercase SHA-256 asset identifiers", () => { + for (const assetId of ["asset-1", "A".repeat(64), "a".repeat(63), "a".repeat(65)]) { + const workflow = starter(); + workflow.nodes.push({ + id: "input-1", + type: "image-input", + position: { x: 0, y: 0 }, + data: { assetId }, + }); + workflow.assetRefs.push(assetId); + + const result = parseWorkflowDocument(workflow); + assert.equal(result.success, false); + if (!result.success) { + assert.ok(result.issues.some((issue) => issue.path.endsWith(".data.assetId"))); + assert.ok(result.issues.some((issue) => issue.path === "$.assetRefs[0]")); + } + } +}); + +test("schema requires exact node asset references and shared viewport bounds", () => { + const unusedAsset = starter(); + unusedAsset.assetRefs = ["b".repeat(64)]; + const unusedResult = parseWorkflowDocument(unusedAsset); + assert.equal(unusedResult.success, false); + if (!unusedResult.success) { + assert.ok(unusedResult.issues.some((issue) => issue.path === "$.assetRefs[0]")); + } + + const reversed = starter(); + const assetA = "a".repeat(64); + const assetB = "b".repeat(64); + reversed.nodes.push( + { id: "image-a", type: "image-input", position: { x: 0, y: 0 }, data: { assetId: assetA } }, + { id: "image-b", type: "image-input", position: { x: 0, y: 100 }, data: { assetId: assetB } }, + ); + reversed.assetRefs = [assetB, assetA]; + const reversedResult = parseWorkflowDocument(reversed); + assert.equal(reversedResult.success, false); + if (!reversedResult.success) { + assert.ok(reversedResult.issues.some((issue) => issue.path === "$.assetRefs")); + } + + for (const zoom of [CREATE_IMAGES_MIN_ZOOM, CREATE_IMAGES_MAX_ZOOM]) { + const workflow = starter(); + workflow.viewport = { x: 0, y: 0, zoom }; + assert.equal(parseWorkflowDocument(workflow).success, true); + } + for (const zoom of [CREATE_IMAGES_MIN_ZOOM - 0.01, CREATE_IMAGES_MAX_ZOOM + 0.01]) { + const workflow = starter(); + workflow.viewport = { x: 0, y: 0, zoom }; + assert.equal(parseWorkflowDocument(workflow).success, false); + } +}); + +test("run validation distinguishes incomplete drafts from structurally invalid graphs", () => { + const workflow = starter(); + assert.deepEqual(validateWorkflowGraph(workflow), []); + const runIssues = validateWorkflowGraph(workflow, { forRun: true }); + assert.ok(runIssues.some((issue) => issue.code === "missing_prompt")); + assert.ok(runIssues.some((issue) => issue.code === "missing_provider")); + assert.ok(runIssues.some((issue) => issue.code === "missing_model")); +}); diff --git a/renderer/shared/create-images/schema.ts b/renderer/shared/create-images/schema.ts new file mode 100644 index 00000000..80dd98c5 --- /dev/null +++ b/renderer/shared/create-images/schema.ts @@ -0,0 +1,802 @@ +export const CREATE_IMAGES_SCHEMA_VERSION = 1 as const; +export const CREATE_IMAGES_MAX_NODES = 500; +export const CREATE_IMAGES_MAX_EDGES = 2_000; +export const CREATE_IMAGES_MAX_ASSET_REFS = 2_000; +export const CREATE_IMAGES_MAX_PROMPT_LENGTH = 32_000; +export const CREATE_IMAGES_MAX_WORKFLOW_BYTES = 8 * 1024 * 1024; +export const CREATE_IMAGES_MAX_TOTAL_ASSET_BYTES = 10 * 1024 * 1024 * 1024; +export const CREATE_IMAGES_MIN_ZOOM = 0.1; +export const CREATE_IMAGES_MAX_ZOOM = 2; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +export const CREATE_IMAGES_ASSET_ID_PATTERN = /^[a-f0-9]{64}$/u; +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,191}$/u; +const TIMESTAMP_MAX_LENGTH = 64; +export const CREATE_IMAGES_POSITION_LIMIT = 1_000_000; + +export const CREATE_IMAGES_NODE_TYPES = [ + "image-input", + "prompt", + "generate-image", + "output", + "output-gallery", +] as const; + +export type CreateImagesNodeType = (typeof CREATE_IMAGES_NODE_TYPES)[number]; +export type CreateImagesAspectRatio = + | "1:1" + | "2:3" + | "3:2" + | "3:4" + | "4:3" + | "4:5" + | "5:4" + | "9:16" + | "16:9" + | "21:9"; +export type CreateImagesImageSize = "1K" | "2K" | "4K"; +export type CreateImagesOutputMime = "image/png" | "image/jpeg"; + +export interface CreateImagesPosition { + x: number; + y: number; +} + +interface CreateImagesNodeBase { + id: string; + type: TType; + position: CreateImagesPosition; + data: TData; +} + +export type ImageInputNodeV1 = CreateImagesNodeBase< + "image-input", + { assetId?: string; label?: string } +>; +export type PromptNodeV1 = CreateImagesNodeBase<"prompt", { text: string }>; +export type GenerateImageNodeV1 = CreateImagesNodeBase< + "generate-image", + { + providerId?: "gemini"; + modelId?: string; + aspectRatio: CreateImagesAspectRatio; + imageSize: CreateImagesImageSize; + outputMime: CreateImagesOutputMime; + count: 1 | 2 | 3 | 4; + } +>; +export type OutputNodeV1 = CreateImagesNodeBase<"output", { label?: string }>; +export type OutputGalleryNodeV1 = CreateImagesNodeBase<"output-gallery", { label?: string }>; + +export type WorkflowNodeV1 = + | ImageInputNodeV1 + | PromptNodeV1 + | GenerateImageNodeV1 + | OutputNodeV1 + | OutputGalleryNodeV1; + +export interface WorkflowEdgeV1 { + id: string; + source: string; + sourcePort: string; + target: string; + targetPort: string; +} + +export interface WorkflowDocumentV1 { + schemaVersion: typeof CREATE_IMAGES_SCHEMA_VERSION; + id: string; + title: string; + revision: number; + createdAt: string; + updatedAt: string; + viewport?: { x: number; y: number; zoom: number }; + nodes: WorkflowNodeV1[]; + edges: WorkflowEdgeV1[]; + assetRefs: string[]; + settings: { + concurrency: 1 | 2 | 3 | 4; + defaultProviderId?: "gemini"; + }; +} + +export interface WorkflowParseIssue { + path: string; + code: "invalid_type" | "invalid_value" | "unknown_field" | "too_large" | "duplicate"; + message: string; +} + +export type WorkflowParseResult = + | { success: true; value: WorkflowDocumentV1 } + | { success: false; issues: WorkflowParseIssue[] }; + +export function createImagesWorkflowSerializedBytes(value: unknown): number | undefined { + try { + const serialized = JSON.stringify(value, null, 2); + if (typeof serialized !== "string") return undefined; + return new TextEncoder().encode(`${serialized}\n`).byteLength; + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function recordAt( + value: unknown, + path: string, + issues: WorkflowParseIssue[], +): Record | undefined { + if (!isRecord(value)) { + issues.push({ path, code: "invalid_type", message: "Expected an object." }); + return undefined; + } + return value; +} + +function hasOwn(value: object, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function rejectUnknownFields( + record: Record, + allowed: readonly string[], + path: string, + issues: WorkflowParseIssue[], +): void { + const allowedSet = new Set(allowed); + for (const field of Object.keys(record)) { + if (!allowedSet.has(field)) { + issues.push({ + path: `${path}.${field}`, + code: "unknown_field", + message: `Unknown field "${field}".`, + }); + } + } +} + +function stringAt( + value: unknown, + path: string, + issues: WorkflowParseIssue[], + options: { maxLength: number; pattern?: RegExp; optional?: boolean }, +): string | undefined { + if (value === undefined && options.optional) return undefined; + if (typeof value !== "string") { + issues.push({ path, code: "invalid_type", message: "Expected a string." }); + return undefined; + } + if (value.length === 0 || value.length > options.maxLength) { + issues.push({ + path, + code: value.length > options.maxLength ? "too_large" : "invalid_value", + message: `Expected between 1 and ${options.maxLength} characters.`, + }); + return undefined; + } + if (options.pattern && !options.pattern.test(value)) { + issues.push({ path, code: "invalid_value", message: "Invalid identifier." }); + return undefined; + } + return value; +} + +function optionalLabelAt( + value: unknown, + path: string, + issues: WorkflowParseIssue[], +): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") { + issues.push({ path, code: "invalid_type", message: "Expected a string." }); + return undefined; + } + if (value.length > 120) { + issues.push({ path, code: "too_large", message: "Labels are limited to 120 characters." }); + return undefined; + } + return value; +} + +function finiteNumberAt( + value: unknown, + path: string, + issues: WorkflowParseIssue[], + min: number, + max: number, +): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + issues.push({ path, code: "invalid_type", message: "Expected a finite number." }); + return undefined; + } + if (value < min || value > max) { + issues.push({ path, code: "invalid_value", message: `Expected ${min} through ${max}.` }); + return undefined; + } + return value; +} + +function integerAt( + value: unknown, + path: string, + issues: WorkflowParseIssue[], + min: number, + max: number, +): number | undefined { + const number = finiteNumberAt(value, path, issues, min, max); + if (number !== undefined && !Number.isInteger(number)) { + issues.push({ path, code: "invalid_value", message: "Expected an integer." }); + return undefined; + } + return number; +} + +function enumAt( + value: unknown, + allowed: readonly T[], + path: string, + issues: WorkflowParseIssue[], + optional = false, +): T | undefined { + if (value === undefined && optional) return undefined; + if (typeof value !== "string" || !allowed.includes(value as T)) { + issues.push({ + path, + code: "invalid_value", + message: `Expected one of: ${allowed.join(", ")}.`, + }); + return undefined; + } + return value as T; +} + +function timestampAt( + value: unknown, + path: string, + issues: WorkflowParseIssue[], +): string | undefined { + const timestamp = stringAt(value, path, issues, { maxLength: TIMESTAMP_MAX_LENGTH }); + if (timestamp !== undefined && !Number.isFinite(Date.parse(timestamp))) { + issues.push({ path, code: "invalid_value", message: "Expected an ISO-8601 timestamp." }); + return undefined; + } + return timestamp; +} + +function positionAt( + value: unknown, + path: string, + issues: WorkflowParseIssue[], +): CreateImagesPosition | undefined { + const record = recordAt(value, path, issues); + if (!record) return undefined; + rejectUnknownFields(record, ["x", "y"], path, issues); + const x = finiteNumberAt( + record.x, + `${path}.x`, + issues, + -CREATE_IMAGES_POSITION_LIMIT, + CREATE_IMAGES_POSITION_LIMIT, + ); + const y = finiteNumberAt( + record.y, + `${path}.y`, + issues, + -CREATE_IMAGES_POSITION_LIMIT, + CREATE_IMAGES_POSITION_LIMIT, + ); + return x === undefined || y === undefined ? undefined : { x, y }; +} + +const ASPECT_RATIOS: readonly CreateImagesAspectRatio[] = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]; +const IMAGE_SIZES: readonly CreateImagesImageSize[] = ["1K", "2K", "4K"]; +const OUTPUT_MIMES: readonly CreateImagesOutputMime[] = ["image/png", "image/jpeg"]; + +function nodeAt( + value: unknown, + index: number, + issues: WorkflowParseIssue[], +): WorkflowNodeV1 | undefined { + const path = `nodes[${index}]`; + const record = recordAt(value, path, issues); + if (!record) return undefined; + rejectUnknownFields(record, ["id", "type", "position", "data"], path, issues); + const id = stringAt(record.id, `${path}.id`, issues, { + maxLength: 128, + pattern: OPAQUE_ID_PATTERN, + }); + const type = enumAt(record.type, CREATE_IMAGES_NODE_TYPES, `${path}.type`, issues); + const position = positionAt(record.position, `${path}.position`, issues); + const data = recordAt(record.data, `${path}.data`, issues); + if (!id || !type || !position || !data) return undefined; + + if (type === "image-input") { + rejectUnknownFields(data, ["assetId", "label"], `${path}.data`, issues); + const assetId = stringAt(data.assetId, `${path}.data.assetId`, issues, { + maxLength: 64, + pattern: CREATE_IMAGES_ASSET_ID_PATTERN, + optional: true, + }); + const label = optionalLabelAt(data.label, `${path}.data.label`, issues); + if (data.assetId !== undefined && assetId === undefined) return undefined; + return { + id, + type, + position, + data: { ...(assetId ? { assetId } : {}), ...(label !== undefined ? { label } : {}) }, + }; + } + + if (type === "prompt") { + rejectUnknownFields(data, ["text"], `${path}.data`, issues); + if (typeof data.text !== "string") { + issues.push({ + path: `${path}.data.text`, + code: "invalid_type", + message: "Expected a string.", + }); + return undefined; + } + if (data.text.length > CREATE_IMAGES_MAX_PROMPT_LENGTH) { + issues.push({ + path: `${path}.data.text`, + code: "too_large", + message: `Prompts are limited to ${CREATE_IMAGES_MAX_PROMPT_LENGTH} characters.`, + }); + return undefined; + } + return { id, type, position, data: { text: data.text } }; + } + + if (type === "generate-image") { + rejectUnknownFields( + data, + ["providerId", "modelId", "aspectRatio", "imageSize", "outputMime", "count"], + `${path}.data`, + issues, + ); + const providerId = enumAt( + data.providerId, + ["gemini"] as const, + `${path}.data.providerId`, + issues, + true, + ); + const modelId = stringAt(data.modelId, `${path}.data.modelId`, issues, { + maxLength: 192, + pattern: MODEL_ID_PATTERN, + optional: true, + }); + const aspectRatio = enumAt(data.aspectRatio, ASPECT_RATIOS, `${path}.data.aspectRatio`, issues); + const imageSize = enumAt(data.imageSize, IMAGE_SIZES, `${path}.data.imageSize`, issues); + const outputMime = enumAt(data.outputMime, OUTPUT_MIMES, `${path}.data.outputMime`, issues); + const count = integerAt(data.count, `${path}.data.count`, issues, 1, 4); + if ( + (data.providerId !== undefined && providerId === undefined) || + (data.modelId !== undefined && modelId === undefined) || + !aspectRatio || + !imageSize || + !outputMime || + !count + ) { + return undefined; + } + return { + id, + type, + position, + data: { + ...(providerId ? { providerId } : {}), + ...(modelId ? { modelId } : {}), + aspectRatio, + imageSize, + outputMime, + count: count as 1 | 2 | 3 | 4, + }, + }; + } + + rejectUnknownFields(data, ["label"], `${path}.data`, issues); + const label = optionalLabelAt(data.label, `${path}.data.label`, issues); + const outputData = label === undefined ? {} : { label }; + return type === "output" + ? { id, type, position, data: outputData } + : { id, type, position, data: outputData }; +} + +function edgeAt( + value: unknown, + index: number, + issues: WorkflowParseIssue[], +): WorkflowEdgeV1 | undefined { + const path = `edges[${index}]`; + const record = recordAt(value, path, issues); + if (!record) return undefined; + rejectUnknownFields(record, ["id", "source", "sourcePort", "target", "targetPort"], path, issues); + const readId = (field: string): string | undefined => + stringAt(record[field], `${path}.${field}`, issues, { + maxLength: 128, + pattern: OPAQUE_ID_PATTERN, + }); + const id = readId("id"); + const source = readId("source"); + const sourcePort = readId("sourcePort"); + const target = readId("target"); + const targetPort = readId("targetPort"); + return id && source && sourcePort && target && targetPort + ? { id, source, sourcePort, target, targetPort } + : undefined; +} + +function duplicates(values: readonly string[]): Set { + const seen = new Set(); + const duplicate = new Set(); + for (const value of values) { + if (seen.has(value)) duplicate.add(value); + seen.add(value); + } + return duplicate; +} + +export function parseWorkflowDocument(value: unknown): WorkflowParseResult { + const issues: WorkflowParseIssue[] = []; + const record = recordAt(value, "$", issues); + if (!record) return { success: false, issues }; + const serializedBytes = createImagesWorkflowSerializedBytes(value); + if (serializedBytes === undefined || serializedBytes > CREATE_IMAGES_MAX_WORKFLOW_BYTES) { + issues.push({ + path: "$", + code: "too_large", + message: "Workflow metadata exceeds its 8 MiB storage limit.", + }); + return { success: false, issues }; + } + rejectUnknownFields( + record, + [ + "schemaVersion", + "id", + "title", + "revision", + "createdAt", + "updatedAt", + "viewport", + "nodes", + "edges", + "assetRefs", + "settings", + ], + "$", + issues, + ); + + if (record.schemaVersion !== CREATE_IMAGES_SCHEMA_VERSION) { + issues.push({ + path: "$.schemaVersion", + code: "invalid_value", + message: `Only schema version ${CREATE_IMAGES_SCHEMA_VERSION} is supported.`, + }); + } + const id = stringAt(record.id, "$.id", issues, { + maxLength: 128, + pattern: OPAQUE_ID_PATTERN, + }); + const title = stringAt(record.title, "$.title", issues, { maxLength: 120 }); + const revision = integerAt(record.revision, "$.revision", issues, 1, Number.MAX_SAFE_INTEGER); + const createdAt = timestampAt(record.createdAt, "$.createdAt", issues); + const updatedAt = timestampAt(record.updatedAt, "$.updatedAt", issues); + + let viewport: WorkflowDocumentV1["viewport"]; + if (record.viewport !== undefined) { + const viewportRecord = recordAt(record.viewport, "$.viewport", issues); + if (viewportRecord) { + rejectUnknownFields(viewportRecord, ["x", "y", "zoom"], "$.viewport", issues); + const x = finiteNumberAt( + viewportRecord.x, + "$.viewport.x", + issues, + -CREATE_IMAGES_POSITION_LIMIT, + CREATE_IMAGES_POSITION_LIMIT, + ); + const y = finiteNumberAt( + viewportRecord.y, + "$.viewport.y", + issues, + -CREATE_IMAGES_POSITION_LIMIT, + CREATE_IMAGES_POSITION_LIMIT, + ); + const zoom = finiteNumberAt( + viewportRecord.zoom, + "$.viewport.zoom", + issues, + CREATE_IMAGES_MIN_ZOOM, + CREATE_IMAGES_MAX_ZOOM, + ); + if (x !== undefined && y !== undefined && zoom !== undefined) viewport = { x, y, zoom }; + } + } + + const nodeValues = Array.isArray(record.nodes) ? record.nodes : undefined; + if (!nodeValues) { + issues.push({ path: "$.nodes", code: "invalid_type", message: "Expected an array." }); + } else if (nodeValues.length > CREATE_IMAGES_MAX_NODES) { + issues.push({ + path: "$.nodes", + code: "too_large", + message: `Workflows are limited to ${CREATE_IMAGES_MAX_NODES} nodes.`, + }); + } + // Reject oversized collections without walking attacker-controlled entries. IPC + // callers perform a byte-size check too, but this parser remains safe in isolation. + const nodes: WorkflowNodeV1[] = []; + if (nodeValues && nodeValues.length <= CREATE_IMAGES_MAX_NODES) { + for (let index = 0; index < nodeValues.length; index += 1) { + if (!hasOwn(nodeValues, index)) { + issues.push({ + path: `$.nodes[${index}]`, + code: "invalid_type", + message: "Sparse workflow arrays are not supported.", + }); + continue; + } + const parsed = nodeAt(nodeValues[index], index, issues); + if (parsed) nodes.push(parsed); + } + } + + const edgeValues = Array.isArray(record.edges) ? record.edges : undefined; + if (!edgeValues) { + issues.push({ path: "$.edges", code: "invalid_type", message: "Expected an array." }); + } else if (edgeValues.length > CREATE_IMAGES_MAX_EDGES) { + issues.push({ + path: "$.edges", + code: "too_large", + message: `Workflows are limited to ${CREATE_IMAGES_MAX_EDGES} edges.`, + }); + } + const edges: WorkflowEdgeV1[] = []; + if (edgeValues && edgeValues.length <= CREATE_IMAGES_MAX_EDGES) { + for (let index = 0; index < edgeValues.length; index += 1) { + if (!hasOwn(edgeValues, index)) { + issues.push({ + path: `$.edges[${index}]`, + code: "invalid_type", + message: "Sparse workflow arrays are not supported.", + }); + continue; + } + const parsed = edgeAt(edgeValues[index], index, issues); + if (parsed) edges.push(parsed); + } + } + + const assetValues = Array.isArray(record.assetRefs) ? record.assetRefs : undefined; + if (!assetValues) { + issues.push({ path: "$.assetRefs", code: "invalid_type", message: "Expected an array." }); + } else if (assetValues.length > CREATE_IMAGES_MAX_ASSET_REFS) { + issues.push({ + path: "$.assetRefs", + code: "too_large", + message: `Workflows are limited to ${CREATE_IMAGES_MAX_ASSET_REFS} asset references.`, + }); + } + const assetRefs: string[] = []; + if (assetValues && assetValues.length <= CREATE_IMAGES_MAX_ASSET_REFS) { + for (let index = 0; index < assetValues.length; index += 1) { + if (!hasOwn(assetValues, index)) { + issues.push({ + path: `$.assetRefs[${index}]`, + code: "invalid_type", + message: "Sparse workflow arrays are not supported.", + }); + continue; + } + const parsed = stringAt(assetValues[index], `$.assetRefs[${index}]`, issues, { + maxLength: 64, + pattern: CREATE_IMAGES_ASSET_ID_PATTERN, + }); + if (parsed) assetRefs.push(parsed); + } + } + + const settingsRecord = recordAt(record.settings, "$.settings", issues); + let settings: WorkflowDocumentV1["settings"] | undefined; + if (settingsRecord) { + rejectUnknownFields(settingsRecord, ["concurrency", "defaultProviderId"], "$.settings", issues); + const concurrency = integerAt( + settingsRecord.concurrency, + "$.settings.concurrency", + issues, + 1, + 4, + ); + const defaultProviderId = enumAt( + settingsRecord.defaultProviderId, + ["gemini"] as const, + "$.settings.defaultProviderId", + issues, + true, + ); + if ( + concurrency !== undefined && + (settingsRecord.defaultProviderId === undefined || defaultProviderId !== undefined) + ) { + settings = { + concurrency: concurrency as 1 | 2 | 3 | 4, + ...(defaultProviderId ? { defaultProviderId } : {}), + }; + } + } + + for (const duplicate of duplicates(nodes.map((node) => node.id))) { + issues.push({ + path: "$.nodes", + code: "duplicate", + message: `Duplicate node ID "${duplicate}".`, + }); + } + for (const duplicate of duplicates(edges.map((edge) => edge.id))) { + issues.push({ + path: "$.edges", + code: "duplicate", + message: `Duplicate edge ID "${duplicate}".`, + }); + } + for (const duplicate of duplicates(assetRefs)) { + issues.push({ + path: "$.assetRefs", + code: "duplicate", + message: `Duplicate asset reference "${duplicate}".`, + }); + } + const assetReferenceSet = new Set(assetRefs); + const nodeAssetReferenceSet = new Set(); + const nodeAssetReferences: string[] = []; + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index]; + const assetId = node?.type === "image-input" ? node.data.assetId : undefined; + if (assetId && !assetReferenceSet.has(assetId)) { + issues.push({ + path: `$.nodes[${index}].data.assetId`, + code: "invalid_value", + message: `Asset "${assetId}" is missing from the workflow asset manifest.`, + }); + } + if (assetId && !nodeAssetReferenceSet.has(assetId)) { + nodeAssetReferenceSet.add(assetId); + nodeAssetReferences.push(assetId); + } + } + for (let index = 0; index < assetRefs.length; index += 1) { + const assetId = assetRefs[index]; + if (assetId && !nodeAssetReferenceSet.has(assetId)) { + issues.push({ + path: `$.assetRefs[${index}]`, + code: "invalid_value", + message: `Asset "${assetId}" is not used by an Image Input node.`, + }); + } + } + if ( + assetRefs.length === nodeAssetReferences.length && + assetRefs.some((assetId, index) => assetId !== nodeAssetReferences[index]) + ) { + issues.push({ + path: "$.assetRefs", + code: "invalid_value", + message: "Asset references must follow their first Image Input node use.", + }); + } + + if ( + issues.length > 0 || + !id || + !title || + revision === undefined || + !createdAt || + !updatedAt || + !nodeValues || + !edgeValues || + !assetValues || + !settings + ) { + return { success: false, issues }; + } + + return { + success: true, + value: { + schemaVersion: CREATE_IMAGES_SCHEMA_VERSION, + id, + title, + revision, + createdAt, + updatedAt, + ...(viewport ? { viewport } : {}), + nodes, + edges, + assetRefs, + settings, + }, + }; +} + +export function createStarterWorkflow(input: { + workflowId: string; + promptNodeId: string; + generationNodeId: string; + outputNodeId: string; + promptEdgeId: string; + outputEdgeId: string; + now: string; +}): WorkflowDocumentV1 { + const candidate: WorkflowDocumentV1 = { + schemaVersion: CREATE_IMAGES_SCHEMA_VERSION, + id: input.workflowId, + title: "Untitled image workflow", + revision: 1, + createdAt: input.now, + updatedAt: input.now, + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [ + { + id: input.promptNodeId, + type: "prompt", + position: { x: 80, y: 180 }, + data: { text: "" }, + }, + { + id: input.generationNodeId, + type: "generate-image", + position: { x: 420, y: 150 }, + data: { + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { + id: input.outputNodeId, + type: "output", + position: { x: 780, y: 180 }, + data: {}, + }, + ], + edges: [ + { + id: input.promptEdgeId, + source: input.promptNodeId, + sourcePort: "text", + target: input.generationNodeId, + targetPort: "prompt", + }, + { + id: input.outputEdgeId, + source: input.generationNodeId, + sourcePort: "images", + target: input.outputNodeId, + targetPort: "images", + }, + ], + assetRefs: [], + settings: { concurrency: 1 }, + }; + const result = parseWorkflowDocument(candidate); + if (!result.success) throw new Error("The built-in starter workflow is invalid."); + return result.value; +} diff --git a/renderer/shared/create-images/templates.test.ts b/renderer/shared/create-images/templates.test.ts new file mode 100644 index 00000000..15c1f22a --- /dev/null +++ b/renderer/shared/create-images/templates.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateWorkflowGraph } from "./ports.js"; +import { parseWorkflowDocument } from "./schema.js"; +import { + CREATE_IMAGES_WORKFLOW_TEMPLATES, + createImagesWorkflowFromTemplate, + type CreateImagesWorkflowTemplateId, +} from "./templates.js"; + +function create(template: CreateImagesWorkflowTemplateId) { + let sequence = 0; + return createImagesWorkflowFromTemplate({ + template, + workflowId: `workflow-${template}`, + now: "2026-08-19T12:00:00.000Z", + nextId: () => `id-${++sequence}`, + }); +} + +test("all shipped Create Images templates are valid, deterministic, and runnable in shape", () => { + assert.deepEqual( + CREATE_IMAGES_WORKFLOW_TEMPLATES.map((template) => template.id), + ["starter", "reference-edit", "variant-set"], + ); + for (const template of ["blank", ...CREATE_IMAGES_WORKFLOW_TEMPLATES.map(({ id }) => id)] as const) { + const first = create(template); + const second = create(template); + assert.deepEqual(first, second); + assert.equal(parseWorkflowDocument(first).success, true); + assert.equal(first.revision, 1); + assert.deepEqual(first.assetRefs, []); + if (template !== "blank") { + const setupIssues = new Set([ + "missing_prompt", + "missing_asset", + "missing_provider", + "missing_model", + ]); + assert.deepEqual( + validateWorkflowGraph(first).filter((issue) => !setupIssues.has(issue.code)), + [], + ); + } + } + const reference = create("reference-edit"); + assert.ok(reference.nodes.some((node) => node.type === "image-input")); + assert.ok(reference.edges.some((edge) => edge.targetPort === "references")); + const variants = create("variant-set"); + assert.equal( + variants.nodes.find((node) => node.type === "generate-image")?.data.count, + 4, + ); + assert.ok(variants.nodes.some((node) => node.type === "output-gallery")); +}); diff --git a/renderer/shared/create-images/templates.ts b/renderer/shared/create-images/templates.ts new file mode 100644 index 00000000..c08ce8fa --- /dev/null +++ b/renderer/shared/create-images/templates.ts @@ -0,0 +1,104 @@ +import { + CREATE_IMAGES_SCHEMA_VERSION, + createStarterWorkflow, + parseWorkflowDocument, + type WorkflowDocumentV1, +} from "./schema.js"; + +export type CreateImagesWorkflowTemplateId = + | "blank" + | "starter" + | "reference-edit" + | "variant-set"; + +export const CREATE_IMAGES_WORKFLOW_TEMPLATES = Object.freeze([ + { + id: "starter" as const, + title: "Prompt to image", + description: "A prompt, one Gemini generation node, and a durable output.", + }, + { + id: "reference-edit" as const, + title: "Reference edit", + description: "Combine an imported reference image with a transformation prompt.", + }, + { + id: "variant-set" as const, + title: "Variant set", + description: "Generate four variants into a durable output gallery.", + }, +] as const); + +export function createImagesWorkflowFromTemplate(input: { + template: CreateImagesWorkflowTemplateId; + workflowId: string; + now: string; + nextId(): string; + title?: string; +}): WorkflowDocumentV1 { + if (input.template === "blank") { + const blank: WorkflowDocumentV1 = { + schemaVersion: CREATE_IMAGES_SCHEMA_VERSION, + id: input.workflowId, + title: input.title ?? "Untitled image workflow", + revision: 1, + createdAt: input.now, + updatedAt: input.now, + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [], + edges: [], + assetRefs: [], + settings: { concurrency: 1 }, + }; + const parsed = parseWorkflowDocument(blank); + if (!parsed.success) throw new Error("The blank workflow template is invalid."); + return parsed.value; + } + + const workflow = createStarterWorkflow({ + workflowId: input.workflowId, + promptNodeId: input.nextId(), + generationNodeId: input.nextId(), + outputNodeId: input.nextId(), + promptEdgeId: input.nextId(), + outputEdgeId: input.nextId(), + now: input.now, + }); + if (input.template === "reference-edit") { + const imageNodeId = input.nextId(); + const generationNode = workflow.nodes.find((node) => node.type === "generate-image")!; + workflow.nodes.unshift({ + id: imageNodeId, + type: "image-input", + position: { x: 80, y: 430 }, + data: { label: "Reference image" }, + }); + workflow.edges.push({ + id: input.nextId(), + source: imageNodeId, + sourcePort: "image", + target: generationNode.id, + targetPort: "references", + }); + workflow.title = input.title ?? "Reference edit workflow"; + } else if (input.template === "variant-set") { + const generationNode = workflow.nodes.find((node) => node.type === "generate-image")!; + const outputNode = workflow.nodes.find((node) => node.type === "output")!; + generationNode.data.count = 4; + const outputGallery: WorkflowDocumentV1["nodes"][number] = { + id: outputNode.id, + type: "output-gallery", + position: outputNode.position, + data: outputNode.data, + }; + workflow.nodes = workflow.nodes.map((node) => + node.id === outputNode.id ? outputGallery : node, + ); + workflow.title = input.title ?? "Image variant workflow"; + } else if (input.title) { + workflow.title = input.title; + } + const parsed = parseWorkflowDocument(workflow); + if (!parsed.success) throw new Error("The built-in workflow template is invalid."); + return parsed.value; +} From c8bf869c648be9a70ad36f608ff4d00427914dd9 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 20 Aug 2026 00:41:48 -0400 Subject: [PATCH 002/110] feat(create-images): add durable asset and workspace storage Implement content-addressed image storage, strict static-raster validation, opaque preview grants, thumbnails, GC and repair, crash-safe workflow manifests, and the required identity-bound Finder workspace mirror. Normalize supported formats through a sandboxed decoder with a bounded macOS TIFF/HEIF fallback. --- .../create-images/asset-delivery-core.ts | 224 ++ .../asset-image-validation-core.ts | 343 +++ .../asset-import-normalization-core.test.ts | 74 + .../asset-import-normalization-core.ts | 166 ++ .../create-images/asset-protocol-core.test.ts | 67 + .../create-images/asset-protocol-core.ts | 72 + main/services/create-images/asset-protocol.ts | 140 ++ .../create-images/asset-store-core.test.ts | 884 +++++++ .../create-images/asset-store-core.ts | 2032 +++++++++++++++++ .../asset-thumbnail-cache-core.ts | 69 + .../electron-asset-image-utility.ts | 127 ++ .../create-images/electron-asset-images.ts | 37 + .../electron-asset-import.test.ts | 194 ++ .../create-images/electron-asset-import.ts | 173 ++ .../image-decoder-boundary.test.ts | 43 + .../create-images/macos-image-normalizer.ts | 87 + .../workflow-manifest-store.test.ts | 800 +++++++ .../create-images/workflow-manifest-store.ts | 1683 ++++++++++++++ .../create-images/workspace-store.test.ts | 240 ++ .../services/create-images/workspace-store.ts | 1163 ++++++++++ 20 files changed, 8618 insertions(+) create mode 100644 main/services/create-images/asset-delivery-core.ts create mode 100644 main/services/create-images/asset-image-validation-core.ts create mode 100644 main/services/create-images/asset-import-normalization-core.test.ts create mode 100644 main/services/create-images/asset-import-normalization-core.ts create mode 100644 main/services/create-images/asset-protocol-core.test.ts create mode 100644 main/services/create-images/asset-protocol-core.ts create mode 100644 main/services/create-images/asset-protocol.ts create mode 100644 main/services/create-images/asset-store-core.test.ts create mode 100644 main/services/create-images/asset-store-core.ts create mode 100644 main/services/create-images/asset-thumbnail-cache-core.ts create mode 100644 main/services/create-images/electron-asset-image-utility.ts create mode 100644 main/services/create-images/electron-asset-images.ts create mode 100644 main/services/create-images/electron-asset-import.test.ts create mode 100644 main/services/create-images/electron-asset-import.ts create mode 100644 main/services/create-images/image-decoder-boundary.test.ts create mode 100644 main/services/create-images/macos-image-normalizer.ts create mode 100644 main/services/create-images/workflow-manifest-store.test.ts create mode 100644 main/services/create-images/workflow-manifest-store.ts create mode 100644 main/services/create-images/workspace-store.test.ts create mode 100644 main/services/create-images/workspace-store.ts diff --git a/main/services/create-images/asset-delivery-core.ts b/main/services/create-images/asset-delivery-core.ts new file mode 100644 index 00000000..663d9469 --- /dev/null +++ b/main/services/create-images/asset-delivery-core.ts @@ -0,0 +1,224 @@ +import { randomBytes } from "node:crypto"; +import type { RendererDocumentOwner } from "../renderer-document-owner.js"; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; + +export const ASSET_DELIVERY_GRANT_TTL_MS = 60_000; + +export interface AssetDeliveryGrantLease { + expiresAt: number; + release(): void; +} + +interface AssetDeliveryGrant { + token: string; + documentId: string; + assetId: string; + expiresAt: number; + createdAt: number; + owner: RendererDocumentOwner; + isAuthorized: (assetId: string) => boolean; + disposeInvalidation: () => void; + lease: AssetDeliveryGrantLease; +} + +interface AuthorizedProtocolRequest { + assetId: string; + expiresAt: number; + remaining: number; +} + +export interface AssetDeliveryGrantView { + token: string; + expiresAt: number; +} + +/** + * Opaque, document-bound grants for a future aiden-asset protocol. No local + * path or asset identifier is encoded in the renderer-visible token. + */ +export class AssetDeliveryGrantRegistry { + private readonly grants = new Map(); + private readonly protocolRequests = new Map(); + + constructor( + private readonly now: () => number = Date.now, + private readonly ttlMs = ASSET_DELIVERY_GRANT_TTL_MS, + // A v1 workflow may reference 2,000 distinct assets. Leave room for the + // renderer's bounded atomic-renewal overlap without evicting live previews. + private readonly maxGrants = 4_096, + ) { + if (!Number.isFinite(ttlMs) || ttlMs < 1_000 || ttlMs > 5 * 60_000) { + throw new Error("Asset delivery grants require a 1–300 second lifetime."); + } + if (!Number.isInteger(maxGrants) || maxGrants < 1 || maxGrants > 10_000) { + throw new Error("Invalid asset delivery grant capacity."); + } + } + + private pruneExpired(): void { + const now = this.now(); + for (const [token, grant] of this.grants) { + if (grant.expiresAt <= now || grant.owner.isDestroyed()) this.deleteGrant(token); + } + for (const [token, request] of this.protocolRequests) { + if (request.expiresAt <= now || !this.grants.has(token)) this.protocolRequests.delete(token); + } + } + + private deleteGrant(token: string): boolean { + const grant = this.grants.get(token); + if (!grant) return false; + this.grants.delete(token); + this.protocolRequests.delete(token); + grant.disposeInvalidation(); + this.releaseLease(grant.lease); + return true; + } + + private releaseLease(lease: AssetDeliveryGrantLease): void { + try { + lease.release(); + } catch { + // A grant must still disappear if best-effort lease cleanup reports an + // error. The lease expiry remains the GC-safe backstop. + } + } + + mint( + owner: RendererDocumentOwner, + assetId: string, + isAuthorized: (assetId: string) => boolean, + lease: AssetDeliveryGrantLease, + ): AssetDeliveryGrantView { + if (!OPAQUE_ID_PATTERN.test(assetId)) { + this.releaseLease(lease); + throw new Error("Asset delivery grants require opaque asset IDs."); + } + if (!owner.documentId || owner.documentId.length > 512 || owner.isDestroyed()) { + this.releaseLease(lease); + throw new Error("Asset delivery grants require a live renderer document owner."); + } + if (!isAuthorized(assetId)) { + this.releaseLease(lease); + throw new Error("The renderer document is not authorized to access this asset."); + } + if (!Number.isFinite(lease.expiresAt) || lease.expiresAt <= this.now()) { + this.releaseLease(lease); + throw new Error("Asset delivery grants require a live preview lease."); + } + this.pruneExpired(); + while (this.grants.size >= this.maxGrants) { + const oldest = [...this.grants.values()].sort( + (left, right) => left.createdAt - right.createdAt, + )[0]; + if (!oldest) break; + this.deleteGrant(oldest.token); + } + const createdAt = this.now(); + const token = randomBytes(32).toString("base64url"); + const grant: AssetDeliveryGrant = { + token, + documentId: owner.documentId, + assetId, + createdAt, + expiresAt: Math.min(createdAt + this.ttlMs, lease.expiresAt), + owner, + isAuthorized, + disposeInvalidation: () => undefined, + lease, + }; + this.grants.set(token, grant); + grant.disposeInvalidation = owner.onInvalidated(() => this.deleteGrant(token)); + return { token, expiresAt: grant.expiresAt }; + } + + resolve(token: string, owner: RendererDocumentOwner): string | undefined { + this.pruneExpired(); + const grant = this.grants.get(token); + if ( + !grant || + owner.isDestroyed() || + grant.owner.isDestroyed() || + grant.documentId !== owner.documentId || + grant.owner.id !== owner.id + ) { + return undefined; + } + try { + if (!grant.isAuthorized(grant.assetId)) { + this.deleteGrant(token); + return undefined; + } + } catch { + this.deleteGrant(token); + return undefined; + } + return grant.assetId; + } + + /** + * Authorize a single protocol request from the exact frame document that + * received this grant. The protocol handler must subsequently consume the + * ticket; calling the handler directly cannot resolve a renderer grant. + */ + authorizeProtocolRequest(token: string, webContentsId: number, documentId: string): boolean { + this.pruneExpired(); + const grant = this.grants.get(token); + if ( + !grant || + grant.owner.isDestroyed() || + grant.owner.id !== webContentsId || + grant.documentId !== documentId + ) { + return false; + } + try { + if (!grant.isAuthorized(grant.assetId)) { + this.deleteGrant(token); + return false; + } + } catch { + this.deleteGrant(token); + return false; + } + const current = this.protocolRequests.get(token); + this.protocolRequests.set(token, { + assetId: grant.assetId, + expiresAt: Math.min(grant.expiresAt, this.now() + 10_000), + remaining: Math.min(8, (current?.remaining ?? 0) + 1), + }); + return true; + } + + consumeProtocolRequest(token: string): string | undefined { + this.pruneExpired(); + const request = this.protocolRequests.get(token); + if (!request || request.remaining < 1) return undefined; + if (request.remaining === 1) this.protocolRequests.delete(token); + else this.protocolRequests.set(token, { ...request, remaining: request.remaining - 1 }); + return request.assetId; + } + + revoke(token: string, owner: RendererDocumentOwner): boolean { + const grant = this.grants.get(token); + if (!grant || grant.documentId !== owner.documentId || grant.owner.id !== owner.id) + return false; + return this.deleteGrant(token); + } + + revokeDocument(owner: RendererDocumentOwner): number { + let revoked = 0; + for (const [token, grant] of this.grants) { + if (grant.documentId !== owner.documentId || grant.owner.id !== owner.id) continue; + this.deleteGrant(token); + revoked += 1; + } + return revoked; + } + + size(): number { + this.pruneExpired(); + return this.grants.size; + } +} diff --git a/main/services/create-images/asset-image-validation-core.ts b/main/services/create-images/asset-image-validation-core.ts new file mode 100644 index 00000000..c311aea5 --- /dev/null +++ b/main/services/create-images/asset-image-validation-core.ts @@ -0,0 +1,343 @@ +import path from "node:path"; + +export type SafeAssetMediaType = "image/jpeg" | "image/png"; +export type SafeAssetExtension = "jpg" | "png"; + +export interface AssetImageLimits { + maxWidth: number; + maxHeight: number; + maxPixels: number; +} + +export interface ValidatedImageDescriptor { + mediaType: SafeAssetMediaType; + extension: SafeAssetExtension; + width: number; + height: number; + pixels: number; +} + +export class AssetImageValidationError extends Error { + constructor( + public readonly code: + | "unsupported_format" + | "mime_mismatch" + | "extension_mismatch" + | "truncated_image" + | "malformed_image" + | "image_dimensions_exceeded", + message: string, + ) { + super(message); + this.name = "AssetImageValidationError"; + } +} + +const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + return crc >>> 0; +}); +const PNG_BIT_DEPTHS: Readonly> = { + 0: [1, 2, 4, 8, 16], + 2: [8, 16], + 3: [1, 2, 4, 8], + 4: [8, 16], + 6: [8, 16], +}; + +function readU32(bytes: Uint8Array, offset: number): number { + return ( + bytes[offset]! * 0x1_000_000 + + bytes[offset + 1]! * 0x1_0000 + + bytes[offset + 2]! * 0x100 + + bytes[offset + 3]! + ); +} + +function crc32(bytes: Uint8Array, start: number, end: number): number { + let crc = 0xffff_ffff; + for (let index = start; index < end; index += 1) { + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ bytes[index]!) & 0xff]!; + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function assertDimensions(width: number, height: number, limits: AssetImageLimits): void { + const pixels = width * height; + if ( + !Number.isSafeInteger(width) || + !Number.isSafeInteger(height) || + width < 1 || + height < 1 || + width > limits.maxWidth || + height > limits.maxHeight || + !Number.isSafeInteger(pixels) || + pixels > limits.maxPixels + ) { + throw new AssetImageValidationError( + "image_dimensions_exceeded", + "The image dimensions exceed Aiden's configured safety limit.", + ); + } +} + +function isPng(bytes: Uint8Array): boolean { + return PNG_SIGNATURE.every((byte, index) => bytes[index] === byte); +} + +function validatePng(bytes: Uint8Array, limits: AssetImageLimits): ValidatedImageDescriptor { + if (bytes.byteLength < 33) { + throw new AssetImageValidationError("truncated_image", "The PNG file is truncated."); + } + let offset = PNG_SIGNATURE.byteLength; + let width = 0; + let height = 0; + let colorType = -1; + let sawHeader = false; + let sawPalette = false; + let sawImageData = false; + let sawEnd = false; + let chunkCount = 0; + while (offset < bytes.byteLength) { + if (offset + 12 > bytes.byteLength) { + throw new AssetImageValidationError("truncated_image", "The PNG chunk header is truncated."); + } + const length = readU32(bytes, offset); + const typeStart = offset + 4; + const dataStart = offset + 8; + const dataEnd = dataStart + length; + const chunkEnd = dataEnd + 4; + if (!Number.isSafeInteger(chunkEnd) || chunkEnd > bytes.byteLength) { + throw new AssetImageValidationError("truncated_image", "The PNG chunk body is truncated."); + } + const type = String.fromCharCode(...bytes.subarray(typeStart, typeStart + 4)); + if (!/^[A-Za-z]{4}$/u.test(type)) { + throw new AssetImageValidationError( + "malformed_image", + "The PNG contains an invalid chunk type.", + ); + } + if (crc32(bytes, typeStart, dataEnd) !== readU32(bytes, dataEnd)) { + throw new AssetImageValidationError("malformed_image", "The PNG contains a corrupt chunk."); + } + chunkCount += 1; + if (chunkCount > 10_000) { + throw new AssetImageValidationError("malformed_image", "The PNG contains too many chunks."); + } + if (!sawHeader && type !== "IHDR") { + throw new AssetImageValidationError( + "malformed_image", + "The PNG header is not the first chunk.", + ); + } + if (type === "IHDR") { + if (sawHeader || length !== 13) { + throw new AssetImageValidationError("malformed_image", "The PNG header is malformed."); + } + width = readU32(bytes, dataStart); + height = readU32(bytes, dataStart + 4); + const bitDepth = bytes[dataStart + 8]!; + colorType = bytes[dataStart + 9]!; + if ( + !PNG_BIT_DEPTHS[colorType]?.includes(bitDepth) || + bytes[dataStart + 10] !== 0 || + bytes[dataStart + 11] !== 0 || + (bytes[dataStart + 12] !== 0 && bytes[dataStart + 12] !== 1) + ) { + throw new AssetImageValidationError( + "malformed_image", + "The PNG header uses unsupported values.", + ); + } + assertDimensions(width, height, limits); + sawHeader = true; + } else if (type === "PLTE") { + if (sawImageData || length < 3 || length > 768 || length % 3 !== 0) { + throw new AssetImageValidationError("malformed_image", "The PNG palette is malformed."); + } + sawPalette = true; + } else if (type === "IDAT") { + if (colorType === 3 && !sawPalette) { + throw new AssetImageValidationError("malformed_image", "The indexed PNG has no palette."); + } + sawImageData = true; + } else if (type === "IEND") { + if (length !== 0 || !sawImageData || chunkEnd !== bytes.byteLength) { + throw new AssetImageValidationError("malformed_image", "The PNG end marker is malformed."); + } + sawEnd = true; + } else if (/^[A-Z]/u.test(type) || type === "acTL" || type === "fcTL" || type === "fdAT") { + throw new AssetImageValidationError( + "unsupported_format", + "Only static PNG images with known critical chunks are supported.", + ); + } + offset = chunkEnd; + if (sawEnd) break; + } + if (!sawHeader || !sawImageData || !sawEnd) { + throw new AssetImageValidationError("truncated_image", "The PNG file is incomplete."); + } + return { mediaType: "image/png", extension: "png", width, height, pixels: width * height }; +} + +const UNSUPPORTED_SOF = new Set([0xc1, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]); + +function validateJpeg(bytes: Uint8Array, limits: AssetImageLimits): ValidatedImageDescriptor { + if (bytes.byteLength < 8 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + throw new AssetImageValidationError("truncated_image", "The JPEG file is truncated."); + } + let offset = 2; + let width = 0; + let height = 0; + let sawFrame = false; + let sawScan = false; + let inEntropy = false; + while (offset < bytes.byteLength) { + if (!inEntropy) { + if (bytes[offset] !== 0xff) { + throw new AssetImageValidationError( + "malformed_image", + "The JPEG marker stream is malformed.", + ); + } + while (bytes[offset] === 0xff) offset += 1; + } else { + while (offset < bytes.byteLength && bytes[offset] !== 0xff) offset += 1; + if (offset >= bytes.byteLength) break; + while (bytes[offset] === 0xff) offset += 1; + if (bytes[offset] === 0x00) { + offset += 1; + continue; + } + if (bytes[offset]! >= 0xd0 && bytes[offset]! <= 0xd7) { + offset += 1; + continue; + } + inEntropy = false; + } + if (offset >= bytes.byteLength) break; + const marker = bytes[offset]!; + offset += 1; + if (marker === 0xd9) { + if (!sawFrame || !sawScan || offset !== bytes.byteLength) { + throw new AssetImageValidationError("malformed_image", "The JPEG end marker is malformed."); + } + return { + mediaType: "image/jpeg", + extension: "jpg", + width, + height, + pixels: width * height, + }; + } + if (marker === 0xd8 || marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) { + throw new AssetImageValidationError( + "malformed_image", + "The JPEG contains an invalid marker.", + ); + } + if (offset + 2 > bytes.byteLength) { + throw new AssetImageValidationError( + "truncated_image", + "The JPEG segment header is truncated.", + ); + } + const length = bytes[offset]! * 256 + bytes[offset + 1]!; + if (length < 2 || offset + length > bytes.byteLength) { + throw new AssetImageValidationError("truncated_image", "The JPEG segment is truncated."); + } + const dataStart = offset + 2; + if (marker === 0xc0 || marker === 0xc2) { + if (sawFrame || length < 11 || bytes[dataStart] !== 8) { + throw new AssetImageValidationError("malformed_image", "The JPEG frame is malformed."); + } + height = bytes[dataStart + 1]! * 256 + bytes[dataStart + 2]!; + width = bytes[dataStart + 3]! * 256 + bytes[dataStart + 4]!; + const components = bytes[dataStart + 5]!; + if (![1, 3, 4].includes(components) || length !== 8 + components * 3) { + throw new AssetImageValidationError( + "malformed_image", + "The JPEG frame components are malformed.", + ); + } + assertDimensions(width, height, limits); + sawFrame = true; + } else if (UNSUPPORTED_SOF.has(marker)) { + throw new AssetImageValidationError( + "unsupported_format", + "Only baseline and progressive 8-bit JPEG images are supported.", + ); + } + if (marker === 0xda) { + if (!sawFrame) { + throw new AssetImageValidationError("malformed_image", "The JPEG scan precedes its frame."); + } + sawScan = true; + inEntropy = true; + } + offset += length; + } + throw new AssetImageValidationError( + "truncated_image", + "The JPEG file has no complete end marker.", + ); +} + +function normalizedDeclaredMime(value: string | undefined): string | undefined { + return value?.split(";", 1)[0]?.trim().toLowerCase(); +} + +export function sanitizeAssetDisplayName(value: string | undefined): string | undefined { + if (!value) return undefined; + const base = [...path.basename(value.replace(/\\/gu, "/"))] + .filter((character) => character.codePointAt(0)! >= 32 && character.codePointAt(0) !== 127) + .join("") + .trim(); + if (!base) return undefined; + return base.slice(0, 255); +} + +export function validateImageBytes( + bytes: Uint8Array, + declaredMimeType: string | undefined, + displayName: string | undefined, + limits: AssetImageLimits, +): ValidatedImageDescriptor { + const descriptor = isPng(bytes) + ? validatePng(bytes, limits) + : bytes[0] === 0xff && bytes[1] === 0xd8 + ? validateJpeg(bytes, limits) + : (() => { + throw new AssetImageValidationError( + "unsupported_format", + "Only validated static PNG and JPEG images are supported.", + ); + })(); + const mime = normalizedDeclaredMime(declaredMimeType); + if (mime !== undefined && mime !== descriptor.mediaType) { + throw new AssetImageValidationError( + "mime_mismatch", + "The declared media type does not match the image contents.", + ); + } + const safeName = sanitizeAssetDisplayName(displayName); + const extension = safeName ? path.extname(safeName).slice(1).toLowerCase() : undefined; + if ( + extension && + !( + (descriptor.mediaType === "image/png" && extension === "png") || + (descriptor.mediaType === "image/jpeg" && (extension === "jpg" || extension === "jpeg")) + ) + ) { + throw new AssetImageValidationError( + "extension_mismatch", + "The filename extension does not match the image contents.", + ); + } + return descriptor; +} diff --git a/main/services/create-images/asset-import-normalization-core.test.ts b/main/services/create-images/asset-import-normalization-core.test.ts new file mode 100644 index 00000000..a024b518 --- /dev/null +++ b/main/services/create-images/asset-import-normalization-core.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createImagesCanonicalValidationName, + createImagesImportSourcePolicy, +} from "./asset-import-normalization-core.js"; + +function bytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +function gif(frames: number): Uint8Array { + const header = [...bytes("GIF89a"), 1, 0, 1, 0, 0, 0, 0]; + const image = [0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, 2, 2, 0x44, 0x01, 0]; + return Uint8Array.from([...header, ...Array.from({ length: frames }, () => image).flat(), 0x3b]); +} + +test("classifies canonical and sandbox-normalized static raster formats", () => { + assert.deepEqual( + createImagesImportSourcePolicy( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + "photo.unknown", + ), + { kind: "canonical", format: "png" }, + ); + assert.deepEqual(createImagesImportSourcePolicy(Uint8Array.from([0xff, 0xd8]), "photo.bin"), { + kind: "canonical", + format: "jpeg", + }); + assert.deepEqual( + createImagesImportSourcePolicy(bytes("RIFF\u0004\u0000\u0000\u0000WEBPVP8 "), "photo.webp"), + { kind: "normalize", format: "webp" }, + ); + assert.deepEqual(createImagesImportSourcePolicy(bytes("BMstatic"), "photo.bmp"), { + kind: "normalize", + format: "bmp", + }); + assert.deepEqual(createImagesImportSourcePolicy(bytes("unknown"), "photo.heic"), { + kind: "normalize", + format: "heic", + }); + assert.deepEqual(createImagesImportSourcePolicy(gif(1), "photo.gif"), { + kind: "normalize", + format: "gif", + }); +}); + +test("rejects vector and animation-bearing sources before sandbox conversion", () => { + assert.deepEqual(createImagesImportSourcePolicy(bytes(""), "image.txt"), { + kind: "reject", + reason: "vector", + }); + assert.deepEqual(createImagesImportSourcePolicy(gif(2), "image.bin"), { + kind: "reject", + reason: "animated", + }); + assert.deepEqual( + createImagesImportSourcePolicy( + bytes("RIFF\u0004\u0000\u0000\u0000WEBPANIM\u0000\u0000\u0000\u0000"), + "image.webp", + ), + { kind: "reject", reason: "animated" }, + ); + assert.deepEqual(createImagesImportSourcePolicy(bytes("anything"), "image.svgz"), { + kind: "reject", + reason: "vector", + }); +}); + +test("creates a bounded canonical validation name without exposing a path", () => { + assert.equal(createImagesCanonicalValidationName("/private/example.WEBP", "png"), "example.png"); + assert.equal(createImagesCanonicalValidationName(undefined, "jpg"), "image.jpg"); + assert.ok(createImagesCanonicalValidationName(`${"a".repeat(500)}.tiff`, "png").length <= 240); +}); diff --git a/main/services/create-images/asset-import-normalization-core.ts b/main/services/create-images/asset-import-normalization-core.ts new file mode 100644 index 00000000..54c6228f --- /dev/null +++ b/main/services/create-images/asset-import-normalization-core.ts @@ -0,0 +1,166 @@ +import path from "node:path"; + +export type CreateImagesImportSourcePolicy = + | { kind: "canonical"; format: "jpeg" | "png" } + | { kind: "normalize"; format: string } + | { kind: "reject"; reason: "animated" | "vector" }; + +function ascii(bytes: Uint8Array, start: number, end: number): string { + return String.fromCharCode(...bytes.subarray(start, Math.min(end, bytes.byteLength))); +} + +function littleEndianU32(bytes: Uint8Array, offset: number): number { + return ( + bytes[offset]! + + bytes[offset + 1]! * 0x100 + + bytes[offset + 2]! * 0x1_0000 + + bytes[offset + 3]! * 0x1_000_000 + ); +} + +function animatedWebp(bytes: Uint8Array): boolean { + let offset = 12; + let chunks = 0; + while (offset + 8 <= bytes.byteLength && chunks < 10_000) { + const type = ascii(bytes, offset, offset + 4); + const length = littleEndianU32(bytes, offset + 4); + if (!Number.isSafeInteger(length)) return false; + if (type === "ANIM" || type === "ANMF") return true; + const next = offset + 8 + length + (length % 2); + if (!Number.isSafeInteger(next) || next <= offset || next > bytes.byteLength) return false; + offset = next; + chunks += 1; + } + return false; +} + +function skipGifSubBlocks(bytes: Uint8Array, start: number): number | undefined { + let offset = start; + let blocks = 0; + while (offset < bytes.byteLength && blocks < 100_000) { + const length = bytes[offset]!; + offset += 1; + if (length === 0) return offset; + if (offset + length > bytes.byteLength) return undefined; + offset += length; + blocks += 1; + } + return undefined; +} + +/** Return true only when a structurally-walkable GIF contains multiple frames. */ +function animatedGif(bytes: Uint8Array): boolean { + if (bytes.byteLength < 13) return false; + let offset = 13; + const globalColorTable = (bytes[10]! & 0x80) !== 0; + if (globalColorTable) offset += 3 * 2 ** ((bytes[10]! & 0x07) + 1); + let frames = 0; + let blocks = 0; + while (offset < bytes.byteLength && blocks < 100_000) { + const marker = bytes[offset]!; + offset += 1; + if (marker === 0x3b) return false; + if (marker === 0x21) { + if (offset >= bytes.byteLength) return false; + offset += 1; + const next = skipGifSubBlocks(bytes, offset); + if (next === undefined) return false; + offset = next; + } else if (marker === 0x2c) { + if (offset + 9 > bytes.byteLength) return false; + const localColorTable = (bytes[offset + 8]! & 0x80) !== 0; + const localColorTableBytes = localColorTable ? 3 * 2 ** ((bytes[offset + 8]! & 0x07) + 1) : 0; + offset += 9 + localColorTableBytes; + if (offset >= bytes.byteLength) return false; + offset += 1; + const next = skipGifSubBlocks(bytes, offset); + if (next === undefined) return false; + offset = next; + frames += 1; + if (frames > 1) return true; + } else { + return false; + } + blocks += 1; + } + return false; +} + +function looksLikeSvg(bytes: Uint8Array): boolean { + const prefix = new TextDecoder("utf-8", { fatal: false, ignoreBOM: true }) + .decode(bytes.subarray(0, Math.min(bytes.byteLength, 16 * 1024))) + .toLowerCase(); + return /)/u.test(prefix); +} + +function extension(value: string | undefined): string { + return value ? path.extname(value).slice(1).toLowerCase() : ""; +} + +/** + * Classify only policy-sensitive formats. The disposable Chromium decoder is + * the authority for whether every other static raster can actually be decoded. + */ +export function createImagesImportSourcePolicy( + bytes: Uint8Array, + displayName?: string, +): CreateImagesImportSourcePolicy { + const fileExtension = extension(displayName); + if (fileExtension === "svg" || fileExtension === "svgz" || looksLikeSvg(bytes)) { + return { kind: "reject", reason: "vector" }; + } + if (ascii(bytes, 0, 6) === "GIF87a" || ascii(bytes, 0, 6) === "GIF89a") { + return animatedGif(bytes) + ? { kind: "reject", reason: "animated" } + : { kind: "normalize", format: "gif" }; + } + if (fileExtension === "gif") { + return { kind: "normalize", format: "gif" }; + } + if ( + bytes.byteLength >= 8 && + bytes[0] === 0x89 && + ascii(bytes, 1, 4) === "PNG" && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return { kind: "canonical", format: "png" }; + } + if (bytes[0] === 0xff && bytes[1] === 0xd8) { + return { kind: "canonical", format: "jpeg" }; + } + if (bytes.byteLength >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") { + return animatedWebp(bytes) + ? { kind: "reject", reason: "animated" } + : { kind: "normalize", format: "webp" }; + } + if (bytes.byteLength >= 12 && ascii(bytes, 4, 8) === "ftyp") { + const brand = ascii(bytes, 8, 12).toLowerCase(); + if (["avis", "hevc", "hevx", "msf1"].includes(brand)) { + return { kind: "reject", reason: "animated" }; + } + return { kind: "normalize", format: brand || "isobmff" }; + } + if (bytes[0] === 0x42 && bytes[1] === 0x4d) { + return { kind: "normalize", format: "bmp" }; + } + if (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0) { + return { kind: "normalize", format: "ico" }; + } + if (ascii(bytes, 0, 4) === "II*\0" || ascii(bytes, 0, 4) === "MM\0*") { + return { kind: "normalize", format: "tiff" }; + } + return { kind: "normalize", format: fileExtension || "unknown" }; +} + +export function createImagesCanonicalValidationName( + displayName: string | undefined, + extension: "jpg" | "png", +): string { + const base = path.basename((displayName || "image").replace(/\\/gu, "/")); + const parsed = path.parse(base); + const stem = (parsed.name || "image").slice(0, 239 - extension.length); + return `${stem}.${extension}`; +} diff --git a/main/services/create-images/asset-protocol-core.test.ts b/main/services/create-images/asset-protocol-core.test.ts new file mode 100644 index 00000000..b4bac758 --- /dev/null +++ b/main/services/create-images/asset-protocol-core.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + authorizeCreateImagesAssetRequest, + createImagesProtocolDocumentId, + parseCreateImagesAssetProtocolToken, +} from "./asset-protocol-core.js"; + +test("asset protocol accepts only the canonical opaque grant URL", () => { + const token = "A".repeat(43); + assert.equal(parseCreateImagesAssetProtocolToken(`aiden-asset://asset/${token}`), token); + for (const url of [ + `aiden-asset://other/${token}`, + `aiden-asset://asset/${token}?path=/tmp/private`, + `aiden-asset://asset/${token}/extra`, + "aiden-asset://asset/../../etc/passwd", + "file:///tmp/private", + ]) { + assert.equal(parseCreateImagesAssetProtocolToken(url), undefined, url); + } +}); + +test("asset protocol document identity accepts only a live main frame", () => { + const frame = { processId: 12, routingId: 34, frameToken: "frame", parent: null, detached: false }; + assert.equal(createImagesProtocolDocumentId(frame), "12:34:frame"); + assert.equal(createImagesProtocolDocumentId({ ...frame, parent: {} }), undefined); + assert.equal(createImagesProtocolDocumentId({ ...frame, detached: true }), undefined); +}); + +test("asset protocol authorization requires a GET image request from the exact main document", () => { + const frame = { + processId: 4, + routingId: 8, + frameToken: "frame-token", + parent: null, + detached: false, + }; + let observed: readonly unknown[] = []; + const allowed = authorizeCreateImagesAssetRequest( + { + url: `aiden-asset://asset/${"a".repeat(43)}`, + method: "GET", + resourceType: "image", + webContentsId: 12, + frame, + }, + (...values) => { + observed = values; + return true; + }, + ); + assert.equal(allowed, true); + assert.deepEqual(observed, ["a".repeat(43), 12, "4:8:frame-token"]); + assert.equal( + authorizeCreateImagesAssetRequest( + { + url: `aiden-asset://asset/${"a".repeat(43)}`, + method: "POST", + resourceType: "image", + webContentsId: 12, + frame, + }, + () => true, + ), + false, + ); +}); diff --git a/main/services/create-images/asset-protocol-core.ts b/main/services/create-images/asset-protocol-core.ts new file mode 100644 index 00000000..b5810efe --- /dev/null +++ b/main/services/create-images/asset-protocol-core.ts @@ -0,0 +1,72 @@ +const GRANT_TOKEN_PATTERN = /^[A-Za-z0-9_-]{32,128}$/u; + +export function parseCreateImagesAssetProtocolToken(value: string): string | undefined { + try { + const url = new URL(value); + if ( + url.protocol !== "aiden-asset:" || + url.hostname !== "asset" || + url.port || + url.username || + url.password || + url.search || + url.hash + ) { + return undefined; + } + const match = /^\/([A-Za-z0-9_-]{32,128})$/u.exec(url.pathname); + return match?.[1] && GRANT_TOKEN_PATTERN.test(match[1]) ? match[1] : undefined; + } catch { + return undefined; + } +} + +export function createImagesProtocolDocumentId(frame: { + processId: number; + routingId: number; + frameToken: string; + parent: unknown; + detached: boolean; +}): string | undefined { + if ( + frame.detached || + frame.parent !== null || + !Number.isInteger(frame.processId) || + !Number.isInteger(frame.routingId) || + typeof frame.frameToken !== "string" || + frame.frameToken.length === 0 + ) { + return undefined; + } + return `${frame.processId}:${frame.routingId}:${frame.frameToken}`; +} + +export function authorizeCreateImagesAssetRequest( + details: { + url: string; + method: string; + resourceType: string; + webContentsId?: number; + frame?: { + processId: number; + routingId: number; + frameToken: string; + parent: unknown; + detached: boolean; + } | null; + }, + authorize: (token: string, webContentsId: number, documentId: string) => boolean, +): boolean { + const token = parseCreateImagesAssetProtocolToken(details.url); + const documentId = details.frame + ? createImagesProtocolDocumentId(details.frame) + : undefined; + return ( + details.method === "GET" && + details.resourceType === "image" && + token !== undefined && + documentId !== undefined && + details.webContentsId !== undefined && + authorize(token, details.webContentsId, documentId) + ); +} diff --git a/main/services/create-images/asset-protocol.ts b/main/services/create-images/asset-protocol.ts new file mode 100644 index 00000000..2d715a28 --- /dev/null +++ b/main/services/create-images/asset-protocol.ts @@ -0,0 +1,140 @@ +import { protocol, session, webContents } from "electron"; +import { isPackagedRuntime } from "../../runtime-mode.js"; +import { AssetDeliveryGrantRegistry } from "./asset-delivery-core.js"; +import { + authorizeCreateImagesAssetRequest, + parseCreateImagesAssetProtocolToken, +} from "./asset-protocol-core.js"; +import { shouldBlockAidenRendererEgress } from "./renderer-egress-core.js"; + +export interface CreateImagesAssetProtocolSource { + response(assetId: string): Promise; +} + +let schemeRegistered = false; +let protocolInstalled = false; + +export interface CreateImagesRequestPolicyObservation { + kind: "asset" | "renderer-egress"; + url: string; + allowed: boolean; + method: string; + resourceType: string; + webContentsIdPresent: boolean; + framePresent: boolean; + frameIsMain: boolean; + frameDetached: boolean; +} + +const requestObservers = new Set<(value: CreateImagesRequestPolicyObservation) => void>(); + +export function observeCreateImagesRequestPolicy( + observer: (value: CreateImagesRequestPolicyObservation) => void, +): () => void { + requestObservers.add(observer); + return () => requestObservers.delete(observer); +} + +function publishRequestObservation( + details: Electron.OnBeforeRequestListenerDetails, + kind: CreateImagesRequestPolicyObservation["kind"], + allowed: boolean, +): void { + const observation: CreateImagesRequestPolicyObservation = { + kind, + url: details.url, + allowed, + method: details.method, + resourceType: details.resourceType, + webContentsIdPresent: details.webContentsId !== undefined, + framePresent: details.frame !== null, + frameIsMain: details.frame?.parent === null, + frameDetached: details.frame?.detached ?? true, + }; + for (const observer of requestObservers) { + try { + observer(observation); + } catch { + // Observability can never alter the production authorization decision. + } + } +} + +/** Must run before `app.whenReady()`. It registers no handler or service. */ +export function registerCreateImagesAssetScheme(): void { + if (schemeRegistered) return; + schemeRegistered = true; + protocol.registerSchemesAsPrivileged([ + { + scheme: "aiden-asset", + privileges: { + standard: true, + secure: true, + bypassCSP: false, + allowServiceWorkers: false, + supportFetchAPI: false, + corsEnabled: false, + stream: true, + }, + }, + ]); +} + +/** Install exact-document authorization and the streaming protocol handler. */ +export async function installCreateImagesAssetProtocol( + grants: AssetDeliveryGrantRegistry, + source: CreateImagesAssetProtocolSource, +): Promise { + if (protocolInstalled) return; + protocolInstalled = true; + const targetSession = session.defaultSession; + + targetSession.webRequest.onBeforeRequest( + { + urls: ["aiden-asset://*/*", "http://*/*", "https://*/*", "ws://*/*", "wss://*/*"], + }, + (details, callback) => { + if (details.url.startsWith("aiden-asset:")) { + const allowed = authorizeCreateImagesAssetRequest( + details, + (token, webContentsId, documentId) => + grants.authorizeProtocolRequest(token, webContentsId, documentId), + ); + publishRequestObservation(details, "asset", allowed); + callback({ cancel: !allowed }); + return; + } + const rendererUrl = + details.webContentsId === undefined + ? undefined + : webContents.fromId(details.webContentsId)?.getURL(); + const blocked = shouldBlockAidenRendererEgress({ + requestUrl: details.url, + rendererUrl, + packaged: isPackagedRuntime(), + }); + publishRequestObservation(details, "renderer-egress", !blocked); + callback({ cancel: blocked }); + }, + ); + + await targetSession.protocol.handle("aiden-asset", async (request) => { + const token = parseCreateImagesAssetProtocolToken(request.url); + const assetId = token ? grants.consumeProtocolRequest(token) : undefined; + if (!assetId) return new Response("Not found", { status: 404 }); + try { + const response = await source.response(assetId); + if (!response || !response.ok || !response.body) { + return new Response("Not found", { status: 404 }); + } + const headers = new Headers(response.headers); + headers.set("Cache-Control", "no-store, max-age=0"); + headers.set("Content-Disposition", "inline"); + headers.set("X-Content-Type-Options", "nosniff"); + headers.delete("Set-Cookie"); + return new Response(response.body, { status: 200, headers }); + } catch { + return new Response("Unavailable", { status: 503 }); + } + }); +} diff --git a/main/services/create-images/asset-store-core.test.ts b/main/services/create-images/asset-store-core.test.ts new file mode 100644 index 00000000..3dbfcedf --- /dev/null +++ b/main/services/create-images/asset-store-core.test.ts @@ -0,0 +1,884 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + AssetStoreError, + ContentAddressedAssetStore, + DEFAULT_ASSET_STORE_LIMITS, + type AssetMetadataDto, + type AssetDeepValidator, + type AssetReferenceAuthority, + type AssetReferenceSnapshot, + type AssetStoreLimits, + type AssetThumbnailGenerator, +} from "./asset-store-core.js"; +import { AssetDeliveryGrantRegistry } from "./asset-delivery-core.js"; +import { AssetImageValidationError, validateImageBytes } from "./asset-image-validation-core.js"; +import { ByteBoundedLru } from "./asset-thumbnail-cache-core.js"; +import type { RendererDocumentOwner } from "../renderer-document-owner.js"; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const checksumInput = new Uint8Array(typeBytes.byteLength + data.byteLength); + checksumInput.set(typeBytes); + checksumInput.set(data, typeBytes.byteLength); + const result = new Uint8Array(12 + data.byteLength); + result.set(u32(data.byteLength)); + result.set(checksumInput, 4); + result.set(u32(crc32(checksumInput)), result.byteLength - 4); + return result; +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +function makePng(width = 1, height = 1, variant = 0): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(width)); + header.set(u32(height), 4); + header[8] = 8; + header[9] = 6; + return concat( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, variant & 0xff, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +function makeJpeg(width = 1, height = 1): Uint8Array { + const frame = Uint8Array.from([ + 0xff, + 0xc0, + 0, + 11, + 8, + (height >>> 8) & 0xff, + height & 0xff, + (width >>> 8) & 0xff, + width & 0xff, + 1, + 1, + 0x11, + 0, + ]); + const scan = Uint8Array.from([0xff, 0xda, 0, 8, 1, 1, 0, 0, 63, 0, 1, 2, 3, 0xff, 0xd9]); + return concat(Uint8Array.from([0xff, 0xd8]), frame, scan); +} + +async function* chunks( + bytes: Uint8Array, + chunkSize = bytes.byteLength, +): AsyncGenerator { + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + yield bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)); + } +} + +class FakeReferenceAuthority implements AssetReferenceAuthority { + snapshot: AssetReferenceSnapshot = { + epoch: "epoch-0", + completeKinds: ["workflow", "run", "export"], + records: [], + }; + + async withSnapshot( + callback: (snapshot: AssetReferenceSnapshot) => Promise, + ): Promise { + return callback(structuredClone(this.snapshot)); + } +} + +const acceptingDecoder: AssetDeepValidator = { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, +}; + +function limits(overrides: Partial = {}): AssetStoreLimits { + return { + ...structuredClone(DEFAULT_ASSET_STORE_LIMITS), + ...overrides, + thumbnailSizes: overrides.thumbnailSizes ?? [...DEFAULT_ASSET_STORE_LIMITS.thumbnailSizes], + }; +} + +async function withRoot(run: (root: string) => Promise): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-assets-test-")); + try { + await run(root); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +function createStore( + root: string, + authority = new FakeReferenceAuthority(), + options: { + limits?: AssetStoreLimits; + now?: () => number; + deepValidator?: AssetDeepValidator; + thumbnailGenerator?: AssetThumbnailGenerator; + onAssetPublished?: (asset: AssetMetadataDto) => Promise | void; + } = {}, +): ContentAddressedAssetStore { + return new ContentAddressedAssetStore(root, authority, { + deepValidator: options.deepValidator ?? acceptingDecoder, + ...(options.limits ? { limits: options.limits } : {}), + ...(options.now ? { now: options.now } : {}), + ...(options.thumbnailGenerator ? { thumbnailGenerator: options.thumbnailGenerator } : {}), + ...(options.onAssetPublished ? { onAssetPublished: options.onAssetPublished } : {}), + }); +} + +test("validates static PNG/JPEG structure, declarations, truncation, and dimension bombs", () => { + const imageLimits = { maxWidth: 10_000, maxHeight: 10_000, maxPixels: 1_000_000 }; + assert.deepEqual(validateImageBytes(makePng(4, 5), "image/png", "safe.png", imageLimits), { + mediaType: "image/png", + extension: "png", + width: 4, + height: 5, + pixels: 20, + }); + assert.deepEqual(validateImageBytes(makeJpeg(7, 9), "image/jpeg", "safe.jpeg", imageLimits), { + mediaType: "image/jpeg", + extension: "jpg", + width: 7, + height: 9, + pixels: 63, + }); + const jpeg = makeJpeg(7, 9); + const exif = Uint8Array.from([0xff, 0xe1, 0, 8, 69, 120, 105, 102, 0, 0]); + assert.equal( + validateImageBytes( + concat(jpeg.subarray(0, 2), exif, jpeg.subarray(2)), + "image/jpeg", + "exif.jpg", + imageLimits, + ).width, + 7, + ); + assert.throws( + () => validateImageBytes(makePng().subarray(0, 40), "image/png", "x.png", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "truncated_image", + ); + assert.throws( + () => validateImageBytes(makeJpeg().subarray(0, -1), "image/jpeg", "x.jpg", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "truncated_image", + ); + assert.throws( + () => validateImageBytes(makePng(), "image/jpeg", "x.png", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "mime_mismatch", + ); + assert.throws( + () => validateImageBytes(makePng(), "image/png", "x.jpg", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "extension_mismatch", + ); + assert.throws( + () => + validateImageBytes( + new TextEncoder().encode(""), + "image/svg+xml", + "x.svg", + imageLimits, + ), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "unsupported_format", + ); + assert.throws( + () => validateImageBytes(makePng(2_000, 2_000), "image/png", "x.png", imageLimits), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "image_dimensions_exceeded", + ); +}); + +test("byte-bounded LRU evicts exactly and never retains an oversized value", () => { + const cache = new ByteBoundedLru<{ byteLength: number; value: string }>(10); + cache.set("a", { byteLength: 4, value: "a" }); + cache.set("b", { byteLength: 6, value: "b" }); + assert.equal(cache.byteLength, 10); + assert.equal(cache.get("a")?.value, "a"); + cache.set("c", { byteLength: 5, value: "c" }); + assert.equal(cache.get("b"), undefined); + assert.equal(cache.byteLength, 9); + cache.set("huge", { byteLength: 11, value: "huge" }); + assert.equal(cache.get("huge"), undefined); + assert.equal(cache.byteLength, 9); +}); + +test("streams into quarantine, publishes by digest, deduplicates, and never returns a path", async () => { + await withRoot(async (root) => { + let decoderCalls = 0; + const deepValidator: AssetDeepValidator = { + async validate({ descriptor, filePath }) { + decoderCalls += 1; + assert.equal(path.isAbsolute(filePath), true); + return { width: descriptor.width, height: descriptor.height }; + }, + }; + const store = createStore(root, new FakeReferenceAuthority(), { deepValidator }); + const bytes = makePng(11, 13); + const first = await store.ingest(chunks(bytes, 7), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "/private/user/portrait.png", + }); + assert.equal(first.asset.assetId, createHash("sha256").update(bytes).digest("hex")); + assert.equal(first.asset.displayName, "portrait.png"); + assert.equal(first.deduplicated, false); + assert.equal(JSON.stringify(first).includes(root), false); + const second = await store.ingest(chunks(bytes, 3), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "other.png", + }); + assert.equal(second.deduplicated, true); + assert.equal(second.asset.assetId, first.asset.assetId); + assert.equal((await store.status()).assetCount, 1); + assert.equal(decoderCalls, 2); + const published = path.join( + root, + "assets", + "sha256", + first.asset.assetId.slice(0, 2), + `${first.asset.assetId}.png`, + ); + assert.deepEqual(new Uint8Array(await fs.readFile(published)), bytes); + assert.deepEqual(await fs.readdir(path.join(root, "asset-quarantine")), []); + }); +}); + +test("notifies an optional observer only after CAS publication and outside its mutation fence", async () => { + await withRoot(async (root) => { + let store!: ContentAddressedAssetStore; + let observed: AssetMetadataDto | undefined; + let observedStatus: Awaited> | undefined; + store = createStore(root, new FakeReferenceAuthority(), { + onAssetPublished: async (asset) => { + observed = asset; + observedStatus = await store.status(); + }, + }); + const result = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "observed.png", + }); + assert.equal(observed?.assetId, result.asset.assetId); + assert.equal(observedStatus?.assetCount, 1); + }); +}); + +test("uses a main-owned canonical validation name while preserving the imported label", async () => { + await withRoot(async (root) => { + const store = createStore(root); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "reference.webp", + validationDisplayName: "reference.png", + declaredMimeType: "image/png", + }); + assert.equal(imported.asset.displayName, "reference.webp"); + assert.equal(imported.asset.mediaType, "image/png"); + }); +}); + +test("a stale second store never deletes a digest already published by another store", async () => { + await withRoot(async (root) => { + const firstStore = createStore(root); + const staleStore = createStore(root); + await Promise.all([firstStore.status(), staleStore.status()]); + const bytes = makePng(); + const first = await firstStore.ingest(chunks(bytes), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + await assert.rejects( + staleStore.ingest(chunks(bytes), { + origin: { kind: "import" }, + displayName: "asset.png", + }), + /changed outside the app/u, + ); + const published = path.join( + root, + "assets", + "sha256", + first.asset.assetId.slice(0, 2), + `${first.asset.assetId}.png`, + ); + assert.deepEqual(new Uint8Array(await fs.readFile(published)), bytes); + assert.ok(await createStore(root).get(first.asset.assetId)); + }); +}); + +test("refuses a symlinked digest directory instead of publishing outside the store", async () => { + await withRoot(async (root) => { + const store = createStore(root); + await store.status(); + const bytes = makePng(); + const assetId = createHash("sha256").update(bytes).digest("hex"); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-assets-outside-")); + try { + await fs.symlink(outside, path.join(root, "assets", "sha256", assetId.slice(0, 2)), "dir"); + await assert.rejects( + store.ingest(chunks(bytes), { + origin: { kind: "import" }, + displayName: "asset.png", + }), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_repair_required", + ); + assert.deepEqual(await fs.readdir(outside), []); + assert.deepEqual(await fs.readdir(path.join(root, "asset-quarantine")), []); + } finally { + await fs.rm(outside, { recursive: true, force: true }); + } + }); +}); + +test("deep decoder rejection leaves no published asset or quarantine temp", async () => { + await withRoot(async (root) => { + const store = createStore(root, new FakeReferenceAuthority(), { + deepValidator: { + async validate() { + throw new Error("decoder rejected compressed payload"); + }, + }, + }); + await assert.rejects( + store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "x.png", + }), + /decoder rejected/u, + ); + assert.equal((await store.status()).assetCount, 0); + assert.deepEqual(await fs.readdir(path.join(root, "asset-quarantine")), []); + }); +}); + +test("enforces per-ingest and aggregate quotas without charging a duplicate", async () => { + await withRoot(async (root) => { + const firstBytes = makePng(1, 1, 1); + const secondBytes = makePng(1, 1, 2); + const store = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ + maxImportBytes: firstBytes.byteLength, + maxProviderResponseBytes: firstBytes.byteLength, + totalAssetBytes: firstBytes.byteLength + 1, + warningAssetBytes: firstBytes.byteLength, + }), + }); + const first = await store.ingest(chunks(firstBytes), { + origin: { kind: "import" }, + displayName: "first.png", + }); + assert.equal(first.quotaWarning, true); + assert.equal( + ( + await store.ingest(chunks(firstBytes), { + origin: { kind: "import" }, + displayName: "first.png", + }) + ).deduplicated, + true, + ); + await assert.rejects( + store.ingest(chunks(secondBytes), { + origin: { kind: "import" }, + displayName: "second.png", + }), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_quota_exceeded", + ); + await assert.rejects( + store.ingest(chunks(concat(firstBytes, Uint8Array.from([1]))), { + origin: { kind: "import" }, + displayName: "large.png", + }), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_ingest_too_large", + ); + assert.equal((await store.status()).assetCount, 1); + assert.deepEqual(await fs.readdir(path.join(root, "asset-quarantine")), []); + }); +}); + +test("treats an otherwise valid over-budget asset index as unsafe", async () => { + await withRoot(async (root) => { + const firstBytes = makePng(1, 1, 1); + const secondBytes = makePng(1, 1, 2); + const store = createStore(root); + await store.ingest(chunks(firstBytes), { + origin: { kind: "import" }, + displayName: "first.png", + }); + await store.ingest(chunks(secondBytes), { + origin: { kind: "import" }, + displayName: "second.png", + }); + + const restarted = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ + totalAssetBytes: firstBytes.byteLength, + warningAssetBytes: firstBytes.byteLength, + }), + }); + assert.equal((await restarted.status()).healthy, false); + await assert.rejects( + restarted.list(), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_repair_required", + ); + }); +}); + +test("repair stops before deep-decoding beyond the aggregate asset quota", async () => { + await withRoot(async (root) => { + const firstBytes = makePng(1, 1, 1); + const secondBytes = makePng(1, 1, 2); + const store = createStore(root); + await store.ingest(chunks(firstBytes), { + origin: { kind: "import" }, + displayName: "first.png", + }); + await store.ingest(chunks(secondBytes), { + origin: { kind: "import" }, + displayName: "second.png", + }); + await fs.writeFile(path.join(root, "asset-index.json"), "{broken", "utf8"); + + let decodeCalls = 0; + const restarted = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ + totalAssetBytes: firstBytes.byteLength, + warningAssetBytes: firstBytes.byteLength, + }), + deepValidator: { + async validate({ descriptor }) { + decodeCalls += 1; + return { width: descriptor.width, height: descriptor.height }; + }, + }, + }); + await assert.rejects( + restarted.repair({ apply: false }), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_quota_exceeded", + ); + assert.equal(decodeCalls, 1); + }); +}); + +test("persists bounded reference accounting and rebuilds it from the authority", async () => { + await withRoot(async (root) => { + const authority = new FakeReferenceAuthority(); + const firstStore = createStore(root, authority); + const imported = await firstStore.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + await firstStore.replaceReferences({ kind: "workflow", id: "workflow-1" }, [ + imported.asset.assetId, + ]); + assert.equal((await firstStore.get(imported.asset.assetId))?.referenceCount, 1); + + const restarted = createStore(root, authority); + assert.equal((await restarted.get(imported.asset.assetId))?.referenceCount, 1); + authority.snapshot = { + epoch: "epoch-1", + completeKinds: ["workflow", "run", "export"], + records: [{ kind: "run", id: "run-1", assetIds: [imported.asset.assetId] }], + }; + assert.deepEqual(await restarted.rebuildReferenceAccounting(), { + missingAssetIds: [], + revision: 3, + }); + assert.equal((await restarted.get(imported.asset.assetId))?.referenceCount, 1); + }); +}); + +test("rejects reference snapshots beyond configured owner/link bounds", async () => { + await withRoot(async (root) => { + const authority = new FakeReferenceAuthority(); + authority.snapshot = { + epoch: "epoch-1", + completeKinds: ["workflow", "run", "export"], + records: [ + { kind: "workflow", id: "workflow-1", assetIds: [] }, + { kind: "run", id: "run-1", assetIds: [] }, + ], + }; + const store = createStore(root, authority, { + limits: limits({ maxReferenceRecords: 1, maxReferenceLinks: 1 }), + }); + await assert.rejects(store.rebuildReferenceAccounting(), /too many owners/u); + }); +}); + +test("preview leases are owner-bound, expiring, byte-bounded, and contain no path", async () => { + await withRoot(async (root) => { + let now = 10_000; + const store = createStore(root, new FakeReferenceAuthority(), { now: () => now }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + const lease = await store.acquirePreviewLease(imported.asset.assetId, "document-1", 1_000); + await assert.rejects( + store.readPreview(lease.token, "document-2"), + (error: unknown) => + error instanceof AssetStoreError && error.code === "preview_lease_invalid", + ); + await assert.rejects( + store.readPreview(lease.token, "document-1", imported.asset.byteLength - 1), + (error: unknown) => error instanceof AssetStoreError && error.code === "preview_too_large", + ); + const preview = await store.readPreview(lease.token, "document-1"); + assert.equal(JSON.stringify(preview.asset).includes(root), false); + assert.equal(preview.bytes.byteLength, imported.asset.byteLength); + now += 1_001; + await assert.rejects( + store.readPreview(lease.token, "document-1"), + (error: unknown) => + error instanceof AssetStoreError && error.code === "preview_lease_invalid", + ); + }); +}); + +test("thumbnail generation is validated, persisted, and cached under an exact byte budget", async () => { + await withRoot(async (root) => { + let generated = 0; + const thumbnailGenerator: AssetThumbnailGenerator = { + async generate({ maxDimension }) { + generated += 1; + return { + bytes: makePng(maxDimension, maxDimension), + width: maxDimension, + height: maxDimension, + mediaType: "image/png", + }; + }, + }; + const store = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ thumbnailCacheBytes: 65, thumbnailSizes: [128, 256] }), + thumbnailGenerator, + }); + const first = await store.ingest(chunks(makePng(1, 1, 1)), { + origin: { kind: "import" }, + displayName: "one.png", + }); + const second = await store.ingest(chunks(makePng(1, 1, 2)), { + origin: { kind: "import" }, + displayName: "two.png", + }); + assert.equal((await store.getThumbnail(first.asset.assetId, 128)).byteLength, 65); + assert.equal((await store.getThumbnail(first.asset.assetId, 128)).byteLength, 65); + assert.equal(generated, 1); + await store.getThumbnail(second.asset.assetId, 128); + assert.deepEqual(store.thumbnailCacheStatus(), { entries: 1, byteLength: 65, maxBytes: 65 }); + assert.equal(generated, 2); + + const restarted = createStore(root, new FakeReferenceAuthority(), { + limits: limits({ thumbnailCacheBytes: 65, thumbnailSizes: [128, 256] }), + thumbnailGenerator, + }); + await restarted.getThumbnail(first.asset.assetId, 128); + assert.equal(generated, 2, "restart should use the validated derived file"); + }); +}); + +test("rejects unsafe thumbnail generator output without publishing metadata", async () => { + await withRoot(async (root) => { + const store = createStore(root, new FakeReferenceAuthority(), { + thumbnailGenerator: { + async generate() { + return { + bytes: new TextEncoder().encode("not a PNG image"), + width: 10, + height: 10, + mediaType: "image/png", + }; + }, + }, + }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + await assert.rejects( + store.getThumbnail(imported.asset.assetId, 128), + (error: unknown) => + error instanceof AssetStoreError && error.code === "thumbnail_unavailable", + ); + assert.deepEqual((await store.get(imported.asset.assetId))?.thumbnailSizes, []); + }); +}); + +test("requires generated thumbnails to pass the injected deep decoder", async () => { + await withRoot(async (root) => { + const store = createStore(root, new FakeReferenceAuthority(), { + deepValidator: { + async validate({ descriptor, filePath }) { + if (filePath.includes(`${path.sep}thumbnails${path.sep}`)) { + throw new Error("decoder rejected thumbnail"); + } + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate({ maxDimension }) { + return { + bytes: makePng(maxDimension, maxDimension), + width: maxDimension, + height: maxDimension, + mediaType: "image/png", + }; + }, + }, + }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + await assert.rejects( + store.getThumbnail(imported.asset.assetId, 128), + (error: unknown) => + error instanceof AssetStoreError && error.code === "thumbnail_unavailable", + ); + assert.deepEqual((await store.get(imported.asset.assetId))?.thumbnailSizes, []); + }); +}); + +test("GC plans are dry runs, stale on reference races, and recheck preview leases", async () => { + await withRoot(async (root) => { + let now = 1_000; + const authority = new FakeReferenceAuthority(); + const store = createStore(root, authority, { now: () => now }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + now = 3_000; + const leasePlan = await store.planGarbageCollection(1_000); + assert.deepEqual(leasePlan.candidateAssetIds, [imported.asset.assetId]); + assert.ok(await store.get(imported.asset.assetId), "dry run does not delete"); + const lease = await store.acquirePreviewLease(imported.asset.assetId, "document-1", 10_000); + const leaseResult = await store.applyGarbageCollection(leasePlan.planId); + assert.deepEqual(leaseResult.skipped, [ + { assetId: imported.asset.assetId, reason: "lease_active" }, + ]); + assert.ok(await store.get(imported.asset.assetId)); + await store.releasePreviewLease(lease.token, "document-1"); + + const racedPlan = await store.planGarbageCollection(1_000); + authority.snapshot = { + epoch: "epoch-1", + completeKinds: ["workflow", "run", "export"], + records: [{ kind: "workflow", id: "workflow-1", assetIds: [imported.asset.assetId] }], + }; + assert.equal((await store.applyGarbageCollection(racedPlan.planId)).stale, true); + assert.ok(await store.get(imported.asset.assetId)); + + authority.snapshot = { + epoch: "epoch-2", + completeKinds: ["workflow", "run", "export"], + records: [], + }; + const finalPlan = await store.planGarbageCollection(1_000); + const applied = await store.applyGarbageCollection(finalPlan.planId); + assert.deepEqual(applied.deletedAssetIds, [imported.asset.assetId]); + assert.equal(await store.get(imported.asset.assetId), undefined); + }); +}); + +test("an opaque protocol grant keeps an unreferenced imported asset out of GC until revoke", async () => { + await withRoot(async (root) => { + let now = 1_000; + const authority = new FakeReferenceAuthority(); + const store = createStore(root, authority, { now: () => now }); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + const owner: RendererDocumentOwner = { + id: 42, + documentId: "document-42", + isDestroyed: () => false, + send: () => undefined, + onInvalidated: () => () => undefined, + }; + const leaseOwnerId = "document-42"; + const lease = await store.acquirePreviewLease(imported.asset.assetId, leaseOwnerId, 10_000); + let releasePromise: Promise | undefined; + const grants = new AssetDeliveryGrantRegistry(() => now, 10_000); + const grant = grants.mint(owner, imported.asset.assetId, () => true, { + expiresAt: lease.expiresAt, + release: () => { + releasePromise = store.releasePreviewLease(lease.token, leaseOwnerId); + }, + }); + + now = 3_000; + assert.deepEqual((await store.planGarbageCollection(1_000)).candidateAssetIds, []); + assert.equal(grants.revoke(grant.token, owner), true); + await releasePromise; + assert.deepEqual((await store.planGarbageCollection(1_000)).candidateAssetIds, [ + imported.asset.assetId, + ]); + }); +}); + +test("repair dry-run rebuilds a corrupt index, quarantines hostile entries, and survives restart", async () => { + await withRoot(async (root) => { + const authority = new FakeReferenceAuthority(); + const store = createStore(root, authority); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + const hostileDirectory = path.join(root, "assets", "sha256", "zz"); + await fs.mkdir(hostileDirectory, { recursive: true }); + await fs.writeFile(path.join(hostileDirectory, "evil.svg"), ""); + await fs.writeFile(path.join(root, "asset-index.json"), "{broken", "utf8"); + + const restarted = createStore(root, authority); + assert.equal((await restarted.status()).healthy, false); + await assert.rejects( + restarted.list(), + (error: unknown) => + error instanceof AssetStoreError && error.code === "asset_store_repair_required", + ); + const dryRun = await restarted.repair({ apply: false }); + assert.equal(dryRun.applied, false); + assert.deepEqual(dryRun.addedAssetIds, [imported.asset.assetId]); + assert.equal( + dryRun.invalidEntries.some((entry) => entry.entryId === "zz"), + true, + ); + assert.equal((await restarted.status()).healthy, false); + + const applied = await restarted.repair({ apply: true }); + assert.equal(applied.quarantinedEntryIds.includes("zz"), true); + assert.equal((await restarted.status()).healthy, true); + assert.ok(await restarted.get(imported.asset.assetId)); + const rootNames = await fs.readdir(root); + assert.equal( + rootNames.some((name) => name.startsWith("asset-index.json.invalid-")), + true, + ); + + const secondRestart = createStore(root, authority); + assert.ok(await secondRestart.get(imported.asset.assetId)); + }); +}); + +test("repair reports an indexed asset whose binary disappeared", async () => { + await withRoot(async (root) => { + const authority = new FakeReferenceAuthority(); + const store = createStore(root, authority); + const imported = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "asset.png", + }); + authority.snapshot = { + epoch: "missing-source", + completeKinds: ["workflow", "run", "export"], + records: [{ kind: "workflow", id: "workflow-1", assetIds: [imported.asset.assetId] }], + }; + await fs.rm( + path.join( + root, + "assets", + "sha256", + imported.asset.assetId.slice(0, 2), + `${imported.asset.assetId}.png`, + ), + ); + assert.deepEqual((await store.rebuildReferenceAccounting()).missingAssetIds, [ + imported.asset.assetId, + ]); + assert.equal(await store.getAvailable(imported.asset.assetId), undefined); + await assert.rejects( + () => store.acquirePreviewLease(imported.asset.assetId, "document-1"), + (error: unknown) => error instanceof AssetStoreError && error.code === "asset_source_missing", + ); + const report = await store.repair({ apply: false }); + assert.deepEqual(report.removedAssetIds, [imported.asset.assetId]); + + const healed = await store.ingest(chunks(makePng()), { + origin: { kind: "import" }, + displayName: "restored-original.png", + }); + assert.equal(healed.deduplicated, true); + assert.equal( + (await store.getAvailable(imported.asset.assetId))?.assetId, + imported.asset.assetId, + ); + assert.deepEqual((await store.rebuildReferenceAccounting()).missingAssetIds, []); + }); +}); + +test("exports a verified asset through a main-owned absolute destination", async () => { + await withRoot(async (root) => { + const store = createStore(root); + const bytes = makePng(3, 2, 7); + const imported = await store.ingest(chunks(bytes), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "reference.png", + }); + const exportDirectory = path.join(root, "native-save-dialog-destination"); + await fs.mkdir(exportDirectory, { mode: 0o700 }); + const destination = path.join(exportDirectory, "saved-reference.png"); + + const exported = await store.exportAssetToFile(imported.asset.assetId, destination); + + assert.deepEqual(exported, imported.asset); + assert.deepEqual(await fs.readFile(destination), Buffer.from(bytes)); + assert.equal((await fs.stat(destination)).mode & 0o777, 0o600); + assert.equal(Object.prototype.hasOwnProperty.call(exported, "filePath"), false); + await assert.rejects( + store.exportAssetToFile(imported.asset.assetId, "relative-output.png"), + (error: unknown) => + error instanceof AssetStoreError && error.code === "invalid_asset_request", + ); + }); +}); diff --git a/main/services/create-images/asset-store-core.ts b/main/services/create-images/asset-store-core.ts new file mode 100644 index 00000000..8d64752b --- /dev/null +++ b/main/services/create-images/asset-store-core.ts @@ -0,0 +1,2032 @@ +import { constants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { DataStore, DataStoreUnsafeWriteError } from "../data-store.js"; +import { CREATE_IMAGES_MAX_TOTAL_ASSET_BYTES } from "../../../renderer/shared/create-images/schema.js"; +import { + AssetImageValidationError, + type AssetImageLimits, + type SafeAssetExtension, + type SafeAssetMediaType, + type ValidatedImageDescriptor, + sanitizeAssetDisplayName, + validateImageBytes, +} from "./asset-image-validation-core.js"; +import { ByteBoundedLru } from "./asset-thumbnail-cache-core.js"; + +const ASSET_ID = /^[a-f0-9]{64}$/u; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const PREVIEW_TOKEN = /^[A-Za-z0-9_-]{43}$/u; +const REQUIRED_REFERENCE_KINDS = ["export", "run", "workflow"] as const; +const THUMBNAIL_MEDIA_TYPE = "image/png" as const; + +export type AssetReferenceKind = (typeof REQUIRED_REFERENCE_KINDS)[number]; + +export type AssetOrigin = + | { kind: "import" } + | { kind: "annotation"; sourceAssetId: string } + | { kind: "provider"; providerId: string; modelId: string; runId: string } + | { kind: "repair" }; + +export interface AssetReferenceOwner { + kind: AssetReferenceKind; + id: string; +} + +export interface AssetReferenceRecord extends AssetReferenceOwner { + assetIds: readonly string[]; +} + +export interface AssetReferenceSnapshot { + /** Monotonic authority epoch owned by the workflow/run/export stores. */ + epoch: string; + completeKinds: readonly AssetReferenceKind[]; + records: readonly AssetReferenceRecord[]; +} + +/** + * The implementation must hold its mutation/read lock for the full callback. + * GC relies on this fence remaining held through the final asset unlink. + */ +export interface AssetReferenceAuthority { + withSnapshot( + callback: (snapshot: AssetReferenceSnapshot) => Promise, + ): Promise; +} + +export interface AssetDeepValidator { + validate(input: { + /** Main-process-only quarantine path; it must never cross IPC. */ + filePath: string; + descriptor: ValidatedImageDescriptor; + byteLength: number; + }): Promise<{ width: number; height: number }>; +} + +export interface AssetThumbnailGenerator { + generate(input: { + /** Main-process-only immutable asset path; it must never cross IPC. */ + sourcePath: string; + source: ValidatedImageDescriptor; + maxDimension: number; + maxOutputBytes: number; + }): Promise<{ bytes: Uint8Array; width: number; height: number; mediaType: "image/png" }>; +} + +export interface AssetStoreLimits extends AssetImageLimits { + maxImportBytes: number; + maxProviderResponseBytes: number; + totalAssetBytes: number; + warningAssetBytes: number; + maxAssets: number; + maxReferenceRecords: number; + maxReferenceLinks: number; + maxIndexBytes: number; + maxRepairEntries: number; + maxPreviewReadBytes: number; + maxPreviewLeases: number; + maxThumbnailBytes: number; + thumbnailCacheBytes: number; + thumbnailSizes: readonly number[]; +} + +export const DEFAULT_ASSET_STORE_LIMITS: Readonly = Object.freeze({ + maxImportBytes: 64 * 1024 * 1024, + maxProviderResponseBytes: 64 * 1024 * 1024, + maxWidth: 32_768, + maxHeight: 32_768, + // A decode can still approach 64 MiB as RGBA. Codec work is isolated in a + // disposable sandboxed renderer, and this ceiling keeps one decoder bounded on + // supported hardware even for highly compressed images. + maxPixels: 16_000_000, + totalAssetBytes: CREATE_IMAGES_MAX_TOTAL_ASSET_BYTES, + warningAssetBytes: 8 * 1024 * 1024 * 1024, + maxAssets: 100_000, + maxReferenceRecords: 100_000, + maxReferenceLinks: 1_000_000, + maxIndexBytes: 64 * 1024 * 1024, + maxRepairEntries: 200_000, + maxPreviewReadBytes: 64 * 1024 * 1024, + maxPreviewLeases: 4_096, + maxThumbnailBytes: 4 * 1024 * 1024, + thumbnailCacheBytes: 64 * 1024 * 1024, + thumbnailSizes: [128, 256, 512], +}); + +export interface AssetMetadataDto { + assetId: string; + mediaType: SafeAssetMediaType; + byteLength: number; + width: number; + height: number; + createdAt: string; + displayName?: string; + origin: AssetOrigin; + generationMetadata?: Readonly>; + referenceCount: number; + thumbnailSizes: number[]; +} + +export interface AssetIngestRequest { + origin: Exclude; + declaredMimeType?: string; + displayName?: string; + /** + * Main-owned canonical filename used only for content/extension validation. + * Normalized imports keep their original display name while stored bytes use + * Aiden's canonical PNG extension. + */ + validationDisplayName?: string; + generationMetadata?: Readonly>; +} + +export interface AssetIngestResult { + asset: AssetMetadataDto; + deduplicated: boolean; + quotaWarning: boolean; + totalAssetBytes: number; +} + +export interface ValidatedQuarantinedAsset { + sha256: string; + mediaType: SafeAssetMediaType; + byteLength: number; + width: number; + height: number; +} + +export interface AssetPreviewLeaseDto { + token: string; + assetId: string; + expiresAt: number; +} + +export interface AssetPreviewDto { + asset: AssetMetadataDto; + bytes: Uint8Array; +} + +export interface AssetThumbnailDto { + assetId: string; + mediaType: typeof THUMBNAIL_MEDIA_TYPE; + width: number; + height: number; + byteLength: number; + bytes: Uint8Array; +} + +export interface AssetRepairReport { + applied: boolean; + indexWasUnhealthy: boolean; + addedAssetIds: string[]; + removedAssetIds: string[]; + correctedAssetIds: string[]; + quarantinedEntryIds: string[]; + invalidEntries: Array<{ entryId: string; reason: string }>; + missingReferenceAssetIds: string[]; +} + +export interface AssetGarbageCollectionPlanDto { + planId: string; + createdAt: number; + expiresAt: number; + indexRevision: number; + referenceEpoch: string; + candidateAssetIds: string[]; + reclaimableBytes: number; +} + +export interface AssetGarbageCollectionResult { + applied: boolean; + stale: boolean; + deletedAssetIds: string[]; + reclaimedBytes: number; + skipped: Array<{ + assetId: string; + reason: "lease_active" | "not_found" | "referenced" | "too_new"; + }>; +} + +export class AssetStoreError extends Error { + constructor( + public readonly code: + | "asset_not_found" + | "asset_source_missing" + | "asset_store_repair_required" + | "asset_store_quota_exceeded" + | "asset_ingest_too_large" + | "asset_index_limit_exceeded" + | "invalid_asset_request" + | "preview_lease_invalid" + | "preview_too_large" + | "thumbnail_unavailable", + message: string, + ) { + super(message); + this.name = "AssetStoreError"; + } +} + +interface StoredThumbnail { + mediaType: typeof THUMBNAIL_MEDIA_TYPE; + byteLength: number; + width: number; + height: number; + updatedAt: string; +} + +interface StoredAsset { + assetId: string; + extension: SafeAssetExtension; + mediaType: SafeAssetMediaType; + byteLength: number; + width: number; + height: number; + createdAt: string; + displayName?: string; + origin: AssetOrigin; + generationMetadata?: Record; + referenceOwners: string[]; + unreferencedAt?: string; + thumbnails: Record; +} + +interface AssetIndexV1 { + schemaVersion: 1; + revision: number; + assets: Record; +} + +interface PreviewLease { + token: string; + assetId: string; + ownerId: string; + expiresAt: number; +} + +interface InternalGcPlan extends AssetGarbageCollectionPlanDto { + graceMs: number; + referenceFingerprint: string; +} + +interface ThumbnailCacheEntry { + mediaType: typeof THUMBNAIL_MEDIA_TYPE; + width: number; + height: number; + byteLength: number; + bytes: Uint8Array; +} + +interface ScannedAsset { + entryId: string; + filePath: string; + descriptor: ValidatedImageDescriptor; + byteLength: number; + createdAt: string; +} + +interface InvalidScannedEntry { + entryId: string; + filePath: string; + reason: string; +} + +const EMPTY_INDEX: AssetIndexV1 = { schemaVersion: 1, revision: 0, assets: {} }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStoredAsset( + value: unknown, + key: string, + limits: AssetStoreLimits, +): value is StoredAsset { + if (!isRecord(value)) return false; + if (value.assetId !== key || !ASSET_ID.test(key)) return false; + if (value.extension !== "jpg" && value.extension !== "png") return false; + if (value.mediaType !== "image/jpeg" && value.mediaType !== "image/png") return false; + if ( + (value.extension === "png" && value.mediaType !== "image/png") || + (value.extension === "jpg" && value.mediaType !== "image/jpeg") + ) { + return false; + } + const createdAt = typeof value.createdAt === "string" ? Date.parse(value.createdAt) : Number.NaN; + const unreferencedAt = + value.unreferencedAt === undefined + ? undefined + : typeof value.unreferencedAt === "string" + ? Date.parse(value.unreferencedAt) + : Number.NaN; + const referenceOwners = value.referenceOwners; + if ( + !Number.isSafeInteger(value.byteLength) || + (value.byteLength as number) < 1 || + (value.byteLength as number) > + Math.max(limits.maxImportBytes, limits.maxProviderResponseBytes) || + !Number.isSafeInteger(value.width) || + (value.width as number) < 1 || + (value.width as number) > limits.maxWidth || + !Number.isSafeInteger(value.height) || + (value.height as number) < 1 || + (value.height as number) > limits.maxHeight || + (value.width as number) * (value.height as number) > limits.maxPixels || + !Number.isFinite(createdAt) || + (unreferencedAt !== undefined && !Number.isFinite(unreferencedAt)) || + !Array.isArray(referenceOwners) || + referenceOwners.length > limits.maxReferenceRecords || + new Set(referenceOwners).size !== referenceOwners.length || + !referenceOwners.every( + (owner) => + typeof owner === "string" && + /^(?:workflow|run|export):[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(owner), + ) || + !isRecord(value.thumbnails) + ) { + return false; + } + if ( + value.displayName !== undefined && + (typeof value.displayName !== "string" || + sanitizeAssetDisplayName(value.displayName) !== value.displayName) + ) { + return false; + } + try { + validateOrigin(value.origin as AssetOrigin); + validateGenerationMetadata( + value.generationMetadata as + | Readonly> + | undefined, + ); + } catch { + return false; + } + for (const [size, thumbnail] of Object.entries(value.thumbnails)) { + if (!limits.thumbnailSizes.includes(Number(size)) || !isRecord(thumbnail)) return false; + if ( + thumbnail.mediaType !== THUMBNAIL_MEDIA_TYPE || + !Number.isSafeInteger(thumbnail.byteLength) || + (thumbnail.byteLength as number) < 33 || + (thumbnail.byteLength as number) > limits.maxThumbnailBytes || + !Number.isSafeInteger(thumbnail.width) || + (thumbnail.width as number) < 1 || + (thumbnail.width as number) > Number(size) || + !Number.isSafeInteger(thumbnail.height) || + (thumbnail.height as number) < 1 || + (thumbnail.height as number) > Number(size) || + typeof thumbnail.updatedAt !== "string" || + !Number.isFinite(Date.parse(thumbnail.updatedAt)) + ) { + return false; + } + } + return true; +} + +function isAssetIndex(value: unknown, limits: AssetStoreLimits): value is AssetIndexV1 { + if (!isRecord(value) || value.schemaVersion !== 1 || !Number.isSafeInteger(value.revision)) { + return false; + } + if (!isRecord(value.assets)) return false; + const entries = Object.entries(value.assets); + if (entries.length > limits.maxAssets) return false; + let aggregateBytes = 0; + for (const [key, asset] of entries) { + if (!isStoredAsset(asset, key, limits)) return false; + if (asset.byteLength > limits.totalAssetBytes - aggregateBytes) return false; + aggregateBytes += asset.byteLength; + } + return true; +} + +function cloneEmptyIndex(): AssetIndexV1 { + return structuredClone(EMPTY_INDEX); +} + +function assertSafeId(value: string, label: string): void { + if (!SAFE_ID.test(value)) { + throw new AssetStoreError( + "invalid_asset_request", + `${label} must be a short opaque identifier.`, + ); + } +} + +function assertAssetId(assetId: string): void { + if (!ASSET_ID.test(assetId)) { + throw new AssetStoreError("invalid_asset_request", "Asset IDs must be SHA-256 identifiers."); + } +} + +function validateOrigin(origin: AssetOrigin): AssetOrigin { + if (origin.kind === "import" || origin.kind === "repair") return { kind: origin.kind }; + if (origin.kind === "annotation") { + assertAssetId(origin.sourceAssetId); + return { kind: "annotation", sourceAssetId: origin.sourceAssetId }; + } + if (origin.kind === "provider") { + assertSafeId(origin.providerId, "Provider ID"); + assertSafeId(origin.modelId, "Model ID"); + assertSafeId(origin.runId, "Run ID"); + return { + kind: "provider", + providerId: origin.providerId, + modelId: origin.modelId, + runId: origin.runId, + }; + } + throw new AssetStoreError("invalid_asset_request", "The asset origin is unsupported."); +} + +function validateGenerationMetadata( + value: Readonly> | undefined, +): Record | undefined { + if (value === undefined) return undefined; + const entries = Object.entries(value); + if (entries.length > 32) { + throw new AssetStoreError("invalid_asset_request", "Generation metadata has too many fields."); + } + const result: Record = {}; + for (const [key, item] of entries) { + if (!/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(key)) { + throw new AssetStoreError( + "invalid_asset_request", + "Generation metadata contains an invalid key.", + ); + } + if (typeof item === "string" && item.length > 1_024) { + throw new AssetStoreError( + "invalid_asset_request", + "Generation metadata contains a long string.", + ); + } + if (typeof item === "number" && !Number.isFinite(item)) { + throw new AssetStoreError( + "invalid_asset_request", + "Generation metadata contains a non-finite number.", + ); + } + if (item !== null && !["string", "number", "boolean"].includes(typeof item)) { + throw new AssetStoreError( + "invalid_asset_request", + "Generation metadata contains an invalid value.", + ); + } + result[key] = item; + } + return result; +} + +function validateLimits(limits: AssetStoreLimits): void { + const integerKeys: Array = [ + "maxImportBytes", + "maxProviderResponseBytes", + "maxWidth", + "maxHeight", + "maxPixels", + "totalAssetBytes", + "warningAssetBytes", + "maxAssets", + "maxReferenceRecords", + "maxReferenceLinks", + "maxIndexBytes", + "maxRepairEntries", + "maxPreviewReadBytes", + "maxPreviewLeases", + "maxThumbnailBytes", + "thumbnailCacheBytes", + ]; + if ( + integerKeys.some((key) => !Number.isSafeInteger(limits[key]) || (limits[key] as number) < 1) + ) { + throw new Error("Asset store limits must be positive safe integers."); + } + if (limits.warningAssetBytes > limits.totalAssetBytes) { + throw new Error("The asset warning threshold cannot exceed the total quota."); + } + if ( + limits.thumbnailSizes.length < 1 || + limits.thumbnailSizes.length > 16 || + limits.thumbnailSizes.some( + (size) => !Number.isSafeInteger(size) || size < 16 || size > 4_096, + ) || + new Set(limits.thumbnailSizes).size !== limits.thumbnailSizes.length + ) { + throw new Error("Thumbnail sizes must be a bounded list of unique dimensions."); + } +} + +function ownerKey(owner: AssetReferenceOwner): string { + if (!REQUIRED_REFERENCE_KINDS.includes(owner.kind)) { + throw new AssetStoreError("invalid_asset_request", "The asset reference kind is unsupported."); + } + assertSafeId(owner.id, "Reference owner ID"); + return `${owner.kind}:${owner.id}`; +} + +function descriptorFor(asset: StoredAsset): ValidatedImageDescriptor { + return { + mediaType: asset.mediaType, + extension: asset.extension, + width: asset.width, + height: asset.height, + pixels: asset.width * asset.height, + }; +} + +function metadataDto(asset: StoredAsset): AssetMetadataDto { + return { + assetId: asset.assetId, + mediaType: asset.mediaType, + byteLength: asset.byteLength, + width: asset.width, + height: asset.height, + createdAt: asset.createdAt, + ...(asset.displayName ? { displayName: asset.displayName } : {}), + origin: structuredClone(asset.origin), + ...(asset.generationMetadata + ? { generationMetadata: structuredClone(asset.generationMetadata) } + : {}), + referenceCount: asset.referenceOwners.length, + thumbnailSizes: Object.keys(asset.thumbnails) + .map(Number) + .sort((left, right) => left - right), + }; +} + +function thumbnailCacheEntry( + metadata: Pick, + bytes: Uint8Array, +): ThumbnailCacheEntry { + return { + mediaType: metadata.mediaType, + byteLength: metadata.byteLength, + width: metadata.width, + height: metadata.height, + bytes: bytes.slice(), + }; +} + +function totalBytes(index: AssetIndexV1): number { + return Object.values(index.assets).reduce((sum, asset) => sum + asset.byteLength, 0); +} + +function validateReferenceSnapshot( + snapshot: AssetReferenceSnapshot, + limits: AssetStoreLimits, +): Map> { + if (!SAFE_ID.test(snapshot.epoch)) { + throw new AssetStoreError("invalid_asset_request", "The reference snapshot epoch is invalid."); + } + const kinds = [...new Set(snapshot.completeKinds)].sort(); + if ( + kinds.length !== REQUIRED_REFERENCE_KINDS.length || + !REQUIRED_REFERENCE_KINDS.every((kind, index) => kind === kinds[index]) + ) { + throw new AssetStoreError( + "invalid_asset_request", + "Asset reference snapshots must cover workflows, runs, and exports.", + ); + } + const byAsset = new Map>(); + const owners = new Set(); + let referenceLinks = 0; + if (snapshot.records.length > limits.maxReferenceRecords) { + throw new AssetStoreError( + "invalid_asset_request", + "The reference snapshot has too many owners.", + ); + } + for (const record of snapshot.records) { + const key = ownerKey(record); + if (owners.has(key)) { + throw new AssetStoreError( + "invalid_asset_request", + "The reference snapshot repeats an owner.", + ); + } + owners.add(key); + referenceLinks += record.assetIds.length; + if ( + referenceLinks > limits.maxReferenceLinks || + record.assetIds.length > limits.maxAssets || + new Set(record.assetIds).size !== record.assetIds.length + ) { + throw new AssetStoreError( + "invalid_asset_request", + "The reference snapshot contains invalid asset IDs.", + ); + } + for (const assetId of record.assetIds) { + assertAssetId(assetId); + const assetOwners = byAsset.get(assetId) ?? new Set(); + assetOwners.add(key); + byAsset.set(assetId, assetOwners); + } + } + return byAsset; +} + +function referenceFingerprint(snapshot: AssetReferenceSnapshot): string { + const canonical = snapshot.records + .map((record) => [ownerKey(record), [...record.assetIds].sort()] as const) + .sort(([left], [right]) => left.localeCompare(right)); + return createHash("sha256") + .update(JSON.stringify([snapshot.epoch, [...snapshot.completeKinds].sort(), canonical])) + .digest("hex"); +} + +async function readBoundedRegularFile(filePath: string, maxBytes: number): Promise { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const handle = await fs.open(filePath, constants.O_RDONLY | noFollow); + try { + const before = await handle.stat(); + if (!before.isFile() || before.size < 1 || before.size > maxBytes) { + throw new Error("The asset is not a bounded regular file."); + } + const chunks: Buffer[] = []; + let total = 0; + while (total <= maxBytes) { + const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, total); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + total += bytesRead; + } + if (total > maxBytes) throw new Error("The asset grew beyond its byte limit while reading."); + const bytes = Buffer.concat(chunks, total); + const after = await handle.stat(); + if (bytes.byteLength !== before.size || after.size !== before.size) { + throw new Error("The asset changed while it was being read."); + } + return bytes; + } finally { + await handle.close(); + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function ensureSafeDirectory(directory: string): Promise { + const created = await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new AssetStoreError( + "asset_store_repair_required", + "The asset store contains an unsafe directory entry.", + ); + } + if (created !== undefined) await syncDirectory(path.dirname(directory)); +} + +function isSafeThumbnailPng(bytes: Uint8Array, width: number, height: number): boolean { + try { + const descriptor = validateImageBytes(bytes, "image/png", "thumbnail.png", { + maxWidth: DEFAULT_ASSET_STORE_LIMITS.maxWidth, + maxHeight: DEFAULT_ASSET_STORE_LIMITS.maxHeight, + maxPixels: DEFAULT_ASSET_STORE_LIMITS.maxPixels, + }); + return ( + descriptor.mediaType === "image/png" && + descriptor.width === width && + descriptor.height === height + ); + } catch { + return false; + } +} + +export class ContentAddressedAssetStore { + private readonly limits: AssetStoreLimits; + private readonly indexStore: DataStore; + private readonly cache: ByteBoundedLru; + private readonly leases = new Map(); + private readonly gcPlans = new Map(); + private index = cloneEmptyIndex(); + private indexHealthy = true; + private initializePromise: Promise | undefined; + private mutationTail: Promise = Promise.resolve(); + + constructor( + private readonly rootDirectory: string, + private readonly referenceAuthority: AssetReferenceAuthority, + private readonly options: { + limits?: AssetStoreLimits; + now?: () => number; + deepValidator: AssetDeepValidator; + thumbnailGenerator?: AssetThumbnailGenerator; + /** Best-effort notification after the asset-store mutation lock is released. */ + onAssetPublished?: (asset: AssetMetadataDto) => Promise | void; + }, + ) { + if (!path.isAbsolute(rootDirectory)) throw new Error("The asset store root must be absolute."); + this.limits = structuredClone(options.limits ?? DEFAULT_ASSET_STORE_LIMITS); + validateLimits(this.limits); + this.cache = new ByteBoundedLru(this.limits.thumbnailCacheBytes); + this.indexStore = new DataStore( + "asset-index.json", + cloneEmptyIndex(), + () => this.rootDirectory, + { + maxBytes: this.limits.maxIndexBytes, + preserveCorruptFile: true, + normalize: (value) => (isAssetIndex(value, this.limits) ? value : cloneEmptyIndex()), + isSafe: (value) => isAssetIndex(value, this.limits), + rejectUnsafeWrite: false, + reloadBeforeWrite: true, + rejectExternalChanges: true, + }, + ); + } + + private get now(): () => number { + return this.options.now ?? Date.now; + } + + private async initialize(): Promise { + if (!this.initializePromise) { + this.initializePromise = (async () => { + await ensureSafeDirectory(this.rootDirectory); + await ensureSafeDirectory(path.join(this.rootDirectory, "assets")); + await ensureSafeDirectory(this.assetsDirectory); + await ensureSafeDirectory(this.thumbnailDirectory); + await ensureSafeDirectory(this.quarantineDirectory); + this.index = structuredClone(await this.indexStore.load()); + this.indexHealthy = + !(await this.indexStore.loadedFromCorruptFile()) && + !(await this.indexStore.loadedFromUnsafeFile()); + })(); + } + await this.initializePromise; + } + + private serialized(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation, operation); + this.mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private get assetsDirectory(): string { + return path.join(this.rootDirectory, "assets", "sha256"); + } + + private get thumbnailDirectory(): string { + return path.join(this.rootDirectory, "thumbnails"); + } + + private get quarantineDirectory(): string { + return path.join(this.rootDirectory, "asset-quarantine"); + } + + private assetPath(assetId: string, extension: SafeAssetExtension): string { + return path.join(this.assetsDirectory, assetId.slice(0, 2), `${assetId}.${extension}`); + } + + private thumbnailPath(assetId: string, size: number): string { + return path.join(this.thumbnailDirectory, assetId, `${size}.png`); + } + + private ensureHealthy(): void { + if (!this.indexHealthy) { + throw new AssetStoreError( + "asset_store_repair_required", + "The Create Images asset index needs repair before it can be changed or served.", + ); + } + } + + private async saveIndex(next: AssetIndexV1): Promise { + if (Object.keys(next.assets).length > this.limits.maxAssets) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset index reached its entry limit.", + ); + } + try { + await this.indexStore.save(next); + } catch (error) { + if (error instanceof DataStoreUnsafeWriteError) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset metadata index exceeds its configured byte limit.", + ); + } + throw error; + } + this.index = next; + this.indexHealthy = true; + } + + private pruneRuntimeState(): void { + const now = this.now(); + for (const [token, lease] of this.leases) { + if (lease.expiresAt <= now) this.leases.delete(token); + } + for (const [planId, plan] of this.gcPlans) { + if (plan.expiresAt <= now) this.gcPlans.delete(planId); + } + } + + async status(): Promise<{ + healthy: boolean; + assetCount: number; + totalAssetBytes: number; + quotaWarning: boolean; + revision: number; + }> { + await this.initialize(); + const bytes = totalBytes(this.index); + return { + healthy: this.indexHealthy, + assetCount: Object.keys(this.index.assets).length, + totalAssetBytes: bytes, + quotaWarning: bytes >= this.limits.warningAssetBytes, + revision: this.index.revision, + }; + } + + async list(): Promise { + await this.initialize(); + this.ensureHealthy(); + return Object.values(this.index.assets) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .map(metadataDto); + } + + async get(assetId: string): Promise { + assertAssetId(assetId); + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + return asset ? metadataDto(asset) : undefined; + } + + private async publishedAssetAvailable(asset: StoredAsset): Promise { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open( + this.assetPath(asset.assetId, asset.extension), + constants.O_RDONLY | constants.O_NONBLOCK | noFollow, + ); + const info = await handle.stat(); + return info.isFile() && info.size === asset.byteLength; + } catch { + return false; + } finally { + await handle?.close().catch(() => undefined); + } + } + + async getAvailable(assetId: string): Promise { + assertAssetId(assetId); + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + return asset && (await this.publishedAssetAvailable(asset)) ? metadataDto(asset) : undefined; + } + + async ingest( + source: AsyncIterable, + request: AssetIngestRequest, + ): Promise { + const result = await this.serialized(async () => { + await this.initialize(); + this.ensureHealthy(); + const origin = validateOrigin(request.origin); + if (origin.kind === "annotation" && !this.index.assets[origin.sourceAssetId]) { + throw new AssetStoreError("asset_not_found", "The annotation source asset does not exist."); + } + const generationMetadata = validateGenerationMetadata(request.generationMetadata); + const displayName = sanitizeAssetDisplayName(request.displayName); + const validationDisplayName = sanitizeAssetDisplayName(request.validationDisplayName); + const maxBytes = + origin.kind === "provider" + ? this.limits.maxProviderResponseBytes + : this.limits.maxImportBytes; + const tempPath = path.join(this.quarantineDirectory, `.ingest-${randomUUID()}.tmp`); + const handle = await fs.open(tempPath, "wx", 0o600); + const hash = createHash("sha256"); + let byteLength = 0; + try { + try { + for await (const rawChunk of source) { + if (!(rawChunk instanceof Uint8Array)) { + throw new AssetStoreError( + "invalid_asset_request", + "Asset ingest accepts byte chunks only.", + ); + } + if (rawChunk.byteLength === 0) continue; + const chunk = new Uint8Array(rawChunk.buffer, rawChunk.byteOffset, rawChunk.byteLength); + byteLength += chunk.byteLength; + if (byteLength > maxBytes) { + throw new AssetStoreError( + "asset_ingest_too_large", + `The image exceeds the ${maxBytes}-byte ingest limit.`, + ); + } + hash.update(chunk); + let written = 0; + while (written < chunk.byteLength) { + const result = await handle.write(chunk, written, chunk.byteLength - written, null); + if (result.bytesWritten < 1) throw new Error("The asset write made no progress."); + written += result.bytesWritten; + } + } + if (byteLength < 1) { + throw new AssetStoreError("invalid_asset_request", "The imported image is empty."); + } + await handle.sync(); + } finally { + await handle.close(); + } + } catch (error) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } + try { + const bytes = await readBoundedRegularFile(tempPath, maxBytes); + const descriptor = validateImageBytes( + bytes, + request.declaredMimeType, + validationDisplayName ?? displayName, + this.limits, + ); + const decoded = await this.options.deepValidator + .validate({ filePath: tempPath, descriptor, byteLength }) + .catch(() => { + throw new AssetStoreError( + "invalid_asset_request", + "The safe image decoder rejected the imported image.", + ); + }); + if (decoded.width !== descriptor.width || decoded.height !== descriptor.height) { + throw new AssetStoreError( + "invalid_asset_request", + "The image decoder dimensions do not match its validated header.", + ); + } + const assetId = hash.digest("hex"); + const decoderCheckedBytes = await readBoundedRegularFile(tempPath, maxBytes); + if ( + decoderCheckedBytes.byteLength !== byteLength || + createHash("sha256").update(decoderCheckedBytes).digest("hex") !== assetId + ) { + throw new AssetStoreError( + "invalid_asset_request", + "The quarantined image changed during validation.", + ); + } + const existing = this.index.assets[assetId]; + if (existing) { + if ( + existing.byteLength !== byteLength || + existing.extension !== descriptor.extension || + existing.mediaType !== descriptor.mediaType || + existing.width !== descriptor.width || + existing.height !== descriptor.height + ) { + throw new AssetStoreError( + "asset_store_repair_required", + "Existing asset metadata does not match the re-imported image.", + ); + } + try { + await this.verifyPublishedAsset(existing); + } catch (verificationError) { + const destination = this.assetPath(assetId, existing.extension); + try { + await fs.lstat(destination); + throw verificationError; + } catch (inspectionError) { + if ((inspectionError as NodeJS.ErrnoException).code !== "ENOENT") { + throw inspectionError; + } + } + await ensureSafeDirectory(path.dirname(destination)); + let republished = false; + try { + try { + await fs.link(tempPath, destination); + republished = true; + await syncDirectory(path.dirname(destination)); + } catch (publishError) { + if ((publishError as NodeJS.ErrnoException).code !== "EEXIST") throw publishError; + } + await this.verifyPublishedAsset(existing); + } catch (publishError) { + if (republished) { + await fs.rm(destination, { force: true }).catch(() => undefined); + await syncDirectory(path.dirname(destination)).catch(() => undefined); + } + throw publishError; + } + } + const bytesUsed = totalBytes(this.index); + return { + asset: metadataDto(existing), + deduplicated: true, + quotaWarning: bytesUsed >= this.limits.warningAssetBytes, + totalAssetBytes: bytesUsed, + }; + } + const currentBytes = totalBytes(this.index); + if (Object.keys(this.index.assets).length >= this.limits.maxAssets) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset index reached its entry limit.", + ); + } + if (currentBytes + byteLength > this.limits.totalAssetBytes) { + throw new AssetStoreError( + "asset_store_quota_exceeded", + "The Create Images asset storage quota is full.", + ); + } + const destination = this.assetPath(assetId, descriptor.extension); + await ensureSafeDirectory(path.dirname(destination)); + let publishedByThisIngest = false; + try { + await fs.link(tempPath, destination); + publishedByThisIngest = true; + await syncDirectory(path.dirname(destination)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const published = await readBoundedRegularFile(destination, maxBytes); + if ( + createHash("sha256").update(published).digest("hex") !== assetId || + published.byteLength !== byteLength + ) { + throw new AssetStoreError( + "asset_store_repair_required", + "A published content-addressed asset does not match its identifier.", + ); + } + } + const createdAt = new Date(this.now()).toISOString(); + const stored: StoredAsset = { + assetId, + extension: descriptor.extension, + mediaType: descriptor.mediaType, + byteLength, + width: descriptor.width, + height: descriptor.height, + createdAt, + ...(displayName ? { displayName } : {}), + origin, + ...(generationMetadata ? { generationMetadata } : {}), + referenceOwners: [], + unreferencedAt: createdAt, + thumbnails: {}, + }; + const next = structuredClone(this.index); + next.revision += 1; + next.assets[assetId] = stored; + try { + await this.saveIndex(next); + } catch (error) { + if (publishedByThisIngest) { + await fs.rm(destination, { force: true }).catch(() => undefined); + } + throw error; + } + const bytesUsed = currentBytes + byteLength; + return { + asset: metadataDto(stored), + deduplicated: false, + quotaWarning: bytesUsed >= this.limits.warningAssetBytes, + totalAssetBytes: bytesUsed, + }; + } finally { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + } + }); + // Workspace materialization and other observers must not run under the + // asset-store mutation fence. A durable CAS publication remains successful + // even when an optional Finder mirror is unavailable or has drifted. + try { + await this.options.onAssetPublished?.(result.asset); + } catch { + // The canonical asset is already durable; observers are best effort. + } + return result; + } + + private async verifyPublishedAsset(asset: StoredAsset): Promise { + const bytes = await readBoundedRegularFile( + this.assetPath(asset.assetId, asset.extension), + Math.max(this.limits.maxImportBytes, this.limits.maxProviderResponseBytes), + ).catch(() => undefined); + if ( + !bytes || + bytes.byteLength !== asset.byteLength || + createHash("sha256").update(bytes).digest("hex") !== asset.assetId + ) { + throw new AssetStoreError( + "asset_store_repair_required", + "A published asset is missing or does not match its content identifier.", + ); + } + let descriptor: ValidatedImageDescriptor; + try { + descriptor = validateImageBytes( + bytes, + asset.mediaType, + `${asset.assetId}.${asset.extension}`, + this.limits, + ); + } catch { + throw new AssetStoreError( + "asset_store_repair_required", + "A published asset no longer passes image validation.", + ); + } + if ( + descriptor.width !== asset.width || + descriptor.height !== asset.height || + descriptor.extension !== asset.extension + ) { + throw new AssetStoreError( + "asset_store_repair_required", + "Published asset metadata does not match its image contents.", + ); + } + } + + async replaceReferences(owner: AssetReferenceOwner, assetIds: readonly string[]): Promise { + return this.serialized(async () => { + await this.initialize(); + this.ensureHealthy(); + const key = ownerKey(owner); + if (assetIds.length > this.limits.maxAssets || new Set(assetIds).size !== assetIds.length) { + throw new AssetStoreError("invalid_asset_request", "The asset reference list is invalid."); + } + for (const assetId of assetIds) { + assertAssetId(assetId); + if (!this.index.assets[assetId]) { + throw new AssetStoreError("asset_not_found", `Asset ${assetId} does not exist.`); + } + } + const desired = new Set(assetIds); + const next = structuredClone(this.index); + let changed = false; + const timestamp = new Date(this.now()).toISOString(); + for (const asset of Object.values(next.assets)) { + const had = asset.referenceOwners.includes(key); + const wants = desired.has(asset.assetId); + if (had === wants) continue; + changed = true; + asset.referenceOwners = wants + ? [...asset.referenceOwners, key].sort() + : asset.referenceOwners.filter((candidate) => candidate !== key); + if (asset.referenceOwners.length === 0) asset.unreferencedAt = timestamp; + else delete asset.unreferencedAt; + } + if (!changed) return; + next.revision += 1; + await this.saveIndex(next); + }); + } + + async rebuildReferenceAccounting(): Promise<{ missingAssetIds: string[]; revision: number }> { + return this.serialized(async () => { + await this.initialize(); + this.ensureHealthy(); + return this.referenceAuthority.withSnapshot(async (snapshot) => { + const byAsset = validateReferenceSnapshot(snapshot, this.limits); + const referencedAssetIds = [...byAsset.keys()]; + const missingAssetIds: string[] = []; + let cursor = 0; + await Promise.all( + Array.from({ length: Math.min(16, referencedAssetIds.length) }, async () => { + while (cursor < referencedAssetIds.length) { + const assetId = referencedAssetIds[cursor++]; + if (!assetId) continue; + const asset = this.index.assets[assetId]; + if (!asset || !(await this.publishedAssetAvailable(asset))) { + missingAssetIds.push(assetId); + } + } + }), + ); + missingAssetIds.sort(); + const next = structuredClone(this.index); + const timestamp = new Date(this.now()).toISOString(); + for (const asset of Object.values(next.assets)) { + const previousCount = asset.referenceOwners.length; + asset.referenceOwners = [...(byAsset.get(asset.assetId) ?? [])].sort(); + if (asset.referenceOwners.length === 0) { + asset.unreferencedAt ??= timestamp; + } else { + delete asset.unreferencedAt; + } + if (previousCount > 0 && asset.referenceOwners.length === 0) + asset.unreferencedAt = timestamp; + } + next.revision += 1; + await this.saveIndex(next); + return { missingAssetIds, revision: next.revision }; + }); + }); + } + + async acquirePreviewLease( + assetId: string, + ownerId: string, + ttlMs = 60_000, + ): Promise { + return this.serialized(async () => { + assertAssetId(assetId); + assertSafeId(ownerId, "Preview owner ID"); + if (!Number.isSafeInteger(ttlMs) || ttlMs < 1_000 || ttlMs > 5 * 60_000) { + throw new AssetStoreError( + "invalid_asset_request", + "Preview leases last between 1 and 300 seconds.", + ); + } + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + if (!asset) { + throw new AssetStoreError("asset_not_found", `Asset ${assetId} does not exist.`); + } + if (!(await this.publishedAssetAvailable(asset))) { + throw new AssetStoreError( + "asset_source_missing", + `Asset ${assetId} is missing its source.`, + ); + } + this.pruneRuntimeState(); + if (this.leases.size >= this.limits.maxPreviewLeases) { + throw new AssetStoreError("invalid_asset_request", "The preview lease limit is reached."); + } + const token = randomBytes(32).toString("base64url"); + const lease = { token, assetId, ownerId, expiresAt: this.now() + ttlMs }; + this.leases.set(token, lease); + return { token, assetId, expiresAt: lease.expiresAt }; + }); + } + + async readPreview( + token: string, + ownerId: string, + maxBytes = this.limits.maxPreviewReadBytes, + ): Promise { + return this.serialized(async () => { + if (!PREVIEW_TOKEN.test(token)) { + throw new AssetStoreError( + "preview_lease_invalid", + "The preview lease is invalid or expired.", + ); + } + assertSafeId(ownerId, "Preview owner ID"); + if ( + !Number.isSafeInteger(maxBytes) || + maxBytes < 1 || + maxBytes > this.limits.maxPreviewReadBytes + ) { + throw new AssetStoreError("invalid_asset_request", "The preview byte limit is invalid."); + } + await this.initialize(); + this.ensureHealthy(); + this.pruneRuntimeState(); + const lease = this.leases.get(token); + if (!lease || lease.ownerId !== ownerId) { + throw new AssetStoreError( + "preview_lease_invalid", + "The preview lease is invalid or expired.", + ); + } + const asset = this.index.assets[lease.assetId]; + if (!asset) + throw new AssetStoreError("asset_not_found", "The preview asset no longer exists."); + if (asset.byteLength > maxBytes) { + throw new AssetStoreError( + "preview_too_large", + "The selected asset exceeds the preview byte limit.", + ); + } + const bytes = await readBoundedRegularFile( + this.assetPath(asset.assetId, asset.extension), + maxBytes, + ).catch(() => { + throw new AssetStoreError( + "asset_store_repair_required", + "The preview asset is missing or unsafe.", + ); + }); + if (createHash("sha256").update(bytes).digest("hex") !== asset.assetId) { + throw new AssetStoreError( + "asset_store_repair_required", + "The preview asset failed integrity validation.", + ); + } + try { + const descriptor = validateImageBytes( + bytes, + asset.mediaType, + `${asset.assetId}.${asset.extension}`, + this.limits, + ); + if ( + descriptor.width !== asset.width || + descriptor.height !== asset.height || + descriptor.extension !== asset.extension + ) { + throw new Error("metadata_mismatch"); + } + } catch { + throw new AssetStoreError( + "asset_store_repair_required", + "The preview asset metadata does not match its image contents.", + ); + } + return { asset: metadataDto(asset), bytes: bytes.slice() }; + }); + } + + async releasePreviewLease(token: string, ownerId: string): Promise { + return this.serialized(async () => { + if (!PREVIEW_TOKEN.test(token)) return false; + assertSafeId(ownerId, "Preview owner ID"); + const lease = this.leases.get(token); + if (!lease || lease.ownerId !== ownerId) return false; + return this.leases.delete(token); + }); + } + + async releasePreviewOwner(ownerId: string): Promise { + return this.serialized(async () => { + assertSafeId(ownerId, "Preview owner ID"); + let released = 0; + for (const [token, lease] of this.leases) { + if (lease.ownerId !== ownerId) continue; + this.leases.delete(token); + released += 1; + } + return released; + }); + } + + async getThumbnail(assetId: string, size: number): Promise { + return this.serialized(async () => { + assertAssetId(assetId); + if (!this.limits.thumbnailSizes.includes(size)) { + throw new AssetStoreError("invalid_asset_request", "The thumbnail size is not allowed."); + } + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + if (!asset) throw new AssetStoreError("asset_not_found", `Asset ${assetId} does not exist.`); + if (!(await this.publishedAssetAvailable(asset))) { + throw new AssetStoreError( + "asset_source_missing", + `Asset ${assetId} is missing its source.`, + ); + } + const key = `${assetId}:${size}`; + const cached = this.cache.get(key); + if (cached) return { assetId, ...cached, bytes: cached.bytes.slice() }; + const thumbnailPath = this.thumbnailPath(assetId, size); + const stored = asset.thumbnails[String(size)]; + if (stored) { + try { + const bytes = await this.readValidatedThumbnail(thumbnailPath, stored); + const entry = thumbnailCacheEntry(stored, bytes); + this.cache.set(key, entry); + return { assetId, ...entry, bytes: entry.bytes.slice() }; + } catch { + // A derived thumbnail is regenerable. Its immutable source remains untouched. + } + } + if (!this.options.thumbnailGenerator) { + throw new AssetStoreError( + "thumbnail_unavailable", + "No safe thumbnail generator is configured.", + ); + } + await this.verifyPublishedAsset(asset); + const generated = await this.options.thumbnailGenerator.generate({ + sourcePath: this.assetPath(assetId, asset.extension), + source: descriptorFor(asset), + maxDimension: size, + maxOutputBytes: this.limits.maxThumbnailBytes, + }); + if ( + generated.mediaType !== THUMBNAIL_MEDIA_TYPE || + !(generated.bytes instanceof Uint8Array) || + generated.bytes.byteLength < 33 || + generated.bytes.byteLength > this.limits.maxThumbnailBytes || + !isSafeThumbnailPng(generated.bytes, generated.width, generated.height) || + !Number.isSafeInteger(generated.width) || + !Number.isSafeInteger(generated.height) || + generated.width < 1 || + generated.height < 1 || + generated.width > size || + generated.height > size + ) { + throw new AssetStoreError( + "thumbnail_unavailable", + "The thumbnail generator returned unsafe output.", + ); + } + await ensureSafeDirectory(path.dirname(thumbnailPath)); + const temp = `${thumbnailPath}.${randomUUID()}.tmp`; + try { + await fs.writeFile(temp, generated.bytes, { flag: "wx", mode: 0o600 }); + const handle = await fs.open(temp, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + const decoded = await this.options.deepValidator + .validate({ + filePath: temp, + descriptor: { + mediaType: "image/png", + extension: "png", + width: generated.width, + height: generated.height, + pixels: generated.width * generated.height, + }, + byteLength: generated.bytes.byteLength, + }) + .catch(() => { + throw new AssetStoreError( + "thumbnail_unavailable", + "The safe image decoder rejected the generated thumbnail.", + ); + }); + if (decoded.width !== generated.width || decoded.height !== generated.height) { + throw new AssetStoreError( + "thumbnail_unavailable", + "The decoded thumbnail dimensions do not match its metadata.", + ); + } + await fs.rename(temp, thumbnailPath); + await syncDirectory(path.dirname(thumbnailPath)); + } finally { + await fs.rm(temp, { force: true }).catch(() => undefined); + } + const updatedAt = new Date(this.now()).toISOString(); + const metadata: StoredThumbnail = { + mediaType: THUMBNAIL_MEDIA_TYPE, + byteLength: generated.bytes.byteLength, + width: generated.width, + height: generated.height, + updatedAt, + }; + const next = structuredClone(this.index); + next.revision += 1; + next.assets[assetId]!.thumbnails[String(size)] = metadata; + await this.saveIndex(next); + const entry = thumbnailCacheEntry(metadata, generated.bytes); + this.cache.set(key, entry); + return { assetId, ...entry, bytes: entry.bytes.slice() }; + }); + } + + thumbnailCacheStatus(): { entries: number; byteLength: number; maxBytes: number } { + return { + entries: this.cache.size, + byteLength: this.cache.byteLength, + maxBytes: this.limits.thumbnailCacheBytes, + }; + } + + /** + * Main-only seam for protocol/export code. The callback must finish consuming + * the file before it resolves; the asset-store mutation lock (and therefore + * GC exclusion) is held for that full interval. Never forward filePath over IPC. + */ + async withAssetFile( + assetId: string, + callback: (input: { + filePath: string; + asset: AssetMetadataDto; + byteLength: number; + mediaType: SafeAssetMediaType; + }) => Promise, + ): Promise { + return this.serialized(async () => { + assertAssetId(assetId); + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + if (!asset) throw new AssetStoreError("asset_not_found", `Asset ${assetId} does not exist.`); + await this.verifyPublishedAsset(asset); + return callback({ + filePath: this.assetPath(assetId, asset.extension), + asset: metadataDto(asset), + byteLength: asset.byteLength, + mediaType: asset.mediaType, + }); + }); + } + + /** + * Main-only validation seam for native archive quarantine files. This does + * not publish bytes or mutate the asset index. Callers must keep the path in + * a private main-owned directory and must never forward it over IPC. + */ + async validateQuarantinedAssetFile( + filePath: string, + input: { declaredMimeType: string; displayName: string }, + ): Promise { + const bytes = await readBoundedRegularFile(filePath, this.limits.maxImportBytes); + const descriptor = validateImageBytes( + bytes, + input.declaredMimeType, + sanitizeAssetDisplayName(input.displayName), + this.limits, + ); + const decoded = await this.options.deepValidator + .validate({ filePath, descriptor, byteLength: bytes.byteLength }) + .catch(() => { + throw new AssetStoreError( + "invalid_asset_request", + "The safe image decoder rejected the archived image.", + ); + }); + if (decoded.width !== descriptor.width || decoded.height !== descriptor.height) { + throw new AssetStoreError( + "invalid_asset_request", + "The archived image decoder dimensions do not match its validated header.", + ); + } + return { + sha256: createHash("sha256").update(bytes).digest("hex"), + mediaType: descriptor.mediaType, + byteLength: bytes.byteLength, + width: descriptor.width, + height: descriptor.height, + }; + } + + /** Main-dialog-only durable export. The destination must never come from a + * renderer payload; callers are responsible for obtaining it from a native + * save dialog. */ + async exportAssetToFile(assetId: string, destination: string): Promise { + return this.serialized(async () => { + assertAssetId(assetId); + if (!path.isAbsolute(destination) || destination.includes("\0")) { + throw new AssetStoreError("invalid_asset_request", "The asset export path is invalid."); + } + await this.initialize(); + this.ensureHealthy(); + const asset = this.index.assets[assetId]; + if (!asset) throw new AssetStoreError("asset_not_found", "The exported asset is missing."); + await this.verifyPublishedAsset(asset); + const directory = path.dirname(destination); + const temp = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`); + try { + await fs.copyFile(this.assetPath(assetId, asset.extension), temp, constants.COPYFILE_EXCL); + await fs.chmod(temp, 0o600); + const handle = await fs.open(temp, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(temp, destination); + await syncDirectory(directory); + } catch (error) { + await fs.rm(temp, { force: true }).catch(() => undefined); + throw error; + } + return metadataDto(asset); + }); + } + + async planGarbageCollection(graceMs: number): Promise { + return this.serialized(async () => { + await this.initialize(); + this.ensureHealthy(); + this.pruneRuntimeState(); + return this.referenceAuthority.withSnapshot(async (snapshot) => { + const references = validateReferenceSnapshot(snapshot, this.limits); + const missing = [...references.keys()].filter((assetId) => !this.index.assets[assetId]); + if (missing.length > 0) { + throw new AssetStoreError( + "asset_store_repair_required", + "Reference accounting includes missing assets; repair is required before collection.", + ); + } + const now = this.now(); + const leased = new Set([...this.leases.values()].map((lease) => lease.assetId)); + const candidates = Object.values(this.index.assets) + .filter((asset) => { + const unreferencedAt = Date.parse(asset.unreferencedAt ?? asset.createdAt); + return ( + !references.has(asset.assetId) && + !leased.has(asset.assetId) && + Number.isFinite(unreferencedAt) && + unreferencedAt <= now - graceMs + ); + }) + .sort((left, right) => left.assetId.localeCompare(right.assetId)); + while (this.gcPlans.size >= 32) { + const oldest = this.gcPlans.keys().next().value as string | undefined; + if (!oldest) break; + this.gcPlans.delete(oldest); + } + const plan: InternalGcPlan = { + planId: randomBytes(24).toString("base64url"), + createdAt: now, + expiresAt: now + 5 * 60_000, + indexRevision: this.index.revision, + referenceEpoch: snapshot.epoch, + referenceFingerprint: referenceFingerprint(snapshot), + graceMs, + candidateAssetIds: candidates.map((asset) => asset.assetId), + reclaimableBytes: candidates.reduce((sum, asset) => sum + asset.byteLength, 0), + }; + this.gcPlans.set(plan.planId, plan); + return { + planId: plan.planId, + createdAt: plan.createdAt, + expiresAt: plan.expiresAt, + indexRevision: plan.indexRevision, + referenceEpoch: plan.referenceEpoch, + candidateAssetIds: [...plan.candidateAssetIds], + reclaimableBytes: plan.reclaimableBytes, + }; + }); + }); + } + + async applyGarbageCollection(planId: string): Promise { + return this.serialized(async () => { + if (!/^[A-Za-z0-9_-]{24,128}$/u.test(planId)) { + throw new AssetStoreError( + "invalid_asset_request", + "The garbage-collection plan ID is invalid.", + ); + } + await this.initialize(); + this.ensureHealthy(); + this.pruneRuntimeState(); + const plan = this.gcPlans.get(planId); + if (!plan) { + return { + applied: false, + stale: true, + deletedAssetIds: [], + reclaimedBytes: 0, + skipped: [], + }; + } + return this.referenceAuthority.withSnapshot(async (snapshot) => { + const references = validateReferenceSnapshot(snapshot, this.limits); + if ( + plan.expiresAt <= this.now() || + plan.indexRevision !== this.index.revision || + plan.referenceEpoch !== snapshot.epoch || + plan.referenceFingerprint !== referenceFingerprint(snapshot) + ) { + this.gcPlans.delete(planId); + return { + applied: false, + stale: true, + deletedAssetIds: [], + reclaimedBytes: 0, + skipped: [], + }; + } + const now = this.now(); + const leased = new Set([...this.leases.values()].map((lease) => lease.assetId)); + const skipped: AssetGarbageCollectionResult["skipped"] = []; + const removable: StoredAsset[] = []; + for (const assetId of plan.candidateAssetIds) { + const asset = this.index.assets[assetId]; + if (!asset) { + skipped.push({ assetId, reason: "not_found" }); + continue; + } + if (references.has(assetId)) { + skipped.push({ assetId, reason: "referenced" }); + continue; + } + if (leased.has(assetId)) { + skipped.push({ assetId, reason: "lease_active" }); + continue; + } + const unreferencedAt = Date.parse(asset.unreferencedAt ?? asset.createdAt); + if (!Number.isFinite(unreferencedAt) || unreferencedAt > now - plan.graceMs) { + skipped.push({ assetId, reason: "too_new" }); + continue; + } + removable.push(asset); + } + const staged: Array<{ asset: StoredAsset; stagedPath: string; destination: string }> = []; + try { + for (const asset of removable) { + const destination = this.assetPath(asset.assetId, asset.extension); + const stagedPath = path.join( + this.quarantineDirectory, + `.gc-${asset.assetId}-${randomUUID()}.${asset.extension}`, + ); + try { + const info = await fs.lstat(destination); + if (!info.isFile() || info.isSymbolicLink()) { + skipped.push({ assetId: asset.assetId, reason: "not_found" }); + continue; + } + await fs.rename(destination, stagedPath); + staged.push({ asset, stagedPath, destination }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + skipped.push({ assetId: asset.assetId, reason: "not_found" }); + continue; + } + throw error; + } + } + if (staged.length > 0) { + const next = structuredClone(this.index); + next.revision += 1; + for (const { asset } of staged) delete next.assets[asset.assetId]; + await this.saveIndex(next); + } + } catch (error) { + for (const item of staged.reverse()) { + await fs.rename(item.stagedPath, item.destination).catch(() => undefined); + } + throw error; + } + for (const { asset, stagedPath } of staged) { + await fs.rm(stagedPath, { force: true }).catch(() => undefined); + await fs + .rm(path.join(this.thumbnailDirectory, asset.assetId), { + recursive: true, + force: true, + }) + .catch(() => undefined); + this.cache.deletePrefix(`${asset.assetId}:`); + } + this.gcPlans.delete(planId); + return { + applied: true, + stale: false, + deletedAssetIds: staged.map(({ asset }) => asset.assetId), + reclaimedBytes: staged.reduce((sum, { asset }) => sum + asset.byteLength, 0), + skipped, + }; + }); + }); + } + + async repair(options: { apply: boolean }): Promise { + return this.serialized(async () => { + await this.initialize(); + const indexWasUnhealthy = !this.indexHealthy; + return this.referenceAuthority.withSnapshot(async (snapshot) => { + const references = validateReferenceSnapshot(snapshot, this.limits); + const { scanned, invalid } = await this.scanPublishedAssets(); + if (scanned.size > this.limits.maxAssets) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The repaired asset index would exceed its entry limit.", + ); + } + const addedAssetIds: string[] = []; + const correctedAssetIds: string[] = []; + const removedAssetIds = Object.keys(this.index.assets) + .filter((assetId) => !scanned.has(assetId)) + .sort(); + const repaired = cloneEmptyIndex(); + repaired.revision = this.index.revision + 1; + const timestamp = new Date(this.now()).toISOString(); + for (const [assetId, entry] of [...scanned.entries()].sort(([left], [right]) => + left.localeCompare(right), + )) { + const existing = this.index.assets[assetId]; + if (!existing) addedAssetIds.push(assetId); + const owners = [...(references.get(assetId) ?? [])].sort(); + const baseMatches = + existing && + existing.extension === entry.descriptor.extension && + existing.mediaType === entry.descriptor.mediaType && + existing.byteLength === entry.byteLength && + existing.width === entry.descriptor.width && + existing.height === entry.descriptor.height; + if (existing && !baseMatches) correctedAssetIds.push(assetId); + const thumbnails = existing && baseMatches ? await this.validThumbnails(existing) : {}; + repaired.assets[assetId] = { + assetId, + extension: entry.descriptor.extension, + mediaType: entry.descriptor.mediaType, + byteLength: entry.byteLength, + width: entry.descriptor.width, + height: entry.descriptor.height, + createdAt: existing?.createdAt ?? entry.createdAt, + ...(existing?.displayName ? { displayName: existing.displayName } : {}), + origin: existing?.origin ?? { kind: "repair" }, + ...(existing?.generationMetadata + ? { generationMetadata: structuredClone(existing.generationMetadata) } + : {}), + referenceOwners: owners, + ...(owners.length === 0 + ? { unreferencedAt: existing?.unreferencedAt ?? timestamp } + : {}), + thumbnails, + }; + } + const missingReferenceAssetIds = [...references.keys()] + .filter((assetId) => !scanned.has(assetId)) + .sort(); + const report: AssetRepairReport = { + applied: options.apply, + indexWasUnhealthy, + addedAssetIds: addedAssetIds.sort(), + removedAssetIds, + correctedAssetIds: correctedAssetIds.sort(), + quarantinedEntryIds: [], + invalidEntries: invalid.map(({ entryId, reason }) => ({ entryId, reason })), + missingReferenceAssetIds, + }; + if (!options.apply) return report; + await ensureSafeDirectory(this.quarantineDirectory); + for (const item of invalid) { + const destination = path.join( + this.quarantineDirectory, + `repair-${randomUUID()}-${path.basename(item.filePath).slice(0, 80)}`, + ); + try { + await fs.rename(item.filePath, destination); + report.quarantinedEntryIds.push(item.entryId); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + await this.saveIndex(repaired); + this.cache.clear(); + return report; + }); + }); + } + + private async validThumbnails(asset: StoredAsset): Promise> { + const valid: Record = {}; + for (const [size, metadata] of Object.entries(asset.thumbnails)) { + if (!this.limits.thumbnailSizes.includes(Number(size))) continue; + try { + await this.readValidatedThumbnail( + this.thumbnailPath(asset.assetId, Number(size)), + metadata, + ); + valid[size] = metadata; + } catch { + // Derived content is omitted from the repaired index and regenerated lazily. + } + } + return valid; + } + + private async readValidatedThumbnail( + filePath: string, + metadata: Pick, + ): Promise { + const bytes = await readBoundedRegularFile(filePath, this.limits.maxThumbnailBytes); + if ( + bytes.byteLength !== metadata.byteLength || + !isSafeThumbnailPng(bytes, metadata.width, metadata.height) + ) { + throw new Error("The thumbnail file does not match its metadata."); + } + const decoded = await this.options.deepValidator.validate({ + filePath, + descriptor: { + mediaType: "image/png", + extension: "png", + width: metadata.width, + height: metadata.height, + pixels: metadata.width * metadata.height, + }, + byteLength: metadata.byteLength, + }); + if (decoded.width !== metadata.width || decoded.height !== metadata.height) { + throw new Error("The decoded thumbnail dimensions do not match its metadata."); + } + return bytes; + } + + private async scanPublishedAssets(): Promise<{ + scanned: Map; + invalid: InvalidScannedEntry[]; + }> { + const scanned = new Map(); + const invalid: InvalidScannedEntry[] = []; + let entryCount = 0; + let scannedBytes = 0; + const prefixDirectory = await fs.opendir(this.assetsDirectory); + for await (const prefix of prefixDirectory) { + entryCount += 1; + if (entryCount > this.limits.maxRepairEntries) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset tree has too many entries.", + ); + } + const prefixPath = path.join(this.assetsDirectory, prefix.name); + if (!prefix.isDirectory() || prefix.isSymbolicLink() || !/^[a-f0-9]{2}$/u.test(prefix.name)) { + invalid.push({ + entryId: prefix.name.slice(0, 255), + filePath: prefixPath, + reason: "invalid_prefix", + }); + continue; + } + const assetDirectory = await fs.opendir(prefixPath); + for await (const entry of assetDirectory) { + entryCount += 1; + if (entryCount > this.limits.maxRepairEntries) { + throw new AssetStoreError( + "asset_index_limit_exceeded", + "The asset tree has too many entries.", + ); + } + const entryId = `${prefix.name}/${entry.name.slice(0, 255)}`; + const filePath = path.join(prefixPath, entry.name); + const match = /^([a-f0-9]{64})\.(png|jpg)$/u.exec(entry.name); + if ( + !entry.isFile() || + entry.isSymbolicLink() || + !match || + match[1]!.slice(0, 2) !== prefix.name + ) { + invalid.push({ entryId, filePath, reason: "invalid_asset_entry" }); + continue; + } + try { + const bytes = await readBoundedRegularFile( + filePath, + Math.max(this.limits.maxImportBytes, this.limits.maxProviderResponseBytes), + ); + if (bytes.byteLength > this.limits.totalAssetBytes - scannedBytes) { + throw new AssetStoreError( + "asset_store_quota_exceeded", + "The asset tree exceeds its aggregate byte quota.", + ); + } + scannedBytes += bytes.byteLength; + const assetId = createHash("sha256").update(bytes).digest("hex"); + if (assetId !== match[1]) throw new Error("digest_mismatch"); + const descriptor = validateImageBytes(bytes, undefined, entry.name, this.limits); + if (descriptor.extension !== match[2]) throw new Error("extension_mismatch"); + const decoded = await this.options.deepValidator.validate({ + filePath, + descriptor, + byteLength: bytes.byteLength, + }); + if (decoded.width !== descriptor.width || decoded.height !== descriptor.height) { + throw new Error("decoder_dimension_mismatch"); + } + const info = await fs.lstat(filePath); + scanned.set(assetId, { + entryId, + filePath, + descriptor, + byteLength: bytes.byteLength, + createdAt: new Date(info.birthtimeMs || info.mtimeMs).toISOString(), + }); + } catch (error) { + if (error instanceof AssetStoreError && error.code === "asset_store_quota_exceeded") { + throw error; + } + invalid.push({ + entryId, + filePath, + reason: + error instanceof AssetImageValidationError + ? error.code + : error instanceof Error && + [ + "digest_mismatch", + "extension_mismatch", + "decoder_dimension_mismatch", + ].includes(error.message) + ? error.message + : "decoder_or_file_validation_failed", + }); + } + } + } + return { scanned, invalid }; + } +} diff --git a/main/services/create-images/asset-thumbnail-cache-core.ts b/main/services/create-images/asset-thumbnail-cache-core.ts new file mode 100644 index 00000000..88c008aa --- /dev/null +++ b/main/services/create-images/asset-thumbnail-cache-core.ts @@ -0,0 +1,69 @@ +export interface ByteSizedValue { + byteLength: number; +} + +/** Strict byte-bounded LRU. Values larger than the entire budget are never retained. */ +export class ByteBoundedLru { + private readonly entries = new Map(); + private retainedBytes = 0; + + constructor(private readonly maxBytes: number) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new Error("Thumbnail cache capacity must be a positive integer byte count."); + } + } + + get(key: string): Value | undefined { + const value = this.entries.get(key); + if (!value) return undefined; + this.entries.delete(key); + this.entries.set(key, value); + return value; + } + + set(key: string, value: Value): void { + if (!Number.isSafeInteger(value.byteLength) || value.byteLength < 0) { + throw new Error("Cached values require an exact non-negative byte length."); + } + this.delete(key); + if (value.byteLength > this.maxBytes) return; + while (this.retainedBytes + value.byteLength > this.maxBytes) { + const oldest = this.entries.keys().next().value as string | undefined; + if (oldest === undefined) break; + this.delete(oldest); + } + this.entries.set(key, value); + this.retainedBytes += value.byteLength; + } + + delete(key: string): boolean { + const value = this.entries.get(key); + if (!value) return false; + this.entries.delete(key); + this.retainedBytes -= value.byteLength; + return true; + } + + deletePrefix(prefix: string): number { + let deleted = 0; + for (const key of [...this.entries.keys()]) { + if (!key.startsWith(prefix)) continue; + this.delete(key); + deleted += 1; + } + return deleted; + } + + clear(): void { + this.entries.clear(); + this.retainedBytes = 0; + } + + get size(): number { + return this.entries.size; + } + + get byteLength(): number { + return this.retainedBytes; + } +} diff --git a/main/services/create-images/electron-asset-image-utility.ts b/main/services/create-images/electron-asset-image-utility.ts new file mode 100644 index 00000000..a00f65db --- /dev/null +++ b/main/services/create-images/electron-asset-image-utility.ts @@ -0,0 +1,127 @@ +import { randomBytes } from "node:crypto"; +import path from "node:path"; +import { app, BrowserWindow, MessageChannelMain } from "electron"; +import { readRegularFile } from "../regular-file-read.js"; + +const IMAGE_UTILITY_TIMEOUT_MS = 20_000; +const IMAGE_UTILITY_MAX_INPUT_BYTES = 64 * 1024 * 1024; +const DECODER_CHANNEL = "create-images:image-decoder-port"; + +interface ImageUtilityRequest { + operation: "normalize" | "thumbnail" | "validate"; + filePath: string; + maxInputBytes?: number; + maxDimension?: number; + maxWidth?: number; + maxHeight?: number; + maxPixels?: number; + maxOutputBytes?: number; +} + +interface ImageUtilitySuccess { + id: string; + ok: true; + width: number; + height: number; + bytes?: Uint8Array; +} + +function preloadPath(): string { + const developmentOverride = process.env.AIDEN_CREATE_IMAGES_DECODER_PRELOAD; + if (!app.isPackaged && developmentOverride) return path.resolve(developmentOverride); + return path.join(app.getAppPath(), "build", "preload", "create-images-image-decoder.cjs"); +} + +/** + * Decode one untrusted image in a disposable, sandboxed Chromium renderer. + * Codec work and decoded pixels therefore live outside the privileged browser + * process. The decoder page has default-src 'none', no Node integration, no + * generic Aiden preload bridge, and receives bounded bytes rather than a path. + */ +export async function runImageUtility(request: ImageUtilityRequest): Promise { + const id = randomBytes(18).toString("base64url"); + const maxInputBytes = request.maxInputBytes ?? IMAGE_UTILITY_MAX_INPUT_BYTES; + if ( + !Number.isSafeInteger(maxInputBytes) || + maxInputBytes < 1 || + maxInputBytes > IMAGE_UTILITY_MAX_INPUT_BYTES + ) { + throw new Error("The image decoder input limit is invalid."); + } + const fileBytes = await readRegularFile(request.filePath, maxInputBytes); + const window = new BrowserWindow({ + show: false, + width: 1, + height: 1, + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + preload: preloadPath(), + sandbox: true, + webSecurity: true, + }, + }); + window.setMenuBarVisibility(false); + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => event.preventDefault()); + try { + await window.loadURL( + "data:text/html;charset=utf-8," + + encodeURIComponent( + 'Aiden Image Decoder', + ), + ); + return await new Promise((resolve, reject) => { + const { port1, port2 } = new MessageChannelMain(); + let settled = false; + const finish = (error?: Error, result?: ImageUtilitySuccess) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + port1.close(); + if (error || !result) reject(error ?? new Error("The image decoder returned no result.")); + else resolve(result); + }; + const timeout = setTimeout( + () => finish(new Error("The image decoder exceeded its time limit.")), + IMAGE_UTILITY_TIMEOUT_MS, + ); + port1.on("message", (event) => { + const value = event.data as unknown; + if (typeof value !== "object" || value === null) return; + const response = value as Partial & { ok?: boolean }; + if (response.id !== id) return; + if ( + response.ok !== true || + !Number.isSafeInteger(response.width) || + !Number.isSafeInteger(response.height) + ) { + finish(new Error("The browser image decoder rejected the image.")); + return; + } + finish(undefined, response as ImageUtilitySuccess); + }); + port1.start(); + window.webContents.postMessage(DECODER_CHANNEL, null, [port2]); + port1.postMessage({ + id, + operation: request.operation, + // MessagePort performs the one required structured-clone copy into the + // sandboxed renderer. Do not first duplicate the bounded file buffer in + // the privileged main process. + bytes: new Uint8Array(fileBytes.buffer, fileBytes.byteOffset, fileBytes.byteLength), + ...(request.maxDimension ? { maxDimension: request.maxDimension } : {}), + ...(request.maxWidth ? { maxWidth: request.maxWidth } : {}), + ...(request.maxHeight ? { maxHeight: request.maxHeight } : {}), + ...(request.maxPixels ? { maxPixels: request.maxPixels } : {}), + ...(request.maxOutputBytes ? { maxOutputBytes: request.maxOutputBytes } : {}), + }); + window.webContents.once("render-process-gone", () => + finish(new Error("The sandboxed image decoder crashed.")), + ); + }); + } finally { + if (!window.isDestroyed()) window.destroy(); + } +} diff --git a/main/services/create-images/electron-asset-images.ts b/main/services/create-images/electron-asset-images.ts new file mode 100644 index 00000000..1a6d5f91 --- /dev/null +++ b/main/services/create-images/electron-asset-images.ts @@ -0,0 +1,37 @@ +import type { + AssetDeepValidator, + AssetThumbnailGenerator, +} from "./asset-store-core.js"; +import { runImageUtility } from "./electron-asset-image-utility.js"; + +export const electronAssetDeepValidator: AssetDeepValidator = { + async validate({ filePath }) { + const size = await runImageUtility({ operation: "validate", filePath }); + if (!Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height)) { + throw new Error("The native image decoder returned invalid dimensions."); + } + return size; + }, +}; + +export const electronAssetThumbnailGenerator: AssetThumbnailGenerator = { + async generate({ sourcePath, maxDimension, maxOutputBytes }) { + const result = await runImageUtility({ + operation: "thumbnail", + filePath: sourcePath, + maxDimension, + maxOutputBytes, + }); + const bytes = result.bytes; + if (!bytes) throw new Error("The image decoder returned no thumbnail bytes."); + if (bytes.byteLength < 1 || bytes.byteLength > maxOutputBytes) { + throw new Error("The generated thumbnail exceeds its byte limit."); + } + return { + bytes: bytes.slice(), + width: result.width, + height: result.height, + mediaType: "image/png", + }; + }, +}; diff --git a/main/services/create-images/electron-asset-import.test.ts b/main/services/create-images/electron-asset-import.test.ts new file mode 100644 index 00000000..eb779d87 --- /dev/null +++ b/main/services/create-images/electron-asset-import.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { AssetImageValidationError } from "./asset-image-validation-core.js"; +import type { AssetIngestRequest, AssetIngestResult } from "./asset-store-core.js"; +import { + CreateImagesImageImportError, + ingestCreateImagesImageFile, + type CreateImagesImageNormalizer, +} from "./electron-asset-import.js"; + +async function collect(source: AsyncIterable): Promise { + const chunks: Uint8Array[] = []; + let length = 0; + for await (const chunk of source) { + chunks.push(chunk.slice()); + length += chunk.byteLength; + } + const result = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +function result(request: AssetIngestRequest, bytes: Uint8Array): AssetIngestResult { + return { + asset: { + assetId: "a".repeat(64), + mediaType: "image/png", + byteLength: bytes.byteLength, + width: 1, + height: 1, + createdAt: "2026-08-18T00:00:00.000Z", + ...(request.displayName ? { displayName: request.displayName } : {}), + origin: request.origin, + referenceCount: 0, + thumbnailSizes: [], + }, + deduplicated: false, + quotaWarning: false, + totalAssetBytes: bytes.byteLength, + }; +} + +async function fixture( + name: string, + contents: Uint8Array, +): Promise<{ directory: string; file: string }> { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-image-import-")); + const file = path.join(directory, name); + await fs.writeFile(file, contents); + return { directory, file }; +} + +test("keeps a canonical import on the direct content-addressed path", async (context) => { + const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const source = await fixture("direct.png", png); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + let normalizeCalls = 0; + const store = { + async ingest(input: AsyncIterable, request: AssetIngestRequest) { + const imported = await collect(input); + assert.deepEqual(imported, png); + assert.equal(request.displayName, "direct.png"); + return result(request, imported); + }, + }; + await ingestCreateImagesImageFile(store, source.file, { + normalizer: { + async normalize() { + normalizeCalls += 1; + throw new Error("unused"); + }, + }, + }); + assert.equal(normalizeCalls, 0); +}); + +test("normalizes a supported static raster in the isolated decoder and preserves its label", async (context) => { + const webp = new TextEncoder().encode("RIFF\u0004\u0000\u0000\u0000WEBPVP8 "); + const normalized = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const source = await fixture("reference.webp", webp); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + const requests: AssetIngestRequest[] = []; + const seen: Uint8Array[] = []; + const store = { + async ingest(input: AsyncIterable, request: AssetIngestRequest) { + requests.push(request); + seen.push(await collect(input)); + if (requests.length === 1) { + throw new AssetImageValidationError("unsupported_format", "unsupported"); + } + return result(request, seen[seen.length - 1]!); + }, + }; + const normalizer: CreateImagesImageNormalizer = { + async normalize(filePath) { + assert.equal(filePath, source.file); + return { bytes: normalized, width: 1, height: 1 }; + }, + }; + const imported = await ingestCreateImagesImageFile(store, source.file, { normalizer }); + assert.equal(imported.asset.displayName, "reference.webp"); + assert.deepEqual(seen, [webp, normalized]); + assert.deepEqual(requests[1], { + origin: { kind: "import" }, + displayName: "reference.webp", + declaredMimeType: "image/png", + validationDisplayName: "reference.png", + }); +}); + +test("corrects a canonical extension mismatch without invoking conversion", async (context) => { + const png = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const source = await fixture("mislabeled.webp", png); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + let call = 0; + let secondRequest: AssetIngestRequest | undefined; + const store = { + async ingest(input: AsyncIterable, request: AssetIngestRequest) { + const imported = await collect(input); + call += 1; + if (call === 1) throw new AssetImageValidationError("extension_mismatch", "mismatch"); + secondRequest = request; + return result(request, imported); + }, + }; + await ingestCreateImagesImageFile(store, source.file, { + normalizer: { + async normalize() { + throw new Error("must not normalize"); + }, + }, + }); + assert.equal(secondRequest?.validationDisplayName, "mislabeled.png"); +}); + +test("rejects vector and animated images before conversion", async (context) => { + for (const [name, contents, code] of [ + ["vector.svg", new TextEncoder().encode(""), "vector_image"], + [ + "animated.webp", + new TextEncoder().encode("RIFF\u0004\u0000\u0000\u0000WEBPANIM\u0000\u0000\u0000\u0000"), + "animated_image", + ], + ] as const) { + const source = await fixture(name, contents); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + const store = { + async ingest(input: AsyncIterable) { + await collect(input); + throw new AssetImageValidationError("unsupported_format", "unsupported"); + }, + }; + await assert.rejects( + ingestCreateImagesImageFile(store, source.file, { + normalizer: { + async normalize() { + throw new Error("must not normalize"); + }, + }, + }), + (error: unknown) => error instanceof CreateImagesImageImportError && error.code === code, + ); + } +}); + +test("does not convert malformed canonical images or accept invalid normalized bounds", async (context) => { + const malformed = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); + const source = await fixture("malformed.png", malformed); + context.after(() => fs.rm(source.directory, { recursive: true, force: true })); + const store = { + async ingest(input: AsyncIterable) { + await collect(input); + throw new AssetImageValidationError("malformed_image", "malformed"); + }, + }; + await assert.rejects( + ingestCreateImagesImageFile(store, source.file, { + normalizer: { + async normalize() { + throw new Error("must not normalize"); + }, + }, + }), + (error: unknown) => + error instanceof AssetImageValidationError && error.code === "malformed_image", + ); +}); diff --git a/main/services/create-images/electron-asset-import.ts b/main/services/create-images/electron-asset-import.ts new file mode 100644 index 00000000..0fbf68fb --- /dev/null +++ b/main/services/create-images/electron-asset-import.ts @@ -0,0 +1,173 @@ +import { constants } from "node:fs"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { readRegularFile } from "../regular-file-read.js"; +import { + AssetImageValidationError, + sanitizeAssetDisplayName, +} from "./asset-image-validation-core.js"; +import { + AssetStoreError, + DEFAULT_ASSET_STORE_LIMITS, + type AssetIngestRequest, + type AssetIngestResult, +} from "./asset-store-core.js"; +import { + createImagesCanonicalValidationName, + createImagesImportSourcePolicy, +} from "./asset-import-normalization-core.js"; + +interface CreateImagesImportAssetStore { + ingest( + source: AsyncIterable, + request: AssetIngestRequest, + ): Promise; +} + +export class CreateImagesImageImportError extends Error { + constructor( + public readonly code: "animated_image" | "normalization_failed" | "vector_image", + message: string, + ) { + super(message); + this.name = "CreateImagesImageImportError"; + } +} + +export interface CreateImagesImageNormalizer { + normalize(filePath: string): Promise<{ bytes: Uint8Array; width: number; height: number }>; +} + +const defaultNormalizer: CreateImagesImageNormalizer = { + async normalize(filePath) { + // Keep Electron's privileged module out of pure Node test/runtime imports; + // conversion is loaded only when a non-canonical raster actually needs it. + const { runImageUtility } = await import("./electron-asset-image-utility.js"); + let result: { bytes?: Uint8Array; width: number; height: number }; + try { + result = await runImageUtility({ + operation: "normalize", + filePath, + maxInputBytes: DEFAULT_ASSET_STORE_LIMITS.maxImportBytes, + maxWidth: DEFAULT_ASSET_STORE_LIMITS.maxWidth, + maxHeight: DEFAULT_ASSET_STORE_LIMITS.maxHeight, + maxPixels: DEFAULT_ASSET_STORE_LIMITS.maxPixels, + maxOutputBytes: DEFAULT_ASSET_STORE_LIMITS.maxImportBytes, + }); + } catch (chromiumError) { + if (process.platform !== "darwin") throw chromiumError; + const { normalizeImageWithMacosImageIo } = await import("./macos-image-normalizer.js"); + return normalizeImageWithMacosImageIo(filePath, { + maxInputBytes: DEFAULT_ASSET_STORE_LIMITS.maxImportBytes, + maxOutputBytes: DEFAULT_ASSET_STORE_LIMITS.maxImportBytes, + maxWidth: DEFAULT_ASSET_STORE_LIMITS.maxWidth, + maxHeight: DEFAULT_ASSET_STORE_LIMITS.maxHeight, + maxPixels: DEFAULT_ASSET_STORE_LIMITS.maxPixels, + }); + } + if (!result.bytes) throw new Error("The image normalizer returned no bytes."); + return { bytes: result.bytes.slice(), width: result.width, height: result.height }; + }, +}; + +async function* selectedFile(filePath: string): AsyncGenerator { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NONBLOCK | noFollow); + try { + const stat = await handle.stat(); + if (!stat.isFile()) throw new Error("The selected image is not a regular file."); + const stream = handle.createReadStream({ autoClose: false }); + for await (const chunk of stream) { + yield new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + } finally { + await handle.close().catch(() => undefined); + } +} + +async function* bytesSource(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +function importRequest(displayName: string | undefined): AssetIngestRequest { + return { origin: { kind: "import" }, ...(displayName ? { displayName } : {}) }; +} + +export async function ingestCreateImagesImageFile( + assets: CreateImagesImportAssetStore, + filePath: string, + options: { + normalizer?: CreateImagesImageNormalizer; + maxInputBytes?: number; + } = {}, +): Promise { + const displayName = sanitizeAssetDisplayName(path.basename(filePath)); + try { + return await assets.ingest(selectedFile(filePath), importRequest(displayName)); + } catch (error) { + if (!(error instanceof AssetImageValidationError)) throw error; + const maxInputBytes = options.maxInputBytes ?? DEFAULT_ASSET_STORE_LIMITS.maxImportBytes; + let original: Uint8Array; + try { + original = await readRegularFile(filePath, maxInputBytes); + } catch (readError) { + if ((readError as NodeJS.ErrnoException).code === "EFBIG") { + throw new AssetStoreError( + "asset_ingest_too_large", + `The image exceeds the ${maxInputBytes}-byte ingest limit.`, + ); + } + throw readError; + } + const policy = createImagesImportSourcePolicy(original, displayName); + if (policy.kind === "reject") { + throw new CreateImagesImageImportError( + policy.reason === "animated" ? "animated_image" : "vector_image", + policy.reason === "animated" + ? "Animated images are not supported." + : "Vector images are not supported.", + ); + } + if (policy.kind === "canonical") { + if (error.code !== "extension_mismatch") throw error; + return assets.ingest(selectedFile(filePath), { + ...importRequest(displayName), + validationDisplayName: createImagesCanonicalValidationName( + displayName, + policy.format === "jpeg" ? "jpg" : "png", + ), + }); + } + if (error.code !== "unsupported_format" && error.code !== "extension_mismatch") throw error; + let normalized: { bytes: Uint8Array; width: number; height: number }; + try { + normalized = await (options.normalizer ?? defaultNormalizer).normalize(filePath); + } catch { + throw new CreateImagesImageImportError( + "normalization_failed", + "The isolated image converter could not decode this file.", + ); + } + if ( + normalized.bytes.byteLength < 1 || + normalized.bytes.byteLength > DEFAULT_ASSET_STORE_LIMITS.maxImportBytes || + !Number.isSafeInteger(normalized.width) || + !Number.isSafeInteger(normalized.height) || + normalized.width < 1 || + normalized.height < 1 || + normalized.width > DEFAULT_ASSET_STORE_LIMITS.maxWidth || + normalized.height > DEFAULT_ASSET_STORE_LIMITS.maxHeight || + normalized.width * normalized.height > DEFAULT_ASSET_STORE_LIMITS.maxPixels + ) { + throw new CreateImagesImageImportError( + "normalization_failed", + "The isolated image converter returned an invalid image.", + ); + } + return assets.ingest(bytesSource(normalized.bytes), { + ...importRequest(displayName), + declaredMimeType: "image/png", + validationDisplayName: createImagesCanonicalValidationName(displayName, "png"), + }); + } +} diff --git a/main/services/create-images/image-decoder-boundary.test.ts b/main/services/create-images/image-decoder-boundary.test.ts new file mode 100644 index 00000000..190ef591 --- /dev/null +++ b/main/services/create-images/image-decoder-boundary.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { DEFAULT_ASSET_STORE_LIMITS } from "./asset-store-core.js"; + +function source(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), "utf8"); +} + +test("untrusted image codecs run in a disposable sandboxed renderer", () => { + const adapter = source("./electron-asset-images.ts"); + const decoder = source("./electron-asset-image-utility.ts"); + const preload = source("../../../renderer/preload-create-images-image-decoder.ts"); + const build = source("../../../scripts/build-electron.mjs"); + + assert.doesNotMatch(adapter, /nativeImage/u); + assert.match(decoder, /new BrowserWindow/u); + assert.match(decoder, /sandbox: true/u); + assert.match(decoder, /contextIsolation: true/u); + assert.match(decoder, /nodeIntegration: false/u); + assert.match(decoder, /default-src 'none'/u); + assert.match(decoder, /readRegularFile\(request\.filePath, maxInputBytes\)/u); + assert.match(preload, /createImageBitmap/u); + assert.match(preload, /OffscreenCanvas/u); + assert.match(preload, /operation === "normalize"/u); + assert.match(preload, /bitmap\.width \* bitmap\.height > request\.maxPixels/u); + assert.match(preload, /convertToBlob\(\{ type: "image\/png" \}\)/u); + assert.match(decoder, /operation: "normalize" \| "thumbnail" \| "validate"/u); + assert.match(decoder, /maxPixels/u); + assert.match(build, /preload-create-images-image-decoder\.ts/u); + assert.equal(DEFAULT_ASSET_STORE_LIMITS.maxPixels, 16_000_000); +}); + +test("asset reads allocate through a maxBytes plus one descriptor loop", () => { + const assetStore = source("./asset-store-core.ts"); + const helper = assetStore.match( + /async function readBoundedRegularFile[\s\S]*?\n\}\n\nasync function syncDirectory/u, + )?.[0]; + assert.ok(helper); + assert.match(helper, /maxBytes \+ 1 - total/u); + assert.match(helper, /if \(total > maxBytes\)/u); + assert.doesNotMatch(helper, /handle\.readFile/u); +}); diff --git a/main/services/create-images/macos-image-normalizer.ts b/main/services/create-images/macos-image-normalizer.ts new file mode 100644 index 00000000..8d8cb916 --- /dev/null +++ b/main/services/create-images/macos-image-normalizer.ts @@ -0,0 +1,87 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { readRegularFile } from "../regular-file-read.js"; +import { validateImageBytes } from "./asset-image-validation-core.js"; + +const IMAGE_IO_TIMEOUT_MS = 20_000; +const MAX_DIAGNOSTIC_BYTES = 4 * 1024; + +export interface MacosImageNormalizerLimits { + maxInputBytes: number; + maxOutputBytes: number; + maxWidth: number; + maxHeight: number; + maxPixels: number; +} + +async function runSips(inputPath: string, outputPath: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn("/usr/bin/sips", ["-s", "format", "png", inputPath, "--out", outputPath], { + shell: false, + stdio: ["ignore", "ignore", "pipe"], + windowsHide: true, + }); + let settled = false; + let diagnostic = ""; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (error) reject(error); + else resolve(); + }; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + if (diagnostic.length < MAX_DIAGNOSTIC_BYTES) { + diagnostic += chunk.slice(0, MAX_DIAGNOSTIC_BYTES - diagnostic.length); + } + }); + child.once("error", (error) => finish(error)); + child.once("close", (code, signal) => { + if (code === 0 && signal === null) finish(); + else { + finish( + new Error( + diagnostic.trim() || + `The macOS image converter stopped with ${signal ?? `exit code ${String(code)}`}.`, + ), + ); + } + }); + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + finish(new Error("The macOS image converter exceeded its time limit.")); + }, IMAGE_IO_TIMEOUT_MS); + }); +} + +/** + * Convert a static raster with macOS ImageIO after Chromium declines it. + * The selected file is copied into a private directory first, the converter + * receives fixed arguments without a shell, and its PNG is fully revalidated. + */ +export async function normalizeImageWithMacosImageIo( + selectedPath: string, + limits: MacosImageNormalizerLimits, +): Promise<{ bytes: Uint8Array; width: number; height: number }> { + if (process.platform !== "darwin") { + throw new Error("The macOS image converter is unavailable on this platform."); + } + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-image-normalize-")); + const inputPath = path.join(temporary, "source.raster"); + const outputPath = path.join(temporary, "normalized.png"); + try { + const input = await readRegularFile(selectedPath, limits.maxInputBytes); + await fs.writeFile(inputPath, input, { flag: "wx", mode: 0o600 }); + await runSips(inputPath, outputPath); + const bytes = await readRegularFile(outputPath, limits.maxOutputBytes); + const descriptor = validateImageBytes(bytes, "image/png", "normalized.png", limits); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return { bytes: copy, width: descriptor.width, height: descriptor.height }; + } finally { + await fs.rm(temporary, { force: true, recursive: true }).catch(() => undefined); + } +} diff --git a/main/services/create-images/workflow-manifest-store.test.ts b/main/services/create-images/workflow-manifest-store.test.ts new file mode 100644 index 00000000..56ed647c --- /dev/null +++ b/main/services/create-images/workflow-manifest-store.test.ts @@ -0,0 +1,800 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test, { type TestContext } from "node:test"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { + WorkflowManifestLoadError, + WorkflowManifestStore, + WorkflowRevisionConflictError, +} from "./workflow-manifest-store.js"; + +async function harness(t: TestContext) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + return { directory, store: new WorkflowManifestStore(() => directory) }; +} + +function workflow(workflowId = "workflow-1", now = "2026-08-11T12:00:00.000Z") { + return createStarterWorkflow({ + workflowId, + promptNodeId: `${workflowId}-prompt`, + generationNodeId: `${workflowId}-generate`, + outputNodeId: `${workflowId}-output`, + promptEdgeId: `${workflowId}-edge-1`, + outputEdgeId: `${workflowId}-edge-2`, + now, + }); +} + +function nextRevision( + current: ReturnType, + title: string, + updatedAt = "2026-08-11T12:01:00.000Z", +) { + return { ...structuredClone(current), title, revision: current.revision + 1, updatedAt }; +} + +test("workflow manifests persist independently with a rebuildable metadata-only index", async (t) => { + const { directory, store } = await harness(t); + assert.deepEqual(await store.health(), { + status: "healthy", + source: "missing", + path: path.join(directory, "index.json"), + }); + const first = workflow(); + await store.create(first); + assert.deepEqual(await store.get(first.id), first); + assert.deepEqual(await store.list(), [ + { + id: first.id, + title: first.title, + revision: 1, + createdAt: first.createdAt, + updatedAt: first.updatedAt, + nodeCount: 3, + edgeCount: 2, + assetCount: 0, + health: "healthy", + recoveryAvailable: true, + }, + ]); + assert.deepEqual(await store.health(), { + status: "healthy", + source: "disk", + path: path.join(directory, "index.json"), + }); + assert.deepEqual((await fs.readdir(path.join(directory, "workflows", first.id))).sort(), [ + "workflow.json", + "workflow.last-known-good.json", + ]); + const indexText = await fs.readFile(path.join(directory, "index.json"), "utf8"); + assert.equal(indexText.includes("nodes"), false); + assert.equal(indexText.includes("prompt"), false); +}); + +test("save, rename, duplicate, and recoverable delete enforce exact revisions", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + await assert.rejects( + () => store.put(nextRevision(first, "stale create"), null), + WorkflowRevisionConflictError, + ); + const saved = nextRevision(first, "Saved revision"); + await store.put(saved, 1); + await assert.rejects(() => store.delete(first.id, 1), WorkflowRevisionConflictError); + const renamed = await store.rename(first.id, "Renamed", 2, "2026-08-11T12:02:00.000Z"); + assert.equal(renamed.revision, 3); + assert.equal(renamed.title, "Renamed"); + await assert.rejects( + () => + store.duplicate(first.id, { + workflowId: "stale-copy", + expectedRevision: 2, + now: "2026-08-11T12:03:00.000Z", + }), + WorkflowRevisionConflictError, + ); + const duplicate = await store.duplicate(first.id, { + workflowId: "workflow-copy", + expectedRevision: 3, + title: "A durable copy", + now: "2026-08-11T12:03:00.000Z", + }); + assert.equal(duplicate.id, "workflow-copy"); + assert.equal(duplicate.revision, 1); + assert.equal(duplicate.nodes.length, renamed.nodes.length); + assert.deepEqual(await store.delete(first.id, 3), renamed); + assert.equal(await store.get(first.id), undefined); + assert.deepEqual( + (await store.list()).map((item) => item.id), + ["workflow-copy"], + ); + const deletedQuarantine = path.join(directory, "quarantine", "deleted-workflows"); + const quarantined = await fs.readdir(deletedQuarantine); + assert.equal(quarantined.length, 1); + const quarantineEntry = quarantined[0]; + assert.ok(quarantineEntry?.startsWith("deleted-workflow-1-")); + const quarantinePath = path.join(deletedQuarantine, quarantineEntry); + assert.equal((await fs.lstat(quarantinePath)).isDirectory(), true); + assert.deepEqual((await fs.readdir(quarantinePath)).sort(), [ + "workflow.json", + "workflow.last-known-good.json", + ]); +}); + +test("concurrent stores serialize mutations and reject the stale autosave", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const left = new WorkflowManifestStore(() => directory); + const right = new WorkflowManifestStore(() => directory); + const results = await Promise.allSettled([ + left.put(nextRevision(first, "Left"), 1), + right.put(nextRevision(first, "Right"), 1), + ]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + const rejected = results.find((result) => result.status === "rejected"); + assert.equal(rejected?.status, "rejected"); + if (rejected?.status === "rejected") { + assert.equal(rejected.reason instanceof WorkflowRevisionConflictError, true); + } + const current = await new WorkflowManifestStore(() => directory).get(first.id); + assert.equal(current?.revision, 2); + assert.equal(["Left", "Right"].includes(current?.title ?? ""), true); + assert.equal((await store.list())[0]?.revision, 2); +}); + +test("autosave can be staged, observed, flushed, and discarded with CAS", async (t) => { + const { store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const second = nextRevision(first, "Pending autosave"); + await store.stageAutosave(second, 1); + assert.deepEqual(await store.autosaveStatus(first.id), { + workflowId: first.id, + state: "pending", + baseRevision: 1, + targetRevision: 2, + stagedAt: second.updatedAt, + }); + const pendingHealth = await store.inspect(first.id); + assert.equal(pendingHealth.status, "recovery-required"); + if (pendingHealth.status === "recovery-required") { + assert.equal(pendingHealth.reason, "journal-pending"); + } + await assert.rejects(() => store.get(first.id), WorkflowManifestLoadError); + await assert.rejects(() => store.flushAutosave(first.id, null), WorkflowRevisionConflictError); + assert.deepEqual(await store.flushAutosave(first.id, 1), second); + assert.deepEqual(await store.autosaveStatus(first.id), { + workflowId: first.id, + state: "none", + }); + + const third = nextRevision(second, "Discard me", "2026-08-11T12:02:00.000Z"); + await store.stageAutosave(third, 2); + await assert.rejects(() => store.discardAutosave(first.id, 4), WorkflowRevisionConflictError); + await store.discardAutosave(first.id, 3); + assert.equal((await store.get(first.id))?.revision, 2); +}); + +test("a crash-survived newer journal requires explicit autosave recovery", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const second = nextRevision(first, "Journal survived"); + const interrupted = new WorkflowManifestStore(() => directory, { + afterJournalPublished: async () => { + throw new Error("simulated journal crash"); + }, + }); + await assert.rejects(() => interrupted.put(second, 1), /simulated journal crash/u); + const reopened = new WorkflowManifestStore(() => directory); + const journalPath = path.join(directory, "workflows", first.id, "autosave.journal"); + const durableJournal = await fs.readFile(journalPath, "utf8"); + const health = await reopened.inspect(first.id); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") assert.equal(health.reason, "journal-pending"); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); + await assert.rejects( + () => reopened.stageAutosave(nextRevision(first, "Must not replace it"), 1), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(journalPath, "utf8"), durableJournal); + assert.equal((await reopened.autosaveStatus(first.id)).state, "pending"); + const recovered = await reopened.recover(first.id, "autosave", 2, "2026-08-11T12:03:00.000Z"); + assert.equal(recovered.title, second.title); + assert.equal(recovered.revision, 3); + assert.equal((await reopened.inspect(first.id)).status, "healthy"); +}); + +test("a crash after current publication is idempotently reconciled on restart", async (t) => { + for (const entrypoint of ["initialize", "get"] as const) { + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), `aiden-create-images-${entrypoint}-reconcile-`), + ); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const first = workflow(`workflow-${entrypoint}`); + await new WorkflowManifestStore(() => directory).put(first, null); + const second = nextRevision(first, "Current survived"); + const interrupted = new WorkflowManifestStore(() => directory, { + afterCurrentPublished: async () => { + throw new Error("simulated post-current crash"); + }, + }); + await assert.rejects(() => interrupted.put(second, 1), /simulated post-current crash/u); + + const workflowDirectory = path.join(directory, "workflows", first.id); + const lastKnownGoodPath = path.join(workflowDirectory, "workflow.last-known-good.json"); + const journalPath = path.join(workflowDirectory, "autosave.journal"); + assert.deepEqual(JSON.parse(await fs.readFile(lastKnownGoodPath, "utf8")), first); + await fs.access(journalPath); + + const reopened = new WorkflowManifestStore(() => directory); + if (entrypoint === "initialize") { + assert.equal((await reopened.initialize())[0]?.revision, second.revision); + } else { + assert.deepEqual(await reopened.get(first.id), second); + } + assert.deepEqual(JSON.parse(await fs.readFile(lastKnownGoodPath, "utf8")), second); + await assert.rejects(() => fs.lstat(journalPath), { code: "ENOENT" }); + assert.deepEqual(await reopened.get(first.id), second); + assert.equal((await reopened.autosaveStatus(first.id)).state, "none"); + assert.equal((await reopened.initialize())[0]?.health, "healthy"); + } +}); + +test("a divergent autosave conflict can be recovered without overwriting either revision", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const autosave = nextRevision(first, "Autosaved branch"); + await store.stageAutosave(autosave, 1); + const independentlySaved = nextRevision(first, "Saved elsewhere"); + await fs.writeFile( + path.join(directory, "workflows", first.id, "workflow.json"), + `${JSON.stringify(independentlySaved)}\n`, + "utf8", + ); + + const reopened = new WorkflowManifestStore(() => directory); + assert.deepEqual(await reopened.inspect(first.id), { + status: "recovery-required", + workflowId: first.id, + currentPath: path.join(directory, "workflows", first.id, "workflow.json"), + reason: "journal-conflict", + currentRevision: 2, + lastKnownGoodAvailable: true, + lastKnownGoodRevision: 1, + autosave: "pending", + autosaveTargetRevision: 2, + }); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); + const recovered = await reopened.recover(first.id, "autosave", 2, "2026-08-11T12:04:00.000Z"); + assert.equal(recovered.title, "Autosaved branch"); + assert.equal(recovered.revision, 3); + assert.equal((await reopened.inspect(first.id)).status, "healthy"); + assert.deepEqual(await reopened.autosaveStatus(first.id), { + workflowId: first.id, + state: "none", + }); +}); + +test("an autosave can recover when last-known-good metadata is also corrupt", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const pending = nextRevision(first, "Pending survives damaged metadata"); + await store.stageAutosave(pending, 1); + const lastGoodPath = path.join(directory, "workflows", first.id, "workflow.last-known-good.json"); + await fs.writeFile(lastGoodPath, "{broken", "utf8"); + + const reopened = new WorkflowManifestStore(() => directory); + const health = await reopened.inspect(first.id); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") { + assert.equal(health.reason, "last-known-good-corrupt"); + assert.equal(health.autosave, "pending"); + assert.equal(health.autosaveTargetRevision, 2); + } + await assert.rejects( + () => reopened.repairRecoveryMetadata(first.id, 1), + /Flush or discard the pending autosave/u, + ); + await assert.rejects( + () => reopened.recover(first.id, "last-known-good", 1, "2026-08-11T12:04:00.000Z"), + /healthy workflow does not require recovery/u, + ); + const recovered = await reopened.recover(first.id, "autosave", 2, "2026-08-11T12:04:00.000Z"); + assert.equal(recovered.title, pending.title); + assert.equal(recovered.revision, 3); + assert.equal((await reopened.inspect(first.id)).status, "healthy"); +}); + +test("corrupt current data opens recovery state and restores an incremented last-known-good", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const currentPath = path.join(directory, "workflows", first.id, "workflow.json"); + await fs.writeFile(currentPath, "{broken", "utf8"); + const reopened = new WorkflowManifestStore(() => directory); + assert.deepEqual(await reopened.inspect(first.id), { + status: "recovery-required", + workflowId: first.id, + currentPath, + reason: "current-corrupt", + lastKnownGoodAvailable: true, + lastKnownGoodRevision: 1, + autosave: "none", + }); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); + await assert.rejects( + () => reopened.put(nextRevision(first, "Never overwrite"), 1), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(currentPath, "utf8"), "{broken"); + const recovered = await reopened.recover( + first.id, + "last-known-good", + 1, + "2026-08-11T12:05:00.000Z", + ); + assert.equal(recovered.revision, 2); + assert.equal((await reopened.inspect(first.id)).status, "healthy"); + assert.equal( + (await fs.readdir(path.join(directory, "quarantine"))).some((name) => + name.startsWith(`${first.id}-current-corrupt-`), + ), + true, + ); +}); + +test("recovery advances past every durable candidate revision", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const pending = nextRevision(first, "Newer pending edit"); + await store.stageAutosave(pending, 1); + await fs.writeFile( + path.join(directory, "workflows", first.id, "workflow.json"), + "{broken", + "utf8", + ); + const reopened = new WorkflowManifestStore(() => directory); + const recovered = await reopened.recover( + first.id, + "last-known-good", + 1, + "2026-08-11T12:05:00.000Z", + ); + assert.equal(recovered.revision, 3); + assert.equal(recovered.title, first.title); + assert.equal((await reopened.autosaveStatus(first.id)).state, "none"); +}); + +test("missing current and corrupt journal states remain explicit and non-destructive", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const paths = path.join(directory, "workflows", first.id); + await fs.rm(path.join(paths, "workflow.json")); + const missing = new WorkflowManifestStore(() => directory); + const missingHealth = await missing.inspect(first.id); + assert.equal(missingHealth.status, "recovery-required"); + if (missingHealth.status === "recovery-required") { + assert.equal(missingHealth.reason, "current-missing"); + } + await assert.rejects(() => missing.get(first.id), WorkflowManifestLoadError); + const restored = await missing.recover( + first.id, + "last-known-good", + 1, + "2026-08-11T12:06:00.000Z", + ); + assert.equal(restored.revision, 2); + + await fs.writeFile(path.join(paths, "autosave.journal"), "{bad", "utf8"); + const corruptJournal = new WorkflowManifestStore(() => directory); + assert.deepEqual(await corruptJournal.autosaveStatus(first.id), { + workflowId: first.id, + state: "corrupt", + }); + await assert.rejects( + () => corruptJournal.stageAutosave(nextRevision(restored, "blocked"), 2), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(path.join(paths, "autosave.journal"), "utf8"), "{bad"); + assert.equal((await corruptJournal.inspect(first.id)).status, "recovery-required"); + await corruptJournal.repairRecoveryMetadata(first.id, 2); + assert.deepEqual(await corruptJournal.autosaveStatus(first.id), { + workflowId: first.id, + state: "none", + }); + assert.equal((await corruptJournal.inspect(first.id)).status, "healthy"); +}); + +test("future workflow and journal schemas are read-only and never quarantined implicitly", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const workflowPath = path.join(directory, "workflows", first.id, "workflow.json"); + const future = `${JSON.stringify({ ...first, schemaVersion: 2 })}\n`; + await fs.writeFile(workflowPath, future, "utf8"); + const reopened = new WorkflowManifestStore(() => directory); + const health = await reopened.inspect(first.id); + assert.equal(health.status, "unsafe"); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); + await assert.rejects( + () => reopened.recover(first.id, "last-known-good", 1, "2026-08-11T12:07:00.000Z"), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(workflowPath, "utf8"), future); + + await fs.writeFile(workflowPath, `${JSON.stringify(first)}\n`, "utf8"); + const journalPath = path.join(directory, "workflows", first.id, "autosave.journal"); + const futureJournal = `${JSON.stringify({ version: 2 })}\n`; + await fs.writeFile(journalPath, futureJournal, "utf8"); + const journalStore = new WorkflowManifestStore(() => directory); + assert.equal((await journalStore.inspect(first.id)).status, "unsafe"); + await assert.rejects( + () => journalStore.stageAutosave(nextRevision(first, "blocked"), 1), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(journalPath, "utf8"), futureJournal); +}); + +test("non-SHA asset references are isolated as workflow recovery instead of poisoning inventory", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const malformed = structuredClone(first); + malformed.nodes.push({ + id: "bad-image", + type: "image-input", + position: { x: 0, y: 0 }, + data: { assetId: "asset-1" }, + }); + malformed.assetRefs = ["asset-1"]; + const workflowDirectory = path.join(directory, "workflows", first.id); + await Promise.all([ + fs.writeFile(path.join(workflowDirectory, "workflow.json"), `${JSON.stringify(malformed)}\n`), + fs.writeFile( + path.join(workflowDirectory, "workflow.last-known-good.json"), + `${JSON.stringify(malformed)}\n`, + ), + ]); + + const reopened = new WorkflowManifestStore(() => directory); + const summaries = await reopened.initialize(); + assert.equal(summaries.length, 1); + assert.equal(summaries[0]?.health, "recovery-required"); + assert.equal((await reopened.inspect(first.id)).status, "recovery-required"); + await assert.rejects(() => reopened.get(first.id), WorkflowManifestLoadError); +}); + +test("future last-known-good metadata is explicit and cannot be replaced by save or repair", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const lastGoodPath = path.join(directory, "workflows", first.id, "workflow.last-known-good.json"); + const future = `${JSON.stringify({ ...first, schemaVersion: 2 })}\n`; + await fs.writeFile(lastGoodPath, future, "utf8"); + const reopened = new WorkflowManifestStore(() => directory); + const health = await reopened.inspect(first.id); + assert.equal(health.status, "unsafe"); + if (health.status === "unsafe") { + assert.equal(health.reason, "last-known-good-future-schema"); + } + await assert.rejects( + () => reopened.put(nextRevision(first, "blocked"), 1), + WorkflowManifestLoadError, + ); + await assert.rejects( + () => reopened.repairRecoveryMetadata(first.id, 1), + WorkflowManifestLoadError, + ); + assert.equal(await fs.readFile(lastGoodPath, "utf8"), future); +}); + +test("Phase 0 aggregate storage migrates without changing workflow revisions", async (t) => { + const { directory } = await harness(t); + const first = workflow(); + await fs.writeFile( + path.join(directory, "workflows.json"), + `${JSON.stringify({ version: 1, workflows: { [first.id]: first } })}\n`, + "utf8", + ); + const store = new WorkflowManifestStore(() => directory); + assert.deepEqual(await store.get(first.id), first); + assert.equal((await store.list())[0]?.revision, 1); + await assert.rejects(() => fs.readFile(path.join(directory, "workflows.json")), { + code: "ENOENT", + }); + assert.equal( + (await fs.readdir(directory)).some((name) => name.startsWith("workflows.phase-0-migrated-")), + true, + ); +}); + +test("corrupt or future Phase 0 aggregate data is preserved and blocks migration", async (t) => { + const { directory } = await harness(t); + const legacyPath = path.join(directory, "workflows.json"); + await fs.writeFile(legacyPath, "{broken", "utf8"); + const corrupt = new WorkflowManifestStore(() => directory); + assert.deepEqual(await corrupt.health(), { status: "corrupt", path: legacyPath }); + await assert.rejects(() => corrupt.list(), WorkflowManifestLoadError); + await assert.rejects(() => corrupt.put(workflow(), null), WorkflowManifestLoadError); + assert.equal(await fs.readFile(legacyPath, "utf8"), "{broken"); + + await fs.writeFile(legacyPath, JSON.stringify({ version: 2, workflows: {} }), "utf8"); + const future = new WorkflowManifestStore(() => directory); + assert.deepEqual(await future.health(), { status: "unsafe", path: legacyPath }); + await assert.rejects(() => future.get("workflow-1"), WorkflowManifestLoadError); + assert.deepEqual(JSON.parse(await fs.readFile(legacyPath, "utf8")), { + version: 2, + workflows: {}, + }); +}); + +test("corrupt index is rebuilt from manifests while a future index is preserved", async (t) => { + const { directory, store } = await harness(t); + await store.put(workflow(), null); + const indexPath = path.join(directory, "index.json"); + await fs.writeFile(indexPath, "{broken", "utf8"); + const reopened = new WorkflowManifestStore(() => directory); + assert.deepEqual(await reopened.health(), { status: "corrupt", path: indexPath }); + assert.equal((await reopened.list()).length, 1); + assert.equal(JSON.parse(await fs.readFile(indexPath, "utf8")).version, 1); + assert.equal( + (await fs.readdir(path.join(directory, "quarantine"))).some((name) => + name.startsWith("index-corrupt-"), + ), + true, + ); + + const future = `${JSON.stringify({ version: 2, workflows: [] })}\n`; + await fs.writeFile(indexPath, future, "utf8"); + const futureStore = new WorkflowManifestStore(() => directory); + assert.deepEqual(await futureStore.health(), { status: "unsafe", path: indexPath }); + assert.equal((await futureStore.list()).length, 1); + assert.equal(await fs.readFile(indexPath, "utf8"), future); +}); + +test("workflow IDs are path-bounded and object-prototype names remain safe", async (t) => { + const { store } = await harness(t); + const document = workflow("constructor"); + await store.put(document, null); + assert.deepEqual(await store.get("constructor"), document); + assert.equal(await store.get("toString"), undefined); + await assert.rejects(() => store.get("../escape"), /Invalid Create Images workflow ID/u); + await assert.rejects( + () => + store.duplicate("constructor", { + workflowId: "../copy", + expectedRevision: 1, + now: document.updatedAt, + }), + /Invalid Create Images workflow ID/u, + ); +}); + +test("workflow inventory fails closed on a same-name directory symlink", async (t) => { + const { directory, store } = await harness(t); + await store.initialize(); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-outside-")); + t.after(() => fs.rm(outside, { recursive: true, force: true })); + const document = workflow("redirected"); + await fs.writeFile(path.join(outside, "workflow.json"), `${JSON.stringify(document)}\n`, "utf8"); + await fs.symlink(outside, path.join(directory, "workflows", document.id)); + await assert.rejects(() => store.get(document.id), WorkflowManifestLoadError); + await assert.rejects(() => store.put(document, null), WorkflowManifestLoadError); + await assert.rejects(() => store.list(), WorkflowManifestLoadError); +}); + +test("workflow inventory fails closed when a workflow directory is renamed to an invalid ID", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await store.put(first, null); + const original = path.join(directory, "workflows", first.id); + const invalid = path.join(directory, "workflows", `${first.id}.renamed`); + await fs.rename(original, invalid); + + await assert.rejects(() => store.list(), WorkflowManifestLoadError); + await assert.rejects(() => store.put(workflow("workflow-2"), null), WorkflowManifestLoadError); + assert.equal((await fs.readdir(invalid)).includes("workflow.json"), true); + await assert.rejects(() => fs.lstat(path.join(directory, "workflows", "workflow-2")), { + code: "ENOENT", + }); +}); + +test("workflow inventory fails closed on unknown workflow files", async (t) => { + const { directory, store } = await harness(t); + await store.create(workflow()); + const unknown = path.join(directory, "workflows", "workflow-1", "unexpected.bin"); + await fs.writeFile(unknown, "not workflow metadata", "utf8"); + + await assert.rejects( + () => store.initialize(), + (error: unknown) => { + assert.equal(error instanceof WorkflowManifestLoadError, true); + assert.equal((error as WorkflowManifestLoadError).status, "unsafe"); + return true; + }, + ); +}); + +test("workflow count and aggregate byte preflights prevent durable unindexed growth", async (t) => { + const { directory } = await harness(t); + const countLimited = new WorkflowManifestStore(() => directory, {}, { maxWorkflowCount: 1 }); + const first = workflow(); + await countLimited.put(first, null); + await assert.rejects( + () => countLimited.put(workflow("workflow-2"), null), + /workflow count limit/u, + ); + await assert.rejects(() => fs.lstat(path.join(directory, "workflows", "workflow-2")), { + code: "ENOENT", + }); + + const firstDirectory = path.join(directory, "workflows", first.id); + const existingBytes = ( + await Promise.all( + ( + await fs.readdir(firstDirectory) + ).map(async (name) => (await fs.lstat(path.join(firstDirectory, name))).size), + ) + ).reduce((sum, size) => sum + size, 0); + const byteLimited = new WorkflowManifestStore( + () => directory, + {}, + { maxAggregateWorkflowBytes: existingBytes + 16 }, + ); + await assert.rejects( + () => byteLimited.stageAutosave(nextRevision(first, "This cannot fit"), 1), + /aggregate byte limit/u, + ); + await assert.rejects(() => fs.lstat(path.join(firstDirectory, "autosave.journal")), { + code: "ENOENT", + }); + assert.deepEqual(await byteLimited.get(first.id), first); +}); + +test("hostile prepopulated inventory hits the aggregate limit before manifest parsing", async (t) => { + const { directory } = await harness(t); + const workflowDirectory = path.join(directory, "workflows", "hostile-workflow"); + await fs.mkdir(workflowDirectory, { recursive: true }); + const hostileBody = "{".repeat(128); + const manifestPath = path.join(workflowDirectory, "workflow.json"); + await fs.writeFile(manifestPath, hostileBody, "utf8"); + const store = new WorkflowManifestStore(() => directory, {}, { maxAggregateWorkflowBytes: 64 }); + + await assert.rejects(() => store.initialize(), /aggregate byte limit/u); + assert.equal(await fs.readFile(manifestPath, "utf8"), hostileBody); + await assert.rejects(() => fs.lstat(path.join(directory, "index.json")), { code: "ENOENT" }); +}); + +test("deleted workflow quarantine is bounded without pruning recovery evidence", async (t) => { + const { directory } = await harness(t); + const store = new WorkflowManifestStore(() => directory, {}, { maxDeletedQuarantineEntries: 1 }); + const recoveryEvidence = path.join(directory, "quarantine", "workflow-corrupt-evidence.json"); + await store.initialize(); + await fs.writeFile(recoveryEvidence, "{broken", "utf8"); + + const first = workflow("workflow-1"); + await store.put(first, null); + await store.delete(first.id, 1); + const second = workflow("workflow-2", "2026-08-11T12:01:00.000Z"); + await store.put(second, null); + await store.delete(second.id, 1); + + const quarantine = await fs.readdir(path.join(directory, "quarantine", "deleted-workflows")); + assert.equal(quarantine.filter((name) => name.startsWith("deleted-")).length, 1); + assert.equal(await fs.readFile(recoveryEvidence, "utf8"), "{broken"); +}); + +test("recovery evidence cannot exhaust the deleted-workflow quarantine scan", async (t) => { + const { directory, store } = await harness(t); + await store.initialize(); + const recoveryPath = path.join(directory, "quarantine"); + for (let offset = 0; offset < 4_100; offset += 100) { + await Promise.all( + Array.from({ length: 100 }, (_, index) => + fs.writeFile(path.join(recoveryPath, `recovery-${offset + index}.json`), "{}"), + ), + ); + } + const first = workflow("workflow-separated-quarantine"); + await store.put(first, null); + await store.delete(first.id, 1); + assert.equal( + (await fs.readdir(path.join(directory, "quarantine", "deleted-workflows"))).filter((name) => + name.startsWith("deleted-"), + ).length, + 1, + ); +}); + +test("deleted workflow quarantine also enforces its aggregate byte budget", async (t) => { + const { directory, store } = await harness(t); + const first = workflow("workflow-1"); + await store.put(first, null); + await store.delete(first.id, 1); + const quarantinePath = path.join(directory, "quarantine", "deleted-workflows"); + const firstDeleted = (await fs.readdir(quarantinePath)).find((name) => + name.startsWith("deleted-workflow-1-"), + ); + assert.ok(firstDeleted); + const firstDeletedPath = path.join(quarantinePath, firstDeleted); + const firstBytes = ( + await Promise.all( + ( + await fs.readdir(firstDeletedPath) + ).map(async (name) => (await fs.lstat(path.join(firstDeletedPath, name))).size), + ) + ).reduce((sum, size) => sum + size, 0); + + const byteBounded = new WorkflowManifestStore( + () => directory, + {}, + { maxDeletedQuarantineBytes: firstBytes + 16 }, + ); + const second = workflow("workflow-2", "2026-08-11T12:01:00.000Z"); + await byteBounded.put(second, null); + await byteBounded.delete(second.id, 1); + assert.equal( + (await fs.readdir(quarantinePath)).filter((name) => name.startsWith("deleted-")).length, + 1, + ); +}); + +test("a projection rebuild failure after publication does not misreport a durable create", async (t) => { + const { directory } = await harness(t); + const unexpected = path.join(directory, "workflows", "invalid.workflow"); + const store = new WorkflowManifestStore(() => directory, { + afterCurrentPublished: async () => { + await fs.mkdir(unexpected); + }, + }); + const first = workflow(); + assert.deepEqual(await store.put(first, null), first); + assert.deepEqual(await store.get(first.id), first); + await assert.rejects(() => store.list(), WorkflowManifestLoadError); + await fs.rmdir(unexpected); + assert.equal((await store.list()).length, 1); +}); + +test("every renderer-owned publication checks document liveness", async (t) => { + const { directory, store } = await harness(t); + const first = workflow(); + await assert.rejects( + () => store.put(first, null, () => false), + /renderer document is no longer active/u, + ); + await assert.rejects( + () => fs.readFile(path.join(directory, "workflows", first.id, "workflow.json")), + { code: "ENOENT" }, + ); + await assert.rejects( + () => fs.readFile(path.join(directory, "workflows", first.id, "autosave.journal")), + { code: "ENOENT" }, + ); + await assert.rejects(() => fs.lstat(path.join(directory, "workflows", first.id)), { + code: "ENOENT", + }); + assert.deepEqual(await store.list(), []); + + await store.put(first, null); + const second = nextRevision(first, "not current"); + await assert.rejects( + () => store.stageAutosave(second, 1, () => false), + /renderer document is no longer active/u, + ); + assert.equal((await store.get(first.id))?.revision, 1); + await assert.rejects( + () => store.delete(first.id, 1, () => false), + /renderer document is no longer active/u, + ); + assert.equal((await store.get(first.id))?.revision, 1); +}); diff --git a/main/services/create-images/workflow-manifest-store.ts b/main/services/create-images/workflow-manifest-store.ts new file mode 100644 index 00000000..253bb676 --- /dev/null +++ b/main/services/create-images/workflow-manifest-store.ts @@ -0,0 +1,1683 @@ +import { randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { + CREATE_IMAGES_MAX_WORKFLOW_BYTES, + parseWorkflowDocument, +} from "../../../renderer/shared/create-images/schema.js"; +import { decodeUtf8, readRegularFile } from "../regular-file-read.js"; + +const INDEX_VERSION = 1 as const; +const JOURNAL_VERSION = 1 as const; +const MAX_WORKFLOW_BYTES = CREATE_IMAGES_MAX_WORKFLOW_BYTES; +const MAX_JOURNAL_BYTES = CREATE_IMAGES_MAX_WORKFLOW_BYTES + 64 * 1024; +const MAX_INDEX_BYTES = 4 * 1024 * 1024; +const DEFAULT_MAX_WORKFLOW_COUNT = 1_000; +const DEFAULT_MAX_AGGREGATE_WORKFLOW_BYTES = 512 * 1024 * 1024; +const DEFAULT_MAX_DELETED_QUARANTINE_ENTRIES = 32; +const DEFAULT_MAX_DELETED_QUARANTINE_BYTES = 128 * 1024 * 1024; +const MAX_QUARANTINE_SCAN_ENTRIES = 4_096; +const WORKFLOW_FILE_NAMES = new Set([ + "autosave.journal", + "workflow.json", + "workflow.last-known-good.json", +]); +const WORKFLOW_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const CURRENT_FILE = "workflow.json"; +const LAST_KNOWN_GOOD_FILE = "workflow.last-known-good.json"; +const AUTOSAVE_FILE = "autosave.journal"; + +interface WorkflowIndexV1 { + version: typeof INDEX_VERSION; + workflows: WorkflowManifestSummary[]; +} + +interface AutosaveJournalV1 { + version: typeof JOURNAL_VERSION; + workflowId: string; + baseRevision: number | null; + targetRevision: number; + stagedAt: string; + snapshot: WorkflowDocumentV1; +} + +type FileInspection = + | { status: "missing" } + | { status: "healthy"; value: T } + | { status: "corrupt" } + | { status: "unsafe" }; + +export interface WorkflowManifestSummary { + id: string; + title: string; + revision: number; + createdAt: string; + updatedAt: string; + nodeCount: number; + edgeCount: number; + assetCount: number; + health: "healthy" | "recovery-required" | "unsafe"; + recoveryAvailable: boolean; +} + +export type WorkflowManifestHealth = + | { status: "healthy"; source: "missing" | "disk"; path: string } + | { status: "corrupt"; path: string } + | { status: "unsafe"; path: string }; + +export type WorkflowRecoveryReason = + | "current-corrupt" + | "current-missing" + | "last-known-good-corrupt" + | "journal-corrupt" + | "journal-pending" + | "journal-conflict"; + +export type WorkflowRecoveryHealth = + | { + status: "missing"; + workflowId: string; + currentPath: string; + lastKnownGoodAvailable: false; + autosave: "none"; + } + | { + status: "healthy"; + workflowId: string; + currentPath: string; + revision: number; + lastKnownGoodAvailable: boolean; + autosave: "none" | "pending"; + autosaveTargetRevision?: number; + } + | { + status: "recovery-required"; + workflowId: string; + currentPath: string; + reason: WorkflowRecoveryReason; + currentRevision?: number; + lastKnownGoodAvailable: boolean; + lastKnownGoodRevision?: number; + autosave: "none" | "pending" | "corrupt"; + autosaveTargetRevision?: number; + } + | { + status: "unsafe"; + workflowId: string; + currentPath: string; + reason: "current-future-schema" | "last-known-good-future-schema" | "journal-future-schema"; + lastKnownGoodAvailable: boolean; + autosave: "none" | "pending" | "unsafe"; + }; + +export interface WorkflowAutosaveStatus { + workflowId: string; + state: "none" | "pending" | "corrupt" | "unsafe"; + baseRevision?: number | null; + targetRevision?: number; + stagedAt?: string; +} + +export interface WorkflowManifestDurability { + /** Test seam representing a crash after the journal is durable. */ + afterJournalPublished?: (workflowId: string) => Promise; + /** Test seam representing a crash after current is durable but before cleanup. */ + afterCurrentPublished?: (workflowId: string) => Promise; +} + +export interface WorkflowManifestStoreLimits { + maxWorkflowCount?: number; + maxAggregateWorkflowBytes?: number; + maxDeletedQuarantineEntries?: number; + maxDeletedQuarantineBytes?: number; +} + +export interface WorkflowReferenceInventory { + complete: boolean; + records: Array<{ + workflowId: string; + assetIds: string[]; + }>; +} + +export class WorkflowManifestLoadError extends Error { + constructor( + readonly status: "corrupt" | "unsafe", + readonly filePath: string, + ) { + super( + status === "corrupt" + ? "The Create Images workflow is damaged and has been kept for recovery." + : "The Create Images workflow belongs to an unsupported future schema and is read-only.", + ); + this.name = "WorkflowManifestLoadError"; + } +} + +export class WorkflowRevisionConflictError extends Error { + constructor( + readonly workflowId: string, + readonly expectedRevision: number | null, + readonly actualRevision: number | null, + ) { + super( + `Workflow "${workflowId}" changed: expected revision ${expectedRevision ?? "absent"}, found ${actualRevision ?? "absent"}.`, + ); + this.name = "WorkflowRevisionConflictError"; + } +} + +const rootMutationTails = new Map>(); + +function serializedAtRoot(root: string, operation: () => Promise): Promise { + const key = path.resolve(root); + const tail = rootMutationTails.get(key) ?? Promise.resolve(); + const result = tail.then(operation, operation); + rootMutationTails.set( + key, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +function validateWorkflowId(workflowId: string): string { + if (!WORKFLOW_ID.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + return workflowId; +} + +function parseSnapshot(value: unknown): WorkflowDocumentV1 { + const parsed = parseWorkflowDocument(value); + if (!parsed.success) { + throw new Error(parsed.issues[0]?.message ?? "Invalid Create Images workflow."); + } + return parsed.value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFutureVersion(value: unknown, field: "schemaVersion" | "version"): boolean { + return isRecord(value) && typeof value[field] === "number" && value[field] > 1; +} + +function parseJournal(value: unknown): AutosaveJournalV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => + ![ + "version", + "workflowId", + "baseRevision", + "targetRevision", + "stagedAt", + "snapshot", + ].includes(key), + ) + ) { + return undefined; + } + const snapshot = parseWorkflowDocument(value.snapshot); + const baseRevision = + value.baseRevision === null + ? null + : typeof value.baseRevision === "number" && Number.isSafeInteger(value.baseRevision) + ? value.baseRevision + : undefined; + const targetRevision = + typeof value.targetRevision === "number" && Number.isSafeInteger(value.targetRevision) + ? value.targetRevision + : undefined; + if ( + value.version !== JOURNAL_VERSION || + typeof value.workflowId !== "string" || + !WORKFLOW_ID.test(value.workflowId) || + !snapshot.success || + snapshot.value.id !== value.workflowId || + baseRevision === undefined || + (baseRevision !== null && baseRevision < 1) || + targetRevision === undefined || + targetRevision < 1 || + snapshot.value.revision !== targetRevision || + targetRevision !== (baseRevision === null ? 1 : baseRevision + 1) || + typeof value.stagedAt !== "string" || + !Number.isFinite(Date.parse(value.stagedAt)) + ) { + return undefined; + } + return { + version: JOURNAL_VERSION, + workflowId: value.workflowId, + baseRevision, + targetRevision, + stagedAt: value.stagedAt, + snapshot: snapshot.value, + }; +} + +function summaryOf( + workflow: WorkflowDocumentV1, + health: WorkflowManifestSummary["health"], + recoveryAvailable: boolean, +): WorkflowManifestSummary { + return { + id: workflow.id, + title: workflow.title, + revision: workflow.revision, + createdAt: workflow.createdAt, + updatedAt: workflow.updatedAt, + nodeCount: workflow.nodes.length, + edgeCount: workflow.edges.length, + assetCount: workflow.assetRefs.length, + health, + recoveryAvailable, + }; +} + +function parseLegacyDatabase(value: unknown): Record | undefined { + if ( + !isRecord(value) || + Object.keys(value).some((key) => key !== "version" && key !== "workflows") || + value.version !== 1 || + !isRecord(value.workflows) + ) { + return undefined; + } + const workflows = Object.create(null) as Record; + for (const [id, candidate] of Object.entries(value.workflows)) { + const parsed = parseWorkflowDocument(candidate); + if (!parsed.success || parsed.value.id !== id) return undefined; + workflows[id] = parsed.value; + } + return workflows; +} + +/** + * Device-local durable Create Images workflow authority. + * + * Each workflow is independently bounded and published. `index.json` is a + * rebuildable projection; workflow manifests, last-known-good snapshots, and + * autosave journals are the authority. Binary assets and run journals remain + * outside this store. + */ +export class WorkflowManifestStore { + private readonly limits: Required; + + constructor( + private readonly rootResolver: () => string, + private readonly durability: WorkflowManifestDurability = {}, + limits: WorkflowManifestStoreLimits = {}, + ) { + this.limits = { + maxWorkflowCount: limits.maxWorkflowCount ?? DEFAULT_MAX_WORKFLOW_COUNT, + maxAggregateWorkflowBytes: + limits.maxAggregateWorkflowBytes ?? DEFAULT_MAX_AGGREGATE_WORKFLOW_BYTES, + maxDeletedQuarantineEntries: + limits.maxDeletedQuarantineEntries ?? DEFAULT_MAX_DELETED_QUARANTINE_ENTRIES, + maxDeletedQuarantineBytes: + limits.maxDeletedQuarantineBytes ?? DEFAULT_MAX_DELETED_QUARANTINE_BYTES, + }; + for (const [name, value] of Object.entries(this.limits)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`Invalid Create Images storage limit: ${name}.`); + } + } + } + + private root(): string { + return path.resolve(this.rootResolver()); + } + + private indexPath(): string { + return path.join(this.root(), "index.json"); + } + + private legacyPath(): string { + return path.join(this.root(), "workflows.json"); + } + + private workflowsPath(): string { + return path.join(this.root(), "workflows"); + } + + private workflowDirectory(workflowId: string): string { + return path.join(this.workflowsPath(), validateWorkflowId(workflowId)); + } + + private deletedWorkflowQuarantinePath(): string { + return path.join(this.root(), "quarantine", "deleted-workflows"); + } + + private workflowPaths(workflowId: string) { + const directory = this.workflowDirectory(workflowId); + return { + directory, + current: path.join(directory, CURRENT_FILE), + lastKnownGood: path.join(directory, LAST_KNOWN_GOOD_FILE), + autosave: path.join(directory, AUTOSAVE_FILE), + }; + } + + private async ensureDirectory(target: string): Promise { + const created = await fs.mkdir(target, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(target); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error("Create Images storage contains an unsafe directory."); + } + if (created !== undefined) await this.syncDirectory(path.dirname(target)); + return created !== undefined; + } + + private async prepareDirectories(): Promise { + await this.ensureDirectory(this.root()); + await this.ensureDirectory(this.workflowsPath()); + await this.ensureDirectory(path.join(this.root(), "quarantine")); + await this.ensureDirectory(this.deletedWorkflowQuarantinePath()); + } + + private async readJson(target: string, maxBytes: number): Promise> { + let bytes: Buffer; + try { + bytes = await readRegularFile(target, maxBytes); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + return { status: "corrupt" }; + } + try { + return { status: "healthy", value: JSON.parse(decodeUtf8(bytes)) as unknown }; + } catch { + return { status: "corrupt" }; + } + } + + private async inspectWorkflowFile(target: string): Promise> { + const raw = await this.readJson(target, MAX_WORKFLOW_BYTES); + if (raw.status !== "healthy") return raw; + if (isFutureVersion(raw.value, "schemaVersion")) return { status: "unsafe" }; + const parsed = parseWorkflowDocument(raw.value); + return parsed.success ? { status: "healthy", value: parsed.value } : { status: "corrupt" }; + } + + private async inspectJournalFile(target: string): Promise> { + const raw = await this.readJson(target, MAX_JOURNAL_BYTES); + if (raw.status !== "healthy") return raw; + if (isFutureVersion(raw.value, "version")) return { status: "unsafe" }; + const journal = parseJournal(raw.value); + return journal ? { status: "healthy", value: journal } : { status: "corrupt" }; + } + + private async syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } + + private async writeAtomic( + target: string, + value: unknown, + maxBytes: number, + isCurrent: () => boolean, + ): Promise { + const directory = path.dirname(target); + const serialized = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(serialized, "utf8") > maxBytes) { + throw new Error("Create Images workflow metadata exceeds its storage limit."); + } + const createdDirectory = await this.ensureDirectory(directory); + const staged = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`); + let publicationError: unknown; + try { + try { + const existing = await fs.lstat(target); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw new Error("Create Images storage contains an unsafe file."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await fs.writeFile(staged, serialized, { encoding: "utf8", flag: "wx", mode: 0o600 }); + const handle = await fs.open(staged, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + await fs.rename(staged, target); + await this.syncDirectory(directory); + } catch (error) { + publicationError = error; + } + await fs.rm(staged, { force: true }).catch(() => undefined); + if (createdDirectory) { + try { + await fs.rmdir(directory); + await this.syncDirectory(path.dirname(directory)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + publicationError === undefined && + code !== "ENOENT" && + code !== "ENOTEMPTY" && + code !== "EEXIST" + ) { + publicationError = error; + } + } + } + if (publicationError !== undefined) throw publicationError; + } + + private async removeFileDurably(target: string): Promise { + try { + await fs.rm(target); + await this.syncDirectory(path.dirname(target)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + private async quarantineFile(target: string, label: string): Promise { + const quarantine = path.join( + this.root(), + "quarantine", + `${label}-${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomUUID()}.json`, + ); + try { + await fs.rename(target, quarantine); + await this.syncDirectory(path.dirname(target)); + await this.syncDirectory(path.dirname(quarantine)); + return quarantine; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + + private serializedBytes(value: unknown): number { + return Buffer.byteLength(`${JSON.stringify(value, null, 2)}\n`, "utf8"); + } + + private unsafeStorageEntry(target: string): WorkflowManifestLoadError { + return new WorkflowManifestLoadError("unsafe", target); + } + + private async boundedDirectoryEntries(directory: string, maxEntries: number): Promise { + const entries: Dirent[] = []; + const handle = await fs.opendir(directory); + for await (const entry of handle) { + entries.push(entry); + if (entries.length > maxEntries) throw this.unsafeStorageEntry(directory); + } + return entries; + } + + private async workflowInventory(): Promise<{ + workflowIds: string[]; + workflowCount: number; + aggregateBytes: number; + }> { + const workflowIds: string[] = []; + let aggregateBytes = 0; + const entries = await this.boundedDirectoryEntries( + this.workflowsPath(), + this.limits.maxWorkflowCount, + ); + for (const entry of entries) { + const entryPath = path.join(this.workflowsPath(), entry.name); + const info = await fs.lstat(entryPath); + if ( + !WORKFLOW_ID.test(entry.name) || + !entry.isDirectory() || + entry.isSymbolicLink() || + !info.isDirectory() || + info.isSymbolicLink() + ) { + throw this.unsafeStorageEntry(entryPath); + } + workflowIds.push(entry.name); + for (const child of await this.boundedDirectoryEntries(entryPath, WORKFLOW_FILE_NAMES.size)) { + const childPath = path.join(entryPath, child.name); + const childInfo = await fs.lstat(childPath); + if ( + !WORKFLOW_FILE_NAMES.has(child.name) || + !child.isFile() || + child.isSymbolicLink() || + !childInfo.isFile() || + childInfo.isSymbolicLink() + ) { + throw this.unsafeStorageEntry(childPath); + } + aggregateBytes += childInfo.size; + if ( + !Number.isSafeInteger(aggregateBytes) || + aggregateBytes > this.limits.maxAggregateWorkflowBytes + ) { + throw new Error("Create Images workflow storage has reached its aggregate byte limit."); + } + } + } + return { workflowIds, workflowCount: workflowIds.length, aggregateBytes }; + } + + async referenceInventory(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const inventory = await this.workflowInventory(); + const records: WorkflowReferenceInventory["records"] = []; + let complete = true; + for (const workflowId of inventory.workflowIds) { + const state = await this.inspected(workflowId); + if ( + state.current.status === "corrupt" || + state.current.status === "unsafe" || + state.lastKnownGood.status === "corrupt" || + state.lastKnownGood.status === "unsafe" || + state.journal.status === "corrupt" || + state.journal.status === "unsafe" + ) { + complete = false; + } + const candidates = [ + this.validRecoveryCandidate(state.current, workflowId), + this.validRecoveryCandidate(state.lastKnownGood, workflowId), + this.validRecoveryCandidate(state.journal, workflowId), + ].filter((candidate): candidate is WorkflowDocumentV1 => candidate !== undefined); + if (candidates.length === 0) complete = false; + records.push({ + workflowId, + assetIds: [...new Set(candidates.flatMap((candidate) => candidate.assetRefs))].sort(), + }); + } + return { complete, records }; + }); + } + + private async assertMutationWithinLimits( + workflowId: string, + replacements: ReadonlyMap, + ): Promise { + const inventory = await this.workflowInventory(); + const isNewWorkflow = !inventory.workflowIds.includes(workflowId); + const projectedCount = inventory.workflowCount + (isNewWorkflow ? 1 : 0); + if (projectedCount > this.limits.maxWorkflowCount) { + throw new Error("Create Images workflow storage has reached its workflow count limit."); + } + let projectedBytes = inventory.aggregateBytes; + for (const [target, replacement] of replacements) { + try { + const existing = await fs.lstat(target); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw this.unsafeStorageEntry(target); + } + projectedBytes -= existing.size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (replacement !== undefined) projectedBytes += this.serializedBytes(replacement); + } + if ( + !Number.isSafeInteger(projectedBytes) || + projectedBytes > this.limits.maxAggregateWorkflowBytes + ) { + throw new Error("Create Images workflow storage has reached its aggregate byte limit."); + } + } + + private async deletedWorkflowDirectoryBytes(directory: string): Promise { + let total = 0; + for (const entry of await this.boundedDirectoryEntries(directory, WORKFLOW_FILE_NAMES.size)) { + const entryPath = path.join(directory, entry.name); + const info = await fs.lstat(entryPath); + if ( + !WORKFLOW_FILE_NAMES.has(entry.name) || + !entry.isFile() || + entry.isSymbolicLink() || + !info.isFile() || + info.isSymbolicLink() + ) { + throw this.unsafeStorageEntry(entryPath); + } + total += info.size; + if (!Number.isSafeInteger(total)) { + throw new Error("Create Images quarantine is too large to inventory safely."); + } + } + return total; + } + + private async pruneDeletedQuarantine(): Promise { + const quarantinePath = this.deletedWorkflowQuarantinePath(); + const deleted: Array<{ path: string; mtimeMs: number; bytes: number }> = []; + for (const entry of await this.boundedDirectoryEntries( + quarantinePath, + MAX_QUARANTINE_SCAN_ENTRIES, + )) { + const entryPath = path.join(quarantinePath, entry.name); + const info = await fs.lstat(entryPath); + if ( + !entry.isDirectory() || + entry.isSymbolicLink() || + !info.isDirectory() || + info.isSymbolicLink() + ) { + throw this.unsafeStorageEntry(entryPath); + } + deleted.push({ + path: entryPath, + mtimeMs: info.mtimeMs, + bytes: await this.deletedWorkflowDirectoryBytes(entryPath), + }); + } + deleted.sort( + (left, right) => right.mtimeMs - left.mtimeMs || right.path.localeCompare(left.path), + ); + let retainedBytes = 0; + const removals: string[] = []; + for (const [index, entry] of deleted.entries()) { + if ( + index >= this.limits.maxDeletedQuarantineEntries || + retainedBytes + entry.bytes > this.limits.maxDeletedQuarantineBytes + ) { + removals.push(entry.path); + } else { + retainedBytes += entry.bytes; + } + } + for (const target of removals) await fs.rm(target, { recursive: true }); + if (removals.length > 0) await this.syncDirectory(quarantinePath); + } + + private async legacyBlocker(): Promise< + Extract | undefined + > { + const raw = await this.readJson(this.legacyPath(), MAX_WORKFLOW_BYTES); + if (raw.status === "missing") return undefined; + if (raw.status === "corrupt") return { status: "corrupt", path: this.legacyPath() }; + if (raw.status === "unsafe") return { status: "unsafe", path: this.legacyPath() }; + if (isFutureVersion(raw.value, "version")) { + return { status: "unsafe", path: this.legacyPath() }; + } + return parseLegacyDatabase(raw.value) + ? undefined + : { status: "corrupt", path: this.legacyPath() }; + } + + private async migrateLegacy(): Promise { + const raw = await this.readJson(this.legacyPath(), MAX_WORKFLOW_BYTES); + if (raw.status === "missing") return; + if ( + raw.status === "corrupt" || + (raw.status === "healthy" && isFutureVersion(raw.value, "version")) + ) { + throw new WorkflowManifestLoadError( + raw.status === "corrupt" ? "corrupt" : "unsafe", + this.legacyPath(), + ); + } + if (raw.status !== "healthy") return; + const legacy = parseLegacyDatabase(raw.value); + if (!legacy) throw new WorkflowManifestLoadError("corrupt", this.legacyPath()); + for (const workflow of Object.values(legacy)) { + const paths = this.workflowPaths(workflow.id); + await this.assertMutationWithinLimits( + workflow.id, + new Map([ + [paths.current, workflow], + [paths.lastKnownGood, workflow], + ]), + ); + await this.ensureDirectory(paths.directory); + const current = await this.inspectWorkflowFile(paths.current); + if (current.status === "missing") { + await this.writeAtomic(paths.current, workflow, MAX_WORKFLOW_BYTES, () => true); + await this.writeAtomic(paths.lastKnownGood, workflow, MAX_WORKFLOW_BYTES, () => true); + } else if ( + current.status !== "healthy" || + JSON.stringify(current.value) !== JSON.stringify(workflow) + ) { + throw new WorkflowManifestLoadError( + current.status === "unsafe" ? "unsafe" : "corrupt", + paths.current, + ); + } + } + await this.rebuildIndexInternal(); + const migrated = path.join(this.root(), `workflows.phase-0-migrated-${randomUUID()}.json`); + await fs.rename(this.legacyPath(), migrated); + await this.syncDirectory(this.root()); + } + + private async prepare(): Promise { + await this.prepareDirectories(); + const blocker = await this.legacyBlocker(); + if (blocker) throw new WorkflowManifestLoadError(blocker.status, blocker.path); + await this.migrateLegacy(); + } + + private async inspected(workflowId: string): Promise<{ + paths: ReturnType; + current: FileInspection; + lastKnownGood: FileInspection; + journal: FileInspection; + }> { + const paths = this.workflowPaths(workflowId); + try { + const directory = await fs.lstat(paths.directory); + if (!directory.isDirectory() || directory.isSymbolicLink()) { + return { + paths, + current: { status: "corrupt" }, + lastKnownGood: { status: "corrupt" }, + journal: { status: "corrupt" }, + }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return { + paths, + current: { status: "missing" }, + lastKnownGood: { status: "missing" }, + journal: { status: "missing" }, + }; + } + const [current, lastKnownGood, journal] = await Promise.all([ + this.inspectWorkflowFile(paths.current), + this.inspectWorkflowFile(paths.lastKnownGood), + this.inspectJournalFile(paths.autosave), + ]); + return { paths, current, lastKnownGood, journal }; + } + + private validRecoveryCandidate( + inspection: FileInspection | FileInspection, + workflowId: string, + ): WorkflowDocumentV1 | undefined { + if (inspection.status !== "healthy") return undefined; + const value = "snapshot" in inspection.value ? inspection.value.snapshot : inspection.value; + return value.id === workflowId ? value : undefined; + } + + private async reconcilePublishedJournal( + workflowId: string, + state: Awaited>, + ): Promise>> { + if ( + state.current.status !== "healthy" || + state.current.value.id !== workflowId || + state.journal.status !== "healthy" || + state.journal.value.workflowId !== workflowId || + state.journal.value.targetRevision !== state.current.value.revision || + JSON.stringify(state.journal.value.snapshot) !== JSON.stringify(state.current.value) || + state.lastKnownGood.status === "corrupt" || + state.lastKnownGood.status === "unsafe" + ) { + return state; + } + // Current is already the exact journal target, so publication completed + // before the crash. Finish main-owned durability cleanup without depending + // on renderer liveness; a repeated restart is safe at either boundary. + await this.writeAtomic( + state.paths.lastKnownGood, + state.current.value, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.removeFileDurably(state.paths.autosave); + return { + ...state, + lastKnownGood: { status: "healthy", value: state.current.value }, + journal: { status: "missing" }, + }; + } + + private recoveryHealthOf( + workflowId: string, + state: Awaited>, + ): WorkflowRecoveryHealth { + const lastGood = this.validRecoveryCandidate(state.lastKnownGood, workflowId); + const journal = + state.journal.status === "healthy" && state.journal.value.workflowId === workflowId + ? state.journal.value + : undefined; + const common = { + workflowId, + currentPath: state.paths.current, + lastKnownGoodAvailable: Boolean(lastGood), + }; + if (state.current.status === "unsafe") { + return { + ...common, + status: "unsafe", + reason: "current-future-schema", + autosave: state.journal.status === "unsafe" ? "unsafe" : journal ? "pending" : "none", + }; + } + if (state.lastKnownGood.status === "unsafe") { + return { + ...common, + status: "unsafe", + reason: "last-known-good-future-schema", + autosave: state.journal.status === "unsafe" ? "unsafe" : journal ? "pending" : "none", + }; + } + if (state.journal.status === "unsafe") { + return { ...common, status: "unsafe", reason: "journal-future-schema", autosave: "unsafe" }; + } + if ( + state.current.status === "missing" && + state.lastKnownGood.status === "missing" && + state.journal.status === "missing" + ) { + return { + status: "missing", + workflowId, + currentPath: state.paths.current, + lastKnownGoodAvailable: false, + autosave: "none", + }; + } + if (state.current.status === "corrupt" || state.current.status === "missing") { + return { + ...common, + status: "recovery-required", + reason: state.current.status === "corrupt" ? "current-corrupt" : "current-missing", + ...(lastGood ? { lastKnownGoodRevision: lastGood.revision } : {}), + autosave: state.journal.status === "corrupt" ? "corrupt" : journal ? "pending" : "none", + ...(journal ? { autosaveTargetRevision: journal.targetRevision } : {}), + }; + } + if (state.lastKnownGood.status === "corrupt") { + return { + ...common, + status: "recovery-required", + reason: "last-known-good-corrupt", + currentRevision: state.current.value.revision, + autosave: state.journal.status === "corrupt" ? "corrupt" : journal ? "pending" : "none", + ...(journal ? { autosaveTargetRevision: journal.targetRevision } : {}), + }; + } + if (state.journal.status === "corrupt") { + return { + ...common, + status: "recovery-required", + reason: "journal-corrupt", + currentRevision: state.current.value.revision, + ...(lastGood ? { lastKnownGoodRevision: lastGood.revision } : {}), + autosave: "corrupt", + }; + } + if (journal && journal.baseRevision === state.current.value.revision) { + return { + ...common, + status: "recovery-required", + reason: "journal-pending", + currentRevision: state.current.value.revision, + ...(lastGood ? { lastKnownGoodRevision: lastGood.revision } : {}), + autosave: "pending", + autosaveTargetRevision: journal.targetRevision, + }; + } + if ( + journal && + !( + journal.baseRevision === state.current.value.revision || + (journal.targetRevision === state.current.value.revision && + JSON.stringify(journal.snapshot) === JSON.stringify(state.current.value)) + ) + ) { + return { + ...common, + status: "recovery-required", + reason: "journal-conflict", + currentRevision: state.current.value.revision, + ...(lastGood ? { lastKnownGoodRevision: lastGood.revision } : {}), + autosave: "pending", + autosaveTargetRevision: journal.targetRevision, + }; + } + return { + ...common, + status: "healthy", + revision: state.current.value.revision, + autosave: journal ? "pending" : "none", + ...(journal ? { autosaveTargetRevision: journal.targetRevision } : {}), + }; + } + + private async scanSummaries(): Promise { + const summaries: WorkflowManifestSummary[] = []; + const inventory = await this.workflowInventory(); + for (const workflowId of inventory.workflowIds) { + const state = await this.reconcilePublishedJournal( + workflowId, + await this.inspected(workflowId), + ); + const health = this.recoveryHealthOf(workflowId, state); + const current = this.validRecoveryCandidate(state.current, workflowId); + const lastGood = this.validRecoveryCandidate(state.lastKnownGood, workflowId); + const journal = this.validRecoveryCandidate(state.journal, workflowId); + const representative = current ?? lastGood ?? journal; + if (!representative) { + summaries.push({ + id: workflowId, + title: "Workflow needs recovery", + revision: 0, + createdAt: "", + updatedAt: "", + nodeCount: 0, + edgeCount: 0, + assetCount: 0, + health: health.status === "unsafe" ? "unsafe" : "recovery-required", + recoveryAvailable: false, + }); + continue; + } + summaries.push( + summaryOf( + representative, + health.status === "healthy" + ? "healthy" + : health.status === "unsafe" + ? "unsafe" + : "recovery-required", + Boolean(lastGood || journal), + ), + ); + } + return summaries.sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id), + ); + } + + private parseIndex(value: unknown): WorkflowIndexV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some((key) => key !== "version" && key !== "workflows") || + value.version !== INDEX_VERSION || + !Array.isArray(value.workflows) + ) { + return undefined; + } + const ids = new Set(); + for (const summary of value.workflows) { + if ( + !isRecord(summary) || + Object.keys(summary).some( + (key) => + ![ + "id", + "title", + "revision", + "createdAt", + "updatedAt", + "nodeCount", + "edgeCount", + "assetCount", + "health", + "recoveryAvailable", + ].includes(key), + ) || + typeof summary.id !== "string" || + !WORKFLOW_ID.test(summary.id) || + ids.has(summary.id) || + typeof summary.title !== "string" || + typeof summary.revision !== "number" || + !Number.isSafeInteger(summary.revision) || + typeof summary.createdAt !== "string" || + typeof summary.updatedAt !== "string" || + typeof summary.nodeCount !== "number" || + !Number.isSafeInteger(summary.nodeCount) || + typeof summary.edgeCount !== "number" || + !Number.isSafeInteger(summary.edgeCount) || + typeof summary.assetCount !== "number" || + !Number.isSafeInteger(summary.assetCount) || + !["healthy", "recovery-required", "unsafe"].includes(summary.health as string) || + typeof summary.recoveryAvailable !== "boolean" + ) { + return undefined; + } + ids.add(summary.id); + } + return value as unknown as WorkflowIndexV1; + } + + private async inspectIndex(): Promise> { + const raw = await this.readJson(this.indexPath(), MAX_INDEX_BYTES); + if (raw.status !== "healthy") return raw; + if (isFutureVersion(raw.value, "version")) return { status: "unsafe" }; + const parsed = this.parseIndex(raw.value); + return parsed ? { status: "healthy", value: parsed } : { status: "corrupt" }; + } + + private async rebuildIndexInternal(): Promise { + const summaries = await this.scanSummaries(); + const existing = await this.inspectIndex(); + if (existing.status === "unsafe") return summaries; + if (existing.status === "corrupt") await this.quarantineFile(this.indexPath(), "index-corrupt"); + if ( + existing.status !== "healthy" || + JSON.stringify(existing.value.workflows) !== JSON.stringify(summaries) + ) { + await this.writeAtomic( + this.indexPath(), + { version: INDEX_VERSION, workflows: summaries } satisfies WorkflowIndexV1, + MAX_INDEX_BYTES, + () => true, + ); + } + return summaries; + } + + private async refreshIndexAfterAuthoritativeMutation(): Promise { + try { + await this.rebuildIndexInternal(); + } catch { + // index.json is a rebuildable projection. Once a manifest mutation is + // durable, reporting the operation as failed invites a retry that can + // create duplicates. A later list/initialize performs the repair or + // reports the underlying inventory problem explicitly. + } + } + + async path(): Promise { + return this.indexPath(); + } + + async health(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepareDirectories(); + const blocker = await this.legacyBlocker(); + if (blocker) return blocker; + await this.migrateLegacy(); + const index = await this.inspectIndex(); + if (index.status === "corrupt") return { status: "corrupt", path: this.indexPath() }; + if (index.status === "unsafe") return { status: "unsafe", path: this.indexPath() }; + return { + status: "healthy", + source: index.status === "missing" ? "missing" : "disk", + path: this.indexPath(), + }; + }); + } + + async inspect(workflowId: string): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + return this.recoveryHealthOf(workflowId, await this.inspected(workflowId)); + }); + } + + async list(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + return structuredClone(await this.rebuildIndexInternal()); + }); + } + + async initialize(): Promise { + return this.list(); + } + + async get(workflowId: string): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.reconcilePublishedJournal( + workflowId, + await this.inspected(workflowId), + ); + if (state.current.status === "missing") { + if (state.lastKnownGood.status !== "missing" || state.journal.status !== "missing") { + throw new WorkflowManifestLoadError("corrupt", state.paths.current); + } + return undefined; + } + if (state.current.status !== "healthy") { + throw new WorkflowManifestLoadError(state.current.status, state.paths.current); + } + const health = this.recoveryHealthOf(workflowId, state); + if (health.status === "unsafe") { + const unsafePath = + health.reason === "current-future-schema" + ? state.paths.current + : health.reason === "last-known-good-future-schema" + ? state.paths.lastKnownGood + : state.paths.autosave; + throw new WorkflowManifestLoadError("unsafe", unsafePath); + } + if (health.status === "recovery-required") { + const corruptPath = + health.reason === "last-known-good-corrupt" + ? state.paths.lastKnownGood + : health.reason.startsWith("journal-") + ? state.paths.autosave + : state.paths.current; + throw new WorkflowManifestLoadError("corrupt", corruptPath); + } + return structuredClone(state.current.value); + }); + } + + private async stageAutosaveInternal( + parsed: WorkflowDocumentV1, + expectedRevision: number | null, + isCurrent: () => boolean, + ): Promise { + const state = await this.inspected(parsed.id); + if (state.current.status === "corrupt" || state.current.status === "unsafe") { + throw new WorkflowManifestLoadError(state.current.status, state.paths.current); + } + if (state.journal.status === "corrupt" || state.journal.status === "unsafe") { + throw new WorkflowManifestLoadError(state.journal.status, state.paths.autosave); + } + if (state.lastKnownGood.status === "corrupt" || state.lastKnownGood.status === "unsafe") { + throw new WorkflowManifestLoadError(state.lastKnownGood.status, state.paths.lastKnownGood); + } + if (state.journal.status === "healthy") { + if ( + state.current.status !== "healthy" || + state.journal.value.targetRevision !== state.current.value.revision || + JSON.stringify(state.journal.value.snapshot) !== JSON.stringify(state.current.value) + ) { + throw new WorkflowManifestLoadError("corrupt", state.paths.autosave); + } + // A crash can leave the journal behind after current became authoritative. + // Reconcile that exact snapshot before accepting another stage; never replace + // a distinct crash-survived journal. + await this.writeAtomic( + state.paths.lastKnownGood, + state.current.value, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.removeFileDurably(state.paths.autosave); + } + const actualRevision = state.current.status === "healthy" ? state.current.value.revision : null; + if (actualRevision !== expectedRevision) { + throw new WorkflowRevisionConflictError(parsed.id, expectedRevision, actualRevision); + } + const requiredRevision = expectedRevision === null ? 1 : expectedRevision + 1; + if (parsed.revision !== requiredRevision) { + throw new WorkflowRevisionConflictError(parsed.id, requiredRevision, parsed.revision); + } + if (state.current.status === "healthy" && state.current.value.createdAt !== parsed.createdAt) { + throw new Error("A workflow's creation timestamp cannot change."); + } + if (state.current.status === "missing" && state.lastKnownGood.status !== "missing") { + throw new WorkflowManifestLoadError("corrupt", state.paths.current); + } + const journal: AutosaveJournalV1 = { + version: JOURNAL_VERSION, + workflowId: parsed.id, + baseRevision: expectedRevision, + targetRevision: parsed.revision, + stagedAt: parsed.updatedAt, + snapshot: parsed, + }; + await this.assertMutationWithinLimits( + parsed.id, + new Map([ + [state.paths.current, parsed], + [state.paths.lastKnownGood, parsed], + [state.paths.autosave, journal], + ]), + ); + await this.writeAtomic(state.paths.autosave, journal, MAX_JOURNAL_BYTES, isCurrent); + await this.durability.afterJournalPublished?.(parsed.id); + return structuredClone(parsed); + } + + async stageAutosave( + snapshot: WorkflowDocumentV1, + expectedRevision: number | null, + isCurrent: () => boolean = () => true, + ): Promise { + const parsed = parseSnapshot(snapshot); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + return this.stageAutosaveInternal(parsed, expectedRevision, isCurrent); + }); + } + + private async finishJournalCommit( + state: Awaited>, + journal: AutosaveJournalV1, + expectedRevision: number | null, + isCurrent: () => boolean, + ): Promise { + if (journal.workflowId !== journal.snapshot.id || journal.baseRevision !== expectedRevision) { + throw new WorkflowRevisionConflictError( + journal.workflowId, + expectedRevision, + journal.baseRevision, + ); + } + if (state.current.status === "unsafe" || state.current.status === "corrupt") { + throw new WorkflowManifestLoadError(state.current.status, state.paths.current); + } + if (state.lastKnownGood.status === "unsafe" || state.lastKnownGood.status === "corrupt") { + throw new WorkflowManifestLoadError(state.lastKnownGood.status, state.paths.lastKnownGood); + } + const actualRevision = state.current.status === "healthy" ? state.current.value.revision : null; + await this.assertMutationWithinLimits( + journal.workflowId, + new Map([ + [state.paths.current, journal.snapshot], + [state.paths.lastKnownGood, journal.snapshot], + [state.paths.autosave, undefined], + ]), + ); + if ( + actualRevision === journal.targetRevision && + state.current.status === "healthy" && + JSON.stringify(state.current.value) === JSON.stringify(journal.snapshot) + ) { + await this.writeAtomic( + state.paths.lastKnownGood, + journal.snapshot, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.removeFileDurably(state.paths.autosave); + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(journal.snapshot); + } + if (actualRevision !== expectedRevision) { + throw new WorkflowRevisionConflictError(journal.workflowId, expectedRevision, actualRevision); + } + if (state.current.status === "healthy") { + await this.writeAtomic( + state.paths.lastKnownGood, + state.current.value, + MAX_WORKFLOW_BYTES, + () => true, + ); + } + await this.writeAtomic(state.paths.current, journal.snapshot, MAX_WORKFLOW_BYTES, isCurrent); + await this.durability.afterCurrentPublished?.(journal.workflowId); + // Once current is committed, cleanup is main-owned reconciliation and must + // finish even if the renderer navigates away during these final steps. + await this.writeAtomic( + state.paths.lastKnownGood, + journal.snapshot, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.removeFileDurably(state.paths.autosave); + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(journal.snapshot); + } + + async flushAutosave( + workflowId: string, + expectedRevision: number | null, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if (state.journal.status === "missing") { + throw new Error("There is no pending autosave to flush."); + } + if (state.journal.status !== "healthy") { + throw new WorkflowManifestLoadError(state.journal.status, state.paths.autosave); + } + return this.finishJournalCommit(state, state.journal.value, expectedRevision, isCurrent); + }); + } + + async autosaveStatus(workflowId: string): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const journal = (await this.inspected(workflowId)).journal; + if (journal.status === "missing") return { workflowId, state: "none" }; + if (journal.status === "corrupt" || journal.status === "unsafe") { + return { workflowId, state: journal.status }; + } + return { + workflowId, + state: "pending", + baseRevision: journal.value.baseRevision, + targetRevision: journal.value.targetRevision, + stagedAt: journal.value.stagedAt, + }; + }); + } + + async discardAutosave( + workflowId: string, + expectedTargetRevision: number, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if (state.current.status !== "healthy") { + throw new WorkflowManifestLoadError( + state.current.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + if (state.journal.status !== "healthy") { + if (state.journal.status === "missing") return; + throw new WorkflowManifestLoadError(state.journal.status, state.paths.autosave); + } + if (state.journal.value.targetRevision !== expectedTargetRevision) { + throw new WorkflowRevisionConflictError( + workflowId, + expectedTargetRevision, + state.journal.value.targetRevision, + ); + } + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + await this.removeFileDurably(state.paths.autosave); + }); + } + + async put( + snapshot: WorkflowDocumentV1, + expectedRevision: number | null, + isCurrent: () => boolean = () => true, + ): Promise { + const parsed = parseSnapshot(snapshot); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.stageAutosaveInternal(parsed, expectedRevision, isCurrent); + const state = await this.inspected(parsed.id); + if (state.journal.status !== "healthy") { + throw new WorkflowManifestLoadError( + state.journal.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.autosave, + ); + } + return this.finishJournalCommit(state, state.journal.value, expectedRevision, isCurrent); + }); + } + + async save( + snapshot: WorkflowDocumentV1, + expectedRevision: number | null, + isCurrent: () => boolean = () => true, + ): Promise { + return this.put(snapshot, expectedRevision, isCurrent); + } + + async create( + snapshot: WorkflowDocumentV1, + isCurrent: () => boolean = () => true, + ): Promise { + return this.put(snapshot, null, isCurrent); + } + + async rename( + workflowId: string, + title: string, + expectedRevision: number, + updatedAt: string, + isCurrent: () => boolean = () => true, + ): Promise { + const current = await this.get(workflowId); + if (!current) throw new WorkflowRevisionConflictError(workflowId, expectedRevision, null); + const next = parseSnapshot({ + ...current, + title, + revision: expectedRevision + 1, + updatedAt, + }); + return this.put(next, expectedRevision, isCurrent); + } + + async duplicate( + sourceWorkflowId: string, + input: { + workflowId: string; + expectedRevision: number; + title?: string; + now: string; + }, + isCurrent: () => boolean = () => true, + ): Promise { + const source = await this.get(sourceWorkflowId); + if (!source) throw new Error("The source workflow does not exist."); + if (source.revision !== input.expectedRevision) { + throw new WorkflowRevisionConflictError( + sourceWorkflowId, + input.expectedRevision, + source.revision, + ); + } + const duplicate = parseSnapshot({ + ...structuredClone(source), + id: validateWorkflowId(input.workflowId), + title: input.title ?? `${source.title} copy`, + revision: 1, + createdAt: input.now, + updatedAt: input.now, + }); + return this.put(duplicate, null, isCurrent); + } + + async delete( + workflowId: string, + expectedRevision: number, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if (state.current.status === "corrupt" || state.current.status === "unsafe") { + throw new WorkflowManifestLoadError(state.current.status, state.paths.current); + } + const current = state.current.status === "healthy" ? state.current.value : undefined; + if (!current || current.revision !== expectedRevision) { + throw new WorkflowRevisionConflictError( + workflowId, + expectedRevision, + current?.revision ?? null, + ); + } + const health = this.recoveryHealthOf(workflowId, state); + if (health.status === "unsafe" || health.status === "recovery-required") { + throw new WorkflowManifestLoadError( + health.status === "unsafe" ? "unsafe" : "corrupt", + health.status !== "unsafe" && health.reason.startsWith("journal-") + ? state.paths.autosave + : state.paths.current, + ); + } + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + const deletedBytes = await this.deletedWorkflowDirectoryBytes(state.paths.directory); + if (deletedBytes > this.limits.maxDeletedQuarantineBytes) { + throw new Error( + "Create Images cannot retain this deleted workflow within its recovery limit.", + ); + } + await this.pruneDeletedQuarantine(); + const quarantine = path.join( + this.deletedWorkflowQuarantinePath(), + `deleted-${workflowId}-${new Date().toISOString().replace(/[:.]/gu, "-")}-${randomUUID()}`, + ); + await fs.rename(state.paths.directory, quarantine); + const touchedAt = new Date(); + await fs.utimes(quarantine, touchedAt, touchedAt); + await this.syncDirectory(this.workflowsPath()); + await this.syncDirectory(path.dirname(quarantine)); + await this.pruneDeletedQuarantine(); + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(current); + }); + } + + async repairRecoveryMetadata( + workflowId: string, + expectedRevision: number, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if (state.current.status !== "healthy") { + throw new WorkflowManifestLoadError( + state.current.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + if (state.current.value.revision !== expectedRevision) { + throw new WorkflowRevisionConflictError( + workflowId, + expectedRevision, + state.current.value.revision, + ); + } + if (state.lastKnownGood.status === "unsafe" || state.journal.status === "unsafe") { + throw new WorkflowManifestLoadError( + "unsafe", + state.lastKnownGood.status === "unsafe" + ? state.paths.lastKnownGood + : state.paths.autosave, + ); + } + if (state.journal.status === "healthy") { + throw new Error( + "Flush or discard the pending autosave before repairing recovery metadata.", + ); + } + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + await this.assertMutationWithinLimits( + workflowId, + new Map([ + [state.paths.lastKnownGood, state.current.value], + [state.paths.autosave, undefined], + ]), + ); + if (state.lastKnownGood.status === "corrupt") { + await this.quarantineFile( + state.paths.lastKnownGood, + `${workflowId}-last-known-good-corrupt`, + ); + } + if (state.journal.status === "corrupt") { + await this.quarantineFile(state.paths.autosave, `${workflowId}-autosave-corrupt`); + } + await this.writeAtomic( + state.paths.lastKnownGood, + state.current.value, + MAX_WORKFLOW_BYTES, + () => true, + ); + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(state.current.value); + }); + } + + async recover( + workflowId: string, + source: "last-known-good" | "autosave", + expectedCandidateRevision: number, + recoveredAt: string, + isCurrent: () => boolean = () => true, + ): Promise { + validateWorkflowId(workflowId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspected(workflowId); + if ( + state.current.status === "unsafe" || + state.lastKnownGood.status === "unsafe" || + state.journal.status === "unsafe" + ) { + throw new WorkflowManifestLoadError( + "unsafe", + state.current.status === "unsafe" + ? state.paths.current + : state.lastKnownGood.status === "unsafe" + ? state.paths.lastKnownGood + : state.paths.autosave, + ); + } + const health = this.recoveryHealthOf(workflowId, state); + const healthyCurrentAutosaveRecovery = + state.current.status === "healthy" && + source === "autosave" && + state.journal.status === "healthy" && + health.status === "recovery-required" && + health.reason === "last-known-good-corrupt"; + if ( + state.current.status === "healthy" && + !healthyCurrentAutosaveRecovery && + !( + health.status === "recovery-required" && + (health.reason === "journal-conflict" || health.reason === "journal-pending") + ) + ) { + throw new Error("A healthy workflow does not require recovery."); + } + const candidate = + source === "last-known-good" + ? this.validRecoveryCandidate(state.lastKnownGood, workflowId) + : this.validRecoveryCandidate(state.journal, workflowId); + if (!candidate || candidate.revision !== expectedCandidateRevision) { + throw new WorkflowRevisionConflictError( + workflowId, + expectedCandidateRevision, + candidate?.revision ?? null, + ); + } + const highestRecoveryRevision = Math.max( + candidate.revision, + state.current.status === "healthy" ? state.current.value.revision : 0, + this.validRecoveryCandidate(state.lastKnownGood, workflowId)?.revision ?? 0, + this.validRecoveryCandidate(state.journal, workflowId)?.revision ?? 0, + ); + const recovered = parseSnapshot({ + ...structuredClone(candidate), + revision: highestRecoveryRevision + 1, + updatedAt: recoveredAt, + }); + if (!isCurrent()) throw new Error("The renderer document is no longer active."); + await this.assertMutationWithinLimits( + workflowId, + new Map([ + [state.paths.current, recovered], + [state.paths.lastKnownGood, recovered], + [state.paths.autosave, undefined], + ]), + ); + if (state.current.status === "corrupt") { + await this.quarantineFile(state.paths.current, `${workflowId}-current-corrupt`); + } + if (state.lastKnownGood.status === "corrupt") { + await this.quarantineFile( + state.paths.lastKnownGood, + `${workflowId}-last-known-good-corrupt`, + ); + } + await this.writeAtomic(state.paths.current, recovered, MAX_WORKFLOW_BYTES, () => true); + await this.writeAtomic(state.paths.lastKnownGood, recovered, MAX_WORKFLOW_BYTES, () => true); + if (state.journal.status !== "missing") { + if (state.journal.status === "corrupt") { + await this.quarantineFile(state.paths.autosave, `${workflowId}-autosave-corrupt`); + } else { + await this.removeFileDurably(state.paths.autosave); + } + } + await this.refreshIndexAfterAuthoritativeMutation(); + return structuredClone(recovered); + }); + } +} diff --git a/main/services/create-images/workspace-store.test.ts b/main/services/create-images/workspace-store.test.ts new file mode 100644 index 00000000..75c8f8d5 --- /dev/null +++ b/main/services/create-images/workspace-store.test.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import type { AssetMetadataDto } from "./asset-store-core.js"; +import { + CreateImagesWorkspaceError, + CreateImagesWorkspaceStore, + createImagesWorkspaceRelativePath, +} from "./workspace-store.js"; + +interface FakeAsset extends AssetMetadataDto { + sourcePath: string; + bytes: Uint8Array; +} + +class FakeAssetStore { + private readonly items = new Map(); + + constructor(private readonly sourceRoot: string) {} + + async add( + label: string, + input: { displayName?: string; origin: AssetMetadataDto["origin"] }, + ): Promise { + const bytes = new TextEncoder().encode(label); + const assetId = createHash("sha256").update(bytes).digest("hex"); + const sourcePath = path.join(this.sourceRoot, `${assetId}.source`); + await fs.writeFile(sourcePath, bytes, { mode: 0o600 }); + const asset: FakeAsset = { + assetId, + mediaType: "image/png", + byteLength: bytes.byteLength, + width: 1, + height: 1, + createdAt: new Date(1_700_000_000_000).toISOString(), + ...(input.displayName ? { displayName: input.displayName } : {}), + origin: input.origin, + referenceCount: 0, + thumbnailSizes: [], + sourcePath, + bytes, + }; + this.items.set(assetId, asset); + return structuredClone(asset); + } + + async list(): Promise { + return [...this.items.values()].map((asset) => structuredClone(asset)); + } + + async get(assetId: string): Promise { + const asset = this.items.get(assetId); + return asset ? structuredClone(asset) : undefined; + } + + async withAssetFile( + assetId: string, + callback: (input: { + filePath: string; + asset: AssetMetadataDto; + byteLength: number; + mediaType: AssetMetadataDto["mediaType"]; + }) => Promise, + ): Promise { + const asset = this.items.get(assetId); + if (!asset) throw new Error("asset missing"); + return callback({ + filePath: asset.sourcePath, + asset: structuredClone(asset), + byteLength: asset.byteLength, + mediaType: asset.mediaType, + }); + } +} + +async function withRoots( + run: (roots: { internal: string; external: string }) => Promise, +): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-workspace-test-")); + const roots = { + internal: path.join(base, "internal"), + external: path.join(base, "external"), + }; + await fs.mkdir(roots.internal); + await fs.mkdir(roots.external); + try { + await run(roots); + } finally { + await fs.rm(base, { recursive: true, force: true }); + } +} + +function readPath(root: string, relativePath: string): string { + return path.join(root, ...relativePath.split("/")); +} + +test("configures a Finder-visible root, auto-syncs assets, and keeps status path-free", async () => { + await withRoots(async ({ internal, external }) => { + const assets = new FakeAssetStore(path.join(internal, "sources")); + await fs.mkdir(path.join(internal, "sources")); + const imported = await assets.add("import-bytes", { + displayName: "family photo.jpg", + origin: { kind: "import" }, + }); + const generated = await assets.add("generated-bytes", { + displayName: "sunset.png", + origin: { kind: "provider", providerId: "provider", modelId: "model", runId: "run" }, + }); + const workspace = new CreateImagesWorkspaceStore(internal, assets); + + assert.equal((await workspace.status()).state, "unconfigured"); + const configured = await workspace.configureChosenDirectory(external); + assert.equal(configured.state, "ready"); + assert.equal(configured.displayName, path.basename(external)); + assert.equal(configured.importedCount, 1); + assert.equal(configured.generatedCount, 1); + assert.equal(configured.lastSyncedAt !== undefined, true); + assert.equal(JSON.stringify(configured).includes(external), false); + assert.equal(JSON.stringify(configured).includes(internal), false); + + const importedRelative = createImagesWorkspaceRelativePath(imported); + const generatedRelative = createImagesWorkspaceRelativePath(generated); + assert.match(importedRelative, /^Imports\/family-photo-[a-f0-9]{64}\.png$/u); + assert.match(generatedRelative, /^Generated\/sunset-[a-f0-9]{64}\.png$/u); + assert.deepEqual( + new Uint8Array(await fs.readFile(readPath(external, importedRelative))), + new TextEncoder().encode("import-bytes"), + ); + assert.deepEqual( + new Uint8Array(await fs.readFile(readPath(external, generatedRelative))), + new TextEncoder().encode("generated-bytes"), + ); + assert.equal( + (await fs.lstat(path.join(external, ".aiden-create-images-workspace.json"))).isFile(), + true, + ); + assert.match(await fs.readFile(path.join(external, "README.txt"), "utf8"), /source of truth/u); + + const reopened = new CreateImagesWorkspaceStore(internal, assets); + assert.equal((await reopened.status()).state, "ready"); + const openRoot = await reopened.openRoot(); + assert.equal(openRoot.filePath, await fs.realpath(external)); + assert.equal(openRoot.displayName, path.basename(external)); + assert.equal((await reopened.openTarget(imported.assetId)).relativePath, importedRelative); + await reopened.syncAsset(imported.assetId); + assert.equal((await reopened.status()).generatedCount, 1); + const repeat = await reopened.syncAll(); + assert.deepEqual(repeat.materializedAssetIds, []); + assert.deepEqual( + repeat.alreadyMaterializedAssetIds.sort(), + [imported.assetId, generated.assetId].sort(), + ); + }); +}); + +test("never overwrites an arbitrary existing target", async () => { + await withRoots(async ({ internal, external }) => { + const assets = new FakeAssetStore(path.join(internal, "sources")); + await fs.mkdir(path.join(internal, "sources")); + const asset = await assets.add("canonical", { + displayName: "same-name.png", + origin: { kind: "import" }, + }); + const workspace = new CreateImagesWorkspaceStore(internal, assets); + await workspace.configureChosenDirectory(external); + const target = readPath(external, createImagesWorkspaceRelativePath(asset)); + await fs.writeFile(target, "user-owned", { mode: 0o600 }); + + const result = await workspace.syncAll(); + assert.equal(result.state, "conflict"); + assert.deepEqual(result.conflictedAssetIds, [asset.assetId]); + assert.equal(await fs.readFile(target, "utf8"), "user-owned"); + await assert.rejects( + workspace.openTarget(asset.assetId), + (error: unknown) => + error instanceof CreateImagesWorkspaceError && error.code === "workspace_target_conflict", + ); + }); +}); + +test("rejects symlinked roots and refuses a symlink target without touching its destination", async () => { + await withRoots(async ({ internal, external }) => { + const assets = new FakeAssetStore(path.join(internal, "sources")); + await fs.mkdir(path.join(internal, "sources")); + const asset = await assets.add("safe-bytes", { + displayName: "safe.png", + origin: { kind: "provider", providerId: "provider", modelId: "model", runId: "run" }, + }); + const linkRoot = path.join(path.dirname(external), "external-link"); + await fs.symlink(external, linkRoot, "dir"); + const workspace = new CreateImagesWorkspaceStore(internal, assets); + await assert.rejects( + workspace.configureChosenDirectory(linkRoot), + (error: unknown) => + error instanceof CreateImagesWorkspaceError && error.code === "workspace_root_unsafe", + ); + + await workspace.configureChosenDirectory(external); + const outside = path.join(path.dirname(external), "outside"); + await fs.mkdir(outside); + const target = readPath(external, createImagesWorkspaceRelativePath(asset)); + const outsideTarget = path.join(outside, "should-stay-empty.png"); + await fs.rm(target); + await fs.symlink(outsideTarget, target, "file"); + const result = await workspace.syncAll(); + assert.equal(result.state, "conflict"); + assert.deepEqual(result.conflictedAssetIds, [asset.assetId]); + await assert.rejects(fs.access(outsideTarget)); + }); +}); + +test("reports replacement/drift after restart and fails closed on corrupt internal config", async () => { + await withRoots(async ({ internal, external }) => { + const assets = new FakeAssetStore(path.join(internal, "sources")); + await fs.mkdir(path.join(internal, "sources")); + const workspace = new CreateImagesWorkspaceStore(internal, assets); + await workspace.configureChosenDirectory(external); + const moved = path.join(path.dirname(external), "external-moved"); + await fs.rename(external, moved); + const restarted = new CreateImagesWorkspaceStore(internal, assets); + assert.equal((await restarted.status()).state, "drifted"); + const preflight = await restarted.preflight(); + assert.equal(preflight.ok, false); + assert.deepEqual(preflight.issues, [{ code: "root_missing" }]); + + const corruptRoot = path.join(path.dirname(internal), "corrupt-internal"); + await fs.mkdir(corruptRoot); + await fs.writeFile(path.join(corruptRoot, "workspace.json"), "{not-json", { mode: 0o600 }); + const corrupt = new CreateImagesWorkspaceStore(corruptRoot, assets); + assert.equal((await corrupt.status()).state, "repair_required"); + assert.equal((await corrupt.configureChosenDirectory(moved)).state, "ready"); + const corruptBackups = (await fs.readdir(corruptRoot)).filter((name) => + name.includes("workspace.json.invalid-"), + ); + assert.equal(corruptBackups.length, 1); + }); +}); diff --git a/main/services/create-images/workspace-store.ts b/main/services/create-images/workspace-store.ts new file mode 100644 index 00000000..5e480867 --- /dev/null +++ b/main/services/create-images/workspace-store.ts @@ -0,0 +1,1163 @@ +import { constants } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { DataStore, DataStoreExternalChangeError } from "../data-store.js"; +import { readRegularFile } from "../regular-file-read.js"; +import type { + AssetMetadataDto, + AssetOrigin, + ContentAddressedAssetStore, +} from "./asset-store-core.js"; + +const ASSET_ID = /^[a-f0-9]{64}$/u; +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const SAFE_FILENAME = /^[A-Za-z0-9][A-Za-z0-9._ -]{0,220}\.(?:jpg|png)$/u; +const WORKSPACE_MARKER = ".aiden-create-images-workspace.json"; +const WORKSPACE_README = "README.txt"; +const WORKSPACE_SCHEMA_VERSION = 1 as const; +const MAX_CONFIG_BYTES = 64 * 1024 * 1024; +const MAX_ENTRIES = 100_000; +const MAX_ENTRY_BYTES = 64 * 1024 * 1024; +const MAX_MARKER_BYTES = 16 * 1024; +const COPY_CHUNK_BYTES = 64 * 1024; +const NO_FOLLOW = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + +export type CreateImagesWorkspaceState = + | "unconfigured" + | "ready" + | "drifted" + | "conflict" + | "unwritable" + | "repair_required"; + +export type CreateImagesWorkspaceEntryState = "materialized" | "conflict" | "drifted" | "orphaned"; + +export type CreateImagesWorkspaceErrorCode = + | "workspace_not_configured" + | "workspace_config_invalid" + | "workspace_config_conflict" + | "workspace_root_invalid" + | "workspace_root_missing" + | "workspace_root_changed" + | "workspace_root_unsafe" + | "workspace_not_writable" + | "workspace_marker_missing" + | "workspace_marker_conflict" + | "workspace_target_conflict" + | "workspace_target_missing" + | "workspace_asset_missing" + | "workspace_sync_failed"; + +export class CreateImagesWorkspaceError extends Error { + constructor( + public readonly code: CreateImagesWorkspaceErrorCode, + message: string, + public readonly assetId?: string, + ) { + super(message); + this.name = "CreateImagesWorkspaceError"; + } +} + +export interface CreateImagesWorkspaceStatus { + state: CreateImagesWorkspaceState; + configured: boolean; + workspaceId?: string; + /** Finder-facing label only; this never contains the selected path. */ + displayName?: string; + lastSyncedAt?: string; + revision: number; + entryCount: number; + materializedCount: number; + driftedCount: number; + conflictCount: number; + importedCount: number; + generatedCount: number; + writable: boolean; +} + +export type CreateImagesWorkspacePreflightIssueCode = + | "root_missing" + | "root_changed" + | "root_unsafe" + | "not_writable" + | "marker_missing" + | "marker_conflict" + | "directory_missing" + | "directory_unsafe" + | "target_missing" + | "target_conflict" + | "target_drifted"; + +export interface CreateImagesWorkspacePreflightIssue { + code: CreateImagesWorkspacePreflightIssueCode; + assetId?: string; +} + +export interface CreateImagesWorkspacePreflight extends CreateImagesWorkspaceStatus { + ok: boolean; + issues: CreateImagesWorkspacePreflightIssue[]; +} + +export interface CreateImagesWorkspaceSyncResult { + state: CreateImagesWorkspaceState; + revision: number; + totalAssets: number; + materializedAssetIds: string[]; + alreadyMaterializedAssetIds: string[]; + conflictedAssetIds: string[]; + driftedAssetIds: string[]; + failed: Array<{ assetId: string; message: string }>; +} + +export interface CreateImagesWorkspaceOpenTarget { + /** Main-process-only absolute path. Never return this over renderer IPC. */ + filePath: string; + assetId: string; + relativePath: string; +} + +export interface CreateImagesWorkspaceOpenRoot { + /** Main-process-only absolute path. Never return this over renderer IPC. */ + filePath: string; + displayName: string; +} + +export interface CreateImagesWorkspaceAssetSource { + list(): Promise; + get(assetId: string): Promise; + withAssetFile( + assetId: string, + callback: (input: { + filePath: string; + asset: AssetMetadataDto; + byteLength: number; + mediaType: AssetMetadataDto["mediaType"]; + }) => Promise, + ): Promise; +} + +interface WorkspacePathIdentity { + device: string; + inode: string; +} + +interface WorkspaceEntry { + assetId: string; + relativePath: string; + mediaType: AssetMetadataDto["mediaType"]; + byteLength: number; + state: CreateImagesWorkspaceEntryState; + updatedAt: string; +} + +interface WorkspaceConfig { + schemaVersion: typeof WORKSPACE_SCHEMA_VERSION; + revision: number; + selectedPath: string | null; + workspaceId: string | null; + identity: WorkspacePathIdentity | null; + lastSyncedAt: string | null; + entries: Record; +} + +interface WorkspaceMarker { + schemaVersion: typeof WORKSPACE_SCHEMA_VERSION; + workspaceId: string; + createdAt: string; +} + +interface WorkspaceRootContext { + selectedPath: string; + identity: WorkspacePathIdentity; +} + +type TargetInspection = "missing" | "materialized" | "conflict"; + +const EMPTY_CONFIG: WorkspaceConfig = { + schemaVersion: WORKSPACE_SCHEMA_VERSION, + revision: 0, + selectedPath: null, + workspaceId: null, + identity: null, + lastSyncedAt: null, + entries: {}, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteDate(value: unknown): value is string { + return typeof value === "string" && Number.isFinite(Date.parse(value)); +} + +function isAbsolutePath(value: unknown): value is string { + return typeof value === "string" && path.isAbsolute(value) && !value.includes("\0"); +} + +function isIdentity(value: unknown): value is WorkspacePathIdentity { + return ( + isRecord(value) && + typeof value.device === "string" && + /^[0-9]+$/u.test(value.device) && + typeof value.inode === "string" && + /^[0-9]+$/u.test(value.inode) + ); +} + +function isSafeRelativePath(value: unknown): value is string { + if (typeof value !== "string" || value.length < 1 || value.length > 240) return false; + if (value.includes("\0") || value.includes("\\") || path.isAbsolute(value)) return false; + const parts = value.split("/"); + return ( + parts.length === 2 && + (parts[0] === "Imports" || parts[0] === "Generated") && + SAFE_FILENAME.test(parts[1] ?? "") + ); +} + +function isWorkspaceEntry(value: unknown, assetId: string): value is WorkspaceEntry { + if (!isRecord(value)) return false; + return ( + value.assetId === assetId && + ASSET_ID.test(assetId) && + isSafeRelativePath(value.relativePath) && + (value.mediaType === "image/jpeg" || value.mediaType === "image/png") && + Number.isSafeInteger(value.byteLength) && + (value.byteLength as number) > 0 && + (value.byteLength as number) <= MAX_ENTRY_BYTES && + (value.state === "materialized" || + value.state === "conflict" || + value.state === "drifted" || + value.state === "orphaned") && + isFiniteDate(value.updatedAt) + ); +} + +function isWorkspaceConfig(value: unknown): value is WorkspaceConfig { + if (!isRecord(value) || value.schemaVersion !== WORKSPACE_SCHEMA_VERSION) return false; + if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) return false; + const selectedPath = value.selectedPath; + const workspaceId = value.workspaceId; + const identity = value.identity; + const unconfigured = selectedPath === null && workspaceId === null && identity === null; + const configured = + isAbsolutePath(selectedPath) && + typeof workspaceId === "string" && + SAFE_ID.test(workspaceId) && + isIdentity(identity); + if (!unconfigured && !configured) return false; + if ( + unconfigured && + (value.lastSyncedAt !== null || Object.keys(value.entries ?? {}).length > 0) + ) { + return false; + } + if (value.lastSyncedAt !== null && !isFiniteDate(value.lastSyncedAt)) return false; + if (!isRecord(value.entries)) return false; + const entries = Object.entries(value.entries); + if (entries.length > MAX_ENTRIES) return false; + return entries.every(([assetId, entry]) => isWorkspaceEntry(entry, assetId)); +} + +function cloneConfig(config: WorkspaceConfig): WorkspaceConfig { + return structuredClone(config); +} + +function normalizeConfig(value: unknown): WorkspaceConfig { + return isWorkspaceConfig(value) ? cloneConfig(value) : cloneConfig(EMPTY_CONFIG); +} + +function assertAbsoluteDirectoryPath(directory: string): string { + if (!isAbsolutePath(directory)) { + throw new CreateImagesWorkspaceError( + "workspace_root_invalid", + "The Create Images workspace directory must be an absolute path.", + ); + } + return path.resolve(directory); +} + +function sameIdentity(left: WorkspacePathIdentity, right: WorkspacePathIdentity): boolean { + return left.device === right.device && left.inode === right.inode; +} + +function directoryForOrigin(origin: AssetOrigin): "Imports" | "Generated" { + return origin.kind === "import" || origin.kind === "repair" ? "Imports" : "Generated"; +} + +function safeStem(displayName: string | undefined, origin: AssetOrigin): string { + const slashNormalized = (displayName ?? "").replace(/\\/gu, "/"); + const basename = slashNormalized.slice(slashNormalized.lastIndexOf("/") + 1); + const withoutExtension = basename.replace(/\.[A-Za-z0-9]{1,12}$/u, ""); + const stem = withoutExtension + .normalize("NFKC") + .replace(/[^A-Za-z0-9._ -]/gu, "-") + .replace(/[ ._-]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 80); + if (stem) return stem; + return origin.kind === "import" || origin.kind === "repair" ? "import" : "generated"; +} + +export function createImagesWorkspaceRelativePath( + asset: Pick, +): string { + if (!ASSET_ID.test(asset.assetId)) { + throw new CreateImagesWorkspaceError( + "workspace_asset_missing", + "The workspace asset ID is invalid.", + asset.assetId, + ); + } + const extension = asset.mediaType === "image/jpeg" ? "jpg" : "png"; + return `${directoryForOrigin(asset.origin)}/${safeStem(asset.displayName, asset.origin)}-${asset.assetId}.${extension}`; +} + +function nowIso(now: () => number): string { + return new Date(now()).toISOString(); +} + +async function syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, constants.O_RDONLY); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function safeLstat(directory: string): Promise { + try { + return await fs.lstat(directory); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new CreateImagesWorkspaceError( + "workspace_root_missing", + "The configured Create Images workspace directory is missing.", + ); + } + throw error; + } +} + +async function identityFor(directory: string): Promise { + const info = await fs.stat(directory, { bigint: true }); + return { device: info.dev.toString(), inode: info.ino.toString() }; +} + +async function ensureDirectoryChild(root: string, name: string): Promise { + const child = path.join(root, name); + try { + const info = await fs.lstat(child); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + `The Create Images workspace entry ${name} is not a regular directory.`, + ); + } + return child; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { + await fs.mkdir(child, { mode: 0o700 }); + } catch (mkdirError) { + if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; + } + const info = await fs.lstat(child); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + `The Create Images workspace entry ${name} is not a regular directory.`, + ); + } + await syncDirectory(root); + return child; + } +} + +async function readWorkspaceMarker(root: string): Promise { + const markerPath = path.join(root, WORKSPACE_MARKER); + let info; + try { + info = await fs.lstat(markerPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + if (info.isSymbolicLink() || !info.isFile()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The Create Images workspace marker is not a regular file.", + ); + } + let parsed: unknown; + try { + parsed = JSON.parse((await readRegularFile(markerPath, MAX_MARKER_BYTES)).toString("utf8")); + } catch { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The selected directory contains an invalid Create Images workspace marker.", + ); + } + if ( + !isRecord(parsed) || + parsed.schemaVersion !== WORKSPACE_SCHEMA_VERSION || + typeof parsed.workspaceId !== "string" || + !SAFE_ID.test(parsed.workspaceId) || + !isFiniteDate(parsed.createdAt) + ) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The selected directory contains an incompatible Create Images workspace marker.", + ); + } + return { + schemaVersion: WORKSPACE_SCHEMA_VERSION, + workspaceId: parsed.workspaceId, + createdAt: parsed.createdAt, + }; +} + +async function createWorkspaceMarker(root: string, workspaceId: string): Promise { + const markerPath = path.join(root, WORKSPACE_MARKER); + const existing = await readWorkspaceMarker(root); + if (existing) { + if (existing.workspaceId !== workspaceId) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The selected directory belongs to another Create Images workspace.", + ); + } + return; + } + const staged = path.join(root, `.${WORKSPACE_MARKER}.${randomUUID()}.tmp`); + const contents = `${JSON.stringify({ + schemaVersion: WORKSPACE_SCHEMA_VERSION, + workspaceId, + createdAt: new Date().toISOString(), + })}\n`; + try { + const handle = await fs.open( + staged, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, + 0o600, + ); + try { + await handle.writeFile(contents, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fs.link(staged, markerPath); + await syncDirectory(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const raced = await readWorkspaceMarker(root); + if (!raced || raced.workspaceId !== workspaceId) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "Another workspace marker appeared in the selected directory.", + ); + } + } + } finally { + await fs.rm(staged, { force: true }).catch(() => undefined); + } +} + +async function createWorkspaceReadme(root: string): Promise { + const destination = path.join(root, WORKSPACE_README); + let existing; + try { + existing = await fs.lstat(destination); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (existing) { + if (existing.isSymbolicLink() || !existing.isFile()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The Create Images workspace README is not a regular file.", + ); + } + return; + } + const staged = path.join(root, `.${WORKSPACE_README}.${randomUUID()}.tmp`); + try { + const handle = await fs.open( + staged, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, + 0o600, + ); + try { + await handle.writeFile( + "This folder is a Finder-visible mirror of Aiden Create Images assets.\n\n" + + "Imports are files added to Aiden. Generated contains provider outputs.\n" + + "Aiden's internal Create Images library remains the source of truth.\n", + "utf8", + ); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await fs.link(staged, destination); + await syncDirectory(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const raced = await fs.lstat(destination); + if (raced.isSymbolicLink() || !raced.isFile()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The Create Images workspace README changed during setup.", + ); + } + } + } finally { + await fs.rm(staged, { force: true }).catch(() => undefined); + } +} + +async function hashRegularFile( + filePath: string, + maxBytes: number, +): Promise<{ byteLength: number; digest: string }> { + const handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NONBLOCK | NO_FOLLOW); + try { + const before = await handle.stat(); + if (!before.isFile() || before.size < 1 || before.size > maxBytes) { + throw new Error("The workspace target is not a bounded regular file."); + } + const hash = createHash("sha256"); + let total = 0; + while (total <= maxBytes) { + const chunk = Buffer.allocUnsafe(Math.min(COPY_CHUNK_BYTES, maxBytes + 1 - total)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, total); + if (bytesRead === 0) break; + hash.update(chunk.subarray(0, bytesRead)); + total += bytesRead; + } + if (total > maxBytes) throw new Error("The workspace target grew beyond its byte limit."); + const after = await handle.stat(); + if (after.size !== before.size || total !== before.size) { + throw new Error("The workspace target changed while it was being read."); + } + return { byteLength: total, digest: hash.digest("hex") }; + } finally { + await handle.close(); + } +} + +async function inspectTarget( + filePath: string, + assetId: string, + byteLength: number, +): Promise { + let info; + try { + info = await fs.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + throw error; + } + if (info.isSymbolicLink() || !info.isFile()) return "conflict"; + try { + const hashed = await hashRegularFile(filePath, byteLength); + return hashed.byteLength === byteLength && hashed.digest === assetId + ? "materialized" + : "conflict"; + } catch { + return "conflict"; + } +} + +async function copyVerifiedFile( + sourcePath: string, + destination: string, + assetId: string, + byteLength: number, +): Promise<"materialized" | "already" | "conflict"> { + const directory = path.dirname(destination); + const staged = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`); + const source = await fs.open(sourcePath, constants.O_RDONLY | constants.O_NONBLOCK | NO_FOLLOW); + let stagedHandle: fs.FileHandle | undefined; + try { + const sourceInfo = await source.stat(); + if (!sourceInfo.isFile() || sourceInfo.size !== byteLength) { + throw new CreateImagesWorkspaceError( + "workspace_sync_failed", + "The canonical asset changed before workspace materialization.", + assetId, + ); + } + stagedHandle = await fs.open( + staged, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, + 0o600, + ); + const hash = createHash("sha256"); + let total = 0; + while (total <= byteLength) { + const chunk = Buffer.allocUnsafe(Math.min(COPY_CHUNK_BYTES, byteLength + 1 - total)); + const { bytesRead } = await source.read(chunk, 0, chunk.byteLength, total); + if (bytesRead === 0) break; + const part = chunk.subarray(0, bytesRead); + hash.update(part); + let written = 0; + while (written < part.byteLength) { + const result = await stagedHandle.write(part, written, part.byteLength - written, null); + if (result.bytesWritten < 1) throw new Error("The workspace write made no progress."); + written += result.bytesWritten; + } + total += bytesRead; + } + if (total !== byteLength || hash.digest("hex") !== assetId) { + throw new CreateImagesWorkspaceError( + "workspace_sync_failed", + "The canonical asset changed during workspace materialization.", + assetId, + ); + } + await stagedHandle.sync(); + await stagedHandle.close(); + stagedHandle = undefined; + try { + await fs.link(staged, destination); + await syncDirectory(directory); + return "materialized"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + const existing = await inspectTarget(destination, assetId, byteLength); + if (existing === "materialized") return "already"; + return "conflict"; + } + } finally { + await stagedHandle?.close().catch(() => undefined); + await source.close().catch(() => undefined); + await fs.rm(staged, { force: true }).catch(() => undefined); + } +} + +export class CreateImagesWorkspaceStore { + private readonly configStore: DataStore; + private config = cloneConfig(EMPTY_CONFIG); + private configHealthy = true; + private configCorrupt = false; + private configUnsafe = false; + private initializePromise: Promise | undefined; + private mutationTail: Promise = Promise.resolve(); + + constructor( + private readonly rootDirectory: string, + private readonly assets: CreateImagesWorkspaceAssetSource | ContentAddressedAssetStore, + private readonly options: { now?: () => number } = {}, + ) { + if (!path.isAbsolute(rootDirectory)) { + throw new Error("The Create Images workspace config root must be absolute."); + } + this.configStore = new DataStore( + "workspace.json", + cloneConfig(EMPTY_CONFIG), + () => this.rootDirectory, + { + maxBytes: MAX_CONFIG_BYTES, + preserveCorruptFile: true, + normalize: normalizeConfig, + isSafe: (value) => isWorkspaceConfig(value), + reloadBeforeWrite: true, + rejectExternalChanges: true, + rejectUnsafeWrite: true, + }, + ); + } + + private get now(): () => number { + return this.options.now ?? Date.now; + } + + private serialized(operation: () => Promise): Promise { + const result = this.mutationTail.then(operation, operation); + this.mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async initialize(): Promise { + if (!this.initializePromise) { + this.initializePromise = (async () => { + await fs.mkdir(this.rootDirectory, { recursive: true, mode: 0o700 }); + const rootInfo = await fs.lstat(this.rootDirectory); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_config_invalid", + "The Create Images config root is not a regular directory.", + ); + } + this.config = cloneConfig(await this.configStore.load()); + this.configCorrupt = await this.configStore.loadedFromCorruptFile(); + this.configUnsafe = await this.configStore.loadedFromUnsafeFile(); + this.configHealthy = !this.configCorrupt && !this.configUnsafe; + })(); + } + try { + await this.initializePromise; + } catch (error) { + this.initializePromise = undefined; + throw error; + } + } + + private async configuredRoot(): Promise { + if (!this.configHealthy) { + throw new CreateImagesWorkspaceError( + "workspace_config_invalid", + "The Create Images workspace configuration needs repair.", + ); + } + if (!this.config.selectedPath || !this.config.workspaceId || !this.config.identity) { + throw new CreateImagesWorkspaceError( + "workspace_not_configured", + "No Create Images workspace directory has been configured.", + ); + } + const info = await safeLstat(this.config.selectedPath); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The configured Create Images workspace is not a regular directory.", + ); + } + const identity = await identityFor(this.config.selectedPath); + if (!sameIdentity(identity, this.config.identity)) { + throw new CreateImagesWorkspaceError( + "workspace_root_changed", + "The configured Create Images workspace was replaced or moved.", + ); + } + const marker = await readWorkspaceMarker(this.config.selectedPath); + if (!marker) { + throw new CreateImagesWorkspaceError( + "workspace_marker_missing", + "The configured Create Images workspace marker is missing.", + ); + } + if (marker.workspaceId !== this.config.workspaceId) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The configured directory belongs to another Create Images workspace.", + ); + } + return { selectedPath: this.config.selectedPath, identity }; + } + + private async writableRoot(context: WorkspaceRootContext): Promise { + try { + await fs.access(context.selectedPath, constants.W_OK); + await fs.access(path.join(context.selectedPath, "Imports"), constants.W_OK); + await fs.access(path.join(context.selectedPath, "Generated"), constants.W_OK); + return true; + } catch { + return false; + } + } + + private statusFromConfig( + state: CreateImagesWorkspaceState, + writable: boolean, + ): CreateImagesWorkspaceStatus { + const entries = Object.values(this.config.entries); + const materialized = entries.filter((entry) => entry.state === "materialized"); + return { + state, + configured: this.config.selectedPath !== null, + ...(this.config.workspaceId ? { workspaceId: this.config.workspaceId } : {}), + ...(this.config.selectedPath + ? { displayName: path.basename(this.config.selectedPath) || "Workspace" } + : {}), + ...(this.config.lastSyncedAt ? { lastSyncedAt: this.config.lastSyncedAt } : {}), + revision: this.config.revision, + entryCount: entries.length, + materializedCount: materialized.length, + driftedCount: entries.filter( + (entry) => entry.state === "drifted" || entry.state === "orphaned", + ).length, + conflictCount: entries.filter((entry) => entry.state === "conflict").length, + importedCount: materialized.filter((entry) => entry.relativePath.startsWith("Imports/")) + .length, + generatedCount: materialized.filter((entry) => entry.relativePath.startsWith("Generated/")) + .length, + writable, + }; + } + + private async statusInside(): Promise { + if (!this.configHealthy) return this.statusFromConfig("repair_required", false); + if (!this.config.selectedPath) return this.statusFromConfig("unconfigured", false); + try { + const context = await this.configuredRoot(); + const writable = await this.writableRoot(context); + return this.statusFromConfig(writable ? "ready" : "unwritable", writable); + } catch (error) { + const code = error instanceof CreateImagesWorkspaceError ? error.code : undefined; + const state: CreateImagesWorkspaceState = + code === "workspace_marker_conflict" + ? "conflict" + : code === "workspace_root_unsafe" + ? "conflict" + : code === "workspace_marker_missing" + ? "drifted" + : code === "workspace_not_writable" + ? "unwritable" + : code === "workspace_root_missing" || code === "workspace_root_changed" + ? "drifted" + : "drifted"; + return this.statusFromConfig(state, false); + } + } + + async status(): Promise { + return this.serialized(async () => { + await this.initialize(); + return this.statusInside(); + }); + } + + async configureChosenDirectory(directory: string): Promise { + return this.serialized(async () => { + await this.initialize(); + if (!this.configHealthy && !this.configCorrupt) { + throw new CreateImagesWorkspaceError( + "workspace_config_invalid", + "The Create Images workspace configuration needs repair.", + ); + } + const requestedPath = assertAbsoluteDirectoryPath(directory); + const requestedInfo = await safeLstat(requestedPath); + if (requestedInfo.isSymbolicLink() || !requestedInfo.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The selected Create Images workspace must be a regular directory, not a symlink.", + ); + } + const selectedPath = await fs.realpath(requestedPath); + const identity = await identityFor(selectedPath); + const sameConfiguredPath = + this.config.selectedPath === selectedPath && + this.config.identity !== null && + sameIdentity(this.config.identity, identity); + const marker = await readWorkspaceMarker(selectedPath); + const workspaceId = + sameConfiguredPath && this.config.workspaceId + ? this.config.workspaceId + : this.configCorrupt && marker + ? marker.workspaceId + : randomUUID(); + if (marker && marker.workspaceId !== workspaceId) { + throw new CreateImagesWorkspaceError( + "workspace_marker_conflict", + "The selected directory belongs to another Create Images workspace.", + ); + } + await createWorkspaceMarker(selectedPath, workspaceId); + await createWorkspaceReadme(selectedPath); + await ensureDirectoryChild(selectedPath, "Imports"); + await ensureDirectoryChild(selectedPath, "Generated"); + const next: WorkspaceConfig = { + schemaVersion: WORKSPACE_SCHEMA_VERSION, + revision: this.config.revision + 1, + selectedPath, + workspaceId, + identity, + lastSyncedAt: sameConfiguredPath ? this.config.lastSyncedAt : null, + entries: sameConfiguredPath ? this.config.entries : {}, + }; + try { + await this.configStore.save(next); + } catch (error) { + if (error instanceof DataStoreExternalChangeError) { + throw new CreateImagesWorkspaceError( + "workspace_config_conflict", + "The Create Images workspace configuration changed outside Aiden.", + ); + } + throw error; + } + this.config = cloneConfig(next); + this.configCorrupt = false; + this.configUnsafe = false; + this.configHealthy = true; + const assets = await this.assets.list(); + await this.syncAssets(assets, true); + return this.statusInside(); + }); + } + + async preflight(): Promise { + return this.serialized(async () => { + await this.initialize(); + const base = await this.statusInside(); + const issues: CreateImagesWorkspacePreflightIssue[] = []; + if (!this.configHealthy) { + issues.push({ code: "root_unsafe" }); + return { ...base, ok: false, issues }; + } + if (!this.config.selectedPath) + return { ...base, ok: false, issues: [{ code: "root_missing" }] }; + let context: WorkspaceRootContext; + try { + context = await this.configuredRoot(); + } catch (error) { + const code = error instanceof CreateImagesWorkspaceError ? error.code : undefined; + const issue: CreateImagesWorkspacePreflightIssue = + code === "workspace_root_changed" + ? { code: "root_changed" } + : code === "workspace_root_missing" + ? { code: "root_missing" } + : code === "workspace_marker_missing" + ? { code: "marker_missing" } + : code === "workspace_marker_conflict" + ? { code: "marker_conflict" } + : { code: "root_unsafe" }; + return { ...base, ok: false, issues: [issue] }; + } + if (!(await this.writableRoot(context))) issues.push({ code: "not_writable" }); + for (const directory of ["Imports", "Generated"] as const) { + try { + const info = await fs.lstat(path.join(context.selectedPath, directory)); + if (info.isSymbolicLink() || !info.isDirectory()) + issues.push({ code: "directory_unsafe" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + issues.push({ code: "directory_missing" }); + else issues.push({ code: "directory_unsafe" }); + } + } + for (const entry of Object.values(this.config.entries)) { + const target = path.join(context.selectedPath, ...entry.relativePath.split("/")); + const inspection = await inspectTarget(target, entry.assetId, entry.byteLength); + if (inspection === "missing") + issues.push({ code: "target_missing", assetId: entry.assetId }); + else if (inspection === "conflict") + issues.push({ code: "target_conflict", assetId: entry.assetId }); + } + return { ...base, ok: issues.length === 0, issues }; + }); + } + + private async syncOne( + context: WorkspaceRootContext, + asset: AssetMetadataDto, + ): Promise<"materialized" | "already" | "conflict"> { + const relativePath = createImagesWorkspaceRelativePath(asset); + const destination = path.join(context.selectedPath, ...relativePath.split("/")); + const current = await inspectTarget(destination, asset.assetId, asset.byteLength); + if (current === "materialized") return "already"; + if (current === "conflict") return "conflict"; + const parent = path.dirname(destination); + const parentInfo = await fs.lstat(parent); + if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory()) { + throw new CreateImagesWorkspaceError( + "workspace_root_unsafe", + "The workspace materialization directory is not safe.", + asset.assetId, + ); + } + const result = await this.assets.withAssetFile(asset.assetId, async (source) => { + if (source.asset.assetId !== asset.assetId || source.byteLength !== asset.byteLength) { + throw new CreateImagesWorkspaceError( + "workspace_sync_failed", + "The canonical asset metadata changed during workspace sync.", + asset.assetId, + ); + } + return copyVerifiedFile(source.filePath, destination, asset.assetId, asset.byteLength); + }); + if (result === "conflict") return "conflict"; + const final = await inspectTarget(destination, asset.assetId, asset.byteLength); + if (final !== "materialized") { + throw new CreateImagesWorkspaceError( + "workspace_sync_failed", + "The workspace target could not be verified after publication.", + asset.assetId, + ); + } + return "materialized"; + } + + private async syncAssets( + assets: AssetMetadataDto[], + fullInventory = false, + ): Promise { + const base = await this.statusInside(); + if (base.state !== "ready") { + return { + state: base.state, + revision: base.revision, + totalAssets: assets.length, + materializedAssetIds: [], + alreadyMaterializedAssetIds: [], + conflictedAssetIds: [], + driftedAssetIds: [], + failed: [], + }; + } + const context = await this.configuredRoot(); + const next = cloneConfig(this.config); + const materializedAssetIds: string[] = []; + const alreadyMaterializedAssetIds: string[] = []; + const conflictedAssetIds: string[] = []; + const driftedAssetIds: string[] = []; + const failed: Array<{ assetId: string; message: string }> = []; + if (fullInventory) { + const active = new Set(assets.map((asset) => asset.assetId)); + for (const entry of Object.values(next.entries)) { + if (!active.has(entry.assetId)) entry.state = "orphaned"; + } + } + for (const asset of assets) { + try { + const result = await this.syncOne(context, asset); + const relativePath = createImagesWorkspaceRelativePath(asset); + const entry: WorkspaceEntry = { + assetId: asset.assetId, + relativePath, + mediaType: asset.mediaType, + byteLength: asset.byteLength, + state: result === "conflict" ? "conflict" : "materialized", + updatedAt: nowIso(this.now), + }; + next.entries[asset.assetId] = entry; + if (result === "materialized") materializedAssetIds.push(asset.assetId); + else if (result === "already") alreadyMaterializedAssetIds.push(asset.assetId); + else conflictedAssetIds.push(asset.assetId); + } catch (error) { + const message = + error instanceof Error ? error.message : "The asset could not be materialized."; + failed.push({ assetId: asset.assetId, message }); + if ( + error instanceof CreateImagesWorkspaceError && + error.code === "workspace_root_changed" + ) { + driftedAssetIds.push(asset.assetId); + break; + } + const previous = next.entries[asset.assetId]; + if (previous) previous.state = "drifted"; + } + } + const entriesChanged = JSON.stringify(next.entries) !== JSON.stringify(this.config.entries); + const changed = entriesChanged || (assets.length > 0 && this.config.lastSyncedAt === null); + if (changed) { + next.lastSyncedAt = nowIso(this.now); + next.revision += 1; + try { + await this.configStore.save(next); + } catch (error) { + if (error instanceof DataStoreExternalChangeError) { + throw new CreateImagesWorkspaceError( + "workspace_config_conflict", + "The Create Images workspace configuration changed outside Aiden.", + ); + } + throw error; + } + this.config = next; + } + const state: CreateImagesWorkspaceState = + conflictedAssetIds.length > 0 + ? "conflict" + : failed.length > 0 || driftedAssetIds.length > 0 + ? "drifted" + : "ready"; + return { + state, + revision: this.config.revision, + totalAssets: assets.length, + materializedAssetIds: materializedAssetIds.sort(), + alreadyMaterializedAssetIds: alreadyMaterializedAssetIds.sort(), + conflictedAssetIds: conflictedAssetIds.sort(), + driftedAssetIds: driftedAssetIds.sort(), + failed, + }; + } + + async syncAll(): Promise { + return this.serialized(async () => { + await this.initialize(); + const assets = this.configHealthy ? await this.assets.list() : []; + return this.syncAssets(assets, true); + }); + } + + async syncAsset(assetId: string): Promise { + return this.serialized(async () => { + await this.initialize(); + if (!ASSET_ID.test(assetId)) { + throw new CreateImagesWorkspaceError("workspace_asset_missing", "The asset ID is invalid."); + } + const asset = await this.assets.get(assetId); + if (!asset) { + throw new CreateImagesWorkspaceError( + "workspace_asset_missing", + "The requested asset does not exist.", + assetId, + ); + } + return this.syncAssets([asset]); + }); + } + + async openTarget(assetId: string): Promise { + return this.serialized(async () => { + await this.initialize(); + if (!ASSET_ID.test(assetId)) { + throw new CreateImagesWorkspaceError("workspace_asset_missing", "The asset ID is invalid."); + } + const asset = await this.assets.get(assetId); + if (!asset) { + throw new CreateImagesWorkspaceError( + "workspace_asset_missing", + "The requested asset does not exist.", + assetId, + ); + } + const context = await this.configuredRoot(); + const relativePath = createImagesWorkspaceRelativePath(asset); + const filePath = path.join(context.selectedPath, ...relativePath.split("/")); + const inspection = await inspectTarget(filePath, asset.assetId, asset.byteLength); + if (inspection === "missing") { + throw new CreateImagesWorkspaceError( + "workspace_target_missing", + "The workspace copy is missing.", + assetId, + ); + } + if (inspection === "conflict") { + throw new CreateImagesWorkspaceError( + "workspace_target_conflict", + "The workspace copy is not an Aiden-created asset.", + assetId, + ); + } + return { filePath, assetId, relativePath }; + }); + } + + async openRoot(): Promise { + return this.serialized(async () => { + await this.initialize(); + const context = await this.configuredRoot(); + return { + filePath: context.selectedPath, + displayName: path.basename(context.selectedPath) || "Workspace", + }; + }); + } +} From 6abf46b51fbaba7af4f2866a08fba61865c9e70a Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 20 Aug 2026 00:41:58 -0400 Subject: [PATCH 003/110] feat(create-images): add durable execution and Gemini provider Add immutable run planning, hash-chained journals, restart reconciliation, cancellation and ambiguity handling, bounded scheduling, reference accounting, and local mock execution. Integrate a fixed-origin Gemini image adapter with curated capabilities, exact consent and credential bindings, durable usage, and no paid automatic retries. --- .../create-images/create-images-service.ts | 600 +++ main/services/create-images/feature-flag.ts | 18 + .../gemini-provider-status-core.test.ts | 52 + .../gemini-provider-status-core.ts | 102 + .../image-provider-execution-core.test.ts | 975 ++++ .../image-provider-execution-core.ts | 1796 +++++++ .../mock-image-provider-core.test.ts | 541 +++ .../create-images/mock-image-provider-core.ts | 755 +++ .../mutation-rate-limit-core.test.ts | 51 + .../create-images/mutation-rate-limit-core.ts | 80 + .../create-images/provider-contract.ts | 57 + .../gemini-image-provider-core.test.ts | 537 ++ .../providers/gemini-image-provider-core.ts | 746 +++ .../providers/gemini-interactions-core.ts | 159 + .../renderer-egress-core.test.ts | 69 + .../create-images/renderer-egress-core.ts | 33 + .../run-journal-performance.test.ts | 304 ++ .../create-images/run-journal-store.test.ts | 1572 ++++++ .../create-images/run-journal-store.ts | 4311 +++++++++++++++++ .../run-publication-binding-core.ts | 16 + .../create-images/run-service.test.ts | 2742 +++++++++++ main/services/create-images/run-service.ts | 2676 ++++++++++ .../create-images/scheduler-core.test.ts | 989 ++++ main/services/create-images/scheduler-core.ts | 1176 +++++ 24 files changed, 20357 insertions(+) create mode 100644 main/services/create-images/create-images-service.ts create mode 100644 main/services/create-images/feature-flag.ts create mode 100644 main/services/create-images/gemini-provider-status-core.test.ts create mode 100644 main/services/create-images/gemini-provider-status-core.ts create mode 100644 main/services/create-images/image-provider-execution-core.test.ts create mode 100644 main/services/create-images/image-provider-execution-core.ts create mode 100644 main/services/create-images/mock-image-provider-core.test.ts create mode 100644 main/services/create-images/mock-image-provider-core.ts create mode 100644 main/services/create-images/mutation-rate-limit-core.test.ts create mode 100644 main/services/create-images/mutation-rate-limit-core.ts create mode 100644 main/services/create-images/provider-contract.ts create mode 100644 main/services/create-images/providers/gemini-image-provider-core.test.ts create mode 100644 main/services/create-images/providers/gemini-image-provider-core.ts create mode 100644 main/services/create-images/providers/gemini-interactions-core.ts create mode 100644 main/services/create-images/renderer-egress-core.test.ts create mode 100644 main/services/create-images/renderer-egress-core.ts create mode 100644 main/services/create-images/run-journal-performance.test.ts create mode 100644 main/services/create-images/run-journal-store.test.ts create mode 100644 main/services/create-images/run-journal-store.ts create mode 100644 main/services/create-images/run-publication-binding-core.ts create mode 100644 main/services/create-images/run-service.test.ts create mode 100644 main/services/create-images/run-service.ts create mode 100644 main/services/create-images/scheduler-core.test.ts create mode 100644 main/services/create-images/scheduler-core.ts diff --git a/main/services/create-images/create-images-service.ts b/main/services/create-images/create-images-service.ts new file mode 100644 index 00000000..a3961bab --- /dev/null +++ b/main/services/create-images/create-images-service.ts @@ -0,0 +1,600 @@ +import path from "node:path"; +import * as electron from "electron"; +import type { + AssetDeepValidator, + AssetPreviewLeaseDto, + AssetReferenceAuthority, + AssetReferenceSnapshot, + AssetThumbnailGenerator, +} from "./asset-store-core.js"; +import { AssetStoreError, ContentAddressedAssetStore } from "./asset-store-core.js"; +import { + ASSET_DELIVERY_GRANT_TTL_MS, + AssetDeliveryGrantRegistry, + type AssetDeliveryGrantView, +} from "./asset-delivery-core.js"; +import type { RendererDocumentOwner } from "../renderer-document-owner.js"; +import { + WorkflowManifestStore, + type WorkflowManifestDurability, +} from "./workflow-manifest-store.js"; +import { + CreateImagesRunService, + type CreateImagesRunReferenceAuthority, + type CreateImagesRunReferenceReservation, +} from "./run-service.js"; +import type { CreateImagesRunJournalStore } from "./run-journal-store.js"; +import { resolveCreateImagesGeminiApiKeyAuth } from "./gemini-provider-status-core.js"; +import { CreateImagesNativeArchiveService } from "./native-archive-service.js"; +import { CreateImagesNodeBananaImportService } from "./node-banana-import-service.js"; +import { CreateImagesWorkspaceStore, type CreateImagesWorkspaceStatus } from "./workspace-store.js"; + +const defaultAssetDeepValidator: AssetDeepValidator = { + async validate(input) { + const { electronAssetDeepValidator } = await import("./electron-asset-images.js"); + return electronAssetDeepValidator.validate(input); + }, +}; + +const defaultAssetThumbnailGenerator: AssetThumbnailGenerator = { + async generate(input) { + const { electronAssetThumbnailGenerator } = await import("./electron-asset-images.js"); + return electronAssetThumbnailGenerator.generate(input); + }, +}; + +interface WorkflowReferenceReservation { + workflowId: string; + next: ReadonlySet; + active: boolean; +} + +class CreateImagesReferenceAuthority + implements AssetReferenceAuthority, CreateImagesRunReferenceAuthority +{ + private readonly workflows = new Map>(); + private readonly runs = new Map>(); + private readonly runReservations = new Map>(); + private tail: Promise = Promise.resolve(); + private epoch = 0; + private workflowsComplete = false; + private runsComplete = false; + + private serialized(operation: () => Promise): Promise { + const result = this.tail.then(operation, operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private async initializeWorkflowsInsideFence(store: WorkflowManifestStore): Promise { + this.workflowsComplete = false; + this.epoch += 1; + try { + const inventory = await store.referenceInventory(); + this.workflows.clear(); + for (const record of inventory.records) { + this.workflows.set(record.workflowId, new Set(record.assetIds)); + } + this.workflowsComplete = inventory.complete; + this.epoch += 1; + return inventory.complete; + } catch (error) { + this.workflowsComplete = false; + this.epoch += 1; + throw error; + } + } + + private async initializeRunsInsideFence(store: CreateImagesRunJournalStore): Promise { + this.runsComplete = false; + this.epoch += 1; + try { + const inventory = await store.referenceInventory(); + this.runs.clear(); + for (const record of inventory.records) { + this.runs.set(record.runId, new Set(record.assetIds)); + } + for (const [runId, reservations] of this.runReservations) { + const protectedIds = new Set(this.runs.get(runId) ?? []); + for (const reservation of reservations) { + if (!reservation.active) continue; + for (const assetId of reservation.next) protectedIds.add(assetId); + } + if (protectedIds.size > 0) this.runs.set(runId, protectedIds); + } + this.runsComplete = inventory.complete; + this.epoch += 1; + return inventory.complete; + } catch (error) { + this.runsComplete = false; + this.epoch += 1; + throw error; + } + } + + async initialize( + workflows: WorkflowManifestStore, + runs: CreateImagesRunJournalStore, + ): Promise { + return this.serialized(async () => { + const workflowsComplete = await this.initializeWorkflowsInsideFence(workflows); + const runsComplete = await this.initializeRunsInsideFence(runs); + return workflowsComplete && runsComplete; + }); + } + + async reserve( + workflowId: string, + assetIds: readonly string[], + ): Promise { + return this.serialized(async () => { + const previous = this.workflows.get(workflowId) ?? new Set(); + const next = new Set(assetIds); + this.workflows.set(workflowId, new Set([...previous, ...next])); + this.epoch += 1; + return { workflowId, next, active: true }; + }); + } + + async commit(reservation: WorkflowReferenceReservation): Promise { + if (!reservation.active) return; + await this.serialized(async () => { + if (!reservation.active) return; + reservation.active = false; + if (reservation.next.size === 0) this.workflows.delete(reservation.workflowId); + else this.workflows.set(reservation.workflowId, new Set(reservation.next)); + this.epoch += 1; + }); + } + + async reconcileFailedMutation( + reservation: WorkflowReferenceReservation, + store: WorkflowManifestStore, + ): Promise { + return this.serialized(async () => { + if (reservation.active) { + reservation.active = false; + // Keep the reservation's previous+next union protected until the + // durable current/LKG/journal inventory replaces it below. If that + // inventory cannot be read safely, initializeInsideFence leaves GC + // fail-closed while this conservative union remains available. + this.epoch += 1; + } + return this.initializeWorkflowsInsideFence(store); + }); + } + + async reserveRun( + runId: string, + assetIds: readonly string[], + ): Promise { + return this.serialized(async () => { + const previous = this.runs.get(runId) ?? new Set(); + const next = new Set([...previous, ...assetIds]); + this.runs.set(runId, next); + this.epoch += 1; + const reservation = { runId, next, active: true }; + const reservations = this.runReservations.get(runId) ?? new Set(); + reservations.add(reservation); + this.runReservations.set(runId, reservations); + return reservation; + }); + } + + async commitRun(reservation: CreateImagesRunReferenceReservation): Promise { + if (!reservation.active) return; + await this.serialized(async () => { + if (!reservation.active) return; + reservation.active = false; + const reservations = this.runReservations.get(reservation.runId); + reservations?.delete(reservation); + if (reservations?.size === 0) this.runReservations.delete(reservation.runId); + const current = this.runs.get(reservation.runId) ?? new Set(); + const committed = new Set([...current, ...reservation.next]); + if (committed.size === 0) this.runs.delete(reservation.runId); + else this.runs.set(reservation.runId, committed); + this.epoch += 1; + }); + } + + async releaseRunReservations(runId: string): Promise { + await this.serialized(async () => { + const reservations = this.runReservations.get(runId); + if (!reservations) return; + for (const reservation of reservations) reservation.active = false; + this.runReservations.delete(runId); + this.epoch += 1; + }); + } + + async reconcileRuns(store: CreateImagesRunJournalStore): Promise { + return this.serialized(() => this.initializeRunsInsideFence(store)); + } + + isWorkflowAssetReferenced(workflowId: string, assetId: string): boolean { + return this.workflows.get(workflowId)?.has(assetId) ?? false; + } + + isRunAssetReferenced(runId: string, assetId: string): boolean { + return this.runs.get(runId)?.has(assetId) ?? false; + } + + workflowAssetIds(workflowId: string): string[] { + return [...(this.workflows.get(workflowId) ?? [])].sort(); + } + + allReferencedAssetIds(): Set { + return new Set( + [...this.workflows.values(), ...this.runs.values()].flatMap((assetIds) => [...assetIds]), + ); + } + + async withSnapshot( + callback: (snapshot: AssetReferenceSnapshot) => Promise, + ): Promise { + return this.serialized(async () => { + if (!this.workflowsComplete || !this.runsComplete) { + throw new Error( + "Asset collection is disabled until every workflow recovery issue is resolved.", + ); + } + return callback({ + epoch: String(this.epoch), + completeKinds: ["export", "run", "workflow"], + records: [ + ...[...this.workflows.entries()].map(([id, assetIds]) => ({ + kind: "workflow" as const, + id, + assetIds: [...assetIds].sort(), + })), + ...[...this.runs.entries()].map(([id, assetIds]) => ({ + kind: "run" as const, + id, + assetIds: [...assetIds].sort(), + })), + ], + }); + }); + } +} + +export interface CreateImagesServiceOptions { + workflowDurability?: WorkflowManifestDurability; + /** Production requires first-open Finder workspace setup; isolated stores/tests may opt out. */ + workspaceRequired?: boolean; + assetStore?: { + now?: () => number; + deepValidator?: AssetDeepValidator; + thumbnailGenerator?: AssetThumbnailGenerator; + }; + runService?: Pick< + ConstructorParameters[0], + "resolveGeminiAuth" | "createGeminiProvider" + >; +} + +export type CreateImagesDeleteWorkflowResult = + | { status: "deleted" } + | { status: "not-found" } + | { status: "unavailable"; message: string }; + +export class CreateImagesService { + readonly workflows: WorkflowManifestStore; + readonly runs: CreateImagesRunService; + readonly archives: CreateImagesNativeArchiveService; + readonly nodeBananaImports: CreateImagesNodeBananaImportService; + readonly workspace: CreateImagesWorkspaceStore; + readonly grants = new AssetDeliveryGrantRegistry(); + readonly assets: ContentAddressedAssetStore; + readonly references = new CreateImagesReferenceAuthority(); + private readonly workspaceRequired: boolean; + private initializePromise: Promise | undefined; + private missingAssetIds = new Set(); + + private pruneResolvedMissingAssets(): void { + const referenced = this.references.allReferencedAssetIds(); + for (const assetId of this.missingAssetIds) { + if (!referenced.has(assetId)) this.missingAssetIds.delete(assetId); + } + } + + constructor(rootDirectory: string, options: CreateImagesServiceOptions = {}) { + this.workspaceRequired = options.workspaceRequired ?? false; + this.workflows = new WorkflowManifestStore(() => rootDirectory, options.workflowDurability); + let workspaceStore: CreateImagesWorkspaceStore | undefined; + this.assets = new ContentAddressedAssetStore(rootDirectory, this.references, { + deepValidator: options.assetStore?.deepValidator ?? defaultAssetDeepValidator, + thumbnailGenerator: options.assetStore?.thumbnailGenerator ?? defaultAssetThumbnailGenerator, + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + onAssetPublished: async (asset) => { + await workspaceStore?.syncAsset(asset.assetId); + }, + }); + this.workspace = new CreateImagesWorkspaceStore(rootDirectory, this.assets, { + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + }); + workspaceStore = this.workspace; + this.runs = new CreateImagesRunService({ + rootResolver: () => rootDirectory, + workflows: this.workflows, + assets: this.assets, + references: this.references, + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + workspaceStatus: (): Promise> => + this.workspace.status(), + workspaceRequired: options.workspaceRequired ?? false, + ...options.runService, + }); + this.archives = new CreateImagesNativeArchiveService({ + rootDirectory, + workflows: this.workflows, + assets: this.assets, + publishImportedWorkflow: (workflow, isCurrent) => + this.mutateWorkflow(workflow.id, workflow.assetRefs, () => + this.workflows.create(workflow, isCurrent), + ), + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + }); + this.nodeBananaImports = new CreateImagesNodeBananaImportService({ + rootDirectory, + assets: this.assets, + publishImportedWorkflow: (workflow, isCurrent) => + this.mutateWorkflow(workflow.id, workflow.assetRefs, () => + this.workflows.create(workflow, isCurrent), + ), + ...(options.assetStore?.now ? { now: options.assetStore.now } : {}), + }); + } + + async initialize(): Promise { + this.initializePromise ??= (async () => { + const workspace = await this.workspace.status(); + if (this.workspaceRequired && (!workspace.configured || workspace.state !== "ready")) { + throw new Error("Configure a writable Create Images workspace before continuing."); + } + const summaries = await this.workflows.initialize(); + await this.runs.initialize(); + const referencesComplete = await this.references.initialize( + this.workflows, + this.runs.journals, + ); + const status = await this.assets.status(); + if ( + status.healthy && + referencesComplete && + summaries.every((summary) => summary.health === "healthy") + ) { + const rebuilt = await this.assets.rebuildReferenceAccounting(); + this.missingAssetIds = new Set(rebuilt.missingAssetIds); + } else { + this.missingAssetIds.clear(); + } + })(); + try { + await this.initializePromise; + } catch (error) { + this.initializePromise = undefined; + throw error; + } + } + + /** + * Initializes the read-only workflow library even when a future run-index + * schema prevents the run service from opening. This fallback is deliberately + * limited to an explicitly unsafe run index: every mutating/run path continues + * to use initialize() and therefore remains fail-closed. + */ + async initializeReadOnlyLibrary(): Promise { + try { + await this.initialize(); + } catch (error) { + const [, runIndex] = await Promise.all([ + this.workflows.initialize(), + this.runs.journals.indexHealth(), + ]); + if (runIndex.status !== "unsafe") throw error; + this.missingAssetIds.clear(); + } + } + + async mutateWorkflow( + workflowId: string, + assetIds: readonly string[], + operation: () => Promise, + options: { allowMissingAssetIds?: readonly string[] } = {}, + ): Promise { + await this.initialize(); + const current = assetIds.length > 0 ? await this.workflows.get(workflowId) : undefined; + const allowedMissingAssetIds = new Set([ + ...(current?.assetRefs ?? []), + ...(options.allowMissingAssetIds ?? []), + ]); + const reservation = await this.references.reserve(workflowId, assetIds); + const presentAssetIds: string[] = []; + try { + for (const assetId of assetIds) { + if (await this.assets.getAvailable(assetId)) presentAssetIds.push(assetId); + else if (!allowedMissingAssetIds.has(assetId)) + throw new Error(`Asset ${assetId} does not exist.`); + else this.missingAssetIds.add(assetId); + } + const result = await operation(); + await this.references.commit(reservation); + for (const assetId of presentAssetIds) this.missingAssetIds.delete(assetId); + this.pruneResolvedMissingAssets(); + try { + await this.assets.replaceReferences({ kind: "workflow", id: workflowId }, presentAssetIds); + } catch { + // The workflow and the in-memory reference authority are already + // committed. Persisted accounting is rebuildable and GC still consults + // the authoritative snapshot, so do not misreport a successful save as + // a CAS failure that the renderer should retry. + console.warn("[create-images] Asset reference accounting needs a rebuild."); + } + return result; + } catch (error) { + await this.references.reconcileFailedMutation(reservation, this.workflows).catch(() => { + // Reconciliation marks the authority incomplete before reading disk, so + // collection remains fail-closed even when the inventory itself is unsafe. + }); + throw error; + } + } + + async deleteWorkflow( + workflowId: string, + expectedRevision: number, + isRendererCurrent: () => boolean, + ): Promise { + await this.initialize(); + const guarded = await this.runs.deleteWorkflowIfRunLifecycleEmpty(workflowId, async () => { + await this.mutateWorkflow(workflowId, [], () => + this.workflows.delete(workflowId, expectedRevision, isRendererCurrent), + ); + }); + if (guarded.status !== "allowed") return guarded; + return { status: "deleted" }; + } + + async refreshReferenceAuthority(): Promise { + const referencesComplete = await this.references.initialize(this.workflows, this.runs.journals); + if (referencesComplete) { + const rebuilt = await this.assets.rebuildReferenceAccounting(); + this.missingAssetIds = new Set(rebuilt.missingAssetIds); + } else { + this.missingAssetIds.clear(); + } + } + + missingAssetIdsForWorkflow(workflowId: string): string[] { + return this.references + .workflowAssetIds(workflowId) + .filter((assetId) => this.missingAssetIds.has(assetId)); + } + + missingAssetCount(): number { + return this.missingAssetIds.size; + } + + noteAssetAvailable(assetId: string): void { + this.missingAssetIds.delete(assetId); + } + + noteAssetMissing(assetId: string): void { + this.missingAssetIds.add(assetId); + } + + async assetResponse(assetId: string): Promise { + await this.initialize(); + let preview: { + bytes: Uint8Array; + byteLength: number; + mediaType: "image/jpeg" | "image/png"; + }; + try { + preview = await this.assets.getThumbnail(assetId, 512); + } catch (error) { + if (error instanceof AssetStoreError && error.code === "asset_source_missing") { + this.noteAssetMissing(assetId); + return undefined; + } + if (!(error instanceof AssetStoreError) || error.code !== "thumbnail_unavailable") { + throw error; + } + + // A derived thumbnail is an optimization, not the authority for whether + // an otherwise-valid reference can be shown. When the isolated thumbnail + // worker is temporarily unavailable, stream the already-validated + // canonical PNG/JPEG through the same opaque protocol grant instead of + // leaving the canvas with a permanent blank preview. + const ownerId = "asset-protocol-fallback"; + let lease: AssetPreviewLeaseDto | undefined; + try { + lease = await this.assets.acquirePreviewLease(assetId, ownerId, 1_000); + const original = await this.assets.readPreview(lease.token, ownerId); + preview = { + bytes: original.bytes, + byteLength: original.bytes.byteLength, + mediaType: original.asset.mediaType, + }; + } catch (fallbackError) { + if ( + fallbackError instanceof AssetStoreError && + fallbackError.code === "asset_source_missing" + ) { + this.noteAssetMissing(assetId); + return undefined; + } + throw fallbackError; + } finally { + if (lease) await this.assets.releasePreviewLease(lease.token, ownerId).catch(() => false); + } + } + const body = new Uint8Array(preview.bytes.byteLength); + body.set(preview.bytes); + return new Response(body.buffer, { + headers: { + "Content-Length": String(preview.byteLength), + "Content-Type": preview.mediaType, + }, + }); + } + + async grantAsset( + owner: RendererDocumentOwner, + assetId: string, + isAuthorized: (assetId: string) => boolean, + ): Promise { + await this.initialize(); + if (!isAuthorized(assetId)) { + throw new Error("The renderer document is not authorized to access this asset."); + } + const leaseOwnerId = `document-${owner.id}`; + const lease = await this.assets.acquirePreviewLease( + assetId, + leaseOwnerId, + ASSET_DELIVERY_GRANT_TTL_MS, + ); + let released = false; + const release = (): void => { + if (released) return; + released = true; + void this.assets.releasePreviewLease(lease.token, leaseOwnerId).catch(() => { + console.warn("[create-images] Asset preview lease cleanup needs reconciliation."); + }); + }; + try { + return this.grants.mint(owner, assetId, isAuthorized, { + expiresAt: lease.expiresAt, + release, + }); + } catch (error) { + release(); + throw error; + } + } +} + +let singleton: CreateImagesService | undefined; + +export function createImagesService(): CreateImagesService { + singleton ??= new CreateImagesService( + path.join(electron.app.getPath("userData"), "create-images"), + { + workspaceRequired: true, + runService: { + resolveGeminiAuth: async () => { + const { providerRegistry } = await import("../provider-registry.js"); + return resolveCreateImagesGeminiApiKeyAuth({ + credentialKind: () => providerRegistry.getBuiltinCredentialKind("google"), + requestAuth: () => providerRegistry.getBuiltinRequestAuth("google"), + }); + }, + }, + }, + ); + return singleton; +} diff --git a/main/services/create-images/feature-flag.ts b/main/services/create-images/feature-flag.ts new file mode 100644 index 00000000..ae3bd809 --- /dev/null +++ b/main/services/create-images/feature-flag.ts @@ -0,0 +1,18 @@ +export const CREATE_IMAGES_FEATURE_FLAG = "AIDEN_CREATE_IMAGES_ENABLED"; + +/** + * Create Images remains fail-closed until its packaged release gates pass. + * Every renderer route and main-process handler must check the same capability. + */ +export function createImagesEnabled( + environment: Readonly> = process.env, +): boolean { + return environment[CREATE_IMAGES_FEATURE_FLAG]?.trim() === "1"; +} + +export function createWhenImagesEnabled( + factory: () => T, + environment: Readonly> = process.env, +): T | undefined { + return createImagesEnabled(environment) ? factory() : undefined; +} diff --git a/main/services/create-images/gemini-provider-status-core.test.ts b/main/services/create-images/gemini-provider-status-core.test.ts new file mode 100644 index 00000000..4c03d062 --- /dev/null +++ b/main/services/create-images/gemini-provider-status-core.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createImagesGeminiProviderStatus, + resolveCreateImagesGeminiApiKeyAuth, + type GeminiProviderCredentialAuthority, +} from "./gemini-provider-status-core.js"; + +function authority( + kind: "api_key" | "oauth" | undefined, + apiKey?: string, +): GeminiProviderCredentialAuthority { + return { + credentialKind: async () => kind, + requestAuth: async () => (apiKey === undefined ? undefined : { auth: { apiKey } }), + }; +} + +test("Gemini image status requires an exact stored API-key credential", async () => { + assert.equal( + (await createImagesGeminiProviderStatus(authority(undefined))).connectionState, + "disconnected", + ); + assert.deepEqual(await createImagesGeminiProviderStatus(authority("oauth", "oauth-token")), { + schemaVersion: 1, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "credential-scope-unverified", + }); + assert.equal( + (await createImagesGeminiProviderStatus(authority("api_key"))).safeErrorCode, + "credential-invalid", + ); + const connected = await createImagesGeminiProviderStatus(authority("api_key", "test-key")); + assert.equal(connected.connectionState, "connected"); + assert.equal(connected.credentialKind, "google-api-key"); + assert.equal(connected.capabilitySnapshot?.models.length, 3); + assert.equal(JSON.stringify(connected).includes("test-key"), false); +}); + +test("Gemini request auth rejects missing, OAuth, and malformed credentials", async () => { + await assert.rejects(resolveCreateImagesGeminiApiKeyAuth(authority(undefined)), /Connect/u); + await assert.rejects(resolveCreateImagesGeminiApiKeyAuth(authority("oauth", "token")), /OAuth/u); + await assert.rejects( + resolveCreateImagesGeminiApiKeyAuth(authority("api_key", "bad key")), + /invalid/u, + ); + assert.deepEqual(await resolveCreateImagesGeminiApiKeyAuth(authority("api_key", "valid-key")), { + auth: { apiKey: "valid-key" }, + }); +}); diff --git a/main/services/create-images/gemini-provider-status-core.ts b/main/services/create-images/gemini-provider-status-core.ts new file mode 100644 index 00000000..f72eee0c --- /dev/null +++ b/main/services/create-images/gemini-provider-status-core.ts @@ -0,0 +1,102 @@ +import type { AuthResult } from "@earendil-works/pi-ai"; +import { + CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + CREATE_IMAGES_PROVIDER_STATUS_VERSION, + type CreateImagesProviderStatus, +} from "../../../renderer/shared/create-images/providers.js"; + +export const CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID = "google" as const; + +export interface GeminiProviderCredentialAuthority { + credentialKind(): Promise<"api_key" | "oauth" | undefined>; + requestAuth(): Promise; +} + +function usableApiKey(auth: AuthResult | undefined): boolean { + const key = auth?.auth.apiKey; + return typeof key === "string" && /^[\x21-\x7e]{1,512}$/u.test(key); +} + +export async function createImagesGeminiProviderStatus( + authority: GeminiProviderCredentialAuthority, +): Promise { + let kind: "api_key" | "oauth" | undefined; + try { + kind = await authority.credentialKind(); + } catch { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "feature-unavailable", + }; + } + if (kind === undefined) { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "disconnected", + safeErrorCode: "credential-missing", + }; + } + if (kind !== "api_key") { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "credential-scope-unverified", + }; + } + let auth: AuthResult | undefined; + try { + auth = await authority.requestAuth(); + } catch { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "invalid", + credentialKind: "google-api-key", + safeErrorCode: "credential-invalid", + }; + } + if (!usableApiKey(auth)) { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "invalid", + credentialKind: "google-api-key", + safeErrorCode: "credential-invalid", + }; + } + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "connected", + credentialKind: "google-api-key", + capabilitySnapshot: CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + }; +} + +export async function resolveCreateImagesGeminiApiKeyAuth( + authority: GeminiProviderCredentialAuthority, +): Promise { + const kind = await authority.credentialKind(); + if (kind !== "api_key") { + throw new Error( + kind === undefined + ? "Connect a Google Gemini API key in Settings before starting this run." + : "Create Images requires a Google API-key connection; OAuth is not authorized for this request.", + ); + } + const auth = await authority.requestAuth(); + if (!usableApiKey(auth)) { + throw new Error("The configured Google API key is unavailable or invalid."); + } + return auth!; +} diff --git a/main/services/create-images/image-provider-execution-core.test.ts b/main/services/create-images/image-provider-execution-core.test.ts new file mode 100644 index 00000000..37f1c151 --- /dev/null +++ b/main/services/create-images/image-provider-execution-core.test.ts @@ -0,0 +1,975 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ImageProviderModelCapabilities } from "./provider-contract.js"; +import { + CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + CreateImagesProviderAdmissionError, + CreateImagesProviderAdmissionGate, + admitCreateImagesProviderExecution, + createCreateImagesMainCredentialBinding, + createCreateImagesProviderAttemptEvent, + createCreateImagesProviderAttemptProjection, + createCreateImagesProviderCapabilitySnapshot, + decideCreateImagesProviderAttemptRecovery, + executeCreateImagesProviderSubmission, + parseCreateImagesProviderConsentClaim, + prepareCreateImagesProviderExecutionConsent, + reduceCreateImagesProviderAttemptEvent, + type CreateImagesProviderAttemptEventV1, + type CreateImagesProviderAttemptProjectionV1, + type CreateImagesProviderCapabilitySnapshotV1, + type CreateImagesProviderExecutionAuthorizationV1, +} from "./image-provider-execution-core.js"; + +const AUTHORITY = { secret: new Uint8Array(32).fill(0x5a) }; +const CREATED_AT = "2026-08-11T12:00:00.000Z"; +const EXPIRES_AT = "2026-08-11T12:05:00.000Z"; +const NOW = "2026-08-11T12:01:00.000Z"; +const SOURCE_FINGERPRINT = "a".repeat(64); +const ASSET_A = "1".repeat(64); +const ASSET_B = "2".repeat(64); + +function model( + overrides: Partial = {}, +): ImageProviderModelCapabilities { + return { + id: "gemini-3.1-flash-image", + label: "Nano Banana 2", + providerId: "gemini", + aspectRatios: ["1:1", "16:9"], + imageSizes: ["1K", "2K"], + outputMimes: ["image/png", "image/jpeg"], + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + ...overrides, + }; +} + +function capability( + overrides: { + catalogRevision?: number; + observedAt?: string; + model?: Partial; + transport?: { + kind: "synchronous" | "asynchronous"; + supportsIdempotency: boolean; + supportsReconciliation: boolean; + }; + } = {}, +): CreateImagesProviderCapabilitySnapshotV1 { + return createCreateImagesProviderCapabilitySnapshot({ + catalogRevision: overrides.catalogRevision ?? 7, + observedAt: overrides.observedAt ?? CREATED_AT, + model: model(overrides.model), + transport: overrides.transport ?? { + kind: "synchronous", + supportsIdempotency: false, + supportsReconciliation: false, + }, + }); +} + +function localCapability(): CreateImagesProviderCapabilitySnapshotV1 { + return createCreateImagesProviderCapabilitySnapshot({ + catalogRevision: 1, + observedAt: CREATED_AT, + model: model({ + id: "deterministic-v1", + label: "Deterministic Phase 3", + providerId: "local-mock", + maxReferenceImages: 14, + maxOutputs: 4, + supportsCancellation: true, + }), + transport: { + kind: "local", + supportsIdempotency: true, + supportsReconciliation: false, + }, + }); +} + +const credential = createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId: "google-images-primary", + revision: 4, + authKind: "api-key", +}); + +function prepareRemote( + overrides: Partial[0]> = {}, +) { + const selectedCapability = overrides.capability ?? capability(); + return prepareCreateImagesProviderExecutionConsent( + { + authorizationId: "authorization-1", + workflowId: "workflow-1", + workflowRevision: 7, + executionMode: "gemini", + capability: selectedCapability, + credentialBinding: credential, + invocations: [ + { + nodeId: "generate-1", + promptBytes: 42, + referenceImageCount: 1, + referenceImageBytes: 1_024, + requestedOutputs: 1, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + maximumAttempts: 1, + estimate: { + kind: "best-effort", + amountMicros: 25_000, + currency: "USD", + estimatedAt: CREATED_AT, + sourceFingerprint: SOURCE_FINGERPRINT, + }, + createdAt: CREATED_AT, + expiresAt: EXPIRES_AT, + ...overrides, + }, + AUTHORITY, + ); +} + +function remoteClaim(prepared = prepareRemote()) { + return { + version: 1 as const, + authorizationId: prepared.rendererPlan.authorizationId, + consentFingerprint: prepared.rendererPlan.consentFingerprint, + token: prepared.rendererPlan.token!, + reviewed: true as const, + }; +} + +function authorizeRemote( + prepared = prepareRemote(), + overrides: Partial[0]> = {}, +): CreateImagesProviderExecutionAuthorizationV1 { + return admitCreateImagesProviderExecution({ + mainPlan: prepared.mainPlan, + claim: remoteClaim(prepared), + authority: AUTHORITY, + currentCapability: prepared.mainPlan.capability, + currentCredential: credential, + now: NOW, + ...overrides, + }); +} + +function attempt(authorization = authorizeRemote()): CreateImagesProviderAttemptProjectionV1 { + return createCreateImagesProviderAttemptProjection(authorization, { + runId: "run-1", + nodeId: "generate-1", + attempt: 1, + }); +} + +function apply( + projection: CreateImagesProviderAttemptProjectionV1, + event: CreateImagesProviderAttemptEventV1, +): CreateImagesProviderAttemptProjectionV1 { + const reduced = reduceCreateImagesProviderAttemptEvent(projection, event); + assert.equal(reduced.accepted, true); + return reduced.projection; +} + +function expectAdmissionCode(operation: () => unknown, code: string): void { + assert.throws(operation, (error) => { + assert.ok(error instanceof CreateImagesProviderAdmissionError); + assert.equal(error.code, code); + return true; + }); +} + +function gate( + overrides: Partial< + ConstructorParameters[0][number] + > = {}, +) { + return new CreateImagesProviderAdmissionGate([ + { + providerId: "gemini", + maxConcurrency: 1, + maxStartsPerWindow: 3, + windowMs: 1_000, + minimumStartIntervalMs: 0, + ...overrides, + }, + ]); +} + +test("capability snapshots are immutable, content-fingerprinted, and provider/model bound", () => { + const snapshot = capability(); + assert.equal(Object.isFrozen(snapshot), true); + assert.equal(Object.isFrozen(snapshot.model.aspectRatios), true); + assert.equal(snapshot.providerId, snapshot.model.providerId); + assert.match(snapshot.fingerprint, /^[a-f0-9]{64}$/u); + assert.notEqual(capability({ catalogRevision: 8 }).fingerprint, snapshot.fingerprint); + assert.throws(() => { + (snapshot.model.aspectRatios as string[]).push("21:9"); + }); + expectAdmissionCode( + () => capability({ model: { aspectRatios: ["https://attacker.invalid" as "1:1"] } }), + "invalid-input", + ); +}); + +test("renderer consent exposes accounting and a token but no credential record or provider endpoint", () => { + const prepared = prepareRemote(); + const rendererJson = JSON.stringify(prepared.rendererPlan); + assert.match(prepared.rendererPlan.token!, /^[a-f0-9]{64}$/u); + assert.equal(prepared.rendererPlan.accounting.retryPolicy, "manual-new-consent"); + assert.equal(prepared.rendererPlan.accounting.maximumAttempts, 1); + assert.equal(prepared.rendererPlan.accounting.initialRequestCount, 1); + assert.equal(prepared.rendererPlan.accounting.dataLeavesDevice, true); + assert.doesNotMatch(rendererJson, /google-images-primary/u); + assert.doesNotMatch(rendererJson, /api-key/u); + assert.doesNotMatch(rendererJson, /https?:\/\//u); +}); + +test("strict renderer claims reject credentials, URLs, model overrides, and unreviewed input", () => { + const claim = remoteClaim(); + assert.deepEqual(parseCreateImagesProviderConsentClaim(claim), claim); + for (const extra of [ + { apiKey: "secret" }, + { url: "https://attacker.invalid" }, + { modelId: "forged-model" }, + ]) { + expectAdmissionCode( + () => parseCreateImagesProviderConsentClaim({ ...claim, ...extra }), + "invalid-consent", + ); + } + expectAdmissionCode( + () => parseCreateImagesProviderConsentClaim({ ...claim, reviewed: false }), + "invalid-consent", + ); +}); + +test("forged fingerprints and tokens cannot authorize remote work", () => { + const prepared = prepareRemote(); + expectAdmissionCode( + () => + authorizeRemote(prepared, { + claim: { ...remoteClaim(prepared), consentFingerprint: "b".repeat(64) }, + }), + "forged-consent", + ); + expectAdmissionCode( + () => + authorizeRemote(prepared, { + claim: { ...remoteClaim(prepared), token: "b".repeat(64) }, + }), + "forged-consent", + ); + const forgedPlan = structuredClone(prepared.mainPlan); + forgedPlan.accounting.maximumAttempts = 2; + expectAdmissionCode(() => authorizeRemote(prepared, { mainPlan: forgedPlan }), "forged-consent"); +}); + +test("expired and not-yet-valid consent fail before admission", () => { + const prepared = prepareRemote(); + for (const now of ["2026-08-11T11:59:59.000Z", "2026-08-11T12:05:00.001Z"]) { + expectAdmissionCode(() => authorizeRemote(prepared, { now }), "stale-consent"); + } +}); + +test("capability catalog, model, and option drift invalidate reviewed consent", () => { + const prepared = prepareRemote(); + expectAdmissionCode( + () => + authorizeRemote(prepared, { + currentCapability: capability({ catalogRevision: 8 }), + }), + "capability-drift", + ); + expectAdmissionCode( + () => + authorizeRemote(prepared, { + currentCapability: capability({ model: { imageSizes: ["1K"] } }), + }), + "capability-drift", + ); +}); + +test("main-owned credential record, revision, and auth kind are exact admission bindings", () => { + const prepared = prepareRemote(); + const drifted = createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId: "google-images-primary", + revision: 5, + authKind: "api-key", + }); + expectAdmissionCode( + () => authorizeRemote(prepared, { currentCredential: drifted }), + "credential-drift", + ); + expectAdmissionCode( + () => authorizeRemote(prepared, { currentCredential: undefined }), + "credential-required", + ); + expectAdmissionCode( + () => + createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId: "google-images-primary", + revision: 4, + authKind: "oauth" as "api-key", + }), + "invalid-input", + ); +}); + +test("remote request, output, byte, and maximum-attempt accounting fails closed", () => { + expectAdmissionCode(() => prepareRemote({ maximumAttempts: 2 }), "unsafe-accounting"); + expectAdmissionCode( + () => + prepareRemote({ + invocations: [ + { + nodeId: "generate-1", + promptBytes: 42, + referenceImageCount: 0, + referenceImageBytes: 1, + requestedOutputs: 1, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + }), + "unsafe-accounting", + ); + expectAdmissionCode( + () => + prepareRemote({ + invocations: [ + { + nodeId: "generate-1", + promptBytes: 42, + referenceImageCount: 1, + referenceImageBytes: CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + requestedOutputs: 1, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + }), + "unsafe-accounting", + ); + expectAdmissionCode( + () => + prepareRemote({ + invocations: [ + { + nodeId: "generate-1", + promptBytes: 42, + referenceImageCount: 1, + referenceImageBytes: 1, + requestedOutputs: 2, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + }), + "unsafe-accounting", + ); +}); + +test("local mock stays credential-free and retains only its bounded automatic retry policy", () => { + const selectedCapability = localCapability(); + const prepared = prepareCreateImagesProviderExecutionConsent( + { + authorizationId: "local-authorization", + workflowId: "workflow-1", + workflowRevision: 7, + executionMode: "local-mock", + capability: selectedCapability, + invocations: [ + { + nodeId: "generate-1", + promptBytes: 10, + referenceImageCount: 0, + referenceImageBytes: 0, + requestedOutputs: 2, + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + }, + ], + maximumAttempts: 3, + estimate: { + kind: "mock", + amountMicros: 0, + currency: "USD", + estimatedAt: CREATED_AT, + sourceFingerprint: SOURCE_FINGERPRINT, + }, + createdAt: CREATED_AT, + expiresAt: EXPIRES_AT, + }, + AUTHORITY, + ); + assert.equal(prepared.rendererPlan.token, undefined); + const authorization = admitCreateImagesProviderExecution({ + mainPlan: prepared.mainPlan, + authority: AUTHORITY, + currentCapability: selectedCapability, + now: NOW, + }); + assert.equal(authorization.credentialBinding, undefined); + assert.equal(authorization.accounting.retryPolicy, "bounded-local-automatic"); + assert.equal(authorization.accounting.dataLeavesDevice, false); + assert.equal( + createCreateImagesProviderAttemptProjection(authorization, { + runId: "run-local", + nodeId: "generate-1", + attempt: 3, + }).attempt, + 3, + ); +}); + +test("paid Gemini attempts are single-attempt and cannot manufacture an automatic retry", () => { + const authorization = authorizeRemote(); + assert.equal(authorization.accounting.retryPolicy, "manual-new-consent"); + assert.equal(authorization.accounting.maximumAttempts, 1); + expectAdmissionCode( + () => + createCreateImagesProviderAttemptProjection(authorization, { + runId: "run-1", + nodeId: "generate-1", + attempt: 2, + }), + "unsafe-accounting", + ); +}); + +test("provider gates enforce exact concurrency ownership and reject forged or double release", () => { + const admissionGate = gate(); + const first = admissionGate.tryAcquire("gemini", 100); + assert.equal(first.status, "acquired"); + assert.deepEqual(admissionGate.tryAcquire("gemini", 101), { + status: "deferred", + reason: "concurrency", + retryAfterMs: 0, + }); + if (first.status !== "acquired") return; + assert.equal(admissionGate.release({ ...first.lease }), false); + assert.equal(admissionGate.release(first.lease), true); + assert.equal(admissionGate.release(first.lease), false); + assert.equal(admissionGate.snapshot("gemini").active, 0); +}); + +test("provider gates enforce minimum intervals and bounded rolling windows", () => { + const admissionGate = gate({ + maxConcurrency: 2, + maxStartsPerWindow: 2, + windowMs: 1_000, + minimumStartIntervalMs: 100, + }); + const first = admissionGate.tryAcquire("gemini", 100); + assert.equal(first.status, "acquired"); + if (first.status === "acquired") admissionGate.release(first.lease); + assert.deepEqual(admissionGate.tryAcquire("gemini", 150), { + status: "deferred", + reason: "rate", + retryAfterMs: 50, + }); + const second = admissionGate.tryAcquire("gemini", 200); + assert.equal(second.status, "acquired"); + if (second.status === "acquired") admissionGate.release(second.lease); + assert.deepEqual(admissionGate.tryAcquire("gemini", 300), { + status: "deferred", + reason: "rate", + retryAfterMs: 800, + }); + assert.equal(admissionGate.tryAcquire("gemini", 1_100).status, "acquired"); +}); + +test("attempt events are identity-bound, contiguous, and output-count checked", () => { + const initial = attempt(); + const preparedEvent = createCreateImagesProviderAttemptEvent(initial, { + kind: "submission-prepared", + }); + const prepared = apply(initial, preparedEvent); + assert.equal(prepared.status, "prepared"); + assert.equal(reduceCreateImagesProviderAttemptEvent(prepared, preparedEvent).accepted, false); + assert.equal( + reduceCreateImagesProviderAttemptEvent(prepared, { + ...createCreateImagesProviderAttemptEvent(prepared, { + kind: "output-published", + outputAssetIds: [ASSET_A], + }), + runId: "run-other", + }).accepted, + false, + ); + assert.equal( + reduceCreateImagesProviderAttemptEvent(prepared, { + ...createCreateImagesProviderAttemptEvent(prepared, { + kind: "output-published", + outputAssetIds: [ASSET_A], + }), + sequence: prepared.lastSequence + 2, + }).accepted, + false, + ); + const mismatched = reduceCreateImagesProviderAttemptEvent( + prepared, + createCreateImagesProviderAttemptEvent(prepared, { + kind: "output-published", + outputAssetIds: [ASSET_A, ASSET_B], + }), + ); + assert.deepEqual(mismatched, { + accepted: false, + projection: prepared, + reason: "output-mismatch", + }); +}); + +test("usage projection is aggregate-only, bounded, and marks reported billing", () => { + let projection = attempt(); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "submission-prepared" }), + ); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "output-published", + outputAssetIds: [ASSET_A], + usage: { + inputUnits: 10, + outputUnits: 20, + totalUnits: 30, + billedRequestCount: 1, + costMicros: 25_000, + currency: "USD", + }, + }), + ); + assert.equal(projection.status, "succeeded"); + assert.deepEqual(projection.usage, { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + requestCount: 1, + outputCount: 1, + billingStatus: "provider-reported", + reported: { + inputUnits: 10, + outputUnits: 20, + totalUnits: 30, + billedRequestCount: 1, + costMicros: 25_000, + currency: "USD", + }, + }); +}); + +test("synchronous prepared or unknown Gemini work needs attention and is never resubmitted", async () => { + let projection = attempt(); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "submission-prepared" }), + ); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "needs-attention", + reason: "prepared-or-unknown", + }); + let submitCalls = 0; + const outcome = await executeCreateImagesProviderSubmission({ + projection, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async () => { + throw new Error("must not persist again"); + }, + submit: async () => { + submitCalls += 1; + return { kind: "completed", output: "impossible", outputCount: 1 }; + }, + }); + assert.deepEqual(outcome, { + kind: "recovery", + decision: { action: "needs-attention", reason: "prepared-or-unknown" }, + }); + assert.equal(submitCalls, 0); + + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "submission-unknown", + errorCode: "transport-unknown", + }), + ); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.usage.billingStatus, "possibly-billable"); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "needs-attention", + reason: "prepared-or-unknown", + }); +}); + +test("execution persists prepared before resolving main credentials and invokes the adapter once", async () => { + const calls: string[] = []; + const initial = attempt(); + let durable = initial; + const output = { stagedAsset: "opaque-staging-record" }; + const outcome = await executeCreateImagesProviderSubmission<{ apiKey: string }, typeof output>({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + calls.push("persist-prepared"); + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => { + calls.push("resolve-credential"); + return { binding, credential: { apiKey: "super-secret-key" } }; + }, + submit: async ({ credential: resolved, idempotencyKey }) => { + calls.push("submit"); + assert.equal(resolved?.apiKey, "super-secret-key"); + assert.match(idempotencyKey, /^aiden-ci-[a-f0-9]{64}$/u); + return { + kind: "completed", + output, + outputCount: 1, + usage: { billedRequestCount: 1 }, + }; + }, + }); + assert.deepEqual(calls, ["persist-prepared", "resolve-credential", "submit"]); + assert.deepEqual(outcome, { + kind: "completed", + output, + outputCount: 1, + usage: { billedRequestCount: 1 }, + }); + assert.equal(durable.status, "prepared"); +}); + +test("credential drift after durable preparation is confirmed not sent and requires new consent", async () => { + const initial = attempt(); + let durable = initial; + let submitted = false; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async () => ({ + binding: createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId: "google-images-primary", + revision: 99, + authKind: "api-key", + }), + credential: "changed-secret", + }), + submit: async () => { + submitted = true; + return { kind: "completed" as const, output: "bad", outputCount: 1 }; + }, + }); + assert.equal(submitted, false); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.retry, "new-consent-required"); + const failed = apply(durable, outcome.event); + assert.equal(failed.submission, "confirmed-not-sent"); + assert.equal(failed.usage.billingStatus, "not-submitted"); +}); + +test("post-send transport loss is ambiguous, possibly billable, and never automatically retried", async () => { + const initial = attempt(); + let durable = initial; + let submitCalls = 0; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => { + submitCalls += 1; + throw new Error("socket reset after request write"); + }, + }); + assert.equal(submitCalls, 1); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.event.kind, "submission-unknown"); + assert.equal(outcome.retry, "none"); + const ambiguous = apply(durable, outcome.event); + assert.equal(ambiguous.status, "needs_attention"); + assert.equal(ambiguous.usage.requestCount, 1); + assert.equal(ambiguous.usage.billingStatus, "possibly-billable"); +}); + +test("rate limits and provider failures are terminal for this paid consent", async () => { + for (const kind of ["rate-limited", "failed"] as const) { + const initial = attempt(); + let durable = initial; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => ({ + kind, + errorCode: kind === "rate-limited" ? "rate-limited" : "refused", + }), + }); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") continue; + assert.equal(outcome.retry, "new-consent-required"); + const failed = apply(durable, outcome.event); + assert.equal(failed.status, "failed"); + assert.equal(failed.usage.requestCount, 1); + } +}); + +test("malformed provider billing metadata becomes a contract ambiguity instead of disappearing", async () => { + const initial = attempt(); + let durable = initial; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => ({ + kind: "failed" as const, + errorCode: "provider-failed", + usage: { billedRequestCount: 2 }, + }), + }); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.event.kind, "submission-unknown"); + assert.equal(outcome.retry, "none"); + assert.equal(apply(durable, outcome.event).status, "needs_attention"); +}); + +test("cancellation before the adapter call is known not sent and does not consume provider work", async () => { + const initial = attempt(); + const controller = new AbortController(); + controller.abort(); + let persisted = false; + let submitted = false; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + signal: controller.signal, + persistPrepared: async () => { + persisted = true; + return initial; + }, + submit: async () => { + submitted = true; + return { kind: "completed" as const, output: "bad", outputCount: 1 }; + }, + }); + assert.equal(persisted, false); + assert.equal(submitted, false); + assert.equal(outcome.kind, "cancelled-before-submit"); + if (outcome.kind !== "cancelled-before-submit") return; + let cancelled = apply(initial, outcome.events[0]); + cancelled = apply(cancelled, outcome.events[1]); + assert.equal(cancelled.status, "cancelled"); + assert.equal(cancelled.usage.billingStatus, "not-submitted"); +}); + +test("cancellation after submit begins becomes unknown unless the adapter proves non-submission", async () => { + const initial = attempt(); + const controller = new AbortController(); + let durable = initial; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + signal: controller.signal, + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => { + controller.abort(); + return { kind: "completed" as const, output: "provider-output", outputCount: 1 }; + }, + }); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.event.kind, "submission-unknown"); + assert.equal(outcome.retry, "none"); + assert.equal(apply(durable, outcome.event).status, "needs_attention"); +}); + +test("synchronous Gemini rejects an async acceptance contract without trusting its job ID", async () => { + const initial = attempt(); + let durable = initial; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ binding, credential: "secret" }), + submit: async () => ({ kind: "accepted" as const, providerJobId: "unexpected-job" }), + }); + assert.equal(outcome.kind, "event"); + if (outcome.kind !== "event") return; + assert.equal(outcome.event.kind, "submission-unknown"); + assert.equal(apply(durable, outcome.event).providerJobId, undefined); +}); + +test("asynchronous accepted jobs reconcile or cancel by durable job ID without resubmission", () => { + const prepared = prepareRemote({ + capability: capability({ + transport: { + kind: "asynchronous", + supportsIdempotency: true, + supportsReconciliation: true, + }, + model: { supportsCancellation: true }, + }), + }); + let projection = attempt(authorizeRemote(prepared)); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "submission-prepared" }), + ); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "reconcile-only", + }); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "submission-accepted", + providerJobId: "job-1", + }), + ); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "reconcile-only", + providerJobId: "job-1", + }); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "cancellation-requested", + reason: "user", + }), + ); + assert.deepEqual(decideCreateImagesProviderAttemptRecovery(projection), { + action: "cancel-only", + providerJobId: "job-1", + }); +}); + +test("late valid outputs stay attached to the exact cancelled attempt without becoming success", () => { + let projection = attempt(); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "submission-prepared" }), + ); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "cancellation-requested", + reason: "user", + }), + ); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { kind: "cancelled" }), + ); + projection = apply( + projection, + createCreateImagesProviderAttemptEvent(projection, { + kind: "late-output-published", + outputAssetIds: [ASSET_A], + }), + ); + assert.equal(projection.status, "cancelled"); + assert.deepEqual(projection.outputAssetIds, []); + assert.deepEqual(projection.lateOutputAssetIds, [ASSET_A]); + assert.equal(projection.usage.billingStatus, "possibly-billable"); + + const forged = reduceCreateImagesProviderAttemptEvent(projection, { + ...createCreateImagesProviderAttemptEvent(projection, { + kind: "late-output-published", + outputAssetIds: [ASSET_A], + }), + nodeId: "generate-other", + }); + assert.equal(forged.accepted, false); +}); + +test("durable authorization and projection contain no consent token, secret, prompt, URL, or raw output", async () => { + const prepared = prepareRemote(); + const authorization = authorizeRemote(prepared); + const initial = attempt(authorization); + let durable = initial; + const sensitive = { + apiKey: "super-secret-key", + prompt: "private prompt text", + signedUrl: "https://storage.invalid/private-token", + }; + const outcome = await executeCreateImagesProviderSubmission({ + projection: initial, + gate: gate(), + nowMs: Date.parse(NOW), + persistPrepared: async (event) => { + durable = apply(durable, event); + return durable; + }, + resolveCredential: async (binding) => ({ + binding, + credential: sensitive.apiKey, + }), + submit: async () => ({ kind: "completed", output: sensitive, outputCount: 1 }), + }); + assert.equal(outcome.kind, "completed"); + const durableJson = JSON.stringify({ authorization, projection: durable }); + for (const forbidden of [ + prepared.rendererPlan.token!, + sensitive.apiKey, + sensitive.prompt, + sensitive.signedUrl, + "https://", + ]) { + assert.equal(durableJson.includes(forbidden), false); + } + assert.equal(durableJson.includes("promptBytes"), true); + assert.equal(durableJson.includes("referenceImageBytes"), true); +}); diff --git a/main/services/create-images/image-provider-execution-core.ts b/main/services/create-images/image-provider-execution-core.ts new file mode 100644 index 00000000..65dc330c --- /dev/null +++ b/main/services/create-images/image-provider-execution-core.ts @@ -0,0 +1,1796 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import type { + CreateImagesAspectRatio, + CreateImagesImageSize, + CreateImagesOutputMime, +} from "../../../renderer/shared/create-images/schema.js"; +import { CREATE_IMAGES_ASSET_ID_PATTERN } from "../../../renderer/shared/create-images/schema.js"; +import type { ImageProviderModelCapabilities } from "./provider-contract.js"; + +export const CREATE_IMAGES_PROVIDER_EXECUTION_VERSION = 1 as const; +export const CREATE_IMAGES_PROVIDER_CONSENT_VERSION = 1 as const; +export const CREATE_IMAGES_MAX_PROVIDER_INVOCATIONS = 500; +export const CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES = 512 * 1024 * 1024; +export const CREATE_IMAGES_MAX_PROVIDER_REQUEST_BYTES = 64 * 1024 * 1024; +export const CREATE_IMAGES_MAX_PROMPT_BYTES = 128 * 1024; +export const CREATE_IMAGES_MAX_PROVIDER_ATTEMPTS = 1_500; +export const CREATE_IMAGES_MAX_CONSENT_LIFETIME_MS = 30 * 60_000; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,191}$/u; +const PROVIDER_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; +const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/u; +const TOKEN_PATTERN = /^[a-f0-9]{64}$/u; +const SAFE_ERROR_CODE_PATTERN = /^[a-z][a-z0-9-]{0,95}$/u; +const CURRENCY_PATTERN = /^[A-Z]{3}$/u; +const ALLOWED_ASPECT_RATIOS = new Set([ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +]); +const ALLOWED_IMAGE_SIZES = new Set(["1K", "2K", "4K"]); +const ALLOWED_OUTPUT_MIMES = new Set(["image/png", "image/jpeg"]); +const MAX_PROVIDER_USAGE_UNITS = 1_000_000_000; + +export type CreateImagesExecutionMode = "local-mock" | "gemini"; +export type CreateImagesProviderTransportKind = "local" | "synchronous" | "asynchronous"; + +export interface CreateImagesProviderTransportCapabilities { + kind: CreateImagesProviderTransportKind; + supportsIdempotency: boolean; + supportsReconciliation: boolean; +} + +export interface CreateImagesProviderCapabilitySnapshotV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + catalogRevision: number; + observedAt: string; + providerId: string; + model: ImageProviderModelCapabilities; + transport: CreateImagesProviderTransportCapabilities; + fingerprint: string; +} + +export interface CreateImagesMainCredentialBindingV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + providerId: "gemini"; + recordId: string; + revision: number; + authKind: "api-key"; +} + +export interface CreateImagesProviderInvocationFactsV1 { + nodeId: string; + promptBytes: number; + referenceImageCount: number; + referenceImageBytes: number; + requestedOutputs: number; + aspectRatio: CreateImagesAspectRatio; + imageSize: CreateImagesImageSize; + outputMime: CreateImagesOutputMime; +} + +export interface CreateImagesProviderExecutionAccountingV1 { + initialRequestCount: number; + expectedOutputCount: number; + maximumAttempts: number; + promptBytes: number; + referenceImageCount: number; + referenceImageBytes: number; + initialProviderInputBytes: number; + dataLeavesDevice: boolean; + retryPolicy: "bounded-local-automatic" | "manual-new-consent"; +} + +export type CreateImagesProviderEstimateV1 = + | { + kind: "mock" | "best-effort"; + amountMicros: number; + currency: string; + estimatedAt: string; + sourceFingerprint: string; + } + | { + kind: "unavailable"; + estimatedAt: string; + sourceFingerprint: string; + }; + +export interface CreateImagesProviderExecutionConsentPlanV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: CreateImagesExecutionMode; + capability: CreateImagesProviderCapabilitySnapshotV1; + credentialBinding?: CreateImagesMainCredentialBindingV1; + invocations: readonly CreateImagesProviderInvocationFactsV1[]; + accounting: CreateImagesProviderExecutionAccountingV1; + estimate: CreateImagesProviderEstimateV1; + createdAt: string; + expiresAt: string; + consentFingerprint: string; +} + +export interface CreateImagesProviderRendererConsentPlanV1 { + version: typeof CREATE_IMAGES_PROVIDER_CONSENT_VERSION; + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: CreateImagesExecutionMode; + providerId: string; + providerLabel: string; + modelId: string; + modelLabel: string; + accounting: CreateImagesProviderExecutionAccountingV1; + estimate: CreateImagesProviderEstimateV1; + createdAt: string; + expiresAt: string; + consentFingerprint: string; + /** Present only for a remote plan. The renderer may echo it but cannot mint it. */ + token?: string; +} + +export interface CreateImagesProviderConsentClaimV1 { + version: typeof CREATE_IMAGES_PROVIDER_CONSENT_VERSION; + authorizationId: string; + consentFingerprint: string; + token: string; + reviewed: true; +} + +export interface CreateImagesProviderConsentAuthority { + /** Main-owned process secret. Never persist it or expose it to a renderer. */ + secret: Uint8Array; +} + +export interface CreateImagesPrepareProviderExecutionConsentInput { + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: CreateImagesExecutionMode; + capability: CreateImagesProviderCapabilitySnapshotV1; + credentialBinding?: CreateImagesMainCredentialBindingV1; + invocations: readonly CreateImagesProviderInvocationFactsV1[]; + maximumAttempts: number; + estimate: CreateImagesProviderEstimateV1; + createdAt: string; + expiresAt: string; +} + +export interface CreateImagesPreparedProviderExecutionConsent { + mainPlan: CreateImagesProviderExecutionConsentPlanV1; + rendererPlan: CreateImagesProviderRendererConsentPlanV1; +} + +export type CreateImagesProviderAdmissionErrorCode = + | "invalid-input" + | "invalid-consent" + | "forged-consent" + | "stale-consent" + | "capability-drift" + | "credential-drift" + | "credential-required" + | "unsafe-accounting"; + +export class CreateImagesProviderAdmissionError extends Error { + constructor( + readonly code: CreateImagesProviderAdmissionErrorCode, + message: string, + ) { + super(message); + this.name = "CreateImagesProviderAdmissionError"; + } +} + +export interface CreateImagesProviderExecutionAuthorizationV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + authorizationId: string; + workflowId: string; + workflowRevision: number; + executionMode: CreateImagesExecutionMode; + capability: CreateImagesProviderCapabilitySnapshotV1; + credentialBinding?: CreateImagesMainCredentialBindingV1; + invocations: readonly CreateImagesProviderInvocationFactsV1[]; + accounting: CreateImagesProviderExecutionAccountingV1; + estimate: CreateImagesProviderEstimateV1; + consentFingerprint: string; + authorizedAt: string; + expiresAt: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function exactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + +function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +function fingerprint(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function canonicalTimestamp(value: string, label: string): string { + let canonical = false; + if (typeof value === "string" && value.length > 0 && value.length <= 64) { + try { + canonical = new Date(value).toISOString() === value; + } catch { + canonical = false; + } + } + if (!canonical) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + `${label} must be a canonical ISO-8601 timestamp.`, + ); + } + return value; +} + +function opaqueId(value: string, label: string): string { + if (!OPAQUE_ID_PATTERN.test(value)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + `${label} must be an opaque identifier.`, + ); + } + return value; +} + +function safeInteger(value: number, minimum: number, maximum: number, label: string): number { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + `${label} must be an integer from ${minimum} through ${maximum}.`, + ); + } + return value; +} + +function safeAdd(left: number, right: number, maximum: number, label: string): number { + if (!Number.isSafeInteger(left) || !Number.isSafeInteger(right) || right > maximum - left) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + `${label} exceeds its safe aggregate bound.`, + ); + } + return left + right; +} + +function uniqueStrings(values: readonly string[], label: string): readonly string[] { + if (values.length === 0 || new Set(values).size !== values.length) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + `${label} must be a non-empty unique list.`, + ); + } + return Object.freeze([...values]); +} + +function capabilityPayload( + input: Omit, +): Omit { + return { + version: input.version, + catalogRevision: input.catalogRevision, + observedAt: input.observedAt, + providerId: input.providerId, + model: input.model, + transport: input.transport, + }; +} + +export function createCreateImagesProviderCapabilitySnapshot(input: { + catalogRevision: number; + observedAt: string; + model: ImageProviderModelCapabilities; + transport: CreateImagesProviderTransportCapabilities; +}): CreateImagesProviderCapabilitySnapshotV1 { + const providerId = input.model.providerId; + if (!PROVIDER_ID_PATTERN.test(providerId) || !["local-mock", "gemini"].includes(providerId)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The execution core supports only local-mock and Gemini provider snapshots.", + ); + } + if (!MODEL_ID_PATTERN.test(input.model.id)) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Model ID is invalid."); + } + safeInteger(input.catalogRevision, 1, Number.MAX_SAFE_INTEGER, "Catalog revision"); + canonicalTimestamp(input.observedAt, "Capability observation time"); + if (!(["local", "synchronous", "asynchronous"] as const).includes(input.transport.kind)) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Provider transport is invalid."); + } + if (providerId === "local-mock" && input.transport.kind !== "local") { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The local mock requires a local transport snapshot.", + ); + } + if (providerId === "gemini" && input.transport.kind === "local") { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Gemini requires a remote transport snapshot.", + ); + } + if (input.transport.supportsReconciliation && input.transport.kind === "local") { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "A local transport cannot advertise remote reconciliation.", + ); + } + const model: ImageProviderModelCapabilities = { + id: input.model.id, + label: input.model.label.trim(), + providerId, + aspectRatios: uniqueStrings( + input.model.aspectRatios, + "Aspect ratios", + ) as readonly CreateImagesAspectRatio[], + imageSizes: uniqueStrings( + input.model.imageSizes, + "Image sizes", + ) as readonly CreateImagesImageSize[], + outputMimes: uniqueStrings( + input.model.outputMimes, + "Output MIME types", + ) as readonly CreateImagesOutputMime[], + maxReferenceImages: safeInteger( + input.model.maxReferenceImages, + 0, + 64, + "Maximum reference images", + ), + maxOutputs: safeInteger(input.model.maxOutputs, 1, 4, "Maximum outputs"), + supportsEditing: input.model.supportsEditing === true, + supportsCancellation: input.model.supportsCancellation === true, + }; + if ( + model.aspectRatios.some((value) => !ALLOWED_ASPECT_RATIOS.has(value)) || + model.imageSizes.some((value) => !ALLOWED_IMAGE_SIZES.has(value)) || + model.outputMimes.some((value) => !ALLOWED_OUTPUT_MIMES.has(value)) + ) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The provider snapshot contains an unsupported image option.", + ); + } + if (!model.label || model.label.length > 128) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Model label is invalid."); + } + const base = deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + catalogRevision: input.catalogRevision, + observedAt: input.observedAt, + providerId, + model, + transport: { + kind: input.transport.kind, + supportsIdempotency: input.transport.supportsIdempotency === true, + supportsReconciliation: input.transport.supportsReconciliation === true, + }, + }); + return deepFreeze({ ...base, fingerprint: fingerprint(base) }); +} + +export function createCreateImagesMainCredentialBinding(input: { + providerId: "gemini"; + recordId: string; + revision: number; + authKind: "api-key"; +}): CreateImagesMainCredentialBindingV1 { + if (input.providerId !== "gemini" || input.authKind !== "api-key") { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Gemini image execution requires an exact main-owned API-key credential binding.", + ); + } + return deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + providerId: input.providerId, + recordId: opaqueId(input.recordId, "Credential record ID"), + revision: safeInteger(input.revision, 1, Number.MAX_SAFE_INTEGER, "Credential revision"), + authKind: input.authKind, + }); +} + +function validateEstimate( + estimate: CreateImagesProviderEstimateV1, +): CreateImagesProviderEstimateV1 { + canonicalTimestamp(estimate.estimatedAt, "Estimate time"); + if (!FINGERPRINT_PATTERN.test(estimate.sourceFingerprint)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Estimate source fingerprint is invalid.", + ); + } + if (estimate.kind === "unavailable") return deepFreeze({ ...estimate }); + safeInteger(estimate.amountMicros, 0, Number.MAX_SAFE_INTEGER, "Estimate amount"); + if (!CURRENCY_PATTERN.test(estimate.currency)) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Estimate currency is invalid."); + } + return deepFreeze({ ...estimate }); +} + +function validateInvocation( + invocation: CreateImagesProviderInvocationFactsV1, + capability: CreateImagesProviderCapabilitySnapshotV1, +): CreateImagesProviderInvocationFactsV1 { + opaqueId(invocation.nodeId, "Invocation node ID"); + safeInteger(invocation.promptBytes, 1, CREATE_IMAGES_MAX_PROMPT_BYTES, "Prompt bytes"); + safeInteger( + invocation.referenceImageCount, + 0, + capability.model.maxReferenceImages, + "Reference image count", + ); + safeInteger( + invocation.referenceImageBytes, + 0, + CREATE_IMAGES_MAX_PROVIDER_REQUEST_BYTES, + "Reference image bytes", + ); + if (invocation.referenceImageCount === 0 && invocation.referenceImageBytes !== 0) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Reference bytes require at least one reference image.", + ); + } + if (invocation.referenceImageCount > 0 && invocation.referenceImageBytes === 0) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Reference images require a positive byte count.", + ); + } + safeInteger(invocation.requestedOutputs, 1, capability.model.maxOutputs, "Requested outputs"); + if (!capability.model.aspectRatios.includes(invocation.aspectRatio)) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The consent plan requests an unsupported aspect ratio.", + ); + } + if (!capability.model.imageSizes.includes(invocation.imageSize)) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The consent plan requests an unsupported image size.", + ); + } + if (!capability.model.outputMimes.includes(invocation.outputMime)) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The consent plan requests an unsupported output type.", + ); + } + if ( + invocation.promptBytes > + CREATE_IMAGES_MAX_PROVIDER_REQUEST_BYTES - invocation.referenceImageBytes + ) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "A provider invocation exceeds its input byte bound.", + ); + } + return deepFreeze({ ...invocation }); +} + +function executionPlanPayload( + plan: Omit, +): Omit { + return { + version: plan.version, + authorizationId: plan.authorizationId, + workflowId: plan.workflowId, + workflowRevision: plan.workflowRevision, + executionMode: plan.executionMode, + capability: plan.capability, + ...(plan.credentialBinding ? { credentialBinding: plan.credentialBinding } : {}), + invocations: plan.invocations, + accounting: plan.accounting, + estimate: plan.estimate, + createdAt: plan.createdAt, + expiresAt: plan.expiresAt, + }; +} + +function consentToken( + authority: CreateImagesProviderConsentAuthority, + consentFingerprint: string, +): string { + if (!(authority.secret instanceof Uint8Array) || authority.secret.byteLength < 32) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Consent authority requires at least 32 bytes of main-owned secret material.", + ); + } + return createHmac("sha256", authority.secret) + .update("aiden-create-images-provider-consent-v1\0") + .update(consentFingerprint) + .digest("hex"); +} + +export function prepareCreateImagesProviderExecutionConsent( + input: CreateImagesPrepareProviderExecutionConsentInput, + authority: CreateImagesProviderConsentAuthority, +): CreateImagesPreparedProviderExecutionConsent { + opaqueId(input.authorizationId, "Authorization ID"); + opaqueId(input.workflowId, "Workflow ID"); + safeInteger(input.workflowRevision, 0, Number.MAX_SAFE_INTEGER, "Workflow revision"); + if (!(["local-mock", "gemini"] as const).includes(input.executionMode)) { + throw new CreateImagesProviderAdmissionError("invalid-input", "Execution mode is invalid."); + } + const createdAt = canonicalTimestamp(input.createdAt, "Consent creation time"); + const expiresAt = canonicalTimestamp(input.expiresAt, "Consent expiry time"); + const createdMs = Date.parse(createdAt); + const expiresMs = Date.parse(expiresAt); + if (expiresMs <= createdMs || expiresMs - createdMs > CREATE_IMAGES_MAX_CONSENT_LIFETIME_MS) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Consent expiry must be after creation and within the bounded lifetime.", + ); + } + const capability = input.capability; + const expectedCapabilityFingerprint = fingerprint(capabilityPayload(capability)); + if (capability.fingerprint !== expectedCapabilityFingerprint) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The provider capability snapshot fingerprint is invalid.", + ); + } + if ( + (input.executionMode === "local-mock" && capability.providerId !== "local-mock") || + (input.executionMode === "gemini" && capability.providerId !== "gemini") + ) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Execution mode does not match the provider snapshot.", + ); + } + if (input.executionMode === "gemini") { + if ( + !input.credentialBinding || + input.credentialBinding.providerId !== "gemini" || + input.credentialBinding.authKind !== "api-key" + ) { + throw new CreateImagesProviderAdmissionError( + "credential-required", + "Remote Gemini execution requires a main-owned API-key binding.", + ); + } + } else if (input.credentialBinding) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The local mock cannot carry a remote credential binding.", + ); + } + if ( + !Array.isArray(input.invocations) || + input.invocations.length < 1 || + input.invocations.length > CREATE_IMAGES_MAX_PROVIDER_INVOCATIONS + ) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Provider invocation count is outside its bounded range.", + ); + } + const invocations = input.invocations.map((invocation) => + validateInvocation(invocation, capability), + ); + if (new Set(invocations.map((invocation) => invocation.nodeId)).size !== invocations.length) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "A consent plan cannot contain duplicate provider node IDs.", + ); + } + let expectedOutputCount = 0; + let promptBytes = 0; + let referenceImageCount = 0; + let referenceImageBytes = 0; + for (const invocation of invocations) { + expectedOutputCount = safeAdd( + expectedOutputCount, + invocation.requestedOutputs, + CREATE_IMAGES_MAX_PROVIDER_INVOCATIONS * 4, + "Expected output count", + ); + promptBytes = safeAdd( + promptBytes, + invocation.promptBytes, + CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + "Prompt bytes", + ); + referenceImageCount = safeAdd( + referenceImageCount, + invocation.referenceImageCount, + CREATE_IMAGES_MAX_PROVIDER_INVOCATIONS * 64, + "Reference image count", + ); + referenceImageBytes = safeAdd( + referenceImageBytes, + invocation.referenceImageBytes, + CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + "Reference image bytes", + ); + } + const initialProviderInputBytes = safeAdd( + promptBytes, + referenceImageBytes, + CREATE_IMAGES_MAX_PROVIDER_INPUT_BYTES, + "Provider input bytes", + ); + const maximumAttempts = safeInteger( + input.maximumAttempts, + invocations.length, + CREATE_IMAGES_MAX_PROVIDER_ATTEMPTS, + "Maximum attempts", + ); + if (input.executionMode === "gemini" && maximumAttempts !== invocations.length) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Paid Gemini consent authorizes exactly one initial attempt per request and no automatic retry.", + ); + } + if (input.executionMode === "local-mock" && maximumAttempts > invocations.length * 3) { + throw new CreateImagesProviderAdmissionError( + "unsafe-accounting", + "Local mock attempts exceed the bounded retry policy.", + ); + } + const accounting = deepFreeze({ + initialRequestCount: invocations.length, + expectedOutputCount, + maximumAttempts, + promptBytes, + referenceImageCount, + referenceImageBytes, + initialProviderInputBytes, + dataLeavesDevice: input.executionMode === "gemini", + retryPolicy: + input.executionMode === "gemini" + ? ("manual-new-consent" as const) + : ("bounded-local-automatic" as const), + }); + const estimate = validateEstimate(input.estimate); + if ( + (input.executionMode === "local-mock" && + (estimate.kind !== "mock" || estimate.amountMicros !== 0)) || + (input.executionMode === "gemini" && estimate.kind === "mock") + ) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Estimate kind does not match the execution mode.", + ); + } + const base = deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + authorizationId: input.authorizationId, + workflowId: input.workflowId, + workflowRevision: input.workflowRevision, + executionMode: input.executionMode, + capability, + ...(input.credentialBinding ? { credentialBinding: input.credentialBinding } : {}), + invocations: Object.freeze(invocations), + accounting, + estimate, + createdAt, + expiresAt, + }); + const consentFingerprint = fingerprint(base); + const mainPlan = deepFreeze({ ...base, consentFingerprint }); + const rendererPlan = deepFreeze({ + version: CREATE_IMAGES_PROVIDER_CONSENT_VERSION, + authorizationId: mainPlan.authorizationId, + workflowId: mainPlan.workflowId, + workflowRevision: mainPlan.workflowRevision, + executionMode: mainPlan.executionMode, + providerId: capability.providerId, + providerLabel: capability.providerId === "gemini" ? "Google Gemini" : "Aiden local mock", + modelId: capability.model.id, + modelLabel: capability.model.label, + accounting, + estimate, + createdAt, + expiresAt, + consentFingerprint, + ...(mainPlan.executionMode === "gemini" + ? { token: consentToken(authority, consentFingerprint) } + : {}), + }); + return deepFreeze({ mainPlan, rendererPlan }); +} + +export function parseCreateImagesProviderConsentClaim( + value: unknown, +): CreateImagesProviderConsentClaimV1 { + if ( + !isRecord(value) || + !exactKeys(value, ["version", "authorizationId", "consentFingerprint", "token", "reviewed"]) || + value.version !== CREATE_IMAGES_PROVIDER_CONSENT_VERSION || + typeof value.authorizationId !== "string" || + !OPAQUE_ID_PATTERN.test(value.authorizationId) || + typeof value.consentFingerprint !== "string" || + !FINGERPRINT_PATTERN.test(value.consentFingerprint) || + typeof value.token !== "string" || + !TOKEN_PATTERN.test(value.token) || + value.reviewed !== true + ) { + throw new CreateImagesProviderAdmissionError( + "invalid-consent", + "Remote execution consent is malformed or contains unsupported fields.", + ); + } + return deepFreeze({ + version: value.version, + authorizationId: value.authorizationId, + consentFingerprint: value.consentFingerprint, + token: value.token, + reviewed: true, + }); +} + +function sameCredential( + left: CreateImagesMainCredentialBindingV1, + right: CreateImagesMainCredentialBindingV1, +): boolean { + return ( + left.providerId === right.providerId && + left.recordId === right.recordId && + left.revision === right.revision && + left.authKind === right.authKind + ); +} + +function assertPlanIntegrity(plan: CreateImagesProviderExecutionConsentPlanV1): void { + const { consentFingerprint: _consentFingerprint, ...withoutFingerprint } = plan; + const expected = fingerprint(executionPlanPayload(withoutFingerprint)); + if (expected !== plan.consentFingerprint) { + throw new CreateImagesProviderAdmissionError( + "forged-consent", + "The main-owned consent plan fingerprint does not match its contents.", + ); + } +} + +export function admitCreateImagesProviderExecution(input: { + mainPlan: CreateImagesProviderExecutionConsentPlanV1; + claim?: unknown; + authority: CreateImagesProviderConsentAuthority; + currentCapability: CreateImagesProviderCapabilitySnapshotV1; + currentCredential?: CreateImagesMainCredentialBindingV1; + now: string; +}): CreateImagesProviderExecutionAuthorizationV1 { + assertPlanIntegrity(input.mainPlan); + const now = canonicalTimestamp(input.now, "Admission time"); + const nowMs = Date.parse(now); + if ( + nowMs < Date.parse(input.mainPlan.createdAt) || + nowMs > Date.parse(input.mainPlan.expiresAt) + ) { + throw new CreateImagesProviderAdmissionError( + "stale-consent", + "The provider consent is not currently valid.", + ); + } + if ( + input.currentCapability.fingerprint !== + fingerprint(capabilityPayload(input.currentCapability)) || + input.currentCapability.fingerprint !== input.mainPlan.capability.fingerprint || + input.currentCapability.catalogRevision !== input.mainPlan.capability.catalogRevision || + input.currentCapability.providerId !== input.mainPlan.capability.providerId || + input.currentCapability.model.id !== input.mainPlan.capability.model.id + ) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "Provider capabilities changed after the user reviewed the run.", + ); + } + if (input.mainPlan.executionMode === "gemini") { + const plannedCredential = input.mainPlan.credentialBinding; + if (!plannedCredential || !input.currentCredential) { + throw new CreateImagesProviderAdmissionError( + "credential-required", + "The reviewed Gemini credential is no longer connected.", + ); + } + if (!sameCredential(plannedCredential, input.currentCredential)) { + throw new CreateImagesProviderAdmissionError( + "credential-drift", + "The main-owned Gemini credential changed after review.", + ); + } + const claim = parseCreateImagesProviderConsentClaim(input.claim); + if ( + claim.authorizationId !== input.mainPlan.authorizationId || + claim.consentFingerprint !== input.mainPlan.consentFingerprint + ) { + throw new CreateImagesProviderAdmissionError( + "forged-consent", + "Consent identity does not match the main-owned plan.", + ); + } + const expectedToken = Buffer.from( + consentToken(input.authority, input.mainPlan.consentFingerprint), + "hex", + ); + const actualToken = Buffer.from(claim.token, "hex"); + if ( + expectedToken.byteLength !== actualToken.byteLength || + !timingSafeEqual(expectedToken, actualToken) + ) { + throw new CreateImagesProviderAdmissionError( + "forged-consent", + "Consent token was not minted by this main process.", + ); + } + } else if (input.claim !== undefined || input.currentCredential !== undefined) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Local mock admission cannot carry remote consent or credentials.", + ); + } + return deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + authorizationId: input.mainPlan.authorizationId, + workflowId: input.mainPlan.workflowId, + workflowRevision: input.mainPlan.workflowRevision, + executionMode: input.mainPlan.executionMode, + capability: input.mainPlan.capability, + ...(input.mainPlan.credentialBinding + ? { credentialBinding: input.mainPlan.credentialBinding } + : {}), + invocations: input.mainPlan.invocations, + accounting: input.mainPlan.accounting, + estimate: input.mainPlan.estimate, + consentFingerprint: input.mainPlan.consentFingerprint, + authorizedAt: now, + expiresAt: input.mainPlan.expiresAt, + }); +} + +export interface CreateImagesProviderGateConfig { + providerId: string; + maxConcurrency: number; + maxStartsPerWindow: number; + windowMs: number; + minimumStartIntervalMs: number; +} + +export interface CreateImagesProviderGateLease { + providerId: string; + leaseId: string; + acquiredAtMs: number; +} + +export type CreateImagesProviderGateDecision = + | { status: "acquired"; lease: CreateImagesProviderGateLease } + | { + status: "deferred"; + reason: "concurrency" | "rate"; + retryAfterMs: number; + }; + +interface ProviderGateState { + config: CreateImagesProviderGateConfig; + active: Map; + starts: number[]; + nextLease: number; +} + +export class CreateImagesProviderAdmissionGate { + readonly #states = new Map(); + + constructor(configs: readonly CreateImagesProviderGateConfig[]) { + if ( + configs.length < 1 || + new Set(configs.map((config) => config.providerId)).size !== configs.length + ) { + throw new Error("Provider gate configuration requires unique providers."); + } + for (const config of configs) { + if (!PROVIDER_ID_PATTERN.test(config.providerId)) + throw new Error("Invalid provider gate ID."); + for (const [value, minimum, maximum, label] of [ + [config.maxConcurrency, 1, 4, "concurrency"], + [config.maxStartsPerWindow, 1, 10_000, "window start count"], + [config.windowMs, 1, 60 * 60_000, "window"], + [config.minimumStartIntervalMs, 0, 60 * 60_000, "start interval"], + ] as const) { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new Error(`Invalid provider gate ${label}.`); + } + } + this.#states.set(config.providerId, { + config: Object.freeze({ ...config }), + active: new Map(), + starts: [], + nextLease: 1, + }); + } + } + + tryAcquire(providerId: string, nowMs: number): CreateImagesProviderGateDecision { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) + throw new Error("Provider gate time is invalid."); + const state = this.#states.get(providerId); + if (!state) throw new Error("Provider gate is not configured for this provider."); + state.starts = state.starts.filter((startedAt) => startedAt > nowMs - state.config.windowMs); + if (state.active.size >= state.config.maxConcurrency) { + return { status: "deferred", reason: "concurrency", retryAfterMs: 0 }; + } + const lastStart = state.starts[state.starts.length - 1]; + if (lastStart !== undefined && nowMs - lastStart < state.config.minimumStartIntervalMs) { + return { + status: "deferred", + reason: "rate", + retryAfterMs: state.config.minimumStartIntervalMs - (nowMs - lastStart), + }; + } + if (state.starts.length >= state.config.maxStartsPerWindow) { + return { + status: "deferred", + reason: "rate", + retryAfterMs: Math.max(0, state.starts[0]! + state.config.windowMs - nowMs), + }; + } + const lease = Object.freeze({ + providerId, + leaseId: `${providerId}:${state.nextLease}`, + acquiredAtMs: nowMs, + }); + state.nextLease += 1; + state.active.set(lease.leaseId, lease); + state.starts.push(nowMs); + return { status: "acquired", lease }; + } + + release(lease: CreateImagesProviderGateLease): boolean { + const state = this.#states.get(lease.providerId); + if (!state) return false; + const current = state.active.get(lease.leaseId); + if (current !== lease) return false; + state.active.delete(lease.leaseId); + return true; + } + + snapshot(providerId: string): Readonly<{ active: number; startsInWindow: number }> { + const state = this.#states.get(providerId); + if (!state) throw new Error("Provider gate is not configured for this provider."); + return Object.freeze({ active: state.active.size, startsInWindow: state.starts.length }); + } +} + +export type CreateImagesProviderBillingStatus = + | "not-submitted" + | "possibly-billable" + | "provider-reported"; + +export interface CreateImagesProviderReportedUsageV1 { + inputUnits?: number; + outputUnits?: number; + totalUnits?: number; + billedRequestCount?: number; + costMicros?: number; + currency?: string; +} + +export interface CreateImagesProviderUsageMetadataV1 { + providerId: string; + modelId: string; + requestCount: 0 | 1; + outputCount: number; + billingStatus: CreateImagesProviderBillingStatus; + reported?: CreateImagesProviderReportedUsageV1; +} + +export type CreateImagesProviderAttemptStatus = + | "ready" + | "prepared" + | "accepted" + | "succeeded" + | "failed" + | "needs_attention" + | "cancel_requested" + | "cancelled"; + +export interface CreateImagesProviderAttemptProjectionV1 { + version: typeof CREATE_IMAGES_PROVIDER_EXECUTION_VERSION; + authorization: CreateImagesProviderExecutionAuthorizationV1; + runId: string; + nodeId: string; + attempt: number; + idempotencyKey: string; + status: CreateImagesProviderAttemptStatus; + submission: "not-prepared" | "prepared" | "accepted" | "confirmed-not-sent" | "unknown"; + providerJobId?: string; + lastSequence: number; + outputAssetIds: readonly string[]; + lateOutputAssetIds: readonly string[]; + usage: CreateImagesProviderUsageMetadataV1; + errorCode?: string; + cancellationReason?: "user" | "renderer-disconnected" | "app-quit"; +} + +interface CreateImagesProviderAttemptEventBase { + authorizationId: string; + runId: string; + nodeId: string; + attempt: number; + sequence: number; +} + +export type CreateImagesProviderAttemptEventV1 = + | (CreateImagesProviderAttemptEventBase & { kind: "submission-prepared" }) + | (CreateImagesProviderAttemptEventBase & { + kind: "submission-accepted"; + providerJobId: string; + usage?: CreateImagesProviderReportedUsageV1; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "submission-confirmed-not-sent"; + errorCode: string; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "submission-unknown"; + errorCode: string; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "provider-failed"; + errorCode: string; + usage?: CreateImagesProviderReportedUsageV1; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "output-published"; + outputAssetIds: readonly string[]; + usage?: CreateImagesProviderReportedUsageV1; + }) + | (CreateImagesProviderAttemptEventBase & { + kind: "cancellation-requested"; + reason: "user" | "renderer-disconnected" | "app-quit"; + }) + | (CreateImagesProviderAttemptEventBase & { kind: "cancelled" }) + | (CreateImagesProviderAttemptEventBase & { + kind: "late-output-published"; + outputAssetIds: readonly string[]; + usage?: CreateImagesProviderReportedUsageV1; + }); + +type CreateImagesProviderAttemptEventPayload = + CreateImagesProviderAttemptEventV1 extends infer Event + ? Event extends CreateImagesProviderAttemptEventV1 + ? Omit + : never + : never; + +export type CreateImagesProviderAttemptReduction = + | { accepted: true; projection: CreateImagesProviderAttemptProjectionV1 } + | { + accepted: false; + projection: CreateImagesProviderAttemptProjectionV1; + reason: + | "wrong-attempt" + | "duplicate-or-stale" + | "out-of-order" + | "invalid-transition" + | "output-mismatch" + | "invalid-event"; + }; + +function invocationFor( + authorization: CreateImagesProviderExecutionAuthorizationV1, + nodeId: string, +): CreateImagesProviderInvocationFactsV1 | undefined { + return authorization.invocations.find((invocation) => invocation.nodeId === nodeId); +} + +function emptyUsage( + authorization: CreateImagesProviderExecutionAuthorizationV1, +): CreateImagesProviderUsageMetadataV1 { + return Object.freeze({ + providerId: authorization.capability.providerId, + modelId: authorization.capability.model.id, + requestCount: 0, + outputCount: 0, + billingStatus: "not-submitted", + }); +} + +export function createCreateImagesProviderAttemptProjection( + authorization: CreateImagesProviderExecutionAuthorizationV1, + input: { runId: string; nodeId: string; attempt: number }, +): CreateImagesProviderAttemptProjectionV1 { + opaqueId(input.runId, "Run ID"); + opaqueId(input.nodeId, "Node ID"); + if (!invocationFor(authorization, input.nodeId)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "The node was not included in the reviewed provider plan.", + ); + } + const maxAttempt = authorization.executionMode === "gemini" ? 1 : 3; + safeInteger(input.attempt, 1, maxAttempt, "Provider attempt"); + const idempotencyKey = `aiden-ci-${createHash("sha256") + .update(authorization.consentFingerprint) + .update("\0") + .update(input.runId) + .update("\0") + .update(input.nodeId) + .update("\0") + .update(String(input.attempt)) + .digest("hex")}`; + return deepFreeze({ + version: CREATE_IMAGES_PROVIDER_EXECUTION_VERSION, + authorization, + runId: input.runId, + nodeId: input.nodeId, + attempt: input.attempt, + idempotencyKey, + status: "ready", + submission: "not-prepared", + lastSequence: 0, + outputAssetIds: Object.freeze([]), + lateOutputAssetIds: Object.freeze([]), + usage: emptyUsage(authorization), + }); +} + +function eventBase( + projection: CreateImagesProviderAttemptProjectionV1, +): CreateImagesProviderAttemptEventBase { + return { + authorizationId: projection.authorization.authorizationId, + runId: projection.runId, + nodeId: projection.nodeId, + attempt: projection.attempt, + sequence: projection.lastSequence + 1, + }; +} + +export function createCreateImagesProviderAttemptEvent< + Event extends CreateImagesProviderAttemptEventPayload, +>( + projection: CreateImagesProviderAttemptProjectionV1, + event: Event, +): CreateImagesProviderAttemptEventV1 { + return deepFreeze({ ...eventBase(projection), ...event } as CreateImagesProviderAttemptEventV1); +} + +function validUsage( + value: CreateImagesProviderReportedUsageV1 | undefined, +): CreateImagesProviderReportedUsageV1 | undefined { + if (!value) return undefined; + const output: CreateImagesProviderReportedUsageV1 = {}; + for (const key of [ + "inputUnits", + "outputUnits", + "totalUnits", + "billedRequestCount", + "costMicros", + ] as const) { + const candidate = value[key]; + if (candidate !== undefined) { + const maximum = key === "costMicros" ? Number.MAX_SAFE_INTEGER : MAX_PROVIDER_USAGE_UNITS; + if (!Number.isSafeInteger(candidate) || candidate < 0 || candidate > maximum) { + return undefined; + } + output[key] = candidate; + } + } + if (output.billedRequestCount !== undefined && output.billedRequestCount > 1) return undefined; + if ( + output.totalUnits !== undefined && + ((output.inputUnits !== undefined && output.totalUnits < output.inputUnits) || + (output.outputUnits !== undefined && output.totalUnits < output.outputUnits)) + ) { + return undefined; + } + if (value.currency !== undefined) { + if (!CURRENCY_PATTERN.test(value.currency)) return undefined; + output.currency = value.currency; + } + if ((output.costMicros === undefined) !== (output.currency === undefined)) return undefined; + return deepFreeze(output); +} + +function usageMetadata( + projection: CreateImagesProviderAttemptProjectionV1, + requestCount: 0 | 1, + outputCount: number, + billingStatus: CreateImagesProviderBillingStatus, + reported?: CreateImagesProviderReportedUsageV1, +): CreateImagesProviderUsageMetadataV1 | undefined { + const validated = validUsage(reported); + if (reported && !validated) return undefined; + return deepFreeze({ + providerId: projection.authorization.capability.providerId, + modelId: projection.authorization.capability.model.id, + requestCount, + outputCount, + billingStatus: validated ? "provider-reported" : billingStatus, + ...(validated ? { reported: validated } : {}), + }); +} + +function validOutputAssetIds( + projection: CreateImagesProviderAttemptProjectionV1, + assetIds: readonly string[], +): boolean { + const invocation = invocationFor(projection.authorization, projection.nodeId); + return ( + invocation !== undefined && + Array.isArray(assetIds) && + assetIds.length === invocation.requestedOutputs && + assetIds.every((assetId) => CREATE_IMAGES_ASSET_ID_PATTERN.test(assetId)) + ); +} + +function terminalAttempt(status: CreateImagesProviderAttemptStatus): boolean { + return ["succeeded", "failed", "needs_attention", "cancelled"].includes(status); +} + +function rejected( + projection: CreateImagesProviderAttemptProjectionV1, + reason: Extract["reason"], +): CreateImagesProviderAttemptReduction { + return { accepted: false, projection, reason }; +} + +export function reduceCreateImagesProviderAttemptEvent( + projection: CreateImagesProviderAttemptProjectionV1, + event: CreateImagesProviderAttemptEventV1, +): CreateImagesProviderAttemptReduction { + if ( + event.authorizationId !== projection.authorization.authorizationId || + event.runId !== projection.runId || + event.nodeId !== projection.nodeId || + event.attempt !== projection.attempt + ) { + return rejected(projection, "wrong-attempt"); + } + if (event.sequence <= projection.lastSequence) return rejected(projection, "duplicate-or-stale"); + if (event.sequence !== projection.lastSequence + 1) return rejected(projection, "out-of-order"); + const nextBase = { ...projection, lastSequence: event.sequence }; + if ( + terminalAttempt(projection.status) && + event.kind !== "late-output-published" && + !(projection.status === "needs_attention" && event.kind === "cancellation-requested") + ) { + return rejected(projection, "invalid-transition"); + } + if (event.kind === "submission-prepared") { + if (projection.status !== "ready" || projection.submission !== "not-prepared") { + return rejected(projection, "invalid-transition"); + } + return { + accepted: true, + projection: deepFreeze({ ...nextBase, status: "prepared", submission: "prepared" }), + }; + } + if (event.kind === "submission-accepted") { + if ( + projection.status !== "prepared" || + projection.authorization.capability.transport.kind !== "asynchronous" || + !PROVIDER_JOB_ID_PATTERN.test(event.providerJobId) + ) { + return rejected(projection, "invalid-transition"); + } + const usage = usageMetadata(projection, 1, 0, "possibly-billable", event.usage); + if (!usage) return rejected(projection, "invalid-event"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "accepted", + submission: "accepted", + providerJobId: event.providerJobId, + usage, + }), + }; + } + if (event.kind === "submission-confirmed-not-sent") { + if (projection.status !== "prepared" || !SAFE_ERROR_CODE_PATTERN.test(event.errorCode)) { + return rejected(projection, "invalid-transition"); + } + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "failed", + submission: "confirmed-not-sent", + usage: emptyUsage(projection.authorization), + errorCode: event.errorCode, + }), + }; + } + if (event.kind === "submission-unknown") { + if ( + !["prepared", "accepted"].includes(projection.status) || + !SAFE_ERROR_CODE_PATTERN.test(event.errorCode) + ) { + return rejected(projection, "invalid-transition"); + } + const usage = usageMetadata(projection, 1, 0, "possibly-billable"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "needs_attention", + submission: "unknown", + usage: usage!, + errorCode: event.errorCode, + }), + }; + } + if (event.kind === "provider-failed") { + if ( + !["prepared", "accepted"].includes(projection.status) || + !SAFE_ERROR_CODE_PATTERN.test(event.errorCode) + ) { + return rejected(projection, "invalid-transition"); + } + const usage = usageMetadata(projection, 1, 0, "possibly-billable", event.usage); + if (!usage) return rejected(projection, "invalid-event"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "failed", + usage, + errorCode: event.errorCode, + }), + }; + } + if (event.kind === "output-published") { + if ( + !["prepared", "accepted"].includes(projection.status) || + !validOutputAssetIds(projection, event.outputAssetIds) + ) { + return rejected(projection, "output-mismatch"); + } + const usage = usageMetadata( + projection, + 1, + event.outputAssetIds.length, + "possibly-billable", + event.usage, + ); + if (!usage) return rejected(projection, "invalid-event"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "succeeded", + outputAssetIds: Object.freeze([...event.outputAssetIds]), + usage, + }), + }; + } + if (event.kind === "cancellation-requested") { + if ( + !["ready", "prepared", "accepted", "needs_attention"].includes(projection.status) || + !(["user", "renderer-disconnected", "app-quit"] as const).includes(event.reason) + ) { + return rejected(projection, "invalid-transition"); + } + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: "cancel_requested", + cancellationReason: event.reason, + }), + }; + } + if (event.kind === "cancelled") { + if (projection.status !== "cancel_requested") { + return rejected(projection, "invalid-transition"); + } + return { + accepted: true, + projection: deepFreeze({ ...nextBase, status: "cancelled" }), + }; + } + if ( + !["cancel_requested", "cancelled", "needs_attention"].includes(projection.status) || + !validOutputAssetIds(projection, event.outputAssetIds) + ) { + return rejected(projection, "output-mismatch"); + } + const usage = usageMetadata( + projection, + 1, + event.outputAssetIds.length, + "possibly-billable", + event.usage, + ); + if (!usage) return rejected(projection, "invalid-event"); + return { + accepted: true, + projection: deepFreeze({ + ...nextBase, + status: projection.status === "needs_attention" ? "needs_attention" : "cancelled", + lateOutputAssetIds: Object.freeze([...event.outputAssetIds]), + usage, + }), + }; +} + +export type CreateImagesProviderRecoveryDecision = + | { action: "resume-before-prepare" } + | { action: "reconcile-only"; providerJobId?: string } + | { action: "cancel-only"; providerJobId: string } + | { action: "finalize-cancel" } + | { action: "needs-attention"; reason: "prepared-or-unknown" | "accepted-unreconcilable" } + | { action: "none" }; + +/** + * A prepared remote attempt is never a submission permit after restart. The + * caller may resume only before the durable prepared boundary. Synchronous + * Gemini has no job ID to reconcile, so prepared/unknown always needs review. + */ +export function decideCreateImagesProviderAttemptRecovery( + projection: CreateImagesProviderAttemptProjectionV1, +): CreateImagesProviderRecoveryDecision { + if (projection.status === "ready") return { action: "resume-before-prepare" }; + if (["succeeded", "failed", "cancelled"].includes(projection.status)) return { action: "none" }; + const transport = projection.authorization.capability.transport; + if (projection.status === "cancel_requested") { + if ( + projection.providerJobId && + projection.authorization.capability.model.supportsCancellation + ) { + return { action: "cancel-only", providerJobId: projection.providerJobId }; + } + if ( + projection.submission === "not-prepared" || + projection.submission === "confirmed-not-sent" + ) { + return { action: "finalize-cancel" }; + } + if (projection.providerJobId && transport.supportsReconciliation) { + return { action: "reconcile-only", providerJobId: projection.providerJobId }; + } + return { action: "needs-attention", reason: "prepared-or-unknown" }; + } + if (projection.status === "accepted") { + return projection.providerJobId && transport.supportsReconciliation + ? { action: "reconcile-only", providerJobId: projection.providerJobId } + : { action: "needs-attention", reason: "accepted-unreconcilable" }; + } + if (projection.status === "prepared") { + return transport.supportsIdempotency && transport.supportsReconciliation + ? { action: "reconcile-only" } + : { action: "needs-attention", reason: "prepared-or-unknown" }; + } + return { action: "needs-attention", reason: "prepared-or-unknown" }; +} + +export interface CreateImagesResolvedMainCredential { + binding: CreateImagesMainCredentialBindingV1; + credential: TCredential; +} + +export type CreateImagesProviderSubmitResult = + | { + kind: "completed"; + output: TOutput; + outputCount: number; + usage?: CreateImagesProviderReportedUsageV1; + } + | { + kind: "accepted"; + providerJobId: string; + usage?: CreateImagesProviderReportedUsageV1; + } + | { + kind: "failed" | "rate-limited"; + errorCode: string; + usage?: CreateImagesProviderReportedUsageV1; + } + | { kind: "confirmed-not-sent"; errorCode: string } + | { kind: "unknown"; errorCode: string }; + +export type CreateImagesProviderSubmissionOutcome = + | { kind: "deferred"; reason: "concurrency" | "rate"; retryAfterMs: number } + | { kind: "not-admitted"; reason: "consent-expired" } + | { kind: "recovery"; decision: CreateImagesProviderRecoveryDecision } + | { + kind: "cancelled-before-submit"; + events: readonly [CreateImagesProviderAttemptEventV1, CreateImagesProviderAttemptEventV1]; + } + | { + kind: "completed"; + output: TOutput; + outputCount: number; + usage?: CreateImagesProviderReportedUsageV1; + } + | { + kind: "event"; + event: CreateImagesProviderAttemptEventV1; + retry: "none" | "new-consent-required"; + }; + +export interface ExecuteCreateImagesProviderSubmissionOptions { + projection: CreateImagesProviderAttemptProjectionV1; + gate: CreateImagesProviderAdmissionGate; + nowMs: number; + signal?: AbortSignal; + persistPrepared( + event: CreateImagesProviderAttemptEventV1, + ): Promise; + resolveCredential?( + binding: CreateImagesMainCredentialBindingV1, + ): Promise>; + submit(input: { + credential?: TCredential; + authorization: CreateImagesProviderExecutionAuthorizationV1; + runId: string; + nodeId: string; + attempt: number; + idempotencyKey: string; + signal?: AbortSignal; + }): Promise>; +} + +function preparedProjectionMatches( + before: CreateImagesProviderAttemptProjectionV1, + after: CreateImagesProviderAttemptProjectionV1, +): boolean { + return ( + after.authorization.authorizationId === before.authorization.authorizationId && + after.runId === before.runId && + after.nodeId === before.nodeId && + after.attempt === before.attempt && + after.idempotencyKey === before.idempotencyKey && + after.lastSequence === before.lastSequence + 1 && + after.status === "prepared" && + after.submission === "prepared" + ); +} + +function cancellationEvents( + prepared: CreateImagesProviderAttemptProjectionV1, +): readonly [CreateImagesProviderAttemptEventV1, CreateImagesProviderAttemptEventV1] { + const requested = createCreateImagesProviderAttemptEvent(prepared, { + kind: "cancellation-requested", + reason: "user", + }); + const requestedProjection = reduceCreateImagesProviderAttemptEvent(prepared, requested); + if (!requestedProjection.accepted) throw new Error("Cancellation event could not be projected."); + return Object.freeze([ + requested, + createCreateImagesProviderAttemptEvent(requestedProjection.projection, { kind: "cancelled" }), + ]); +} + +/** + * Executes at most one fresh provider submission. It journals `prepared` + * before resolving the credential/entering the adapter, never retries paid + * work, and treats every thrown post-call failure as an unknown submission. + * Callers must ingest `completed.output` before creating output-published. + */ +export async function executeCreateImagesProviderSubmission( + options: ExecuteCreateImagesProviderSubmissionOptions, +): Promise> { + const recovery = decideCreateImagesProviderAttemptRecovery(options.projection); + if (recovery.action !== "resume-before-prepare") { + return { kind: "recovery", decision: recovery }; + } + if (Date.parse(options.projection.authorization.expiresAt) < options.nowMs) { + return { kind: "not-admitted", reason: "consent-expired" }; + } + if (options.signal?.aborted) { + const requested = createCreateImagesProviderAttemptEvent(options.projection, { + kind: "cancellation-requested", + reason: "user", + }); + const reduced = reduceCreateImagesProviderAttemptEvent(options.projection, requested); + if (!reduced.accepted) throw new Error("Cancellation event could not be projected."); + return { + kind: "cancelled-before-submit", + events: Object.freeze([ + requested, + createCreateImagesProviderAttemptEvent(reduced.projection, { kind: "cancelled" }), + ]), + }; + } + const gateDecision = options.gate.tryAcquire( + options.projection.authorization.capability.providerId, + options.nowMs, + ); + if (gateDecision.status === "deferred") return { kind: "deferred", ...gateDecision }; + try { + const preparedEvent = createCreateImagesProviderAttemptEvent(options.projection, { + kind: "submission-prepared", + }); + const prepared = await options.persistPrepared(preparedEvent); + if (!preparedProjectionMatches(options.projection, prepared)) { + throw new Error("The durable prepared projection does not match the authorized attempt."); + } + if (options.signal?.aborted) { + return { kind: "cancelled-before-submit", events: cancellationEvents(prepared) }; + } + let credential: TCredential | undefined; + if (prepared.authorization.executionMode === "gemini") { + const binding = prepared.authorization.credentialBinding; + if (!binding || !options.resolveCredential) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-confirmed-not-sent", + errorCode: "credential-unavailable", + }), + retry: "new-consent-required", + }; + } + let resolved: CreateImagesResolvedMainCredential; + try { + resolved = await options.resolveCredential(binding); + } catch { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-confirmed-not-sent", + errorCode: "credential-unavailable", + }), + retry: "new-consent-required", + }; + } + if (!sameCredential(binding, resolved.binding)) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-confirmed-not-sent", + errorCode: "credential-drift", + }), + retry: "new-consent-required", + }; + } + credential = resolved.credential; + } + if (options.signal?.aborted) { + return { kind: "cancelled-before-submit", events: cancellationEvents(prepared) }; + } + let result: CreateImagesProviderSubmitResult; + try { + result = await options.submit({ + ...(credential === undefined ? {} : { credential }), + authorization: prepared.authorization, + runId: prepared.runId, + nodeId: prepared.nodeId, + attempt: prepared.attempt, + idempotencyKey: prepared.idempotencyKey, + ...(options.signal ? { signal: options.signal } : {}), + }); + } catch { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "transport-unknown", + }), + retry: "none", + }; + } + if (result.kind === "completed") { + const invocation = invocationFor(prepared.authorization, prepared.nodeId)!; + if ( + result.outputCount !== invocation.requestedOutputs || + (result.usage !== undefined && !validUsage(result.usage)) + ) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "provider-output-mismatch", + }), + retry: "none", + }; + } + if (options.signal?.aborted) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "cancelled-after-send", + }), + retry: "none", + }; + } + return { + kind: "completed", + output: result.output, + outputCount: result.outputCount, + ...(result.usage ? { usage: validUsage(result.usage)! } : {}), + }; + } + if (result.kind === "accepted") { + if ( + prepared.authorization.capability.transport.kind !== "asynchronous" || + !PROVIDER_JOB_ID_PATTERN.test(result.providerJobId) || + (result.usage !== undefined && !validUsage(result.usage)) + ) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "provider-contract-mismatch", + }), + retry: "none", + }; + } + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-accepted", + providerJobId: result.providerJobId, + ...(result.usage ? { usage: validUsage(result.usage)! } : {}), + }), + retry: "none", + }; + } + if (result.kind === "confirmed-not-sent") { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-confirmed-not-sent", + errorCode: SAFE_ERROR_CODE_PATTERN.test(result.errorCode) + ? result.errorCode + : "provider-error", + }), + retry: "new-consent-required", + }; + } + if (result.kind === "unknown") { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: SAFE_ERROR_CODE_PATTERN.test(result.errorCode) + ? result.errorCode + : "provider-error", + }), + retry: "none", + }; + } + if (result.usage !== undefined && !validUsage(result.usage)) { + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "submission-unknown", + errorCode: "provider-contract-mismatch", + }), + retry: "none", + }; + } + return { + kind: "event", + event: createCreateImagesProviderAttemptEvent(prepared, { + kind: "provider-failed", + errorCode: SAFE_ERROR_CODE_PATTERN.test(result.errorCode) + ? result.errorCode + : "provider-error", + ...(result.usage ? { usage: validUsage(result.usage)! } : {}), + }), + retry: "new-consent-required", + }; + } finally { + options.gate.release(gateDecision.lease); + } +} diff --git a/main/services/create-images/mock-image-provider-core.test.ts b/main/services/create-images/mock-image-provider-core.test.ts new file mode 100644 index 00000000..6390860b --- /dev/null +++ b/main/services/create-images/mock-image-provider-core.test.ts @@ -0,0 +1,541 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { validateImageBytes } from "./asset-image-validation-core.js"; +import { + DeterministicMockImageProvider, + MOCK_IMAGE_MAX_OUTPUT_BYTES, + MockProviderEventCoordinator, + MockProviderCrashError, + reduceMockProviderEvent, + type MockImageOutputBatch, + type MockProviderEvent, +} from "./mock-image-provider-core.js"; +import type { + CoordinatorClock, + CoordinatorNodeExecutionContext, +} from "./scheduler-core.js"; + +class ImmediateClock implements CoordinatorClock { + readonly delays: number[] = []; + + now(): number { + return 1; + } + + async sleep(delayMs: number, signal: AbortSignal): Promise { + this.delays.push(delayMs); + if (signal.aborted) throw signal.reason ?? new Error("cancelled"); + } +} + +function context( + overrides: Partial = {}, +): CoordinatorNodeExecutionContext { + return { + workflowId: "workflow-1", + workflowRevision: 1, + runId: "run-1", + node: { + id: "generate-1", + type: "generate-image", + position: { x: 0, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + lane: "remote", + attempt: 1, + signal: new AbortController().signal, + dependencyOutputs: new Map(), + async recordRemoteJobId() {}, + ...overrides, + }; +} + +function successfulOutput( + result: Awaited>, +): MockImageOutputBatch { + assert.equal(result.kind, "success"); + if (result.kind !== "success") throw new Error("Expected mock success."); + return result.output as MockImageOutputBatch; +} + +test("mock success is deterministic, bounded, static-PNG-valid, and asset-ingest-compatible", async () => { + const clock = new ImmediateClock(); + const script = { + nodes: { + "generate-1": [ + { + outcome: "success" as const, + delayMs: 25, + width: 37, + height: 19, + seed: 0x1234_5678, + outputByteLimit: 64 * 1024, + }, + ], + }, + }; + const first = successfulOutput( + await new DeterministicMockImageProvider({ clock, script }).execute( + context(), + ), + ); + const second = successfulOutput( + await new DeterministicMockImageProvider({ clock, script }).execute( + context(), + ), + ); + assert.deepEqual(first, second); + assert.equal(first.images.length, 1); + const image = first.images[0]!; + assert.equal(image.metadata.byteLength, image.bytes.byteLength); + assert.equal(first.metadata.totalByteLength, image.bytes.byteLength); + assert.ok(image.bytes.byteLength > 64); + assert.ok(image.bytes.byteLength <= 64 * 1024); + assert.ok(image.bytes.byteLength <= MOCK_IMAGE_MAX_OUTPUT_BYTES); + assert.deepEqual( + validateImageBytes(image.bytes, "image/png", "mock.png", { + maxWidth: 1_024, + maxHeight: 1_024, + maxPixels: 1_048_576, + }), + { + mediaType: "image/png", + extension: "png", + width: 37, + height: 19, + pixels: 703, + }, + ); + assert.deepEqual(clock.delays, [25, 25]); + const serializedMetadata = JSON.stringify({ + batch: first.metadata, + image: image.metadata, + }); + assert.doesNotMatch(serializedMetadata, /(?:file:|https?:|path|url)/iu); +}); + +test("mock returns exactly the requested count as distinct valid PNGs under one aggregate bound", async () => { + const base = context(); + assert.equal(base.node.type, "generate-image"); + if (base.node.type !== "generate-image") return; + const batch = successfulOutput( + await new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + width: 24, + height: 24, + seed: 99, + outputByteLimit: 128 * 1024, + }, + ], + }, + }, + }).execute({ + ...base, + node: { ...base.node, data: { ...base.node.data, count: 4 } }, + }), + ); + assert.equal(batch.images.length, 4); + assert.equal(batch.metadata.count, 4); + assert.equal( + batch.metadata.totalByteLength, + batch.images.reduce((total, image) => total + image.bytes.byteLength, 0), + ); + assert.ok(batch.metadata.totalByteLength <= 128 * 1024); + const digests = batch.images.map((image) => { + assert.deepEqual( + validateImageBytes(image.bytes, "image/png", "mock.png", { + maxWidth: 1_024, + maxHeight: 1_024, + maxPixels: 1_048_576, + }), + { + mediaType: "image/png", + extension: "png", + width: 24, + height: 24, + pixels: 576, + }, + ); + return createHash("sha256").update(image.bytes).digest("hex"); + }); + assert.equal(new Set(digests).size, 4); +}); + +test("mock exposes deterministic failure, rate limit, ambiguity, and exact crash boundaries", async () => { + const clock = new ImmediateClock(); + const provider = new DeterministicMockImageProvider({ + clock, + script: { + nodes: { + "generate-1": [ + { outcome: "failure", error: "refused", retrySafety: "never" }, + { + outcome: "rate-limit", + error: "limited", + retrySafety: "same-idempotency-key", + retryAfterMs: 321, + idempotencyKey: "idempotency-key-1", + durableRemoteJob: true, + }, + { outcome: "ambiguous-submit", error: "unknown acceptance" }, + { outcome: "crash-before-send", error: "before send" }, + { outcome: "accepted-before-response" }, + { outcome: "crash-after-send" }, + ], + }, + }, + }); + const recordedAttempts: number[] = []; + const executionContext = (attempt: number) => + context({ + attempt, + recordRemoteJobId: async () => { + recordedAttempts.push(attempt); + }, + }); + assert.deepEqual(await provider.execute(executionContext(1)), { + kind: "failure", + error: "refused", + retrySafety: "never", + }); + assert.deepEqual(await provider.execute(executionContext(2)), { + kind: "rate-limited", + error: "limited", + retrySafety: "same-idempotency-key", + retryAfterMs: 321, + idempotencyKey: "idempotency-key-1", + }); + assert.deepEqual(await provider.execute(executionContext(3)), { + kind: "ambiguous-submit", + error: "unknown acceptance", + }); + assert.deepEqual(await provider.execute(executionContext(4)), { + kind: "failure", + error: "before send", + retrySafety: "confirmed-not-submitted", + }); + await assert.rejects( + provider.execute(executionContext(5)), + (error: unknown) => { + return ( + error instanceof MockProviderCrashError && + error.boundary === "accepted-before-response" + ); + }, + ); + await assert.rejects( + provider.execute(executionContext(6)), + (error: unknown) => { + return ( + error instanceof MockProviderCrashError && + error.boundary === "after-send" + ); + }, + ); + assert.deepEqual(recordedAttempts, [2, 6]); +}); + +test("mock event stream can deterministically duplicate and reorder provider notifications", async () => { + const events: MockProviderEvent[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + duplicateSubmittedEvent: true, + outOfOrderCompletionEvent: true, + }, + ], + }, + }, + onProviderEvent: (event) => events.push(event), + }); + await provider.execute(context()); + assert.deepEqual( + events.map((event) => [event.kind, event.sequence]), + [ + ["submitted", 1], + ["submitted", 1], + ["completed", 3], + ["progress", 2], + ], + ); + let cursor = { + runId: "run-1", + nodeId: "generate-1", + remoteJobId: events[0]!.remoteJobId, + attempt: 1, + lastSequence: 0, + terminal: false, + }; + const reasons: string[] = []; + for (const event of events) { + const reduced = reduceMockProviderEvent(cursor, event); + if (reduced.accepted) cursor = reduced.cursor; + else reasons.push(reduced.reason); + } + assert.deepEqual(reasons, ["duplicate-or-stale", "out-of-order"]); + assert.equal(cursor.lastSequence, 2); + assert.equal(cursor.terminal, false); +}); + +test("product event coordinator accepts only ordered provider terminal notifications", async () => { + const coordinator = new MockProviderEventCoordinator(); + const events: MockProviderEvent[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + duplicateSubmittedEvent: true, + outOfOrderCompletionEvent: true, + }, + ], + }, + }, + onProviderEvent: (event) => { + events.push(event); + coordinator.observe(event); + }, + }); + await provider.execute(context()); + const identity = { runId: "run-1", nodeId: "generate-1", attempt: 1 }; + assert.equal(coordinator.acceptedTerminalKind(identity), undefined); + assert.deepEqual(coordinator.rejectionReasons(identity), [ + "duplicate-or-stale", + "out-of-order", + ]); + coordinator.observe({ + ...events[events.length - 1]!, + kind: "completed", + sequence: 3, + }); + assert.equal(coordinator.acceptedTerminalKind(identity), "completed"); +}); + +test("accepted mock jobs reconcile deterministically without a second submission", async () => { + const submitted: MockProviderEvent[] = []; + const recorded: string[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { outcome: "crash-after-send", width: 8, height: 8, seed: 91 }, + ], + }, + }, + onProviderEvent: (event) => submitted.push(event), + }); + const executionContext = context({ + idempotencyKey: + "aiden-ci-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + recordRemoteJobId: async (remoteJobId) => { + recorded.push(remoteJobId); + }, + }); + await assert.rejects( + provider.execute(executionContext), + MockProviderCrashError, + ); + assert.equal(recorded.length, 1); + const reconciled = provider.reconcileAccepted({ + runId: executionContext.runId, + node: executionContext.node, + attempt: executionContext.attempt, + idempotencyKey: executionContext.idempotencyKey!, + remoteJobId: recorded[0]!, + }); + assert.equal(reconciled.kind, "success"); + assert.deepEqual( + submitted.map((event) => event.kind), + ["submitted"], + ); +}); + +test("late mock completion after cancellation is emitted but rejected by the event reducer", async () => { + const events: MockProviderEvent[] = []; + const controller = new AbortController(); + controller.abort(new Error("cancel now")); + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [{ outcome: "success", lateCompletionAfterCancel: true }], + }, + }, + onProviderEvent: (event) => events.push(event), + }); + const result = await provider.execute(context({ signal: controller.signal })); + assert.equal(result.kind, "cancelled"); + assert.deepEqual( + events.map((event) => [event.kind, event.sequence]), + [ + ["submitted", 1], + ["cancelled", 2], + ["completed", 3], + ], + ); + let cursor = { + runId: "run-1", + nodeId: "generate-1", + remoteJobId: events[0]!.remoteJobId, + attempt: 1, + lastSequence: 0, + terminal: false, + }; + const first = reduceMockProviderEvent(cursor, events[0]!); + assert.equal(first.accepted, true); + if (first.accepted) cursor = first.cursor; + const cancelled = reduceMockProviderEvent(cursor, events[1]!); + assert.equal(cancelled.accepted, true); + if (cancelled.accepted) cursor = cancelled.cursor; + const late = reduceMockProviderEvent(cursor, events[2]!); + assert.equal(late.accepted, false); + if (!late.accepted) assert.equal(late.reason, "late-after-terminal"); +}); + +test("mock validates script size, output dimensions, output byte ceilings, and identifiers", () => { + assert.throws( + () => + new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { "generate-1": [{ outcome: "success", width: 1_025 }] }, + }, + }), + /width/u, + ); + assert.throws( + () => + new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { nodes: { "bad/id": [{ outcome: "success" }] } }, + }), + /opaque node IDs/u, + ); + assert.throws( + () => + new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [{ outcome: "success", outputByteLimit: 63 }], + }, + }, + }), + /byte limit/u, + ); +}); + +test("max-length node IDs derive bounded journal-compatible provider and idempotency IDs", async () => { + const nodeId = `n${"x".repeat(127)}`; + const recordedJobIds: string[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + [nodeId]: [ + { + outcome: "rate-limit", + retrySafety: "same-idempotency-key", + durableRemoteJob: true, + }, + ], + }, + }, + }); + const base = context(); + const result = await provider.execute({ + ...base, + node: { ...base.node, id: nodeId }, + recordRemoteJobId: async (remoteJobId) => { + recordedJobIds.push(remoteJobId); + }, + }); + assert.equal(recordedJobIds.length, 1); + assert.match(recordedJobIds[0]!, /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u); + assert.ok(recordedJobIds[0]!.length <= 256); + assert.equal(result.kind, "rate-limited"); + if (result.kind !== "rate-limited") return; + assert.match( + result.idempotencyKey ?? "", + /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u, + ); + assert.ok((result.idempotencyKey?.length ?? 0) <= 192); +}); + +test("same idempotency key derives the same mock job across retry attempts", async () => { + const recorded: string[] = []; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { + nodes: { + "generate-1": [ + { + outcome: "failure", + retrySafety: "same-idempotency-key", + durableRemoteJob: true, + }, + { outcome: "success" }, + ], + }, + }, + }); + const stableKey = + "aiden-ci-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + for (const attempt of [1, 2]) { + await provider.execute( + context({ + attempt, + idempotencyKey: stableKey, + recordRemoteJobId: async (remoteJobId) => { + recorded.push(remoteJobId); + }, + }), + ); + } + assert.equal(recorded.length, 2); + assert.equal(recorded[0], recorded[1]); +}); + +test("ambiguous and confirmed-not-submitted outcomes never record a durable provider job", async () => { + for (const attemptScript of [ + { outcome: "ambiguous-submit" as const }, + { + outcome: "rate-limit" as const, + retrySafety: "confirmed-not-submitted" as const, + }, + ]) { + let durableJobs = 0; + const provider = new DeterministicMockImageProvider({ + clock: new ImmediateClock(), + script: { nodes: { "generate-1": [attemptScript] } }, + }); + await provider.execute( + context({ + recordRemoteJobId: async () => { + durableJobs += 1; + }, + }), + ); + assert.equal(durableJobs, 0); + } +}); diff --git a/main/services/create-images/mock-image-provider-core.ts b/main/services/create-images/mock-image-provider-core.ts new file mode 100644 index 00000000..fd6fd7dd --- /dev/null +++ b/main/services/create-images/mock-image-provider-core.ts @@ -0,0 +1,755 @@ +import { createHash } from "node:crypto"; +import { deflateSync } from "node:zlib"; +import type { + CoordinatorAttemptResult, + CoordinatorClock, + CoordinatorNodeExecutionContext, + CoordinatorRetrySafety, +} from "./scheduler-core.js"; + +export const MOCK_IMAGE_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; +export const MOCK_IMAGE_MAX_DIMENSION = 1_024; +export const MOCK_IMAGE_MAX_PIXELS = 1_048_576; +const MOCK_MAX_SCRIPTED_NODES = 500; +const MOCK_MAX_ATTEMPTS_PER_NODE = 6; +const MOCK_MAX_DELAY_MS = 5 * 60_000; +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u; +const PROVIDER_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; + +export interface MockImageOutput { + bytes: Uint8Array; + metadata: { + source: "deterministic-local-mock"; + seed: number; + mimeType: "image/png"; + width: number; + height: number; + byteLength: number; + }; +} + +export interface MockImageOutputBatch { + images: readonly MockImageOutput[]; + metadata: { + source: "deterministic-local-mock"; + count: 1 | 2 | 3 | 4; + totalByteLength: number; + }; +} + +export type MockProviderOutcome = + | "success" + | "failure" + | "rate-limit" + | "crash-before-send" + | "accepted-before-response" + | "crash-after-send" + /** @deprecated Use an explicit crash boundary. This aliases crash-after-send. */ + | "crash" + | "ambiguous-submit"; + +export interface MockProviderAttemptScript { + outcome: MockProviderOutcome; + delayMs?: number; + error?: string; + retrySafety?: CoordinatorRetrySafety; + retryAfterMs?: number; + idempotencyKey?: string; + remoteJobId?: string; + /** Whether the provider returned an accepted, durable job ID to Aiden. */ + durableRemoteJob?: boolean; + /** Strict ceiling for the generated PNG, not padding or claimed media length. */ + outputByteLimit?: number; + width?: number; + height?: number; + seed?: number; + duplicateSubmittedEvent?: boolean; + outOfOrderCompletionEvent?: boolean; + lateCompletionAfterCancel?: boolean; +} + +export interface MockImageProviderScript { + nodes: Readonly>; +} + +export type MockProviderEventKind = + "submitted" | "progress" | "completed" | "failed" | "cancelled"; + +export interface MockProviderEvent { + runId: string; + nodeId: string; + remoteJobId: string; + attempt: number; + sequence: number; + kind: MockProviderEventKind; + output?: MockImageOutputBatch; + error?: string; +} + +export interface MockProviderEventCursor { + runId: string; + nodeId: string; + remoteJobId: string; + attempt: number; + lastSequence: number; + terminal: boolean; +} + +export type MockProviderEventRejectionReason = + "wrong-job" | "duplicate-or-stale" | "out-of-order" | "late-after-terminal"; + +export type MockProviderEventReduction = + | { accepted: true; cursor: MockProviderEventCursor } + | { + accepted: false; + cursor: MockProviderEventCursor; + reason: MockProviderEventRejectionReason; + }; + +export function reduceMockProviderEvent( + cursor: MockProviderEventCursor, + event: MockProviderEvent, +): MockProviderEventReduction { + if ( + cursor.runId !== event.runId || + cursor.nodeId !== event.nodeId || + cursor.remoteJobId !== event.remoteJobId || + cursor.attempt !== event.attempt + ) { + return { accepted: false, cursor, reason: "wrong-job" }; + } + if (event.sequence <= cursor.lastSequence) { + return { accepted: false, cursor, reason: "duplicate-or-stale" }; + } + if (event.sequence !== cursor.lastSequence + 1) { + return { accepted: false, cursor, reason: "out-of-order" }; + } + if (cursor.terminal) + return { accepted: false, cursor, reason: "late-after-terminal" }; + return { + accepted: true, + cursor: Object.freeze({ + ...cursor, + lastSequence: event.sequence, + terminal: ["completed", "failed", "cancelled"].includes(event.kind), + }), + }; +} + +export interface MockProviderEventAttemptIdentity { + runId: string; + nodeId: string; + attempt: number; +} + +interface MockProviderEventState { + cursor: MockProviderEventCursor; + terminalKind?: Extract< + MockProviderEventKind, + "completed" | "failed" | "cancelled" + >; + rejectionReasons: MockProviderEventRejectionReason[]; +} + +function eventAttemptKey(identity: MockProviderEventAttemptIdentity): string { + return `${identity.runId}\0${identity.nodeId}\0${identity.attempt}`; +} + +/** Product-facing reducer for mock provider notifications; provider callback order is never trusted. */ +export class MockProviderEventCoordinator { + readonly #states = new Map(); + + observe(event: MockProviderEvent): MockProviderEventReduction { + const key = eventAttemptKey(event); + let state = this.#states.get(key); + if (!state) { + state = { + cursor: { + runId: event.runId, + nodeId: event.nodeId, + remoteJobId: event.remoteJobId, + attempt: event.attempt, + lastSequence: 0, + terminal: false, + }, + rejectionReasons: [], + }; + this.#states.set(key, state); + } + const reduction = reduceMockProviderEvent(state.cursor, event); + if (reduction.accepted) { + state.cursor = reduction.cursor; + if (["completed", "failed", "cancelled"].includes(event.kind)) { + state.terminalKind = + event.kind as MockProviderEventState["terminalKind"]; + } + } else { + state.rejectionReasons.push(reduction.reason); + } + return reduction; + } + + acceptedTerminalKind( + identity: MockProviderEventAttemptIdentity, + ): MockProviderEventState["terminalKind"] { + return this.#states.get(eventAttemptKey(identity))?.terminalKind; + } + + rejectionReasons( + identity: MockProviderEventAttemptIdentity, + ): readonly string[] { + return [ + ...(this.#states.get(eventAttemptKey(identity))?.rejectionReasons ?? []), + ]; + } +} + +export interface DeterministicMockImageProviderOptions { + clock: CoordinatorClock; + script: MockImageProviderScript; + onProviderEvent?(event: MockProviderEvent): void; +} + +export class MockProviderCrashError extends Error { + readonly code = "MOCK_PROVIDER_CRASH"; + + constructor( + readonly nodeId: string, + readonly attempt: number, + readonly boundary: "accepted-before-response" | "after-send", + ) { + super( + `The deterministic mock crashed at ${nodeId} attempt ${attempt} (${boundary}).`, + ); + this.name = "MockProviderCrashError"; + } +} + +function assertInteger( + value: number, + minimum: number, + maximum: number, + label: string, +): void { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error( + `${label} must be an integer between ${minimum} and ${maximum}.`, + ); + } +} + +function validateAttempt( + nodeId: string, + attempt: MockProviderAttemptScript, +): void { + const delayMs = attempt.delayMs ?? 0; + assertInteger(delayMs, 0, MOCK_MAX_DELAY_MS, "Mock delay"); + if ( + attempt.error !== undefined && + (attempt.error.length === 0 || attempt.error.length > 1_000) + ) { + throw new Error("Mock errors must contain between 1 and 1000 characters."); + } + if (attempt.retryAfterMs !== undefined) { + assertInteger( + attempt.retryAfterMs, + 0, + MOCK_MAX_DELAY_MS, + "Mock retry-after delay", + ); + } + if ( + attempt.remoteJobId !== undefined && + !PROVIDER_JOB_ID_PATTERN.test(attempt.remoteJobId) + ) { + throw new Error( + "Mock remote job ID must be a bounded provider identifier.", + ); + } + if ( + attempt.idempotencyKey !== undefined && + !IDEMPOTENCY_KEY_PATTERN.test(attempt.idempotencyKey) + ) { + throw new Error( + "Mock idempotency key must match the run journal contract.", + ); + } + const durableRemoteJob = + attempt.durableRemoteJob ?? + (attempt.remoteJobId !== undefined || + attempt.outcome === "success" || + attempt.outcome === "crash-after-send" || + attempt.outcome === "crash"); + if (attempt.remoteJobId !== undefined && durableRemoteJob !== true) { + throw new Error("A scripted remote job ID must be marked durable."); + } + if ( + durableRemoteJob && + (attempt.outcome === "ambiguous-submit" || + attempt.outcome === "crash-before-send" || + attempt.outcome === "accepted-before-response" || + attempt.retrySafety === "confirmed-not-submitted") + ) { + throw new Error( + "Ambiguous or confirmed-not-submitted outcomes cannot have a durable remote job.", + ); + } + if ( + (attempt.outcome === "crash-after-send" || attempt.outcome === "crash") && + !durableRemoteJob + ) { + throw new Error( + "A crash-after-send outcome requires a durable remote job.", + ); + } + const byteLimit = attempt.outputByteLimit ?? MOCK_IMAGE_MAX_OUTPUT_BYTES; + assertInteger( + byteLimit, + 64, + MOCK_IMAGE_MAX_OUTPUT_BYTES, + "Mock output byte limit", + ); + const width = attempt.width ?? 16; + const height = attempt.height ?? 16; + assertInteger(width, 1, MOCK_IMAGE_MAX_DIMENSION, "Mock output width"); + assertInteger(height, 1, MOCK_IMAGE_MAX_DIMENSION, "Mock output height"); + if (width * height > MOCK_IMAGE_MAX_PIXELS) + throw new Error("Mock output exceeds the pixel limit."); + const seed = attempt.seed ?? 1; + assertInteger(seed, 0, 0xffff_ffff, "Mock seed"); + if ( + (attempt.outcome === "rate-limit" || attempt.outcome === "failure") && + attempt.retrySafety === "local-safe" + ) { + throw new Error( + `Remote mock node "${nodeId}" cannot use local-safe retry classification.`, + ); + } +} + +function validateScript( + script: MockImageProviderScript, +): MockImageProviderScript { + const entries = Object.entries(script.nodes); + if (entries.length > MOCK_MAX_SCRIPTED_NODES) + throw new Error("The mock provider script has too many nodes."); + const copy: Record = + Object.create(null); + for (const [nodeId, attempts] of entries) { + if (!OPAQUE_ID_PATTERN.test(nodeId)) + throw new Error("Mock scripts require opaque node IDs."); + if ( + !Array.isArray(attempts) || + attempts.length === 0 || + attempts.length > MOCK_MAX_ATTEMPTS_PER_NODE + ) { + throw new Error( + `Mock node "${nodeId}" requires 1 through ${MOCK_MAX_ATTEMPTS_PER_NODE} attempts.`, + ); + } + copy[nodeId] = Object.freeze( + attempts.map((attempt) => { + validateAttempt(nodeId, attempt); + return Object.freeze({ ...attempt }); + }), + ); + } + return Object.freeze({ nodes: Object.freeze(copy) }); +} + +const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]); +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => { + let crc = value; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + return crc >>> 0; +}); + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concatenate(parts: readonly Uint8Array[]): Uint8Array { + const bytes = new Uint8Array( + parts.reduce((total, part) => total + part.byteLength, 0), + ); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + return bytes; +} + +function pngChunk( + type: "IHDR" | "IDAT" | "IEND", + data: Uint8Array, +): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const crcInput = concatenate([typeBytes, data]); + let crc = 0xffff_ffff; + for (const byte of crcInput) + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 0xff]!; + return concatenate([ + u32(data.byteLength), + typeBytes, + data, + u32((crc ^ 0xffff_ffff) >>> 0), + ]); +} + +function deterministicPng( + width: number, + height: number, + seed: number, +): Uint8Array { + const rowBytes = width * 4 + 1; + const raw = new Uint8Array(rowBytes * height); + const red = seed & 0xff; + const green = (seed >>> 8) & 0xff; + const blue = (seed >>> 16) & 0xff; + for (let y = 0; y < height; y += 1) { + const row = y * rowBytes; + raw[row] = 0; + for (let x = 0; x < width; x += 1) { + const pixel = row + 1 + x * 4; + raw[pixel] = (red + x) & 0xff; + raw[pixel + 1] = (green + y) & 0xff; + raw[pixel + 2] = (blue + x + y) & 0xff; + raw[pixel + 3] = 0xff; + } + } + const header = concatenate([ + u32(width), + u32(height), + Uint8Array.from([8, 6, 0, 0, 0]), + ]); + const compressed = new Uint8Array(deflateSync(raw, { level: 9 })); + return concatenate([ + PNG_SIGNATURE, + pngChunk("IHDR", header), + pngChunk("IDAT", compressed), + pngChunk("IEND", new Uint8Array()), + ]); +} + +function outputFrom( + attempt: MockProviderAttemptScript, + seed: number, +): MockImageOutput { + const width = attempt.width ?? 16; + const height = attempt.height ?? 16; + const bytes = deterministicPng(width, height, seed); + return Object.freeze({ + bytes, + metadata: Object.freeze({ + source: "deterministic-local-mock" as const, + seed, + mimeType: "image/png" as const, + width, + height, + byteLength: bytes.byteLength, + }), + }); +} + +function outputBatchFrom( + attempt: MockProviderAttemptScript, + count: 1 | 2 | 3 | 4, +): MockImageOutputBatch { + const seed = attempt.seed ?? 1; + const images = Object.freeze( + Array.from({ length: count }, (_, index) => + outputFrom(attempt, (seed + index) >>> 0), + ), + ); + const totalByteLength = images.reduce( + (total, image) => total + image.bytes.byteLength, + 0, + ); + const byteLimit = attempt.outputByteLimit ?? MOCK_IMAGE_MAX_OUTPUT_BYTES; + if ( + totalByteLength > byteLimit || + totalByteLength > MOCK_IMAGE_MAX_OUTPUT_BYTES + ) { + throw new Error( + "The deterministic PNG batch exceeds the configured mock output byte limit.", + ); + } + return Object.freeze({ + images, + metadata: Object.freeze({ + source: "deterministic-local-mock" as const, + count, + totalByteLength, + }), + }); +} + +function derivedIdentifier( + prefix: "mock-job" | "mock-idempotency", + context: Pick< + CoordinatorNodeExecutionContext, + "runId" | "attempt" | "idempotencyKey" + > & { + node: Pick; + }, +): string { + const digest = createHash("sha256") + .update(context.runId) + .update("\0") + .update(context.node.id) + .update("\0") + .update(context.idempotencyKey ?? String(context.attempt)) + .digest("hex"); + return `${prefix}-${digest}`; +} + +export interface MockAcceptedJobReconciliationContext { + runId: string; + node: CoordinatorNodeExecutionContext["node"]; + attempt: number; + idempotencyKey: string; + remoteJobId: string; +} + +export class DeterministicMockImageProvider { + readonly providerId = "local-mock"; + readonly #clock: CoordinatorClock; + readonly #script: MockImageProviderScript; + readonly #onProviderEvent?: (event: MockProviderEvent) => void; + + constructor(options: DeterministicMockImageProviderOptions) { + this.#clock = options.clock; + this.#script = validateScript(options.script); + this.#onProviderEvent = options.onProviderEvent; + } + + reconcileAccepted( + context: MockAcceptedJobReconciliationContext, + ): CoordinatorAttemptResult { + if (context.node.type !== "generate-image") { + return { + kind: "failure", + error: "Only Generate Image mock jobs can be reconciled.", + retrySafety: "never", + }; + } + const script = this.#script.nodes[context.node.id]?.[context.attempt - 1]; + if (!script) { + return { + kind: "ambiguous-submit", + error: + "The accepted mock job has no deterministic reconciliation outcome.", + }; + } + const expectedJobId = + script.remoteJobId ?? derivedIdentifier("mock-job", context); + if (context.remoteJobId !== expectedJobId) { + return { + kind: "ambiguous-submit", + error: + "The durable mock job ID does not match its deterministic reconciliation record.", + }; + } + const durableRemoteJob = + script.durableRemoteJob ?? + (script.remoteJobId !== undefined || + script.outcome === "success" || + script.outcome === "crash-after-send" || + script.outcome === "crash"); + if (!durableRemoteJob) { + return { + kind: "ambiguous-submit", + error: "The mock outcome does not prove a durable accepted job.", + }; + } + if ( + script.outcome === "success" || + script.outcome === "crash-after-send" || + script.outcome === "crash" + ) { + return { + kind: "success", + output: outputBatchFrom(script, context.node.data.count), + }; + } + if (script.outcome === "rate-limit") { + return { + kind: "rate-limited", + error: script.error ?? "Mock rate limit.", + retrySafety: "never", + }; + } + return { + kind: "failure", + error: script.error ?? "The accepted mock job failed.", + retrySafety: "never", + }; + } + + async execute( + context: CoordinatorNodeExecutionContext, + ): Promise { + if (context.lane !== "remote" || context.node.type !== "generate-image") { + return { + kind: "failure", + error: + "The local image mock only executes remote Generate Image nodes.", + retrySafety: "never", + }; + } + const attempts = this.#script.nodes[context.node.id]; + const script = attempts?.[context.attempt - 1]; + if (!script) { + return { + kind: "failure", + error: `No deterministic mock outcome exists for attempt ${context.attempt}.`, + retrySafety: "never", + }; + } + const remoteJobId = + script.remoteJobId ?? derivedIdentifier("mock-job", context); + const durableRemoteJob = + script.durableRemoteJob ?? + (script.remoteJobId !== undefined || + script.outcome === "success" || + script.outcome === "crash-after-send" || + script.outcome === "crash"); + if (script.outcome === "crash-before-send") { + return { + kind: "failure", + error: script.error ?? "Mock crashed before submission.", + retrySafety: "confirmed-not-submitted", + }; + } + if (durableRemoteJob) await context.recordRemoteJobId(remoteJobId); + let providerSequence = 1; + const emit = ( + event: Omit< + MockProviderEvent, + "runId" | "nodeId" | "remoteJobId" | "attempt" + >, + ): void => { + this.#onProviderEvent?.({ + runId: context.runId, + nodeId: context.node.id, + remoteJobId, + attempt: context.attempt, + ...event, + }); + }; + emit({ kind: "submitted", sequence: providerSequence }); + if (script.duplicateSubmittedEvent) + emit({ kind: "submitted", sequence: providerSequence }); + + if (script.outcome === "accepted-before-response") { + throw new MockProviderCrashError( + context.node.id, + context.attempt, + "accepted-before-response", + ); + } + + try { + await this.#clock.sleep(script.delayMs ?? 0, context.signal); + } catch (error) { + if (!context.signal.aborted) throw error; + providerSequence += 1; + emit({ + kind: "cancelled", + sequence: providerSequence, + error: "Mock execution was cancelled.", + }); + if (script.lateCompletionAfterCancel) { + providerSequence += 1; + emit({ + kind: "completed", + sequence: providerSequence, + output: outputBatchFrom(script, context.node.data.count), + }); + } + return { kind: "cancelled", error: "Mock execution was cancelled." }; + } + + if (script.outcome === "crash" || script.outcome === "crash-after-send") { + throw new MockProviderCrashError( + context.node.id, + context.attempt, + "after-send", + ); + } + if (script.outcome === "ambiguous-submit") { + return { + kind: "ambiguous-submit", + error: script.error ?? "Mock submission outcome is ambiguous.", + }; + } + if (script.outcome === "rate-limit") { + providerSequence += 1; + emit({ + kind: "failed", + sequence: providerSequence, + error: script.error ?? "Mock rate limit.", + }); + return { + kind: "rate-limited", + error: script.error ?? "Mock rate limit.", + retrySafety: script.retrySafety ?? "confirmed-not-submitted", + ...(script.retryAfterMs === undefined + ? {} + : { retryAfterMs: script.retryAfterMs }), + ...(script.retrySafety === "same-idempotency-key" + ? { + idempotencyKey: + script.idempotencyKey ?? + context.idempotencyKey ?? + derivedIdentifier("mock-idempotency", context), + } + : script.idempotencyKey === undefined + ? {} + : { idempotencyKey: script.idempotencyKey }), + }; + } + if (script.outcome === "failure") { + providerSequence += 1; + emit({ + kind: "failed", + sequence: providerSequence, + error: script.error ?? "Mock provider failure.", + }); + return { + kind: "failure", + error: script.error ?? "Mock provider failure.", + retrySafety: script.retrySafety ?? "never", + ...(script.retrySafety === "same-idempotency-key" + ? { + idempotencyKey: + script.idempotencyKey ?? + context.idempotencyKey ?? + derivedIdentifier("mock-idempotency", context), + } + : script.idempotencyKey === undefined + ? {} + : { idempotencyKey: script.idempotencyKey }), + }; + } + const output = outputBatchFrom(script, context.node.data.count); + if (script.outOfOrderCompletionEvent) { + emit({ kind: "completed", sequence: providerSequence + 2, output }); + providerSequence += 1; + emit({ kind: "progress", sequence: providerSequence }); + } else { + providerSequence += 1; + emit({ kind: "completed", sequence: providerSequence, output }); + } + return { kind: "success", output }; + } +} diff --git a/main/services/create-images/mutation-rate-limit-core.test.ts b/main/services/create-images/mutation-rate-limit-core.test.ts new file mode 100644 index 00000000..0b18d23c --- /dev/null +++ b/main/services/create-images/mutation-rate-limit-core.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { CreateImagesMutationRateLimiter } from "./mutation-rate-limit-core.js"; + +test("bounds renderer document mutations and recovers after the window", () => { + let now = 1_000; + const limiter = new CreateImagesMutationRateLimiter(() => now, 3, 1_000); + assert.equal(limiter.consume("owner:document"), true); + assert.equal(limiter.consume("owner:document"), true); + assert.equal(limiter.consume("owner:document"), true); + assert.equal(limiter.consume("owner:document"), false); + assert.equal(limiter.consume("other:document"), true); + + now += 1_001; + assert.equal(limiter.consume("owner:document"), true); +}); + +test("fails closed for malformed owner keys and invalid bounds", () => { + const limiter = new CreateImagesMutationRateLimiter(); + assert.equal(limiter.consume(""), false); + assert.equal(limiter.consume("x".repeat(769)), false); + assert.throws( + () => new CreateImagesMutationRateLimiter(Date.now, 0), + /capacity/u, + ); + assert.throws( + () => new CreateImagesMutationRateLimiter(Date.now, 1, 999), + /window/u, + ); + assert.throws( + () => new CreateImagesMutationRateLimiter(Date.now, 1, 1_000, 0), + /owner capacity/u, + ); +}); + +test("charges weighted operations and never grows past the owner bound", () => { + let now = 1_000; + const limiter = new CreateImagesMutationRateLimiter(() => now, 10, 1_000, 2); + assert.equal(limiter.consume("webcontents:1", 8), true); + assert.equal(limiter.consume("webcontents:1", 3), false); + assert.equal(limiter.retryAfterMs("webcontents:1"), 0); + assert.equal(limiter.consume("webcontents:1", 2), true); + assert.equal(limiter.retryAfterMs("webcontents:1"), 1_000); + assert.equal(limiter.consume("webcontents:2"), true); + assert.equal(limiter.consume("webcontents:3"), false); + assert.equal(limiter.ownerCountForTests(), 2); + + now += 1_001; + assert.equal(limiter.consume("webcontents:3"), true); + assert.equal(limiter.ownerCountForTests(), 1); +}); diff --git a/main/services/create-images/mutation-rate-limit-core.ts b/main/services/create-images/mutation-rate-limit-core.ts new file mode 100644 index 00000000..0e379e99 --- /dev/null +++ b/main/services/create-images/mutation-rate-limit-core.ts @@ -0,0 +1,80 @@ +export class CreateImagesMutationRateLimiter { + private readonly events = new Map(); + + constructor( + private readonly now: () => number = Date.now, + private readonly maxMutations = 120, + private readonly windowMs = 60_000, + private readonly maxOwners = 64, + ) { + if ( + !Number.isSafeInteger(maxMutations) || + maxMutations < 1 || + maxMutations > 10_000 + ) { + throw new Error("Create Images mutation capacity is invalid."); + } + if ( + !Number.isSafeInteger(windowMs) || + windowMs < 1_000 || + windowMs > 60 * 60_000 + ) { + throw new Error("Create Images mutation window is invalid."); + } + if ( + !Number.isSafeInteger(maxOwners) || + maxOwners < 1 || + maxOwners > 1_024 + ) { + throw new Error("Create Images owner capacity is invalid."); + } + } + + private pruneExpired(now: number): void { + const cutoff = now - this.windowMs; + for (const [key, timestamps] of this.events) { + const active = timestamps.filter((timestamp) => timestamp > cutoff); + if (active.length === 0) this.events.delete(key); + else this.events.set(key, active); + } + } + + consume(ownerKey: string, cost = 1): boolean { + if (!ownerKey || ownerKey.length > 768) return false; + if (!Number.isSafeInteger(cost) || cost < 1 || cost > this.maxMutations) + return false; + const now = this.now(); + const cutoff = now - this.windowMs; + if (!this.events.has(ownerKey) && this.events.size >= this.maxOwners) { + this.pruneExpired(now); + if (this.events.size >= this.maxOwners) return false; + } + const recent = (this.events.get(ownerKey) ?? []).filter( + (timestamp) => timestamp > cutoff, + ); + if (recent.length + cost > this.maxMutations) { + this.events.set(ownerKey, recent); + return false; + } + for (let index = 0; index < cost; index += 1) recent.push(now); + this.events.set(ownerKey, recent); + + if (this.events.size > Math.min(32, this.maxOwners)) this.pruneExpired(now); + return true; + } + + retryAfterMs(ownerKey: string): number { + if (!ownerKey || ownerKey.length > 768) return this.windowMs; + const now = this.now(); + const cutoff = now - this.windowMs; + const recent = (this.events.get(ownerKey) ?? []).filter( + (timestamp) => timestamp > cutoff, + ); + if (recent.length < this.maxMutations) return 0; + return Math.max(1, recent[0]! + this.windowMs - now); + } + + ownerCountForTests(): number { + return this.events.size; + } +} diff --git a/main/services/create-images/provider-contract.ts b/main/services/create-images/provider-contract.ts new file mode 100644 index 00000000..88025eeb --- /dev/null +++ b/main/services/create-images/provider-contract.ts @@ -0,0 +1,57 @@ +import type { + CreateImagesAspectRatio, + CreateImagesImageSize, + CreateImagesOutputMime, +} from "../../../renderer/shared/create-images/schema.js"; + +export interface ImageProviderModelCapabilities { + id: string; + label: string; + providerId: string; + aspectRatios: readonly CreateImagesAspectRatio[]; + imageSizes: readonly CreateImagesImageSize[]; + outputMimes: readonly CreateImagesOutputMime[]; + maxReferenceImages: number; + maxOutputs: number; + supportsEditing: boolean; + supportsCancellation: boolean; +} + +export interface ImageGenerationReference { + assetId: string; + bytes: Uint8Array; + mimeType: "image/png" | "image/jpeg" | "image/webp"; +} + +export interface ValidatedImageGenerationRequest { + providerId: string; + modelId: string; + prompt: string; + aspectRatio: CreateImagesAspectRatio; + imageSize: CreateImagesImageSize; + outputMime: CreateImagesOutputMime; + count: number; + references: readonly ImageGenerationReference[]; +} + +export interface ImageProviderJob { + providerId: string; + kind: "synchronous" | "asynchronous"; + remoteId?: string; +} + +export interface ImageProviderAdapter { + readonly providerId: string; + listModels(): readonly ImageProviderModelCapabilities[]; + validate(request: ValidatedImageGenerationRequest): ValidatedImageGenerationRequest; + submit( + credential: TCredential, + request: ValidatedImageGenerationRequest, + context: { runId: string; nodeId: string; signal: AbortSignal }, + ): Promise; + cancel?( + credential: TCredential, + job: ImageProviderJob, + context: { signal: AbortSignal }, + ): Promise; +} diff --git a/main/services/create-images/providers/gemini-image-provider-core.test.ts b/main/services/create-images/providers/gemini-image-provider-core.test.ts new file mode 100644 index 00000000..72874946 --- /dev/null +++ b/main/services/create-images/providers/gemini-image-provider-core.test.ts @@ -0,0 +1,537 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { AuthResult } from "@earendil-works/pi-ai"; +import type { ValidatedImageGenerationRequest } from "../provider-contract.js"; +import { + GEMINI_IMAGE_MAX_RESPONSE_BYTES, + GEMINI_IMAGE_MAX_RETRY_AFTER_MS, + GeminiImageProvider, +} from "./gemini-image-provider-core.js"; +import { GEMINI_INTERACTIONS_ENDPOINT } from "./gemini-interactions-core.js"; + +const SECRET_KEY = "AIzaSy_TEST_GEMINI_KEY_NEVER_LEAK"; +const PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + +function auth(overrides: Partial = {}): AuthResult { + return { + auth: { + apiKey: SECRET_KEY, + ...overrides, + }, + source: "configured API key", + }; +} + +function request( + overrides: Partial = {}, +): ValidatedImageGenerationRequest { + return { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + prompt: "Draw a quiet harbor at dawn.", + aspectRatio: "16:9", + imageSize: "2K", + outputMime: "image/png", + count: 1, + references: [], + ...overrides, + }; +} + +function context(signal = new AbortController().signal) { + return { runId: "run-1", nodeId: "generate-1", signal }; +} + +function interaction(overrides: Record = {}): Record { + return { + id: "interactions/interaction-1", + status: "completed", + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", data: PNG_BASE64 }], + }, + ], + usage: { + total_input_tokens: 10, + total_output_tokens: 20, + total_thought_tokens: 5, + total_tokens: 35, + }, + ...overrides, + }; +} + +function jsonResponse(value: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(value), { + status: init.status ?? 200, + statusText: init.statusText, + headers: { "content-type": "application/json; charset=utf-8", ...init.headers }, + }); +} + +function injectedFetch( + implementation: (input: string | URL | Request, init?: RequestInit) => Promise, +): typeof globalThis.fetch { + return implementation as typeof globalThis.fetch; +} + +test("uses only the fixed endpoint and main-owned API-key header, with bounded inline stateless input", async () => { + let capturedUrl: string | URL | Request | undefined; + let capturedInit: RequestInit | undefined; + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async (url, init) => { + capturedUrl = url; + capturedInit = init; + return jsonResponse(interaction()); + }), + }); + const result = await provider.execute( + auth({ + baseUrl: "http://127.0.0.1:9999/private", + headers: { Authorization: "Bearer inherited-secret" }, + }), + request({ + references: [ + { + assetId: "reference-1", + mimeType: "image/png", + bytes: Uint8Array.from([1, 2, 3]), + }, + ], + }), + context(), + ); + + assert.equal(result.kind, "success"); + assert.equal(capturedUrl, GEMINI_INTERACTIONS_ENDPOINT); + assert.equal(capturedInit?.method, "POST"); + assert.equal(capturedInit?.redirect, "error"); + assert.equal(capturedInit?.headers?.["x-goog-api-key" as never], SECRET_KEY); + assert.deepEqual(capturedInit?.headers, { + "content-type": "application/json", + "x-goog-api-key": SECRET_KEY, + }); + const serialized = String(capturedInit?.body); + assert.doesNotMatch(serialized, /(?:127\.0\.0\.1|Authorization|inherited-secret|api.?key)/iu); + assert.deepEqual(JSON.parse(serialized).response_format, { + type: "image", + mime_type: "image/png", + aspect_ratio: "16:9", + image_size: "2K", + delivery: "inline", + }); + assert.equal(JSON.parse(serialized).store, false); + assert.equal(JSON.parse(serialized).background, false); +}); + +test("returns validated path-free image and aggregate usage metadata", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => jsonResponse(interaction())), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "success"); + if (result.kind !== "success") return; + assert.equal(result.output.images.length, 1); + assert.equal(Buffer.from(result.output.images[0].bytes).toString("base64"), PNG_BASE64); + assert.deepEqual(result.output.images[0].metadata, { + source: "gemini-interactions", + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + mimeType: "image/png", + width: 1, + height: 1, + byteLength: 68, + outputIndex: 0, + }); + assert.deepEqual(result.output.metadata, { + source: "gemini-interactions", + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + count: 1, + totalByteLength: 68, + interactionId: "interactions/interaction-1", + usage: { + totalInputTokens: 10, + totalOutputTokens: 20, + totalThoughtTokens: 5, + totalTokens: 35, + }, + }); + assert.doesNotMatch( + JSON.stringify(result), + /(?:AIza|quiet harbor|file:|https?:\/\/|absolute|path)/iu, + ); +}); + +test("never follows redirects or accepts an unexpected/private response URL", async () => { + const calls: string[] = []; + const redirectProvider = new GeminiImageProvider({ + fetch: injectedFetch(async (url) => { + calls.push(String(url)); + return jsonResponse( + {}, + { + status: 302, + headers: { location: "http://127.0.0.1/internal" }, + }, + ); + }), + }); + const redirect = await redirectProvider.execute(auth(), request(), context()); + assert.equal(redirect.kind, "failure"); + assert.equal(redirect.providerErrorCode, "redirect-rejected"); + assert.deepEqual(calls, [GEMINI_INTERACTIONS_ENDPOINT]); + + const unexpectedResponse = jsonResponse(interaction()); + Object.defineProperty(unexpectedResponse, "url", { value: "http://[::1]/internal" }); + const originProvider = new GeminiImageProvider({ + fetch: injectedFetch(async () => unexpectedResponse), + }); + const origin = await originProvider.execute(auth(), request(), context()); + assert.equal(origin.kind, "failure"); + assert.equal(origin.providerErrorCode, "redirect-rejected"); +}); + +test("redacts credentials and raw network errors while preserving post-send ambiguity", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => { + throw new Error(`socket failed for ${SECRET_KEY}: raw provider response`); + }), + }); + const result = await provider.execute(auth(), request(), context()); + assert.deepEqual(result, { + kind: "ambiguous-submit", + providerErrorCode: "offline", + error: "A network error left the Gemini request's submission state unknown.", + }); + assert.doesNotMatch(JSON.stringify(result), new RegExp(SECRET_KEY, "u")); + assert.doesNotMatch(JSON.stringify(result), /raw provider response/u); +}); + +test("rejects missing credentials and invalid requests before calling fetch", async () => { + let calls = 0; + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => { + calls += 1; + return jsonResponse(interaction()); + }), + }); + assert.deepEqual(await provider.execute(undefined, request(), context()), { + kind: "failure", + providerErrorCode: "authentication-required", + error: "Connect a Google Gemini API key before creating remote images.", + retrySafety: "confirmed-not-submitted", + }); + const invalid = await provider.execute( + auth(), + request({ modelId: "http://127.0.0.1/private-model" }), + context(), + ); + assert.equal(invalid.kind, "failure"); + assert.equal(invalid.providerErrorCode, "invalid-request"); + assert.equal(invalid.retrySafety, "confirmed-not-submitted"); + assert.equal(calls, 0); +}); + +test("normalizes auth, permission, rate limit, provider, and request status without body leakage", async () => { + const cases = [ + [401, "authentication-required"], + [403, "permission-denied"], + [500, "provider-unavailable"], + [400, "request-rejected"], + ] as const; + for (const [status, code] of cases) { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse({ error: { message: `secret ${SECRET_KEY}` } }, { status }), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "failure"); + assert.equal(result.providerErrorCode, code); + assert.equal(result.retrySafety, "never"); + assert.doesNotMatch(JSON.stringify(result), new RegExp(SECRET_KEY, "u")); + } + + const limited = new GeminiImageProvider({ + now: () => 1_000, + fetch: injectedFetch(async () => + jsonResponse({}, { status: 429, headers: { "retry-after": "999999" } }), + ), + }); + assert.deepEqual(await limited.execute(auth(), request(), context()), { + kind: "rate-limited", + providerErrorCode: "rate-limited", + error: "Gemini is rate limiting image requests.", + retrySafety: "never", + retryAfterMs: GEMINI_IMAGE_MAX_RETRY_AFTER_MS, + }); +}); + +test("requires an exact JSON response type and bounded complete body", async () => { + const wrongType = new GeminiImageProvider({ + fetch: injectedFetch( + async () => + new Response("not json", { status: 200, headers: { "content-type": "text/plain" } }), + ), + }); + const wrongTypeResult = await wrongType.execute(auth(), request(), context()); + assert.equal(wrongTypeResult.kind, "failure"); + assert.equal(wrongTypeResult.providerErrorCode, "response-malformed"); + + const huge = new GeminiImageProvider({ + maxResponseBytes: 1_024, + fetch: injectedFetch( + async () => + new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "content-length": String(GEMINI_IMAGE_MAX_RESPONSE_BYTES), + }, + }), + ), + }); + const hugeResult = await huge.execute(auth(), request(), context()); + assert.equal(hugeResult.kind, "failure"); + assert.equal(hugeResult.providerErrorCode, "response-too-large"); + + const truncated = new GeminiImageProvider({ + fetch: injectedFetch( + async () => + new Response('{"status":"completed"', { + status: 200, + headers: { "content-type": "application/json", "content-length": "999" }, + }), + ), + }); + const truncatedResult = await truncated.execute(auth(), request(), context()); + assert.equal(truncatedResult.kind, "failure"); + assert.equal(truncatedResult.providerErrorCode, "response-malformed"); + assert.equal(truncatedResult.retrySafety, "never"); +}); + +test("rejects malformed, truncated, non-canonical, and oversized base64", async () => { + const values = ["%%%%", PNG_BASE64.slice(0, -1), "AA=A"]; + for (const data of values) { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse( + interaction({ + steps: [ + { type: "model_output", content: [{ type: "image", mime_type: "image/png", data }] }, + ], + }), + ), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "failure"); + assert.equal(result.providerErrorCode, "response-malformed"); + } + + const oversized = new GeminiImageProvider({ + maxOutputBytes: 32, + fetch: injectedFetch(async () => jsonResponse(interaction())), + }); + const oversizedResult = await oversized.execute(auth(), request(), context()); + assert.equal(oversizedResult.kind, "failure"); + assert.equal(oversizedResult.providerErrorCode, "response-malformed"); +}); + +test("rejects zero, multiple, remote, and wrong-MIME final images", async () => { + const cases: Array<[Record, string]> = [ + [ + interaction({ steps: [{ type: "model_output", content: [{ type: "text", text: "none" }] }] }), + "response-malformed", + ], + [ + interaction({ + steps: [ + { + type: "model_output", + content: [ + { type: "image", mime_type: "image/png", data: PNG_BASE64 }, + { type: "image", mime_type: "image/png", data: PNG_BASE64 }, + ], + }, + ], + }), + "response-malformed", + ], + [ + interaction({ + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", uri: "http://127.0.0.1/x" }], + }, + ], + }), + "response-malformed", + ], + [ + interaction({ + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/jpeg", data: PNG_BASE64 }], + }, + ], + }), + "response-mime-mismatch", + ], + ]; + for (const [response, code] of cases) { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => jsonResponse(response)), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "failure"); + assert.equal(result.providerErrorCode, code); + assert.equal(result.retrySafety, "never"); + } +}); + +test("normalizes content-policy refusal without exposing the provider message", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse( + interaction({ + status: "failed", + steps: [ + { + type: "model_output", + error: { status: "SAFETY", message: `blocked ${SECRET_KEY}` }, + content: [], + }, + ], + }), + ), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.deepEqual(result, { + kind: "failure", + providerErrorCode: "refused", + error: "Gemini declined this image request under its content policy.", + retrySafety: "never", + }); +}); + +test("pre-send abort is definitely cancelled without touching the transport", async () => { + const controller = new AbortController(); + controller.abort(new Error("stop")); + let called = false; + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => { + called = true; + return jsonResponse(interaction()); + }), + }); + assert.deepEqual(await provider.execute(auth(), request(), context(controller.signal)), { + kind: "cancelled", + providerErrorCode: "cancelled-before-send", + error: "The Gemini image request was cancelled before it was sent.", + }); + assert.equal(called, false); +}); + +test("abort after transport invocation is ambiguous even before headers arrive", async () => { + const controller = new AbortController(); + let invoked!: () => void; + const invokedPromise = new Promise((resolve) => { + invoked = resolve; + }); + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async (_url, init) => { + invoked(); + return await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + }), + }); + const pending = provider.execute(auth(), request(), context(controller.signal)); + await invokedPromise; + controller.abort(new Error("user cancelled")); + assert.deepEqual(await pending, { + kind: "ambiguous-submit", + providerErrorCode: "cancelled-after-send", + error: "The Gemini request was cancelled after submission; completion is unknown.", + }); +}); + +test("timeout remains active while waiting for headers", async () => { + const provider = new GeminiImageProvider({ + timeoutMs: 10, + fetch: injectedFetch( + async (_url, init) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("timeout")), { + once: true, + }); + }), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "ambiguous-submit"); + assert.equal(result.providerErrorCode, "timeout"); +}); + +test("timeout remains active through a stalled response body", async () => { + const provider = new GeminiImageProvider({ + timeoutMs: 10, + fetch: injectedFetch( + async () => + new Response(new ReadableStream({ start() {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + }); + const result = await provider.execute(auth(), request(), context()); + assert.equal(result.kind, "ambiguous-submit"); + assert.equal(result.providerErrorCode, "timeout"); +}); + +test("user abort during a stalled response body remains post-send ambiguous", async () => { + const controller = new AbortController(); + let bodyStarted!: () => void; + const bodyStartedPromise = new Promise((resolve) => { + bodyStarted = resolve; + }); + const provider = new GeminiImageProvider({ + fetch: injectedFetch( + async () => + new Response( + new ReadableStream({ + start() { + bodyStarted(); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + }); + const pending = provider.execute(auth(), request(), context(controller.signal)); + await bodyStartedPromise; + controller.abort(new Error("stop")); + const result = await pending; + assert.equal(result.kind, "ambiguous-submit"); + assert.equal(result.providerErrorCode, "cancelled-after-send"); +}); + +test("nonterminal synchronous response is never silently resubmitted", async () => { + const provider = new GeminiImageProvider({ + fetch: injectedFetch(async () => + jsonResponse(interaction({ status: "in_progress", steps: [] })), + ), + }); + assert.deepEqual(await provider.execute(auth(), request(), context()), { + kind: "ambiguous-submit", + providerErrorCode: "submission-ambiguous", + error: "Gemini accepted the request but did not return a terminal response.", + }); +}); diff --git a/main/services/create-images/providers/gemini-image-provider-core.ts b/main/services/create-images/providers/gemini-image-provider-core.ts new file mode 100644 index 00000000..3a4d62ee --- /dev/null +++ b/main/services/create-images/providers/gemini-image-provider-core.ts @@ -0,0 +1,746 @@ +import type { AuthResult } from "@earendil-works/pi-ai"; +import { AssetImageValidationError, validateImageBytes } from "../asset-image-validation-core.js"; +import type { CoordinatorRetrySafety } from "../scheduler-core.js"; +import type { ValidatedImageGenerationRequest } from "../provider-contract.js"; +import { + buildGeminiInteractionsRequest, + GEMINI_IMAGE_MODELS, + GEMINI_INTERACTIONS_ENDPOINT, + validateGeminiImageRequest, +} from "./gemini-interactions-core.js"; + +export const GEMINI_IMAGE_REQUEST_TIMEOUT_MS = 180_000; +export const GEMINI_IMAGE_MAX_REQUEST_BYTES = 96 * 1024 * 1024; +export const GEMINI_IMAGE_MAX_RESPONSE_BYTES = 96 * 1024 * 1024; +export const GEMINI_IMAGE_MAX_OUTPUT_BYTES = 64 * 1024 * 1024; +export const GEMINI_IMAGE_MAX_RETRY_AFTER_MS = 5 * 60_000; + +const MAX_RESPONSE_STEPS = 128; +const MAX_CONTENT_BLOCKS_PER_STEP = 32; +const MAX_INTERACTION_ID_LENGTH = 256; +const MAX_TOKEN_COUNT = 1_000_000_000; +const API_KEY_PATTERN = /^[\x21-\x7e]{1,512}$/u; +const INTERACTION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u; +const JSON_CONTENT_TYPE = /^application\/json(?:\s*;|$)/iu; +const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; + +export type GeminiImageProviderErrorCode = + | "authentication-required" + | "permission-denied" + | "rate-limited" + | "provider-unavailable" + | "request-rejected" + | "refused" + | "provider-failed" + | "provider-cancelled" + | "incomplete" + | "invalid-request" + | "response-too-large" + | "response-malformed" + | "response-mime-mismatch" + | "redirect-rejected" + | "offline" + | "timeout" + | "cancelled-before-send" + | "cancelled-after-send" + | "submission-ambiguous"; + +export interface GeminiImageUsageMetadata { + totalInputTokens?: number; + totalOutputTokens?: number; + totalThoughtTokens?: number; + totalTokens?: number; +} + +export interface GeminiImageProviderOutput { + images: readonly [ + { + bytes: Uint8Array; + metadata: { + source: "gemini-interactions"; + providerId: "gemini"; + modelId: string; + mimeType: "image/png" | "image/jpeg"; + width: number; + height: number; + byteLength: number; + outputIndex: 0; + }; + }, + ]; + metadata: { + source: "gemini-interactions"; + providerId: "gemini"; + modelId: string; + count: 1; + totalByteLength: number; + interactionId?: string; + usage?: GeminiImageUsageMetadata; + }; +} + +interface GeminiAttemptBase { + providerErrorCode: GeminiImageProviderErrorCode; +} + +export type GeminiImageProviderAttemptResult = + | { kind: "success"; output: GeminiImageProviderOutput } + | (GeminiAttemptBase & { + kind: "failure"; + error: string; + retrySafety: CoordinatorRetrySafety; + }) + | (GeminiAttemptBase & { + kind: "rate-limited"; + error: string; + retrySafety: "never"; + retryAfterMs?: number; + }) + | (GeminiAttemptBase & { kind: "cancelled"; error: string }) + | (GeminiAttemptBase & { kind: "ambiguous-submit"; error: string }); + +export interface GeminiImageProviderExecutionContext { + runId: string; + nodeId: string; + signal: AbortSignal; +} + +export interface GeminiImageProviderOptions { + fetch?: typeof globalThis.fetch; + timeoutMs?: number; + maxResponseBytes?: number; + maxOutputBytes?: number; + now?: () => number; +} + +function failure( + providerErrorCode: GeminiImageProviderErrorCode, + error: string, + retrySafety: CoordinatorRetrySafety = "never", +): GeminiImageProviderAttemptResult { + return { kind: "failure", providerErrorCode, error, retrySafety }; +} + +function ambiguous( + providerErrorCode: Extract< + GeminiImageProviderErrorCode, + "offline" | "timeout" | "cancelled-after-send" | "submission-ambiguous" + >, + error: string, +): GeminiImageProviderAttemptResult { + return { kind: "ambiguous-submit", providerErrorCode, error }; +} + +function safeApiKey(auth: AuthResult | undefined): string | undefined { + const key = auth?.auth.apiKey; + if (typeof key !== "string" || !API_KEY_PATTERN.test(key)) return undefined; + return key; +} + +function timeoutValue(value: number | undefined): number { + const timeoutMs = value ?? GEMINI_IMAGE_REQUEST_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10 * 60_000) { + throw new Error("Gemini image timeout must be between 1 ms and 10 minutes."); + } + return timeoutMs; +} + +function boundedLimit(value: number | undefined, maximum: number, label: string): number { + const limit = value ?? maximum; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > maximum) { + throw new Error(`${label} must be a positive integer within Aiden's hard limit.`); + } + return limit; +} + +function createCombinedSignal( + external: AbortSignal, + timeoutMs: number, +): { + signal: AbortSignal; + didTimeout(): boolean; + dispose(): void; +} { + const controller = new AbortController(); + let timedOut = false; + const onExternalAbort = () => controller.abort(external.reason); + external.addEventListener("abort", onExternalAbort, { once: true }); + if (external.aborted) onExternalAbort(); + const timer = setTimeout(() => { + timedOut = true; + controller.abort(new Error("Gemini image request timed out.")); + }, timeoutMs); + return { + signal: controller.signal, + didTimeout: () => timedOut, + dispose: () => { + clearTimeout(timer); + external.removeEventListener("abort", onExternalAbort); + }, + }; +} + +function contentLength(response: Response): number | undefined { + const header = response.headers.get("content-length"); + if (header === null) return undefined; + if (!/^(?:0|[1-9][0-9]*)$/u.test(header)) return Number.NaN; + const value = Number(header); + return Number.isSafeInteger(value) ? value : Number.NaN; +} + +async function readBoundedResponse( + response: Response, + signal: AbortSignal, + maxResponseBytes: number, +): Promise { + const declared = contentLength(response); + if ( + declared !== undefined && + (!Number.isSafeInteger(declared) || declared < 1 || declared > maxResponseBytes) + ) { + throw new BoundedResponseError("too-large"); + } + if (!response.body) throw new BoundedResponseError("malformed"); + const reader = response.body.getReader(); + const onAbort = () => { + void reader.cancel().catch(() => undefined); + }; + signal.addEventListener("abort", onAbort, { once: true }); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + if (signal.aborted) throw signal.reason ?? new Error("Request aborted."); + const next = await reader.read(); + if (next.done) break; + if (!(next.value instanceof Uint8Array) || next.value.byteLength === 0) continue; + total += next.value.byteLength; + if (total > maxResponseBytes) { + throw new BoundedResponseError("too-large"); + } + chunks.push(next.value); + } + } finally { + signal.removeEventListener("abort", onAbort); + reader.releaseLock(); + } + if (total === 0 || (declared !== undefined && declared !== total)) { + throw new BoundedResponseError("malformed"); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +class BoundedResponseError extends Error { + constructor(readonly reason: "too-large" | "malformed") { + super("The Gemini response did not satisfy Aiden's response bounds."); + this.name = "BoundedResponseError"; + } +} + +function plainRecord(value: unknown): Record | undefined { + return value !== null && + typeof value === "object" && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ? (value as Record) + : undefined; +} + +function parseTokenCount(value: unknown): number | undefined { + return Number.isSafeInteger(value) && + (value as number) >= 0 && + (value as number) <= MAX_TOKEN_COUNT + ? (value as number) + : undefined; +} + +function parseUsage(value: unknown): GeminiImageUsageMetadata | undefined { + if (value === undefined) return undefined; + const record = plainRecord(value); + if (!record) throw new Error("usage"); + const fieldMap = [ + ["total_input_tokens", "totalInputTokens"], + ["total_output_tokens", "totalOutputTokens"], + ["total_thought_tokens", "totalThoughtTokens"], + ["total_tokens", "totalTokens"], + ] as const; + const parsed: GeminiImageUsageMetadata = {}; + for (const [wireName, resultName] of fieldMap) { + if (record[wireName] === undefined) continue; + const count = parseTokenCount(record[wireName]); + if (count === undefined) throw new Error("usage"); + parsed[resultName] = count; + } + return Object.keys(parsed).length > 0 ? Object.freeze(parsed) : undefined; +} + +function strictBase64(value: unknown, maxOutputBytes: number): Uint8Array { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > Math.ceil(maxOutputBytes / 3) * 4 || + value.length % 4 !== 0 || + !BASE64_PATTERN.test(value) + ) { + throw new Error("base64"); + } + const bytes = Buffer.from(value, "base64"); + if ( + bytes.byteLength === 0 || + bytes.byteLength > maxOutputBytes || + bytes.toString("base64") !== value + ) { + throw new Error("base64"); + } + // Do not expose a view onto Node's pooled Buffer backing store. + return Uint8Array.from(bytes); +} + +function safeInteractionId(value: unknown): string | undefined { + if (value === undefined) return undefined; + if ( + typeof value !== "string" || + value.length > MAX_INTERACTION_ID_LENGTH || + !INTERACTION_ID_PATTERN.test(value) + ) { + throw new Error("interaction-id"); + } + return value; +} + +function containsRefusalMarker(steps: readonly unknown[]): boolean { + for (const stepValue of steps) { + const step = plainRecord(stepValue); + const error = plainRecord(step?.error); + const marker = error?.status; + if ( + typeof marker === "string" && + /^(?:SAFETY|BLOCKED|CONTENT_FILTERED|PERMISSION_DENIED)$/u.test(marker) + ) { + return true; + } + } + return false; +} + +type ParsedCompletedResponse = + | { kind: "success"; output: GeminiImageProviderOutput } + | { kind: "failure"; code: GeminiImageProviderErrorCode; message: string } + | { kind: "ambiguous" }; + +function parseCompletedResponse( + value: unknown, + request: ValidatedImageGenerationRequest, + maxOutputBytes: number, +): ParsedCompletedResponse { + const response = plainRecord(value); + if (!response) { + return { + kind: "failure", + code: "response-malformed", + message: "Gemini returned an invalid response.", + }; + } + const steps = response.steps; + if (!Array.isArray(steps) || steps.length > MAX_RESPONSE_STEPS) { + return { + kind: "failure", + code: "response-malformed", + message: "Gemini returned an invalid response timeline.", + }; + } + const status = response.status; + if (typeof status !== "string") { + return { + kind: "failure", + code: "response-malformed", + message: "Gemini returned an invalid response status.", + }; + } + if (["in_progress", "queued", "requires_action"].includes(status)) { + return { kind: "ambiguous" }; + } + if (status !== "completed") { + if (containsRefusalMarker(steps)) { + return { + kind: "failure", + code: "refused", + message: "Gemini declined this image request under its content policy.", + }; + } + const statusError: Record = { + failed: ["provider-failed", "Gemini could not complete this image request."], + cancelled: ["provider-cancelled", "Gemini cancelled this image request."], + incomplete: ["incomplete", "Gemini returned an incomplete image response."], + }; + const normalized = statusError[status]; + return normalized + ? { kind: "failure", code: normalized[0], message: normalized[1] } + : { + kind: "failure", + code: "response-malformed", + message: "Gemini returned an unsupported response status.", + }; + } + + const images: Array<{ data: unknown; mimeType: unknown }> = []; + for (const stepValue of steps) { + const step = plainRecord(stepValue); + if (!step || typeof step.type !== "string") { + return { + kind: "failure", + code: "response-malformed", + message: "Gemini returned an invalid response step.", + }; + } + if (step.type !== "model_output") continue; + if (!Array.isArray(step.content) || step.content.length > MAX_CONTENT_BLOCKS_PER_STEP) { + return { + kind: "failure", + code: "response-malformed", + message: "Gemini returned invalid model output.", + }; + } + for (const blockValue of step.content) { + const block = plainRecord(blockValue); + if (!block || typeof block.type !== "string") { + return { + kind: "failure", + code: "response-malformed", + message: "Gemini returned an invalid output block.", + }; + } + if (block.type === "image") { + if (block.uri !== undefined) { + return { + kind: "failure", + code: "response-malformed", + message: "Gemini returned a remote image instead of bounded inline bytes.", + }; + } + images.push({ data: block.data, mimeType: block.mime_type }); + } + } + } + if (images.length === 0) { + return containsRefusalMarker(steps) + ? { + kind: "failure", + code: "refused", + message: "Gemini declined this image request under its content policy.", + } + : { kind: "failure", code: "response-malformed", message: "Gemini returned no final image." }; + } + if (images.length !== request.count) { + return { + kind: "failure", + code: "response-malformed", + message: "Gemini returned an unexpected number of final images.", + }; + } + const image = images[0]!; + if (image.mimeType !== request.outputMime) { + return { + kind: "failure", + code: "response-mime-mismatch", + message: "Gemini returned an image with the wrong media type.", + }; + } + try { + const bytes = strictBase64(image.data, maxOutputBytes); + const descriptor = validateImageBytes(bytes, request.outputMime, undefined, { + maxWidth: 32_768, + maxHeight: 32_768, + maxPixels: 16_000_000, + }); + const interactionId = safeInteractionId(response.id); + const usage = parseUsage(response.usage); + const metadata = Object.freeze({ + source: "gemini-interactions" as const, + providerId: "gemini" as const, + modelId: request.modelId, + mimeType: descriptor.mediaType, + width: descriptor.width, + height: descriptor.height, + byteLength: bytes.byteLength, + outputIndex: 0 as const, + }); + const output: GeminiImageProviderOutput = Object.freeze({ + images: Object.freeze([{ bytes, metadata }]) as GeminiImageProviderOutput["images"], + metadata: Object.freeze({ + source: "gemini-interactions" as const, + providerId: "gemini" as const, + modelId: request.modelId, + count: 1 as const, + totalByteLength: bytes.byteLength, + ...(interactionId ? { interactionId } : {}), + ...(usage ? { usage } : {}), + }), + }); + return { kind: "success", output }; + } catch (error) { + const code = + error instanceof AssetImageValidationError && error.code === "mime_mismatch" + ? "response-mime-mismatch" + : "response-malformed"; + return { + kind: "failure", + code, + message: + code === "response-mime-mismatch" + ? "Gemini returned bytes that do not match the declared media type." + : "Gemini returned invalid image data.", + }; + } +} + +function retryAfterMs(value: string | null, now: number): number | undefined { + if (value === null || value.length > 128) return undefined; + let delay: number; + if (/^(?:0|[1-9][0-9]*)$/u.test(value)) { + delay = Number(value) * 1_000; + } else { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return undefined; + delay = Math.max(0, timestamp - now); + } + if (!Number.isSafeInteger(delay) || delay < 0) return undefined; + return Math.min(delay, GEMINI_IMAGE_MAX_RETRY_AFTER_MS); +} + +function statusResult( + response: Response, + now: number, +): GeminiImageProviderAttemptResult | undefined { + if (response.status >= 200 && response.status < 300) return undefined; + if (response.status === 401) { + return failure("authentication-required", "Gemini rejected the configured API key."); + } + if (response.status === 403) { + return failure( + "permission-denied", + "The configured Gemini account cannot use this image model.", + ); + } + if (response.status === 429) { + const delay = retryAfterMs(response.headers.get("retry-after"), now); + return { + kind: "rate-limited", + providerErrorCode: "rate-limited", + error: "Gemini is rate limiting image requests.", + retrySafety: "never", + ...(delay === undefined ? {} : { retryAfterMs: delay }), + }; + } + if (response.status >= 500 && response.status <= 599) { + return failure("provider-unavailable", "Gemini is temporarily unavailable."); + } + if (response.status >= 300 && response.status <= 399) { + return failure("redirect-rejected", "Gemini returned a redirect that Aiden will not follow."); + } + return failure("request-rejected", "Gemini rejected this image request."); +} + +/** + * Main-process-only stateless Gemini Interactions adapter. It accepts Pi's + * resolved request auth, but deliberately uses only `auth.apiKey`; alternate + * endpoints, inherited headers, OAuth bearer tokens, and provider URLs never + * cross this fixed transport boundary. + */ +export class GeminiImageProvider { + readonly providerId = "gemini" as const; + readonly #fetch: typeof globalThis.fetch; + readonly #timeoutMs: number; + readonly #maxResponseBytes: number; + readonly #maxOutputBytes: number; + readonly #now: () => number; + + constructor(options: GeminiImageProviderOptions = {}) { + this.#fetch = options.fetch ?? globalThis.fetch; + this.#timeoutMs = timeoutValue(options.timeoutMs); + this.#maxResponseBytes = boundedLimit( + options.maxResponseBytes, + GEMINI_IMAGE_MAX_RESPONSE_BYTES, + "Gemini response byte limit", + ); + this.#maxOutputBytes = boundedLimit( + options.maxOutputBytes, + GEMINI_IMAGE_MAX_OUTPUT_BYTES, + "Gemini output byte limit", + ); + this.#now = options.now ?? Date.now; + } + + listModels() { + return GEMINI_IMAGE_MODELS; + } + + validate(request: ValidatedImageGenerationRequest): ValidatedImageGenerationRequest { + return validateGeminiImageRequest(request); + } + + async execute( + auth: AuthResult | undefined, + request: ValidatedImageGenerationRequest, + context: GeminiImageProviderExecutionContext, + ): Promise { + if (context.signal.aborted) { + return { + kind: "cancelled", + providerErrorCode: "cancelled-before-send", + error: "The Gemini image request was cancelled before it was sent.", + }; + } + const key = safeApiKey(auth); + if (!key) { + return failure( + "authentication-required", + "Connect a Google Gemini API key before creating remote images.", + "confirmed-not-submitted", + ); + } + let validated: ValidatedImageGenerationRequest; + let body: string; + try { + validated = this.validate(request); + const serialized = buildGeminiInteractionsRequest(validated); + body = JSON.stringify({ + ...serialized, + response_format: { ...serialized.response_format, delivery: "inline" }, + }); + if (Buffer.byteLength(body, "utf8") > GEMINI_IMAGE_MAX_REQUEST_BYTES) { + return failure( + "invalid-request", + "The Gemini image request exceeds Aiden's request-size limit.", + "confirmed-not-submitted", + ); + } + } catch { + return failure( + "invalid-request", + "The Gemini image request is invalid.", + "confirmed-not-submitted", + ); + } + if (context.signal.aborted) { + return { + kind: "cancelled", + providerErrorCode: "cancelled-before-send", + error: "The Gemini image request was cancelled before it was sent.", + }; + } + + const combined = createCombinedSignal(context.signal, this.#timeoutMs); + const done = (result: Result): Result => { + combined.dispose(); + return result; + }; + let response: Response; + try { + response = await this.#fetch(GEMINI_INTERACTIONS_ENDPOINT, { + method: "POST", + headers: { + "content-type": "application/json", + "x-goog-api-key": key, + }, + body, + redirect: "error", + signal: combined.signal, + }); + } catch { + if (combined.didTimeout()) { + return done( + ambiguous("timeout", "The Gemini request timed out after it may have been submitted."), + ); + } + if (context.signal.aborted) { + return done( + ambiguous( + "cancelled-after-send", + "The Gemini request was cancelled after submission; completion is unknown.", + ), + ); + } + return done( + ambiguous("offline", "A network error left the Gemini request's submission state unknown."), + ); + } + + if ( + response.redirected || + (response.url !== "" && response.url !== GEMINI_INTERACTIONS_ENDPOINT) + ) { + response.body?.cancel().catch(() => undefined); + return done( + failure( + "redirect-rejected", + "Gemini returned a redirect or unexpected response origin that Aiden rejected.", + ), + ); + } + const contentType = response.headers.get("content-type") ?? ""; + if (!JSON_CONTENT_TYPE.test(contentType)) { + response.body?.cancel().catch(() => undefined); + return done(failure("response-malformed", "Gemini returned an unexpected response type.")); + } + const normalizedStatus = statusResult(response, this.#now()); + if (normalizedStatus) { + response.body?.cancel().catch(() => undefined); + return done(normalizedStatus); + } + if (response.status !== 200) { + response.body?.cancel().catch(() => undefined); + return done(failure("response-malformed", "Gemini returned an unsupported success status.")); + } + + let bytes: Uint8Array; + try { + bytes = await readBoundedResponse(response, combined.signal, this.#maxResponseBytes); + } catch (error) { + if (combined.didTimeout()) { + return done(ambiguous("timeout", "The Gemini response timed out after submission.")); + } + if (context.signal.aborted) { + return done( + ambiguous( + "cancelled-after-send", + "The Gemini request was cancelled after submission; completion is unknown.", + ), + ); + } + return done( + failure( + error instanceof BoundedResponseError && error.reason === "too-large" + ? "response-too-large" + : "response-malformed", + error instanceof BoundedResponseError && error.reason === "too-large" + ? "Gemini returned a response larger than Aiden's safe limit." + : "Gemini returned a truncated or malformed response.", + ), + ); + } + let decoded: unknown; + try { + decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + return done(failure("response-malformed", "Gemini returned invalid JSON.")); + } + const parsed = parseCompletedResponse(decoded, validated, this.#maxOutputBytes); + if (parsed.kind === "success") return done(parsed); + if (parsed.kind === "ambiguous") { + return done( + ambiguous( + "submission-ambiguous", + "Gemini accepted the request but did not return a terminal response.", + ), + ); + } + return done(failure(parsed.code, parsed.message)); + } +} diff --git a/main/services/create-images/providers/gemini-interactions-core.ts b/main/services/create-images/providers/gemini-interactions-core.ts new file mode 100644 index 00000000..e581df8d --- /dev/null +++ b/main/services/create-images/providers/gemini-interactions-core.ts @@ -0,0 +1,159 @@ +import type { + ImageProviderModelCapabilities, + ValidatedImageGenerationRequest, +} from "../provider-contract.js"; + +export const GEMINI_INTERACTIONS_ENDPOINT = + "https://generativelanguage.googleapis.com/v1beta/interactions"; + +const COMMON_ASPECT_RATIOS = [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", +] as const; + +/** + * Release-pinned image catalog verified against Google's Interactions API on + * 2026-08-11. Runtime execution accepts no arbitrary renderer model ID. + */ +export const GEMINI_IMAGE_MODELS: readonly ImageProviderModelCapabilities[] = [ + { + id: "gemini-3.1-flash-lite-image", + label: "Nano Banana 2 Lite", + providerId: "gemini", + aspectRatios: COMMON_ASPECT_RATIOS, + imageSizes: ["1K"], + outputMimes: ["image/png", "image/jpeg"], + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }, + { + id: "gemini-3.1-flash-image", + label: "Nano Banana 2", + providerId: "gemini", + aspectRatios: COMMON_ASPECT_RATIOS, + imageSizes: ["1K", "2K", "4K"], + outputMimes: ["image/png", "image/jpeg"], + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }, + { + id: "gemini-3-pro-image", + label: "Nano Banana Pro", + providerId: "gemini", + aspectRatios: COMMON_ASPECT_RATIOS, + imageSizes: ["1K", "2K", "4K"], + outputMimes: ["image/png", "image/jpeg"], + maxReferenceImages: 14, + maxOutputs: 1, + supportsEditing: true, + supportsCancellation: false, + }, +]; + +export interface GeminiInteractionsRequestBody { + model: string; + input: Array< + | { type: "text"; text: string } + | { type: "image"; mime_type: "image/png" | "image/jpeg" | "image/webp"; data: string } + >; + response_format: { + type: "image"; + mime_type: "image/png" | "image/jpeg"; + aspect_ratio: string; + image_size: string; + }; + store: false; + background: false; +} + +function selectedModel(modelId: string): ImageProviderModelCapabilities { + const model = GEMINI_IMAGE_MODELS.find((candidate) => candidate.id === modelId); + if (!model) throw new Error("This Gemini image model is not supported by this Aiden release."); + return model; +} + +export function validateGeminiImageRequest( + request: ValidatedImageGenerationRequest, +): ValidatedImageGenerationRequest { + if (request.providerId !== "gemini") throw new Error("Expected a Gemini image request."); + const model = selectedModel(request.modelId); + const prompt = request.prompt.trim(); + if (!prompt) throw new Error("Gemini image generation requires a prompt."); + if (prompt.length > 32_000) throw new Error("The image prompt exceeds Aiden's safe limit."); + if (!model.aspectRatios.includes(request.aspectRatio)) { + throw new Error("The selected Gemini model does not support this aspect ratio."); + } + if (!model.imageSizes.includes(request.imageSize)) { + throw new Error("The selected Gemini model does not support this image size."); + } + if (!model.outputMimes.includes(request.outputMime)) { + throw new Error("The selected Gemini model does not support this output format."); + } + if (request.count !== 1) { + throw new Error( + "The Gemini Interactions image adapter currently supports one output per call.", + ); + } + const references = request.references; + if (references.length > model.maxReferenceImages) { + throw new Error( + `The selected Gemini model accepts at most ${model.maxReferenceImages} references.`, + ); + } + let totalBytes = 0; + for (const reference of references) { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(reference.assetId)) { + throw new Error("Gemini references require opaque Aiden asset IDs."); + } + const byteLength = reference.bytes.byteLength; + if (byteLength === 0 || byteLength > 20 * 1024 * 1024) { + throw new Error("Each Gemini reference must be between 1 byte and 20 MB."); + } + totalBytes += byteLength; + } + if (totalBytes > 64 * 1024 * 1024) { + throw new Error("Gemini reference images exceed Aiden's 64 MB request limit."); + } + return { ...request, prompt }; +} + +export function buildGeminiInteractionsRequest( + request: ValidatedImageGenerationRequest, +): GeminiInteractionsRequestBody { + const validated = validateGeminiImageRequest(request); + return { + model: validated.modelId, + input: [ + { type: "text", text: validated.prompt }, + ...validated.references.map((reference) => ({ + type: "image" as const, + mime_type: reference.mimeType, + data: Buffer.from( + reference.bytes.buffer, + reference.bytes.byteOffset, + reference.bytes.byteLength, + ).toString("base64"), + })), + ], + response_format: { + type: "image", + mime_type: validated.outputMime, + aspect_ratio: validated.aspectRatio, + image_size: validated.imageSize, + }, + store: false, + background: false, + }; +} diff --git a/main/services/create-images/renderer-egress-core.test.ts b/main/services/create-images/renderer-egress-core.test.ts new file mode 100644 index 00000000..3a53784a --- /dev/null +++ b/main/services/create-images/renderer-egress-core.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { isAidenMainRendererUrl, shouldBlockAidenRendererEgress } from "./renderer-egress-core.js"; + +test("packaged main renderer egress is denied while non-Aiden windows stay independent", () => { + const rendererUrl = + "file:///Applications/Aiden.app/Contents/Resources/app.asar/build/renderer/main-window.html"; + assert.equal(isAidenMainRendererUrl(rendererUrl), true); + for (const requestUrl of [ + "https://attacker.example/collect", + "http://attacker.example/pixel", + "wss://attacker.example/socket", + ]) { + assert.equal(shouldBlockAidenRendererEgress({ requestUrl, rendererUrl, packaged: true }), true); + } + assert.equal( + shouldBlockAidenRendererEgress({ + requestUrl: "https://accounts.example/login", + rendererUrl: "https://accounts.example/login", + packaged: true, + }), + false, + ); +}); + +test("development permits only loopback renderer transport", () => { + const rendererUrl = "http://127.0.0.1:4143/main-window.html"; + assert.equal( + shouldBlockAidenRendererEgress({ + requestUrl: "ws://127.0.0.1:4143/hmr", + rendererUrl, + packaged: false, + }), + false, + ); + assert.equal( + shouldBlockAidenRendererEgress({ + requestUrl: "https://attacker.example/collect", + rendererUrl, + packaged: false, + }), + true, + ); +}); + +test("main renderer CSP has no broad remote image, media, or connection source", () => { + const html = readFileSync(new URL("../../../main-window.html", import.meta.url), "utf8"); + const policy = html.match(/http-equiv="Content-Security-Policy"\s+content="([^"]+)"/u)?.[1] ?? ""; + const directive = (name: string) => + policy + .split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith(`${name} `)) ?? ""; + for (const name of ["connect-src", "img-src", "media-src"]) { + const value = directive(name); + assert.ok(value, `${name} must be present`); + const sources = new Set(value.split(/\s+/u).slice(1)); + assert.equal(sources.has("http:"), false); + assert.equal(sources.has("https:"), false); + assert.equal(sources.has("file:"), false); + } +}); + +test("the installed request policy honors the branded development runtime profile", () => { + const source = readFileSync(new URL("./asset-protocol.ts", import.meta.url), "utf8"); + assert.match(source, /packaged: isPackagedRuntime\(\)/u); + assert.doesNotMatch(source, /packaged: app\.isPackaged/u); +}); diff --git a/main/services/create-images/renderer-egress-core.ts b/main/services/create-images/renderer-egress-core.ts new file mode 100644 index 00000000..f85cfcba --- /dev/null +++ b/main/services/create-images/renderer-egress-core.ts @@ -0,0 +1,33 @@ +const REMOTE_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); + +function parsed(value: string): URL | undefined { + try { + return new URL(value); + } catch { + return undefined; + } +} + +export function isAidenMainRendererUrl(value: string): boolean { + const url = parsed(value); + if (!url) return false; + if (url.protocol === "file:") return url.pathname.endsWith("/main-window.html"); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + (url.hostname === "127.0.0.1" || url.hostname === "localhost") && + url.pathname.endsWith("/main-window.html") + ); +} + +/** Main-window web content has no direct production network capability. */ +export function shouldBlockAidenRendererEgress(input: { + requestUrl: string; + rendererUrl: string | undefined; + packaged: boolean; +}): boolean { + const request = parsed(input.requestUrl); + if (!request || !REMOTE_PROTOCOLS.has(request.protocol) || !input.rendererUrl) return false; + if (!isAidenMainRendererUrl(input.rendererUrl)) return false; + if (input.packaged) return true; + return request.hostname !== "127.0.0.1" && request.hostname !== "localhost"; +} diff --git a/main/services/create-images/run-journal-performance.test.ts b/main/services/create-images/run-journal-performance.test.ts new file mode 100644 index 00000000..b8eb7138 --- /dev/null +++ b/main/services/create-images/run-journal-performance.test.ts @@ -0,0 +1,304 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { performance } from "node:perf_hooks"; +import test, { type TestContext } from "node:test"; +import { + appendCreateImagesRunEvent, + createCreateImagesRunJournal, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { + CreateImagesRunJournalStore, + createImagesWorkflowSnapshotFingerprint, +} from "./run-journal-store.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; + +async function temporaryRoot(t: TestContext): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-run-journal-performance-")); + t.after(() => fs.rm(root, { force: true, recursive: true })); + return root; +} + +function workload(nodeCount: number): { + snapshot: WorkflowDocumentV1; + orderedNodeIds: string[]; +} { + const orderedNodeIds = Array.from({ length: nodeCount }, (_, index) => `prompt-${index + 1}`); + return { + snapshot: { + schemaVersion: 1, + id: "workflow-performance", + title: "Journal performance gate", + revision: 1, + createdAt: NOW, + updatedAt: NOW, + nodes: orderedNodeIds.map((id, index) => ({ + id, + type: "prompt" as const, + position: { x: (index % 25) * 100, y: Math.floor(index / 25) * 100 }, + data: { text: `Prompt ${index + 1}` }, + })), + edges: [], + assetRefs: [], + settings: { concurrency: 4 }, + }, + orderedNodeIds, + }; +} + +function event( + journal: CreateImagesRunJournalV1, + type: T, + fields: Omit< + Extract, + "type" | "workflowId" | "workflowRevision" | "runId" | "sequence" | "at" + >, +): Extract { + return { + type, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: NOW, + ...fields, + } as Extract; +} + +test("100/250/500-node successful journals append and replay within bounded linear-storage gates", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const { snapshot, orderedNodeIds } = workload(500); + let journal = await store.start( + { + runId: "run-performance", + workflowSnapshot: snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds, + dependencies: Object.fromEntries(orderedNodeIds.map((nodeId) => [nodeId, []])), + }, + createdAt: NOW, + }, + () => true, + ); + const runDirectory = path.join(root, "runs", journal.runId); + const checkpointBytes = (await fs.lstat(path.join(runDirectory, "run.json"))).size; + const startedAt = performance.now(); + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "run-started", {}), + ); + const indexBeforeProgress = await fs.readFile(path.join(root, "run-index.json"), "utf8"); + const elapsedByNode = new Map(); + for (const [index, nodeId] of orderedNodeIds.entries()) { + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "node-started", { nodeId }), + ); + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "node-output-published", { nodeId, outputAssetIds: [] }), + ); + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "node-succeeded", { nodeId, outputAssetIds: [] }), + ); + const completedNodes = index + 1; + if ([100, 250, 500].includes(completedNodes)) { + elapsedByNode.set(completedNodes, performance.now() - startedAt); + } + } + + assert.ok((elapsedByNode.get(100) ?? Infinity) < 45_000, "100-node append gate exceeded 45s"); + assert.ok((elapsedByNode.get(250) ?? Infinity) < 105_000, "250-node append gate exceeded 105s"); + assert.ok((elapsedByNode.get(500) ?? Infinity) < 240_000, "500-node append gate exceeded 240s"); + assert.equal(await fs.readFile(path.join(root, "run-index.json"), "utf8"), indexBeforeProgress); + journal = await store.append( + journal.runId, + journal.journalRevision, + event(journal, "run-terminal", { status: "succeeded" }), + ); + assert.equal((await fs.lstat(path.join(runDirectory, "run.json"))).size, checkpointBytes); + assert.equal( + (await fs.lstat(path.join(runDirectory, "run.last-known-good.json"))).size, + checkpointBytes, + ); + await assert.rejects(fs.lstat(path.join(runDirectory, "run.pending.json")), { + code: "ENOENT", + }); + + const replayStartedAt = performance.now(); + const replayed = await new CreateImagesRunJournalStore(() => root).get(journal.runId); + const replayMs = performance.now() - replayStartedAt; + assert.equal(replayed?.journalRevision, journal.journalRevision); + assert.ok(replayMs < 3_500, "500-node replay gate exceeded 3.5s"); + + t.diagnostic( + JSON.stringify({ + appendMs: Object.fromEntries(elapsedByNode), + replayMs: Math.round(replayMs), + eventCount: journal.events.length, + currentLogBytes: (await fs.lstat(path.join(runDirectory, "run.events.jsonl"))).size, + }), + ); +}); + +test("1,000 output-rich terminal journals restart, reconcile, and inventory within bounded storage gates", async (t) => { + const root = await temporaryRoot(t); + const runsPath = path.join(root, "runs"); + await fs.mkdir(runsPath, { recursive: true }); + const { snapshot, orderedNodeIds } = workload(1); + const plan = { + scope: { kind: "all" } as const, + orderedNodeIds, + dependencies: { [orderedNodeIds[0] as string]: [] }, + }; + const entries: Array> = []; + const writes: Array> = []; + for (let index = 1; index <= 1_000; index += 1) { + const runId = `retained-${String(index).padStart(4, "0")}`; + let journal = createCreateImagesRunJournal({ + runId, + workflowSnapshot: snapshot, + workflowFingerprint: createImagesWorkflowSnapshotFingerprint(snapshot), + plan, + createdAt: NOW, + }); + journal = appendCreateImagesRunEvent(journal, event(journal, "run-started", {})); + journal = appendCreateImagesRunEvent( + journal, + event(journal, "node-started", { nodeId: orderedNodeIds[0] as string }), + ); + journal = appendCreateImagesRunEvent( + journal, + event(journal, "node-output-published", { + nodeId: orderedNodeIds[0] as string, + outputAssetIds: Array.from({ length: 250 }, (_, assetIndex) => + (index * 1_000 + assetIndex).toString(16).padStart(64, "0"), + ), + }), + ); + const outputAssetIds = ( + journal.events[journal.events.length - 1] as Extract< + CreateImagesRunEventV1, + { type: "node-output-published" } + > + ).outputAssetIds; + journal = appendCreateImagesRunEvent( + journal, + event(journal, "node-succeeded", { + nodeId: orderedNodeIds[0] as string, + outputAssetIds, + }), + ); + journal = appendCreateImagesRunEvent( + journal, + event(journal, "run-terminal", { status: "succeeded" }), + ); + const directory = path.join(runsPath, runId); + const serialized = `${JSON.stringify(journal, null, 2)}\n`; + writes.push( + (async () => { + await fs.mkdir(directory, { recursive: true }); + await Promise.all([ + fs.writeFile(path.join(directory, "run.json"), serialized, "utf8"), + fs.writeFile(path.join(directory, "run.last-known-good.json"), serialized, "utf8"), + ]); + })(), + ); + entries.push({ + runId, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + journalRevision: journal.journalRevision, + status: "succeeded", + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + terminal: true, + health: "healthy", + }); + if (writes.length === 50) { + await Promise.all(writes.splice(0)); + } + } + await Promise.all(writes); + await fs.writeFile( + path.join(root, "run-index.json"), + `${JSON.stringify({ version: 1, revision: 1, entries }, null, 2)}\n`, + "utf8", + ); + + const restarted = new CreateImagesRunJournalStore(() => root); + const productPathStartedAt = performance.now(); + await restarted.initialize(); + const initializedAt = performance.now(); + const admissionStartedAt = performance.now(); + const admission = await restarted.auditWorkflowAdmission("workflow-performance"); + const admissionMs = performance.now() - admissionStartedAt; + const firstReferences = await restarted.referenceInventory(); + const secondReferences = await restarted.referenceInventory(); + const reconciliation = await restarted.reconciliationCandidates(); + const thirdReferences = await restarted.referenceInventory(); + const productPathMs = performance.now() - productPathStartedAt; + const restartMs = initializedAt - productPathStartedAt; + assert.ok(restartMs < 20_000, "1,000-journal restart gate exceeded 20s"); + assert.ok(admissionMs < 20_000, "1,000-journal admission audit exceeded 20s"); + assert.deepEqual(admission, { + hasDegradedAuthority: false, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }); + assert.ok( + productPathMs < 60_000, + "1,000-journal product reference/reconciliation gate exceeded 60s", + ); + assert.equal(firstReferences.complete, true); + assert.equal(secondReferences.complete, true); + assert.equal(thirdReferences.complete, true); + assert.equal(firstReferences.records.length, 1_000); + assert.equal( + firstReferences.records.reduce((total, record) => total + record.assetIds.length, 0), + 250_000, + ); + assert.deepEqual(reconciliation, []); + const cache = restarted.cacheStats(); + assert.ok(cache.journalCount <= 32); + assert.ok(cache.journalBytes <= 32 * 1024 * 1024); + assert.ok(cache.tailCount <= 128); + assert.ok(cache.tailBytes <= 64 * 1024); + assert.equal((await restarted.terminalHistory()).length, 1_000); + assert.ok( + (await fs.lstat(path.join(root, "run-index.json"))).size < 1024 * 1024, + "metadata-only run index exceeded 1 MiB", + ); + const retentionStartedAt = performance.now(); + const retention = await restarted.terminalRetentionCandidates({ + keepLatest: 900, + limit: 100, + }); + const retentionMs = performance.now() - retentionStartedAt; + assert.equal(retention.length, 40); + assert.ok(retentionMs < 5_000, "1,000-journal high-reference retention lookup exceeded 5s"); + t.diagnostic( + JSON.stringify({ + terminalJournalCount: 1_000, + outputAssetIdsPerRun: 250, + restartMs: Math.round(restartMs), + admissionMs: Math.round(admissionMs), + productPathMs: Math.round(productPathMs), + retentionMs: Math.round(retentionMs), + indexBytes: (await fs.lstat(path.join(root, "run-index.json"))).size, + cache, + }), + ); +}); diff --git a/main/services/create-images/run-journal-store.test.ts b/main/services/create-images/run-journal-store.test.ts new file mode 100644 index 00000000..12222e81 --- /dev/null +++ b/main/services/create-images/run-journal-store.test.ts @@ -0,0 +1,1572 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test, { type TestContext } from "node:test"; +import { + appendCreateImagesRunEvent, + createCreateImagesRunJournal, + projectCreateImagesRun, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { + CreateImagesRunJournalLoadError, + CreateImagesRunJournalRevisionConflictError, + CreateImagesRunJournalStore, + createImagesWorkflowSnapshotFingerprint, +} from "./run-journal-store.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; +const LATER = "2026-08-11T12:00:01.000Z"; +const ASSET_ID = "c".repeat(64); +const INPUT_ASSET_ID = "a".repeat(64); + +async function temporaryRoot(t: TestContext): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-run-journal-")); + t.after(() => fs.rm(root, { force: true, recursive: true })); + return root; +} + +function workflow(): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "workflow-1", + title: "Durable run", + revision: 3, + createdAt: NOW, + updatedAt: NOW, + nodes: [ + { + id: "prompt-1", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "A durable prompt" }, + }, + { + id: "generate-1", + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { id: "output-1", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + edges: [ + { + id: "edge-prompt", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "edge-output", + source: "generate-1", + sourcePort: "images", + target: "output-1", + targetPort: "images", + }, + ], + assetRefs: [], + settings: { concurrency: 1 }, + }; +} + +function startInput(runId = "run-1") { + return { + runId, + workflowSnapshot: workflow(), + plan: { + scope: { kind: "all" } as const, + orderedNodeIds: ["prompt-1", "generate-1", "output-1"], + dependencies: { + "prompt-1": [], + "generate-1": ["prompt-1"], + "output-1": ["generate-1"], + }, + }, + createdAt: NOW, + }; +} + +function emptyStartInput(runId: string) { + const snapshot: WorkflowDocumentV1 = { + ...workflow(), + id: `workflow-${runId}`, + nodes: [ + { + id: "prompt-only", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "retire me" }, + }, + ], + edges: [], + assetRefs: [], + }; + return { + runId, + workflowSnapshot: snapshot, + plan: { + scope: { kind: "all" } as const, + orderedNodeIds: ["prompt-only"], + dependencies: { "prompt-only": [] }, + }, + createdAt: NOW, + }; +} + +function event( + journal: CreateImagesRunJournalV1, + type: T, + fields: Omit< + Extract, + "type" | "workflowId" | "workflowRevision" | "runId" | "sequence" | "at" + >, +): Extract { + return { + type, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: LATER, + ...fields, + } as Extract; +} + +async function append( + store: CreateImagesRunJournalStore, + journal: CreateImagesRunJournalV1, + type: T, + fields: Omit< + Extract, + "type" | "workflowId" | "workflowRevision" | "runId" | "sequence" | "at" + >, +): Promise { + return store.append(journal.runId, journal.journalRevision, event(journal, type, fields)); +} + +interface PendingAppendFixture { + kind: "append"; + event: CreateImagesRunEventV1; + targetJournalDigest: string; +} + +async function expectedPendingEventRecord( + root: string, + runId: string, + checkpointFile: "run.json" | "run.last-known-good.json", + eventLogFile: "run.events.jsonl" | "run.last-known-good.events.jsonl", +): Promise<{ pending: PendingAppendFixture; bytes: Buffer }> { + const directory = path.join(root, "runs", runId); + const pending = JSON.parse( + await fs.readFile(path.join(directory, "run.pending.json"), "utf8"), + ) as PendingAppendFixture; + assert.equal(pending.kind, "append"); + const checkpoint = JSON.parse( + await fs.readFile(path.join(directory, checkpointFile), "utf8"), + ) as CreateImagesRunJournalV1; + let previousDigest = createHash("sha256") + .update(JSON.stringify(checkpoint), "utf8") + .digest("hex"); + const log = await fs.readFile(path.join(directory, eventLogFile), "utf8").catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + }); + const lines = log.trimEnd().split("\n"); + const lastLine = lines[lines.length - 1]; + if (lastLine) previousDigest = (JSON.parse(lastLine) as { digest: string }).digest; + const journalRevision = pending.event.sequence + 1; + const digest = createHash("sha256") + .update( + JSON.stringify({ + runId, + journalRevision, + previousDigest, + event: pending.event, + }), + "utf8", + ) + .digest("hex"); + return { + pending, + bytes: Buffer.from( + `${JSON.stringify({ + version: 1, + runId, + journalRevision, + previousDigest, + digest, + event: pending.event, + })}\n`, + "utf8", + ), + }; +} + +async function startGenerateNode( + store: CreateImagesRunJournalStore, + journal: CreateImagesRunJournalV1, +): Promise { + let next = await append(store, journal, "node-started", { + nodeId: "prompt-1", + }); + next = await append(store, next, "node-output-published", { + nodeId: "prompt-1", + outputAssetIds: [], + }); + next = await append(store, next, "node-succeeded", { + nodeId: "prompt-1", + outputAssetIds: [], + }); + return append(store, next, "node-started", { nodeId: "generate-1" }); +} + +async function terminalFailedRun( + store: CreateImagesRunJournalStore, + runId: string, + workflowId = `workflow-${runId}`, +): Promise { + const input = emptyStartInput(runId); + input.workflowSnapshot.id = workflowId; + let journal = await store.start(input, () => true); + journal = await append(store, journal, "run-started", {}); + journal = await append(store, journal, "node-started", { + nodeId: "prompt-only", + }); + journal = await append(store, journal, "node-failed", { + nodeId: "prompt-only", + errorCode: "test-failure", + }); + return append(store, journal, "run-terminal", { status: "failed" }); +} + +async function terminalAmbiguousRun( + store: CreateImagesRunJournalStore, + runId = "run-ambiguous", + beforeTerminal?: () => void, +): Promise { + let journal = await store.start(startInput(runId), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + journal = await append(store, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: `idem-${runId}-0001`, + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = await append(store, journal, "node-submission-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = await append(store, journal, "node-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = await append(store, journal, "node-blocked", { + nodeId: "output-1", + upstreamNodeIds: ["generate-1"], + }); + beforeTerminal?.(); + return append(store, journal, "run-terminal", { status: "needs_attention" }); +} + +test("start publishes fingerprinted current and recovery journals atomically", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const journal = await store.start(startInput(), () => true); + assert.equal(journal.workflowFingerprint, createImagesWorkflowSnapshotFingerprint(workflow())); + assert.equal((await store.health("run-1")).status, "healthy"); + assert.equal((await store.get("run-1"))?.journalRevision, 1); + assert.equal( + await fs.readFile(path.join(root, "runs", "run-1", "run.json"), "utf8"), + await fs.readFile(path.join(root, "runs", "run-1", "run.last-known-good.json"), "utf8"), + ); + await assert.rejects( + store.start(startInput(), () => true), + CreateImagesRunJournalRevisionConflictError, + ); +}); + +test("stale renderer ownership blocks only pre-intent start publication", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await assert.rejects( + store.start(startInput(), () => false), + /no longer active/u, + ); + assert.equal((await store.health("run-1")).status, "missing"); + + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + assert.equal(projectCreateImagesRun(journal).status, "running"); +}); + +test("restart completes a start that crashed after durable intent without renderer liveness", async (t) => { + const root = await temporaryRoot(t); + let crash = true; + const crashing = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async () => { + if (crash) throw new Error("simulated process loss"); + }, + }); + await assert.rejects( + crashing.start(startInput(), () => true), + /simulated process loss/u, + ); + crash = false; + const restarted = new CreateImagesRunJournalStore(() => root); + const health = await restarted.initialize(); + assert.equal(health[0]?.status, "healthy"); + assert.equal((await restarted.get("run-1"))?.journalRevision, 1); +}); + +for (const boundary of ["pending", "current", "last-known-good"] as const) { + test(`restart completes append crashed after ${boundary} publication`, async (t) => { + const root = await temporaryRoot(t); + let crash = false; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async () => { + if (crash && boundary === "pending") throw new Error("crash-pending"); + }, + afterCurrentPublished: async () => { + if (crash && boundary === "current") throw new Error("crash-current"); + }, + afterLastKnownGoodPublished: async () => { + if (crash && boundary === "last-known-good") throw new Error("crash-last-known-good"); + }, + }); + const initial = await store.start(startInput(), () => true); + crash = true; + await assert.rejects( + append(store, initial, "run-started", {}), + new RegExp(`crash-${boundary}`, "u"), + ); + const restarted = new CreateImagesRunJournalStore(() => root); + const recovered = await restarted.get("run-1"); + assert.equal(recovered?.journalRevision, 2); + assert.equal(recovered && projectCreateImagesRun(recovered).status, "running"); + await assert.rejects( + restarted.append("run-1", 1, event(initial, "run-started", {})), + CreateImagesRunJournalRevisionConflictError, + ); + }); +} + +for (const boundary of ["current", "last-known-good"] as const) { + for (const fragment of ["partial-json", "valid-json-without-newline"] as const) { + test(`restart atomically repairs a ${fragment} torn ${boundary} event append`, async (t) => { + const root = await temporaryRoot(t); + let tear = false; + const tearEventLog = async (runId: string): Promise => { + if (!tear) return; + tear = false; + const checkpointFile = boundary === "current" ? "run.json" : "run.last-known-good.json"; + const eventLogFile = + boundary === "current" ? "run.events.jsonl" : "run.last-known-good.events.jsonl"; + const expected = await expectedPendingEventRecord( + root, + runId, + checkpointFile, + eventLogFile, + ); + const tornBytes = + fragment === "partial-json" + ? expected.bytes.subarray(0, Math.floor(expected.bytes.length / 2)) + : expected.bytes.subarray(0, expected.bytes.length - 1); + await fs.appendFile(path.join(root, "runs", runId, eventLogFile), tornBytes); + throw new Error(`crash-torn-${boundary}`); + }; + const store = new CreateImagesRunJournalStore(() => root, { + ...(boundary === "current" + ? { afterPendingPublished: tearEventLog } + : { afterCurrentPublished: tearEventLog }), + }); + const initial = await store.start(startInput(), () => true); + const started = await append(store, initial, "run-started", {}); + tear = true; + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + new RegExp(`crash-torn-${boundary}`, "u"), + ); + + const restarted = new CreateImagesRunJournalStore(() => root); + const recovered = await restarted.get(started.runId); + assert.equal(recovered?.journalRevision, started.journalRevision + 1); + assert.equal(recovered?.events[recovered.events.length - 1]?.type, "node-started"); + assert.equal((await restarted.health(started.runId)).status, "healthy"); + await assert.rejects(fs.lstat(path.join(root, "runs", started.runId, "run.pending.json")), { + code: "ENOENT", + }); + assert.equal( + (await new CreateImagesRunJournalStore(() => root).get(started.runId))?.journalRevision, + started.journalRevision + 1, + ); + }); + } +} + +test("torn append recovery refuses a pending target digest mismatch", async (t) => { + const root = await temporaryRoot(t); + let tear = false; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async (runId) => { + if (!tear) return; + tear = false; + const expected = await expectedPendingEventRecord( + root, + runId, + "run.json", + "run.events.jsonl", + ); + const directory = path.join(root, "runs", runId); + await fs.appendFile( + path.join(directory, "run.events.jsonl"), + expected.bytes.subarray(0, Math.floor(expected.bytes.length / 2)), + ); + await fs.writeFile( + path.join(directory, "run.pending.json"), + `${JSON.stringify({ ...expected.pending, targetJournalDigest: "0".repeat(64) }, null, 2)}\n`, + "utf8", + ); + throw new Error("crash-with-wrong-target-digest"); + }, + }); + const initial = await store.start(startInput(), () => true); + const started = await append(store, initial, "run-started", {}); + tear = true; + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + /crash-with-wrong-target-digest/u, + ); + + const restarted = new CreateImagesRunJournalStore(() => root); + await assert.rejects(restarted.get(started.runId), CreateImagesRunJournalLoadError); + const health = await restarted.health(started.runId); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") assert.equal(health.reason, "pending-conflict"); + await fs.access(path.join(root, "runs", started.runId, "run.pending.json")); +}); + +test("CAS and monotonic event identity reject stale, duplicate, and out-of-order writes", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const journal = await store.start(startInput(), () => true); + const started = await append(store, journal, "run-started", {}); + await assert.rejects( + store.append("run-1", 1, event(journal, "run-started", {})), + CreateImagesRunJournalRevisionConflictError, + ); + await assert.rejects( + store.append("run-1", started.journalRevision, { + ...event(started, "node-started", { nodeId: "generate-1" }), + sequence: 99, + }), + /monotonic/u, + ); + await assert.rejects( + store.append("run-1", started.journalRevision, { + ...event(started, "node-started", { nodeId: "generate-1" }), + runId: "run-stale", + }), + /identity/u, + ); + assert.equal((await store.get("run-1"))?.journalRevision, 2); +}); + +for (const authoritativeFile of [ + "run.json", + "run.last-known-good.json", + "run.events.jsonl", + "run.last-known-good.events.jsonl", +] as const) { + test(`cached authority detects same-size ${authoritativeFile} tampering before append`, async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const initial = await store.start(startInput(), () => true); + const started = await append(store, initial, "run-started", {}); + assert.equal((await store.get(started.runId))?.journalRevision, started.journalRevision); + + const target = path.join(root, "runs", started.runId, authoritativeFile); + const before = await fs.stat(target); + const bytes = await fs.readFile(target); + assert.ok(bytes.length > 0); + bytes[0] = bytes[0] === 0x7b ? 0x5b : bytes[0] === 0x5b ? 0x7b : bytes[0] ^ 1; + await fs.writeFile(target, bytes); + await fs.utimes(target, before.atime, before.mtime); + assert.equal((await fs.stat(target)).size, before.size); + + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + CreateImagesRunJournalLoadError, + ); + }); +} + +for (const authoritativeFile of [ + "run.json", + "run.last-known-good.json", + "run.events.jsonl", + "run.last-known-good.events.jsonl", +] as const) { + test(`durable append intent detects post-pending ${authoritativeFile} replacement`, async (t) => { + const root = await temporaryRoot(t); + let tamperAfterPending = false; + let tampered = false; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async (runId) => { + if (!tamperAfterPending) return; + tamperAfterPending = false; + const target = path.join(root, "runs", runId, authoritativeFile); + const before = await fs.stat(target); + const bytes = await fs.readFile(target); + assert.ok(bytes.length > 0); + if (authoritativeFile.endsWith(".jsonl")) { + const digestOffset = bytes.indexOf(Buffer.from('"digest":"', "utf8")); + assert.notEqual(digestOffset, -1); + const firstDigestByte = digestOffset + Buffer.byteLength('"digest":"', "utf8"); + bytes[firstDigestByte] = bytes[firstDigestByte] === 0x61 ? 0x62 : 0x61; + } else { + bytes[0] = bytes[0] === 0x7b ? 0x5b : bytes[0] === 0x5b ? 0x7b : bytes[0] ^ 1; + } + await fs.writeFile(target, bytes); + await fs.utimes(target, before.atime, before.mtime); + const after = await fs.stat(target); + assert.equal(after.size, before.size); + assert.ok(Math.abs(after.mtimeMs - before.mtimeMs) < 1); + tampered = true; + }, + }); + const initial = await store.start(startInput(), () => true); + const started = await append(store, initial, "run-started", {}); + tamperAfterPending = true; + + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + CreateImagesRunJournalLoadError, + ); + assert.equal(tampered, true); + const pendingPath = path.join(root, "runs", started.runId, "run.pending.json"); + const pendingBytes = await fs.readFile(pendingPath, "utf8"); + assert.match(pendingBytes, /"authority"/u); + const health = await store.health(started.runId); + assert.equal(health.status, "recovery-required"); + if (health.status !== "recovery-required") return; + assert.equal(health.reason, "pending-conflict"); + assert.equal(health.canRecover, false); + assert.equal(health.workflowId, started.workflowId); + await assert.rejects( + append(store, started, "node-started", { nodeId: "prompt-1" }), + CreateImagesRunJournalLoadError, + ); + assert.equal(await fs.readFile(pendingPath, "utf8"), pendingBytes); + }); +} + +test("safe retry state survives a crash before the next attempt", async (t) => { + const root = await temporaryRoot(t); + let crashOnRetry = false; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async () => { + if (crashOnRetry) throw new Error("crash-after-retry-intent"); + }, + }); + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + journal = await append(store, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + crashOnRetry = true; + await assert.rejects( + append(store, journal, "node-retry-scheduled", { + nodeId: "generate-1", + attempt: 1, + errorCode: "rate-limited", + delayMs: 2_000, + retrySafety: "confirmed-not-submitted", + }), + /crash-after-retry-intent/u, + ); + const restarted = new CreateImagesRunJournalStore(() => root); + journal = (await restarted.get("run-1")) as CreateImagesRunJournalV1; + assert.equal( + projectCreateImagesRun(journal).nodes["generate-1"]?.attempts[0]?.submission, + "retry-scheduled", + ); + journal = await append(restarted, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0002", + providerId: "mock", + modelId: "mock-image-v1", + }); + assert.equal(projectCreateImagesRun(journal).nodes["generate-1"]?.attempts.length, 2); +}); + +test("unresolved ambiguity is durable terminal history and never looks runnable after restart", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + journal = await append(store, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = await append(store, journal, "node-submission-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = await append(store, journal, "node-ambiguous", { + nodeId: "generate-1", + attempt: 1, + }); + journal = await append(store, journal, "node-blocked", { + nodeId: "output-1", + upstreamNodeIds: ["generate-1"], + }); + await append(store, journal, "run-terminal", { status: "needs_attention" }); + + const restarted = new CreateImagesRunJournalStore(() => root); + const loaded = (await restarted.get("run-1")) as CreateImagesRunJournalV1; + assert.equal(projectCreateImagesRun(loaded).status, "needs_attention"); + assert.equal((await restarted.terminalHistory())[0]?.status, "needs_attention"); + await assert.rejects( + append(restarted, loaded, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 2, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }), + /Terminal runs/u, + ); +}); + +test("unresolved ambiguity cannot be retired until its CAS acknowledgement is durable", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await terminalAmbiguousRun(store); + assert.deepEqual(await store.terminalRetentionCandidates({ keepLatest: 0, limit: 100 }), []); + await assert.rejects( + store.planTerminalPrune([{ runId: journal.runId, journalRevision: journal.journalRevision }]), + /must be acknowledged/u, + ); + + journal = await append(store, journal, "run-ambiguity-acknowledged", { + expectedNeedsAttentionJournalRevision: journal.journalRevision, + }); + assert.equal( + (await store.terminalRetentionCandidates({ keepLatest: 0, limit: 100 }))[0]?.runId, + journal.runId, + ); +}); + +test("an index write failure after terminal ambiguity dirties admission until rebuild", async (t) => { + const root = await temporaryRoot(t); + let failIndex = false; + const store = new CreateImagesRunJournalStore(() => root, { + beforeIndexPublished: async () => { + if (failIndex) throw new Error("simulated-index-write-failure"); + }, + }); + await assert.rejects( + terminalAmbiguousRun(store, "run-index-dirty", () => { + failIndex = true; + }), + /simulated-index-write-failure/u, + ); + const authoritative = await store.get("run-index-dirty"); + assert.equal(projectCreateImagesRun(authoritative!).status, "needs_attention"); + const health = await store.indexHealth(); + assert.equal(health.status, "degraded"); + if (health.status === "degraded") assert.equal(health.diagnostic, "stale-derived-index"); + await assert.rejects( + store.hasUnresolvedAmbiguity("workflow-1"), + /simulated-index-write-failure/u, + ); + + failIndex = false; + assert.equal(await store.hasUnresolvedAmbiguity("workflow-1"), true); + assert.equal((await store.indexHealth()).status, "healthy"); +}); + +test("the workflow admission audit exposes authoritative nonterminal runs", async (t) => { + const queuedRoot = await temporaryRoot(t); + const queuedStore = new CreateImagesRunJournalStore(() => queuedRoot); + await queuedStore.start(startInput(), () => true); + assert.equal(await queuedStore.hasNonterminalRun("workflow-1"), true); + assert.deepEqual(await queuedStore.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: true, + hasUnresolvedAmbiguity: false, + }); + + const terminalRoot = await temporaryRoot(t); + const terminalStore = new CreateImagesRunJournalStore(() => terminalRoot); + await terminalFailedRun(terminalStore, "terminal-run", "workflow-1"); + assert.equal(await terminalStore.hasNonterminalRun("workflow-1"), false); + assert.deepEqual(await terminalStore.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }); +}); + +test("same-process checkpoint corruption cannot be hidden by journal or index caches", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await terminalFailedRun(store, "cached-terminal", "workflow-1"); + assert.deepEqual(await store.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }); + const directory = path.join(root, "runs", "cached-terminal"); + await Promise.all([ + fs.writeFile(path.join(directory, "run.json"), "{broken-current", "utf8"), + fs.writeFile(path.join(directory, "run.last-known-good.json"), "{broken-recovery", "utf8"), + ]); + + assert.deepEqual(await store.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: true, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }); + assert.equal( + (await store.auditWorkflowAdmission("unrelated-workflow")).hasDegradedAuthority, + true, + ); + await assert.rejects(store.get("cached-terminal"), CreateImagesRunJournalLoadError); + assert.deepEqual( + (await store.workflowDegradedCandidates("workflow-1")).map((candidate) => candidate.runId), + ["cached-terminal"], + ); +}); + +test("the admission audit completes a valid crash-pending mutation before deciding", async (t) => { + const root = await temporaryRoot(t); + let crash = true; + const store = new CreateImagesRunJournalStore(() => root, { + afterPendingPublished: async () => { + if (crash) throw new Error("simulated crash after pending authority"); + }, + }); + await assert.rejects( + store.start(startInput("pending-run"), () => true), + /simulated crash/u, + ); + crash = false; + + assert.deepEqual(await store.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: true, + hasUnresolvedAmbiguity: false, + }); + assert.equal((await store.health("pending-run")).status, "healthy"); +}); + +test("durable cancellation intent survives restart before node cancellation", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + const cancelled = await store.requestCancellation("run-1", journal.journalRevision, { + at: LATER, + reason: "renderer-disconnected", + }); + assert.equal(projectCreateImagesRun(cancelled).status, "cancel_requested"); + const restarted = new CreateImagesRunJournalStore(() => root); + const projection = projectCreateImagesRun( + (await restarted.get("run-1")) as CreateImagesRunJournalV1, + ); + assert.equal(projection.status, "cancel_requested"); + assert.equal(projection.cancellation?.reason, "renderer-disconnected"); + assert.deepEqual( + (await restarted.reconciliationCandidates()).map((candidate) => candidate.runId), + ["run-1"], + ); +}); + +test("restart removes only strictly named orphan atomic staging files", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput(), () => true); + const runDirectory = path.join(root, "runs", "run-1"); + const staged = path.join(runDirectory, ".run.json.12345678-1234-4123-8123-123456789abc.tmp"); + await fs.writeFile(staged, "partial", "utf8"); + assert.equal((await store.initialize())[0]?.status, "healthy"); + await assert.rejects(fs.lstat(staged), { code: "ENOENT" }); + + await fs.writeFile(path.join(runDirectory, ".unexpected.tmp"), "untrusted", "utf8"); + await assert.rejects(store.initialize(), CreateImagesRunJournalLoadError); +}); + +test("corrupt current is distinguishable and explicit last-known-good recovery is CAS guarded", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput(), () => true); + const currentPath = path.join(root, "runs", "run-1", "run.json"); + await fs.writeFile(currentPath, "{broken", "utf8"); + const health = await store.health("run-1"); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") assert.equal(health.reason, "current-corrupt"); + await assert.rejects(store.get("run-1"), CreateImagesRunJournalLoadError); + await assert.rejects( + store.recoverFromLastKnownGood("run-1", 99), + CreateImagesRunJournalRevisionConflictError, + ); + const recovered = await store.recoverFromLastKnownGood("run-1", 1); + assert.equal(recovered.journalRevision, 1); + assert.equal((await store.health("run-1")).status, "healthy"); +}); + +test("degraded discard refuses healthy and recoverable records and binds corrupt state", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("healthy-run"), () => true); + assert.deepEqual(await store.planDegradedRunDiscard("healthy-run"), { + status: "not-degraded", + }); + + await store.start(startInput("recoverable-run"), () => true); + await fs.writeFile( + path.join(root, "runs", "recoverable-run", "run.json"), + "{broken-current", + "utf8", + ); + assert.deepEqual(await store.planDegradedRunDiscard("recoverable-run"), { + status: "recoverable", + }); + + await store.start(startInput("discard-run"), () => true); + await Promise.all([ + fs.writeFile(path.join(root, "runs", "discard-run", "run.json"), "{broken", "utf8"), + fs.writeFile( + path.join(root, "runs", "discard-run", "run.last-known-good.json"), + "{broken", + "utf8", + ), + ]); + const planned = await store.planDegradedRunDiscard("discard-run"); + assert.equal(planned.status, "ready"); + if (planned.status !== "ready") return; + assert.equal(planned.plan.association, "workflow"); + assert.equal(planned.plan.workflowId, "workflow-1"); + await fs.writeFile( + path.join(root, "runs", "discard-run", "run.json"), + "{changed-corruption", + "utf8", + ); + assert.deepEqual( + await store.discardDegradedRun({ + runId: planned.plan.runId, + authorizationToken: planned.plan.authorizationToken, + }), + { status: "conflict" }, + ); + assert.notEqual((await store.health("discard-run")).status, "missing"); +}); + +test("unassociated degraded discard is crash-resumable and fail-closed for references", async (t) => { + const root = await temporaryRoot(t); + const seeded = new CreateImagesRunJournalStore(() => root); + await seeded.start(startInput("unassociated-run"), () => true); + await Promise.all([ + fs.writeFile( + path.join(root, "runs", "unassociated-run", "run.json"), + "{broken-current", + "utf8", + ), + fs.writeFile( + path.join(root, "runs", "unassociated-run", "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + fs.rm(path.join(root, "run-index.json")), + ]); + const rebuilt = new CreateImagesRunJournalStore(() => root); + await rebuilt.initialize(); + const planned = await rebuilt.planDegradedRunDiscard("unassociated-run"); + assert.equal(planned.status, "ready"); + if (planned.status !== "ready") return; + assert.equal(planned.plan.association, "unassociated"); + + const crashing = new CreateImagesRunJournalStore(() => root, { + afterDiscardManifestPublished: async () => { + throw new Error("discard-manifest-crash"); + }, + }); + await assert.rejects( + crashing.discardDegradedRun({ + runId: planned.plan.runId, + authorizationToken: planned.plan.authorizationToken, + }), + /discard-manifest-crash/u, + ); + assert.equal((await crashing.referenceInventory()).complete, false); + assert.equal(await crashing.get("unassociated-run"), undefined); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + assert.equal(await restarted.get("unassociated-run"), undefined); + assert.equal(await restarted.degradedRunCount(), 0); + assert.equal((await restarted.referenceInventory()).complete, true); + await assert.rejects(fs.lstat(path.join(root, "run-discard.pending.json")), { + code: "ENOENT", + }); +}); + +test("degraded discard resumes after atomic retirement and post-delete crash boundaries", async (t) => { + for (const boundary of ["afterDegradedRunRetired", "afterDiscardedRunDeleted"] as const) { + const root = await temporaryRoot(t); + const seeded = new CreateImagesRunJournalStore(() => root); + await seeded.start(startInput(`discard-${boundary}`), () => true); + await Promise.all([ + fs.writeFile( + path.join(root, "runs", `discard-${boundary}`, "run.json"), + "{broken-current", + "utf8", + ), + fs.writeFile( + path.join(root, "runs", `discard-${boundary}`, "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + const planned = await seeded.planDegradedRunDiscard(`discard-${boundary}`); + assert.equal(planned.status, "ready"); + if (planned.status !== "ready") continue; + const crashing = new CreateImagesRunJournalStore(() => root, { + [boundary]: async () => { + throw new Error(`crash-${boundary}`); + }, + }); + await assert.rejects( + crashing.discardDegradedRun({ + runId: planned.plan.runId, + authorizationToken: planned.plan.authorizationToken, + }), + new RegExp(`crash-${boundary}`, "u"), + ); + assert.equal((await crashing.referenceInventory()).complete, false); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + assert.equal(await restarted.get(planned.plan.runId), undefined); + assert.equal(await restarted.degradedRunCount(), 0); + assert.equal((await restarted.referenceInventory()).complete, true); + } +}); + +test("a forged discard manifest cannot retire a healthy journal", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("healthy-authority"), () => true); + const forgedPlan = { + version: 1 as const, + runId: "healthy-authority", + reason: "current-corrupt" as const, + association: "workflow" as const, + workflowId: "workflow-1", + expectedCurrentJournalRevision: 1, + expectedLastKnownGoodJournalRevision: 1, + recordFingerprint: "d".repeat(64), + }; + await fs.writeFile( + path.join(root, "run-discard.pending.json"), + `${JSON.stringify({ + ...forgedPlan, + authorizationToken: createHash("sha256") + .update(JSON.stringify(forgedPlan), "utf8") + .digest("hex"), + createdAt: NOW, + })}\n`, + "utf8", + ); + const restarted = new CreateImagesRunJournalStore(() => root); + await assert.rejects(restarted.initialize(), CreateImagesRunJournalRevisionConflictError); + const persisted = JSON.parse( + await fs.readFile(path.join(root, "runs", "healthy-authority", "run.json"), "utf8"), + ) as { runId?: string }; + assert.equal(persisted.runId, "healthy-authority"); +}); + +test("future schema and corrupt pending metadata fail closed without overwrite", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const journal = await store.start(startInput(), () => true); + const runDirectory = path.join(root, "runs", "run-1"); + const currentPath = path.join(runDirectory, "run.json"); + const future = { ...journal, version: 2 }; + await fs.writeFile(currentPath, `${JSON.stringify(future)}\n`, "utf8"); + const unsafe = await store.health("run-1"); + assert.equal(unsafe.status, "unsafe"); + await assert.rejects( + store.append("run-1", 1, event(journal, "run-started", {})), + CreateImagesRunJournalLoadError, + ); + assert.equal(JSON.parse(await fs.readFile(currentPath, "utf8")).version, 2); + + const root2 = await temporaryRoot(t); + const second = new CreateImagesRunJournalStore(() => root2); + await second.start(startInput(), () => true); + await fs.writeFile(path.join(root2, "runs", "run-1", "run.pending.json"), "{broken", "utf8"); + const corrupt = await second.health("run-1"); + assert.equal(corrupt.status, "recovery-required"); + if (corrupt.status === "recovery-required") assert.equal(corrupt.reason, "pending-corrupt"); +}); + +test("reference inventory retains durable outputs and fails closed around corruption", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await store.start(startInput(), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await startGenerateNode(store, journal); + journal = await append(store, journal, "node-submission-prepared", { + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-run1-node1-0001", + providerId: "mock", + modelId: "mock-image-v1", + }); + journal = await append(store, journal, "node-submission-accepted", { + nodeId: "generate-1", + attempt: 1, + providerJobId: "mock-job-1", + }); + const indexBeforeOutput = await fs.readFile(path.join(root, "run-index.json"), "utf8"); + journal = await append(store, journal, "node-output-published", { + nodeId: "generate-1", + outputAssetIds: [ASSET_ID], + }); + assert.equal(await fs.readFile(path.join(root, "run-index.json"), "utf8"), indexBeforeOutput); + await append(store, journal, "node-succeeded", { + nodeId: "generate-1", + outputAssetIds: [ASSET_ID], + }); + let inventory = await store.referenceInventory(); + assert.deepEqual(inventory, { + complete: true, + records: [{ runId: "run-1", assetIds: [ASSET_ID] }], + }); + await fs.writeFile(path.join(root, "runs", "run-1", "run.json"), "{broken", "utf8"); + inventory = await store.referenceInventory(); + assert.equal(inventory.complete, false); + assert.deepEqual(inventory.records[0]?.assetIds, [ASSET_ID]); +}); + +test("reference inventory unions immutable snapshot inputs before any run event", async (t) => { + const root = await temporaryRoot(t); + const snapshot: WorkflowDocumentV1 = { + ...workflow(), + nodes: [ + { + id: "input-1", + type: "image-input", + position: { x: -100, y: 0 }, + data: { assetId: INPUT_ASSET_ID }, + }, + ...workflow().nodes, + ], + assetRefs: [INPUT_ASSET_ID], + }; + const store = new CreateImagesRunJournalStore(() => root); + await store.start( + { + ...startInput(), + workflowSnapshot: snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds: ["input-1", "prompt-1", "generate-1", "output-1"], + dependencies: { + "input-1": [], + "prompt-1": [], + "generate-1": ["prompt-1"], + "output-1": ["generate-1"], + }, + }, + }, + () => true, + ); + assert.deepEqual(await store.referenceInventory(), { + complete: true, + records: [{ runId: "run-1", assetIds: [INPUT_ASSET_ID] }], + }); +}); + +test("corrupt recovery copies remain listable with trusted identity and explicit repair direction", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput(), () => true); + await fs.writeFile( + path.join(root, "runs", "run-1", "run.last-known-good.json"), + "{broken", + "utf8", + ); + const health = await store.health("run-1"); + assert.deepEqual(health, { + status: "recovery-required", + runId: "run-1", + reason: "last-known-good-corrupt", + canRecover: "from-current", + workflowId: "workflow-1", + workflowRevision: 3, + currentJournalRevision: 1, + }); + assert.deepEqual(await store.recoveryCandidates(), [ + { + runId: "run-1", + workflowId: "workflow-1", + workflowRevision: 3, + reason: "last-known-good-corrupt", + canRecover: "from-current", + expectedJournalRevision: 1, + }, + ]); + await assert.rejects( + store.recoverLastKnownGoodFromCurrent("run-1", 2), + CreateImagesRunJournalRevisionConflictError, + ); + await store.recoverLastKnownGoodFromCurrent("run-1", 1); + assert.equal((await store.health("run-1")).status, "healthy"); +}); + +test("future-schema event logs and hostile durable indexes fail closed without overwrite", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const journal = await store.start(startInput(), () => true); + await append(store, journal, "run-started", {}); + const logPath = path.join(root, "runs", "run-1", "run.events.jsonl"); + const record = JSON.parse((await fs.readFile(logPath, "utf8")).trim()) as Record; + record.version = 2; + await fs.writeFile(logPath, `${JSON.stringify(record)}\n`, "utf8"); + const health = await store.health("run-1"); + assert.equal(health.status, "unsafe"); + if (health.status === "unsafe") assert.equal(health.reason, "current-future-schema"); + + const indexPath = path.join(root, "run-index.json"); + const hostile = '{"version":1,"revision":1,"entries":[{"runId":"../escape"}]}\n'; + await fs.writeFile(indexPath, hostile, "utf8"); + const restarted = new CreateImagesRunJournalStore(() => root); + assert.deepEqual(await restarted.indexHealth(), { status: "corrupt" }); + await restarted.initialize(); + const rebuiltHealth = await restarted.indexHealth(); + assert.equal(rebuiltHealth.status, "degraded"); + if (rebuiltHealth.status === "degraded") { + assert.equal(rebuiltHealth.degradedEntryCount, 1); + assert.equal(rebuiltHealth.diagnostic, "rebuilt-corrupt-index"); + assert.equal(rebuiltHealth.quarantinedIndexCount, 1); + } + const quarantine = (await fs.readdir(root)).find((name) => + /^run-index\.corrupt\..+\.json$/u.test(name), + ); + assert.ok(quarantine); + assert.equal(await fs.readFile(path.join(root, quarantine), "utf8"), hostile); + + await fs.writeFile(indexPath, '{"version":2,"revision":1,"entries":[]}\n', "utf8"); + const future = new CreateImagesRunJournalStore(() => root); + assert.deepEqual(await future.indexHealth(), { status: "unsafe" }); + await assert.rejects(future.initialize(), (error: unknown) => { + assert.ok(error instanceof CreateImagesRunJournalLoadError); + assert.equal(error.status, "unsafe"); + return true; + }); +}); + +test("same-process index cache rejects an atomically replaced future schema without overwrite", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput(), () => true); + await store.initialize(); + assert.deepEqual(await store.auditWorkflowAdmission("workflow-1"), { + hasDegradedAuthority: false, + hasNonterminalRun: true, + hasUnresolvedAmbiguity: false, + }); + + const indexPath = path.join(root, "run-index.json"); + const replacementPath = path.join(root, "run-index.future-replacement.json"); + const futureBytes = '{"version":2,"revision":99,"entries":[],"degraded":[]}\n'; + await fs.writeFile(replacementPath, futureBytes, "utf8"); + await fs.rename(replacementPath, indexPath); + + await assert.rejects(store.auditWorkflowAdmission("workflow-1"), (error: unknown) => { + assert.ok(error instanceof CreateImagesRunJournalLoadError); + assert.equal(error.status, "unsafe"); + return true; + }); + assert.equal(await fs.readFile(indexPath, "utf8"), futureBytes); + assert.deepEqual(await store.indexHealth(), { status: "unsafe" }); +}); + +test("restart preserves future-schema and both-corrupt runs as bounded degraded records", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("future-run"), () => true); + await store.start(startInput("corrupt-run"), () => true); + + const futurePath = path.join(root, "runs", "future-run", "run.json"); + const future = JSON.parse(await fs.readFile(futurePath, "utf8")) as Record; + future.version = 2; + await fs.writeFile(futurePath, `${JSON.stringify(future)}\n`, "utf8"); + await Promise.all([ + fs.writeFile(path.join(root, "runs", "corrupt-run", "run.json"), "{broken-current", "utf8"), + fs.writeFile( + path.join(root, "runs", "corrupt-run", "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + const indexHealth = await restarted.indexHealth(); + assert.equal(indexHealth.status, "degraded"); + if (indexHealth.status === "degraded") assert.equal(indexHealth.degradedEntryCount, 2); + assert.deepEqual(await restarted.workflowDegradedCandidates("workflow-1"), [ + { + status: "recovery-required", + runId: "corrupt-run", + workflowId: "workflow-1", + workflowRevision: 3, + reason: "current-corrupt", + canRecover: false, + }, + { + status: "unsafe", + runId: "future-run", + workflowId: "workflow-1", + workflowRevision: 3, + reason: "current-future-schema", + }, + ]); + assert.equal((await restarted.degradedRuns()).length, 2); + assert.deepEqual(await restarted.referenceInventory(), { + complete: false, + records: [ + { runId: "corrupt-run", assetIds: [] }, + { runId: "future-run", assetIds: [] }, + ], + }); +}); + +test("startup revalidates a stale terminal index entry before reconciliation", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("queued-run"), () => true); + const indexPath = path.join(root, "run-index.json"); + const index = JSON.parse(await fs.readFile(indexPath, "utf8")) as { + entries: Array>; + }; + index.entries[0]!.status = "succeeded"; + index.entries[0]!.terminal = true; + await fs.writeFile(indexPath, `${JSON.stringify(index)}\n`, "utf8"); + + const restarted = new CreateImagesRunJournalStore(() => root); + const health = await restarted.initialize(); + assert.deepEqual(health, [ + { + status: "healthy", + runId: "queued-run", + journalRevision: 1, + runStatus: "queued", + }, + ]); + assert.deepEqual( + (await restarted.reconciliationCandidates()).map((journal) => journal.runId), + ["queued-run"], + ); + assert.deepEqual(await restarted.terminalHistory(), []); +}); + +test("startup surfaces a corrupt oldest terminal run beyond a 100-item history window", async (t) => { + const root = await temporaryRoot(t); + const runsPath = path.join(root, "runs"); + await fs.mkdir(runsPath, { recursive: true }); + const snapshot = emptyStartInput("seed").workflowSnapshot; + snapshot.id = "workflow-1"; + const plan = { + scope: { kind: "all" } as const, + orderedNodeIds: ["prompt-only"], + dependencies: { "prompt-only": [] }, + }; + const entries: Array> = []; + for (let index = 1; index <= 101; index += 1) { + const runId = `terminal-${String(index).padStart(3, "0")}`; + const createdAt = new Date(Date.parse(NOW) + index * 10_000).toISOString(); + let journal = createCreateImagesRunJournal({ + runId, + workflowSnapshot: snapshot, + workflowFingerprint: createImagesWorkflowSnapshotFingerprint(snapshot), + plan, + createdAt, + }); + for (const next of [ + { type: "run-started" as const }, + { type: "node-started" as const, nodeId: "prompt-only" }, + { + type: "node-failed" as const, + nodeId: "prompt-only", + errorCode: "test-failure", + }, + { type: "run-terminal" as const, status: "failed" as const }, + ]) { + journal = appendCreateImagesRunEvent(journal, { + ...next, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId, + sequence: journal.events.length + 1, + at: new Date(Date.parse(createdAt) + (journal.events.length + 1) * 1_000).toISOString(), + } as CreateImagesRunEventV1); + } + const directory = path.join(runsPath, runId); + await fs.mkdir(directory); + const serialized = `${JSON.stringify(journal)}\n`; + await Promise.all([ + fs.writeFile(path.join(directory, "run.json"), serialized, "utf8"), + fs.writeFile(path.join(directory, "run.last-known-good.json"), serialized, "utf8"), + ]); + entries.push({ + runId, + workflowId: "workflow-1", + workflowRevision: snapshot.revision, + journalRevision: journal.journalRevision, + status: "failed", + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + terminal: true, + health: "healthy", + }); + } + await fs.writeFile( + path.join(root, "run-index.json"), + `${JSON.stringify({ version: 1, revision: 1, entries })}\n`, + "utf8", + ); + await fs.writeFile( + path.join(runsPath, "terminal-001", "run.last-known-good.json"), + "{broken-oldest", + "utf8", + ); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + const degraded = await restarted.workflowDegradedCandidates("workflow-1"); + assert.equal(degraded.length, 1); + assert.equal(degraded[0]?.runId, "terminal-001"); + assert.equal((await restarted.terminalHistory()).length, 100); + const health = await restarted.indexHealth(); + assert.equal(health.status, "degraded"); + assert.equal((await restarted.referenceInventory()).complete, false); +}); + +test("terminal pruning is explicit, CAS-bound, and releases references only after durable retirement", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + let journal = await store.start(emptyStartInput("run-prune"), () => true); + journal = await append(store, journal, "run-started", {}); + journal = await append(store, journal, "node-started", { + nodeId: "prompt-only", + }); + journal = await append(store, journal, "node-failed", { + nodeId: "prompt-only", + errorCode: "test-failure", + }); + journal = await append(store, journal, "run-terminal", { status: "failed" }); + const plan = await store.planTerminalPrune([ + { runId: journal.runId, journalRevision: journal.journalRevision }, + ]); + assert.match(plan.token, /^[a-f0-9]{64}$/u); + assert.equal((await store.terminalHistory()).length, 1); + assert.deepEqual(await store.terminalPruneStatus(), { status: "none" }); + await assert.rejects( + store.pruneTerminalRuns({ + ...plan, + candidates: [{ runId: journal.runId, journalRevision: journal.journalRevision - 1 }], + }), + /stale|changed/u, + ); + assert.equal((await store.terminalHistory()).length, 1); + const result = await store.pruneTerminalRuns(plan); + assert.deepEqual(result, { + removedRunIds: ["run-prune"], + releasedAssetIds: [], + }); + assert.deepEqual(await store.referenceInventory(), { + complete: true, + records: [], + }); + assert.deepEqual(await store.terminalHistory(), []); + assert.equal((await store.health("run-prune")).status, "missing"); +}); + +test("directory identity mismatches and copied journals fail closed", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + await store.start(startInput("run-source"), () => true); + await fs.cp(path.join(root, "runs", "run-source"), path.join(root, "runs", "run-copy"), { + recursive: true, + }); + const copiedStore = new CreateImagesRunJournalStore(() => root); + const copied = await copiedStore.health("run-copy"); + assert.equal(copied.status, "recovery-required"); + if (copied.status === "recovery-required") { + assert.equal(copied.reason, "current-corrupt"); + assert.equal(copied.canRecover, false); + assert.equal(copied.workflowId, undefined); + } + assert.equal((await copiedStore.referenceInventory()).complete, false); + + const pendingRoot = await temporaryRoot(t); + const crashing = new CreateImagesRunJournalStore(() => pendingRoot, { + afterPendingPublished: async () => { + throw new Error("pending-boundary"); + }, + }); + await assert.rejects( + crashing.start(startInput("run-pending-source"), () => true), + /pending-boundary/u, + ); + await fs.rename( + path.join(pendingRoot, "runs", "run-pending-source"), + path.join(pendingRoot, "runs", "run-pending-copy"), + ); + const pendingHealth = await new CreateImagesRunJournalStore(() => pendingRoot).health( + "run-pending-copy", + ); + assert.equal(pendingHealth.status, "recovery-required"); + if (pendingHealth.status === "recovery-required") { + assert.equal(pendingHealth.reason, "pending-corrupt"); + } +}); + +test("journal and tail caches remain count-and-byte bounded across initialize", async (t) => { + const root = await temporaryRoot(t); + const limits = { + maxJournalCacheCount: 2, + maxJournalCacheBytes: 256 * 1024, + maxTailCacheCount: 2, + maxTailCacheBytes: 4 * 1024, + }; + const store = new CreateImagesRunJournalStore(() => root, {}, limits); + for (let index = 1; index <= 5; index += 1) { + await store.start(startInput(`run-cache-${index}`), () => true); + } + assert.ok(store.cacheStats().journalCount <= 2); + assert.ok(store.cacheStats().journalBytes <= limits.maxJournalCacheBytes); + + const restarted = new CreateImagesRunJournalStore(() => root, {}, limits); + await restarted.initialize(); + const stats = restarted.cacheStats(); + assert.ok(stats.journalCount <= 2); + assert.ok(stats.journalBytes <= limits.maxJournalCacheBytes); + assert.ok(stats.tailCount <= 2); + assert.ok(stats.tailBytes <= limits.maxTailCacheBytes); +}); + +test("terminal prune crash boundaries retain the manifest, tombstone caches, and resume on startup", async (t) => { + for (const boundary of [ + "afterPruneManifestPublished", + "afterRunRetired", + "beforeRetiredDelete", + "afterRetiredDelete", + ] as const) { + const root = await temporaryRoot(t); + let fail = true; + const hook = async () => { + if (!fail) return; + fail = false; + throw new Error(`crash-${boundary}`); + }; + const store = new CreateImagesRunJournalStore(() => root, { + [boundary]: hook, + }); + const journal = await terminalFailedRun(store, `run-prune-${boundary}`); + const plan = await store.planTerminalPrune([ + { runId: journal.runId, journalRevision: journal.journalRevision }, + ]); + await assert.rejects(store.pruneTerminalRuns(plan), new RegExp(`crash-${boundary}`, "u")); + assert.equal((await store.terminalPruneStatus()).status, "pending"); + assert.equal(await store.get(journal.runId), undefined); + await fs.lstat(path.join(root, "run-prune.pending.json")); + + const restarted = new CreateImagesRunJournalStore(() => root); + await restarted.initialize(); + assert.deepEqual(await restarted.terminalPruneStatus(), { status: "none" }); + assert.equal(await restarted.get(journal.runId), undefined); + assert.deepEqual(await restarted.terminalHistory(), []); + } +}); + +test("workflow recovery refresh and retention candidates are bounded index seams", async (t) => { + const root = await temporaryRoot(t); + const store = new CreateImagesRunJournalStore(() => root); + const first = await terminalFailedRun(store, "run-retention-1", "workflow-retention"); + const second = await terminalFailedRun(store, "run-retention-2", "workflow-retention"); + const global = await store.terminalRetentionCandidates({ + keepLatest: 1, + limit: 100, + }); + assert.equal(global.length, 1); + assert.equal(global[0]?.workflowId, "workflow-retention"); + const scoped = await store.terminalRetentionCandidates({ + workflowId: "workflow-retention", + keepLatest: 1, + limit: 100, + }); + assert.deepEqual(scoped, global); + + await fs.writeFile( + path.join(root, "runs", first.runId, "run.last-known-good.json"), + "{broken", + "utf8", + ); + const refreshed = await store.refreshWorkflowRecoveryMetadata("workflow-retention", [ + first.runId, + second.runId, + ]); + assert.equal(refreshed.length, 1); + assert.equal(refreshed[0]?.runId, first.runId); + assert.equal(refreshed[0]?.canRecover, "from-current"); + assert.deepEqual(await store.workflowRecoveryCandidates("workflow-retention"), refreshed); +}); + +test("run count and aggregate bytes are bounded before publication", async (t) => { + const root = await temporaryRoot(t); + const countBounded = new CreateImagesRunJournalStore(() => root, {}, { maxRunCount: 1 }); + await countBounded.start(startInput("run-1"), () => true); + await assert.rejects( + countBounded.start(startInput("run-2"), () => true), + /run count limit/u, + ); + + const root2 = await temporaryRoot(t); + const byteBounded = new CreateImagesRunJournalStore(() => root2, {}, { maxAggregateRunBytes: 1 }); + await assert.rejects( + byteBounded.start(startInput(), () => true), + /byte limit/u, + ); + assert.equal((await byteBounded.health("run-1")).status, "missing"); +}); diff --git a/main/services/create-images/run-journal-store.ts b/main/services/create-images/run-journal-store.ts new file mode 100644 index 00000000..45559615 --- /dev/null +++ b/main/services/create-images/run-journal-store.ts @@ -0,0 +1,4311 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants, type BigIntStats, type Dirent } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { + appendCreateImagesRunEvent, + CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES, + CREATE_IMAGES_RUN_JOURNAL_VERSION, + createCreateImagesRunJournal, + createImagesWorkflowSnapshotFingerprintMaterial, + isFutureCreateImagesRunJournal, + hasUnresolvedCreateImagesRunAmbiguity, + parseCreateImagesRunJournal, + projectCreateImagesRun, + type CreateImagesCancellationReason, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, + type CreateImagesRunPlanV1, + type CreateImagesRunProjection, + type CreateImagesRunProviderAuthorizationV1, + type CreateImagesRunTerminalStatus, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import { decodeUtf8, readRegularFile } from "../regular-file-read.js"; + +const PENDING_VERSION = 1 as const; +const CURRENT_FILE = "run.json"; +const LAST_KNOWN_GOOD_FILE = "run.last-known-good.json"; +const PENDING_FILE = "run.pending.json"; +const CURRENT_EVENTS_FILE = "run.events.jsonl"; +const LAST_KNOWN_GOOD_EVENTS_FILE = "run.last-known-good.events.jsonl"; +const RUN_INDEX_FILE = "run-index.json"; +const PRUNE_PENDING_FILE = "run-prune.pending.json"; +const DISCARD_PENDING_FILE = "run-discard.pending.json"; +const RETIRED_RUNS_DIRECTORY = "retired-runs"; +const DISCARDED_RUNS_DIRECTORY = "discarded-runs"; +const RUN_FILES = new Set([ + CURRENT_FILE, + LAST_KNOWN_GOOD_FILE, + PENDING_FILE, + CURRENT_EVENTS_FILE, + LAST_KNOWN_GOOD_EVENTS_FILE, +]); +const STAGED_FILE_PATTERN = + /^\.(?:run\.json|run\.last-known-good\.json|run\.pending\.json|run\.events\.jsonl|run\.last-known-good\.events\.jsonl)\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u; +const ROOT_STAGED_FILE_PATTERN = + /^\.(?:run-index\.json|run-prune\.pending\.json|run-discard\.pending\.json)\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/u; +const MAX_STAGED_FILES_PER_RUN = 8; +const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const MAX_PENDING_BYTES = CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES + 64 * 1024; +const MAX_EVENT_LOG_BYTES = CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES; +const MAX_HEALTH_PAGE_SIZE = 250; +const MAX_PRUNE_BATCH_SIZE = 100; +const MAX_DISCARD_DIRECTORY_ENTRIES = 32; +const MAX_DISCARD_FINGERPRINT_BYTES = MAX_PENDING_BYTES * 6; +const DEFAULT_MAX_RUN_COUNT = 1_000; +const DEFAULT_MAX_AGGREGATE_RUN_BYTES = 2 * 1024 * 1024 * 1024; +const DEFAULT_MAX_JOURNAL_CACHE_COUNT = 32; +const DEFAULT_MAX_JOURNAL_CACHE_BYTES = 32 * 1024 * 1024; +const DEFAULT_MAX_TAIL_CACHE_COUNT = 128; +const DEFAULT_MAX_TAIL_CACHE_BYTES = 64 * 1024; +const MAX_INDEX_QUARANTINES = 4; +const INDEX_QUARANTINE_PATTERN = + /^run-index\.corrupt\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.json$/u; + +interface PendingRunStartMutationV1 { + version: typeof PENDING_VERSION; + kind?: "start"; + runId: string; + baseJournalRevision: number | null; + targetJournalRevision: number; + stagedAt: string; + next: CreateImagesRunJournalV1; +} + +interface PendingRunAppendMutationV1 { + version: typeof PENDING_VERSION; + kind: "append"; + runId: string; + baseJournalRevision: number; + targetJournalRevision: number; + stagedAt: string; + event: CreateImagesRunEventV1; + authority: RunAuthorityIdentity; + targetJournalDigest: string; +} + +type PendingRunMutationV1 = PendingRunStartMutationV1 | PendingRunAppendMutationV1; + +interface RunEventLogRecordV1 { + version: 1; + runId: string; + journalRevision: number; + previousDigest: string; + digest: string; + event: CreateImagesRunEventV1; +} + +type RunAuthorityFileIdentity = + | { kind: "missing" } + | { + kind: "file"; + device: string; + inode: string; + size: string; + modifiedAtNs: string; + changedAtNs: string; + } + | { kind: "other" }; + +interface RunAuthorityIdentity { + current: RunAuthorityFileIdentity; + lastKnownGood: RunAuthorityFileIdentity; + currentEvents: RunAuthorityFileIdentity; + lastKnownGoodEvents: RunAuthorityFileIdentity; +} + +interface RunEventLogTailCache { + bytes: number; + digest: string; + identity: RunAuthorityFileIdentity; +} + +interface ParsedRunEventLog { + inspection: FileInspection; + tailDigest?: string; +} + +interface RunIndexEntryV1 { + runId: string; + workflowId: string; + workflowRevision: number; + journalRevision: number; + status: CreateImagesRunProjection["status"]; + createdAt: string; + updatedAt: string; + terminal: boolean; + unresolvedAmbiguity: boolean; + health: "healthy" | "recovery-required" | "unsafe"; + recoveryReason?: CreateImagesRunRecoveryReason; + unsafeReason?: CreateImagesRunUnsafeReason; + canRecover?: "from-last-known-good" | "from-current" | false; + expectedJournalRevision?: number; +} + +interface RunIndexV1 { + version: 1; + revision: number; + entries: RunIndexEntryV1[]; + degraded: RunUnassociatedDegradedEntryV1[]; +} + +interface RunUnassociatedDegradedEntryV1 { + runId: string; + status: "recovery-required" | "unsafe"; + recoveryReason?: CreateImagesRunRecoveryReason; + unsafeReason?: CreateImagesRunUnsafeReason; + canRecover: false; +} + +interface TerminalPruneManifestV1 extends CreateImagesTerminalPrunePlan { + createdAt: string; +} + +interface DegradedRunDiscardManifestV1 extends CreateImagesDegradedRunDiscardPlan { + createdAt: string; +} + +type FileInspection = + | { status: "missing" } + | { status: "healthy"; value: T } + | { status: "corrupt" } + | { status: "unsafe"; reason: "future-schema" | "unsafe-storage" }; + +export interface CreateImagesRunStartInput { + runId: string; + workflowSnapshot: WorkflowDocumentV1; + plan: CreateImagesRunPlanV1; + providerAuthorization?: CreateImagesRunProviderAuthorizationV1; + createdAt: string; +} + +export interface CreateImagesRunJournalDurability { + /** Crash seam after an authorized start/append intent is durable. */ + afterPendingPublished?: (runId: string) => Promise; + /** Crash seam after the new current journal is durable. */ + afterCurrentPublished?: (runId: string) => Promise; + /** Crash seam after the matching recovery copy is durable. */ + afterLastKnownGoodPublished?: (runId: string) => Promise; + /** Failure-injection seam after a terminal prune manifest is durable. */ + afterPruneManifestPublished?: (token: string) => Promise; + /** Failure-injection seam after each run directory is atomically retired. */ + afterRunRetired?: (runId: string) => Promise; + /** Failure-injection seam immediately before retired data is deleted. */ + beforeRetiredDelete?: (token: string) => Promise; + /** Failure-injection seam after deletion and parent fsync, before commit. */ + afterRetiredDelete?: (token: string) => Promise; + /** Failure-injection seam before the derived run index is atomically published. */ + beforeIndexPublished?: (revision: number) => Promise; + /** Failure-injection seam after a degraded-run discard manifest is durable. */ + afterDiscardManifestPublished?: (token: string) => Promise; + /** Failure-injection seam after a degraded run is atomically quarantined. */ + afterDegradedRunRetired?: (runId: string) => Promise; + /** Failure-injection seam after discarded data is deleted and its parent is synced. */ + afterDiscardedRunDeleted?: (token: string) => Promise; +} + +export interface CreateImagesRunJournalStoreLimits { + maxRunCount?: number; + maxAggregateRunBytes?: number; + maxJournalCacheCount?: number; + maxJournalCacheBytes?: number; + maxTailCacheCount?: number; + maxTailCacheBytes?: number; +} + +export type CreateImagesRunRecoveryReason = + | "current-corrupt" + | "current-missing" + | "last-known-good-corrupt" + | "last-known-good-missing" + | "last-known-good-mismatch" + | "pending-corrupt" + | "pending-conflict"; + +export type CreateImagesRunUnsafeReason = + | "current-future-schema" + | "last-known-good-future-schema" + | "pending-future-schema" + | "unsafe-storage"; + +export type CreateImagesRunJournalHealth = + | { + status: "missing"; + runId: string; + } + | { + status: "healthy"; + runId: string; + journalRevision: number; + runStatus: CreateImagesRunProjection["status"]; + } + | { + status: "recovery-required"; + runId: string; + reason: CreateImagesRunRecoveryReason; + canRecover: "from-last-known-good" | "from-current" | false; + workflowId?: string; + workflowRevision?: number; + currentJournalRevision?: number; + lastKnownGoodJournalRevision?: number; + } + | { + status: "unsafe"; + runId: string; + reason: CreateImagesRunUnsafeReason; + workflowId?: string; + workflowRevision?: number; + }; + +export interface CreateImagesTerminalRunSummary { + runId: string; + workflowId: string; + workflowRevision: number; + journalRevision: number; + status: CreateImagesRunTerminalStatus; + createdAt: string; + updatedAt: string; +} + +export interface CreateImagesRunReferenceInventory { + /** False means asset GC must fail closed and retain all assets. */ + complete: boolean; + records: Array<{ runId: string; assetIds: string[] }>; +} + +export interface CreateImagesRunHealthPage { + records: CreateImagesRunJournalHealth[]; + nextCursor?: string; +} + +export interface CreateImagesRunRecoveryCandidate { + runId: string; + workflowId?: string; + workflowRevision?: number; + reason: CreateImagesRunRecoveryReason; + canRecover: "from-last-known-good" | "from-current" | false; + expectedJournalRevision?: number; +} + +export interface CreateImagesRunUnsafeCandidate { + runId: string; + workflowId: string; + workflowRevision: number; + reason: CreateImagesRunUnsafeReason; +} + +export type CreateImagesRunDegradedCandidate = + | ({ status: "recovery-required" } & CreateImagesRunRecoveryCandidate) + | ({ status: "unsafe" } & CreateImagesRunUnsafeCandidate); + +export type CreateImagesRunStorageDegradedRecord = + | CreateImagesRunDegradedCandidate + | { + status: "recovery-required" | "unsafe"; + runId: string; + reason: CreateImagesRunRecoveryReason | CreateImagesRunUnsafeReason; + canRecover: false; + }; + +export interface CreateImagesTerminalPruneCandidate { + runId: string; + journalRevision: number; +} + +export interface CreateImagesTerminalPrunePlan { + version: 1; + candidates: CreateImagesTerminalPruneCandidate[]; + token: string; + assetIds: string[]; +} + +export interface CreateImagesTerminalPruneResult { + removedRunIds: string[]; + releasedAssetIds: string[]; +} + +export interface CreateImagesDegradedRunDiscardPlan { + version: 1; + runId: string; + reason: CreateImagesRunRecoveryReason | CreateImagesRunUnsafeReason; + association: "workflow" | "unassociated"; + workflowId?: string; + expectedCurrentJournalRevision?: number; + expectedLastKnownGoodJournalRevision?: number; + authorizationToken: string; + recordFingerprint: string; +} + +export type CreateImagesDegradedRunDiscardPlanResult = + | { status: "ready"; plan: CreateImagesDegradedRunDiscardPlan } + | { status: "not-found" | "not-degraded" | "recoverable" }; + +export interface CreateImagesDegradedRunDiscardResult { + runId: string; + workflowId?: string; +} + +export interface CreateImagesDegradedRunDiscardRequest { + runId: string; + expectedCurrentJournalRevision?: number; + expectedLastKnownGoodJournalRevision?: number; + authorizationToken: string; +} + +export type CreateImagesDegradedRunDiscardMutationResult = + | { status: "discarded"; result: CreateImagesDegradedRunDiscardResult } + | { status: "conflict" | "not-found" | "not-degraded" | "recoverable" }; + +export type CreateImagesRunIndexHealth = + | { status: "missing" } + | { + status: "healthy"; + revision: number; + entryCount: number; + diagnostic?: "rebuilt-corrupt-index" | "stale-derived-index"; + quarantinedIndexCount?: number; + } + | { + status: "degraded"; + revision: number; + entryCount: number; + degradedEntryCount: number; + diagnostic?: "rebuilt-corrupt-index" | "stale-derived-index"; + quarantinedIndexCount?: number; + } + | { status: "corrupt" } + | { status: "unsafe" }; + +export type CreateImagesTerminalPruneStatus = + | { status: "none" } + | { status: "pending"; plan: CreateImagesTerminalPrunePlan } + | { status: "corrupt" } + | { status: "unsafe" }; + +export interface CreateImagesTerminalRetentionQuery { + workflowId?: string; + keepLatest: number; + olderThan?: string; + limit?: number; +} + +export interface CreateImagesTerminalRetentionCandidate extends CreateImagesTerminalPruneCandidate { + workflowId: string; + updatedAt: string; + assetIds: string[]; +} + +export interface CreateImagesRunCacheStats { + journalCount: number; + journalBytes: number; + tailCount: number; + tailBytes: number; +} + +export interface CreateImagesWorkflowAdmissionAudit { + hasDegradedAuthority: boolean; + hasNonterminalRun: boolean; + hasUnresolvedAmbiguity: boolean; +} + +export class CreateImagesRunJournalLoadError extends Error { + constructor( + readonly status: "corrupt" | "unsafe", + readonly filePath: string, + ) { + super( + status === "unsafe" + ? "The Create Images run belongs to an unsupported schema or unsafe storage and is read-only." + : "The Create Images run journal is damaged and has been kept for recovery.", + ); + this.name = "CreateImagesRunJournalLoadError"; + } +} + +export class CreateImagesRunJournalRevisionConflictError extends Error { + constructor( + readonly runId: string, + readonly expectedJournalRevision: number | null, + readonly actualJournalRevision: number | null, + ) { + super( + `Run "${runId}" changed: expected journal revision ${expectedJournalRevision ?? "absent"}, found ${actualJournalRevision ?? "absent"}.`, + ); + this.name = "CreateImagesRunJournalRevisionConflictError"; + } +} + +const rootMutationTails = new Map>(); + +function serializedAtRoot(root: string, operation: () => Promise): Promise { + const key = path.resolve(root); + const tail = rootMutationTails.get(key) ?? Promise.resolve(); + const result = tail.then(operation, operation); + rootMutationTails.set( + key, + result.then( + () => undefined, + () => undefined, + ), + ); + return result; +} + +function validateRunId(runId: string): string { + if (!RUN_ID_PATTERN.test(runId)) throw new Error("Invalid Create Images run ID."); + return runId; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function futureVersion(value: unknown, field: "version"): boolean { + return isRecord(value) && typeof value[field] === "number" && value[field] > 1; +} + +function parsedJournal(value: unknown): CreateImagesRunJournalV1 | undefined { + const parsed = parseCreateImagesRunJournal(value); + return parsed.success ? parsed.value : undefined; +} + +function parseRunAuthorityFileIdentity(value: unknown): RunAuthorityFileIdentity | undefined { + if (!isRecord(value) || typeof value.kind !== "string") return undefined; + if (value.kind === "missing" || value.kind === "other") { + return Object.keys(value).length === 1 ? { kind: value.kind } : undefined; + } + if ( + value.kind !== "file" || + Object.keys(value).some( + (key) => !["kind", "device", "inode", "size", "modifiedAtNs", "changedAtNs"].includes(key), + ) || + ![value.device, value.inode, value.size, value.modifiedAtNs, value.changedAtNs].every( + (field) => typeof field === "string" && /^\d+$/u.test(field), + ) + ) { + return undefined; + } + return value as unknown as RunAuthorityFileIdentity; +} + +function parseRunAuthorityIdentity(value: unknown): RunAuthorityIdentity | undefined { + if ( + !isRecord(value) || + Object.keys(value).length !== 4 || + !["current", "lastKnownGood", "currentEvents", "lastKnownGoodEvents"].every((key) => + Object.prototype.hasOwnProperty.call(value, key), + ) + ) { + return undefined; + } + const current = parseRunAuthorityFileIdentity(value.current); + const lastKnownGood = parseRunAuthorityFileIdentity(value.lastKnownGood); + const currentEvents = parseRunAuthorityFileIdentity(value.currentEvents); + const lastKnownGoodEvents = parseRunAuthorityFileIdentity(value.lastKnownGoodEvents); + return current && lastKnownGood && currentEvents && lastKnownGoodEvents + ? { current, lastKnownGood, currentEvents, lastKnownGoodEvents } + : undefined; +} + +function parsePending(value: unknown): PendingRunMutationV1 | undefined { + if (isRecord(value) && value.kind === "append") { + if ( + Object.keys(value).some( + (key) => + ![ + "version", + "kind", + "runId", + "baseJournalRevision", + "targetJournalRevision", + "stagedAt", + "event", + "authority", + "targetJournalDigest", + ].includes(key), + ) || + value.version !== PENDING_VERSION || + typeof value.runId !== "string" || + !RUN_ID_PATTERN.test(value.runId) || + !Number.isSafeInteger(value.baseJournalRevision) || + (value.baseJournalRevision as number) < 1 || + !Number.isSafeInteger(value.targetJournalRevision) || + value.targetJournalRevision !== (value.baseJournalRevision as number) + 1 || + typeof value.stagedAt !== "string" || + !isRecord(value.event) || + value.event.runId !== value.runId || + value.event.sequence !== value.baseJournalRevision || + value.event.at !== value.stagedAt + ) { + return undefined; + } + const authority = parseRunAuthorityIdentity(value.authority); + if ( + !authority || + typeof value.targetJournalDigest !== "string" || + !/^[a-f0-9]{64}$/u.test(value.targetJournalDigest) + ) { + return undefined; + } + return { + version: PENDING_VERSION, + kind: "append", + runId: value.runId, + baseJournalRevision: value.baseJournalRevision as number, + targetJournalRevision: value.targetJournalRevision as number, + stagedAt: value.stagedAt, + event: value.event as unknown as CreateImagesRunEventV1, + authority, + targetJournalDigest: value.targetJournalDigest, + }; + } + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => + ![ + "version", + "kind", + "runId", + "baseJournalRevision", + "targetJournalRevision", + "stagedAt", + "next", + ].includes(key), + ) + ) { + return undefined; + } + const next = parsedJournal(value.next); + const base = + value.baseJournalRevision === null + ? null + : Number.isSafeInteger(value.baseJournalRevision) && + (value.baseJournalRevision as number) >= 1 + ? (value.baseJournalRevision as number) + : undefined; + const target = + Number.isSafeInteger(value.targetJournalRevision) && + (value.targetJournalRevision as number) >= 1 + ? (value.targetJournalRevision as number) + : undefined; + if ( + value.version !== PENDING_VERSION || + (value.kind !== undefined && value.kind !== "start") || + typeof value.runId !== "string" || + !RUN_ID_PATTERN.test(value.runId) || + base === undefined || + target === undefined || + target !== (base === null ? 1 : base + 1) || + !next || + next.runId !== value.runId || + next.journalRevision !== target || + typeof value.stagedAt !== "string" || + value.stagedAt !== next.updatedAt + ) { + return undefined; + } + return { + version: PENDING_VERSION, + ...(value.kind === "start" ? { kind: "start" as const } : {}), + runId: value.runId, + baseJournalRevision: base, + targetJournalRevision: target, + stagedAt: value.stagedAt, + next, + }; +} + +function identical(left: CreateImagesRunJournalV1, right: CreateImagesRunJournalV1): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function eventRecordDigest( + runId: string, + journalRevision: number, + previousDigest: string, + event: CreateImagesRunEventV1, +): string { + return createHash("sha256") + .update(JSON.stringify({ runId, journalRevision, previousDigest, event }), "utf8") + .digest("hex"); +} + +function journalDigest(journal: CreateImagesRunJournalV1): string { + return createHash("sha256").update(JSON.stringify(journal), "utf8").digest("hex"); +} + +function initialEventDigest(journal: CreateImagesRunJournalV1): string { + return journalDigest(journal); +} + +function serializedEventRecord( + base: CreateImagesRunJournalV1, + event: CreateImagesRunEventV1, + previousDigest: string, +): { record: RunEventLogRecordV1; bytes: Buffer } { + const journalRevision = event.sequence + 1; + const record: RunEventLogRecordV1 = { + version: 1, + runId: base.runId, + journalRevision, + previousDigest, + digest: eventRecordDigest(base.runId, journalRevision, previousDigest, event), + event, + }; + return { record, bytes: Buffer.from(`${JSON.stringify(record)}\n`, "utf8") }; +} + +function referencedAssetIds(journal: CreateImagesRunJournalV1): string[] { + const assetIds = new Set(journal.workflowSnapshot.assetRefs); + for (const event of journal.events) { + if (event.type === "node-succeeded" || event.type === "node-output-published") { + for (const assetId of event.outputAssetIds) assetIds.add(assetId); + } + } + return [...assetIds].sort(); +} + +export function createImagesWorkflowSnapshotFingerprint(snapshot: WorkflowDocumentV1): string { + return createHash("sha256") + .update(createImagesWorkflowSnapshotFingerprintMaterial(snapshot), "utf8") + .digest("hex"); +} + +/** + * Main-owned crash-safe run authority. + * + * Renderer liveness is consulted only until a start intent is durably + * published. Every later append and restart reconciliation is main-owned. + */ +export class CreateImagesRunJournalStore { + private readonly limits: Required; + private inventoryCache?: { + runIds: string[]; + aggregateBytes: number; + runBytes: Map; + }; + private indexCache?: RunIndexV1; + private indexAuthorityCache?: RunAuthorityFileIdentity; + private readonly journalCache = new Map(); + private readonly journalAuthorityCache = new Map(); + private readonly eventLogTailCache = new Map(); + private journalCacheBytes = 0; + private tailCacheBytes = 0; + private indexDiagnostic?: "rebuilt-corrupt-index"; + private indexDirty = false; + private readonly pruneTombstones = new Set(); + private pruneStateLoaded = false; + private discardStateLoaded = false; + constructor( + private readonly rootResolver: () => string, + private readonly durability: CreateImagesRunJournalDurability = {}, + limits: CreateImagesRunJournalStoreLimits = {}, + ) { + this.limits = { + maxRunCount: limits.maxRunCount ?? DEFAULT_MAX_RUN_COUNT, + maxAggregateRunBytes: limits.maxAggregateRunBytes ?? DEFAULT_MAX_AGGREGATE_RUN_BYTES, + maxJournalCacheCount: limits.maxJournalCacheCount ?? DEFAULT_MAX_JOURNAL_CACHE_COUNT, + maxJournalCacheBytes: limits.maxJournalCacheBytes ?? DEFAULT_MAX_JOURNAL_CACHE_BYTES, + maxTailCacheCount: limits.maxTailCacheCount ?? DEFAULT_MAX_TAIL_CACHE_COUNT, + maxTailCacheBytes: limits.maxTailCacheBytes ?? DEFAULT_MAX_TAIL_CACHE_BYTES, + }; + for (const [name, value] of Object.entries(this.limits)) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`Invalid Create Images run storage limit: ${name}.`); + } + } + } + + private journalCacheSize(journal: CreateImagesRunJournalV1): number { + return Buffer.byteLength(JSON.stringify(journal), "utf8"); + } + + private getCachedJournal(runId: string): CreateImagesRunJournalV1 | undefined { + const journal = this.journalCache.get(runId); + if (!journal) return undefined; + this.journalCache.delete(runId); + this.journalCache.set(runId, journal); + return journal; + } + + private cacheJournal( + runId: string, + journal: CreateImagesRunJournalV1, + authority: RunAuthorityIdentity, + ): void { + this.evictJournal(runId); + const bytes = this.journalCacheSize(journal); + if (bytes > this.limits.maxJournalCacheBytes) return; + this.journalCache.set(runId, journal); + this.journalAuthorityCache.set(runId, authority); + this.journalCacheBytes += bytes; + while ( + this.journalCache.size > this.limits.maxJournalCacheCount || + this.journalCacheBytes > this.limits.maxJournalCacheBytes + ) { + const oldest = this.journalCache.keys().next().value as string | undefined; + if (!oldest) break; + this.evictJournal(oldest); + } + } + + private evictJournal(runId: string): void { + const existing = this.journalCache.get(runId); + if (existing) { + this.journalCacheBytes -= this.journalCacheSize(existing); + this.journalCache.delete(runId); + } + this.journalAuthorityCache.delete(runId); + } + + private async fileAuthorityIdentity(target: string): Promise { + try { + const info = await fs.lstat(target, { bigint: true }); + return this.authorityIdentityFromStats(info); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" }; + throw error; + } + } + + private authorityIdentityFromStats(info: BigIntStats): RunAuthorityFileIdentity { + if (!info.isFile() || info.isSymbolicLink()) return { kind: "other" }; + return { + kind: "file", + device: info.dev.toString(), + inode: info.ino.toString(), + size: info.size.toString(), + modifiedAtNs: info.mtimeNs.toString(), + changedAtNs: info.ctimeNs.toString(), + }; + } + + private async runAuthorityIdentity(runId: string): Promise { + const paths = this.paths(runId); + const [current, lastKnownGood, currentEvents, lastKnownGoodEvents] = await Promise.all([ + this.fileAuthorityIdentity(paths.current), + this.fileAuthorityIdentity(paths.lastKnownGood), + this.fileAuthorityIdentity(paths.currentEvents), + this.fileAuthorityIdentity(paths.lastKnownGoodEvents), + ]); + return { current, lastKnownGood, currentEvents, lastKnownGoodEvents }; + } + + private clearIndexCache(): void { + this.indexCache = undefined; + this.indexAuthorityCache = undefined; + } + + private sameFileAuthorityIdentity( + left: RunAuthorityFileIdentity | undefined, + right: RunAuthorityFileIdentity, + ): boolean { + return left !== undefined && JSON.stringify(left) === JSON.stringify(right); + } + + private async bindIndexCache(index: RunIndexV1): Promise { + const identity = await this.fileAuthorityIdentity(this.indexPath()); + if (identity.kind !== "file") { + this.clearIndexCache(); + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + this.indexCache = index; + this.indexAuthorityCache = identity; + } + + private async cacheHealthyJournal( + runId: string, + journal: CreateImagesRunJournalV1, + ): Promise { + this.cacheJournal(runId, journal, await this.runAuthorityIdentity(runId)); + } + + private async cachedAuthorityIsCurrent(runId: string): Promise { + const expected = this.journalAuthorityCache.get(runId); + if (!expected) return false; + const pendingExists = await fs + .lstat(this.paths(runId).pending) + .then(() => true) + .catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + }); + if (pendingExists) return false; + return JSON.stringify(expected) === JSON.stringify(await this.runAuthorityIdentity(runId)); + } + + private sameRunAuthorityIdentity( + left: RunAuthorityIdentity | undefined, + right: RunAuthorityIdentity, + ): boolean { + return left !== undefined && JSON.stringify(left) === JSON.stringify(right); + } + + private tailCacheSize(target: string): number { + return Buffer.byteLength(target, "utf8") + 320; + } + + private getCachedTail(target: string): RunEventLogTailCache | undefined { + const tail = this.eventLogTailCache.get(target); + if (!tail) return undefined; + this.eventLogTailCache.delete(target); + this.eventLogTailCache.set(target, tail); + return tail; + } + + private cacheTail(target: string, tail: RunEventLogTailCache): void { + this.evictTail(target); + const bytes = this.tailCacheSize(target); + if (bytes > this.limits.maxTailCacheBytes) return; + this.eventLogTailCache.set(target, tail); + this.tailCacheBytes += bytes; + while ( + this.eventLogTailCache.size > this.limits.maxTailCacheCount || + this.tailCacheBytes > this.limits.maxTailCacheBytes + ) { + const oldest = this.eventLogTailCache.keys().next().value as string | undefined; + if (!oldest) break; + this.evictTail(oldest); + } + } + + private evictTail(target: string): void { + if (!this.eventLogTailCache.delete(target)) return; + this.tailCacheBytes -= this.tailCacheSize(target); + } + + private evictRunCaches(runId: string): void { + this.evictJournal(runId); + const paths = this.paths(runId); + this.evictTail(paths.currentEvents); + this.evictTail(paths.lastKnownGoodEvents); + } + + private root(): string { + return path.resolve(this.rootResolver()); + } + + private runsPath(): string { + return path.join(this.root(), "runs"); + } + + private runDirectory(runId: string): string { + return path.join(this.runsPath(), validateRunId(runId)); + } + + private paths(runId: string) { + const directory = this.runDirectory(runId); + return { + directory, + current: path.join(directory, CURRENT_FILE), + lastKnownGood: path.join(directory, LAST_KNOWN_GOOD_FILE), + pending: path.join(directory, PENDING_FILE), + currentEvents: path.join(directory, CURRENT_EVENTS_FILE), + lastKnownGoodEvents: path.join(directory, LAST_KNOWN_GOOD_EVENTS_FILE), + }; + } + + private indexPath(): string { + return path.join(this.root(), RUN_INDEX_FILE); + } + + private prunePendingPath(): string { + return path.join(this.root(), PRUNE_PENDING_FILE); + } + + private discardPendingPath(): string { + return path.join(this.root(), DISCARD_PENDING_FILE); + } + + private async syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } + + private async ensureDirectory(target: string): Promise { + const created = await fs.mkdir(target, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(target); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error("Create Images run storage contains an unsafe directory."); + } + if (created !== undefined) await this.syncDirectory(path.dirname(target)); + return created !== undefined; + } + + private async prepare(): Promise { + await this.ensureDirectory(this.root()); + await this.ensureDirectory(this.runsPath()); + let removed = false; + let stagedCount = 0; + const handle = await fs.opendir(this.root()); + for await (const entry of handle) { + if (!ROOT_STAGED_FILE_PATTERN.test(entry.name)) continue; + stagedCount += 1; + if (stagedCount > MAX_STAGED_FILES_PER_RUN) { + throw new CreateImagesRunJournalLoadError("unsafe", this.root()); + } + const target = path.join(this.root(), entry.name); + const info = await fs.lstat(target); + if (!entry.isFile() || entry.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", target); + } + await fs.rm(target); + removed = true; + } + if (removed) await this.syncDirectory(this.root()); + } + + private async readJson(target: string, maxBytes: number): Promise> { + let bytes: Buffer; + try { + bytes = await readRegularFile(target, maxBytes); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + if (["ELOOP", "EFTYPE", "ENXIO"].includes((error as NodeJS.ErrnoException).code ?? "")) { + return { status: "unsafe", reason: "unsafe-storage" }; + } + return { status: "corrupt" }; + } + try { + return { + status: "healthy", + value: JSON.parse(decodeUtf8(bytes)) as unknown, + }; + } catch { + return { status: "corrupt" }; + } + } + + private async inspectJournal(target: string): Promise> { + const raw = await this.readJson(target, CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + if (raw.status !== "healthy") return raw; + if (isFutureCreateImagesRunJournal(raw.value)) { + return { status: "unsafe", reason: "future-schema" }; + } + const journal = parsedJournal(raw.value); + if (!journal) return { status: "corrupt" }; + const fingerprint = createImagesWorkflowSnapshotFingerprint(journal.workflowSnapshot); + return fingerprint === journal.workflowFingerprint + ? { status: "healthy", value: journal } + : { status: "corrupt" }; + } + + private parseEventLogBytes( + bytes: Buffer, + checkpoint: CreateImagesRunJournalV1, + ): ParsedRunEventLog { + const text = decodeUtf8(bytes); + if (text.length > 0 && !text.endsWith("\n")) { + return { inspection: { status: "corrupt" } }; + } + const records: RunEventLogRecordV1[] = []; + let previousDigest = initialEventDigest(checkpoint); + let revision = checkpoint.journalRevision; + for (const line of text.split("\n")) { + if (line.length === 0) continue; + let raw: unknown; + try { + raw = JSON.parse(line) as unknown; + } catch { + return { inspection: { status: "corrupt" } }; + } + if (isRecord(raw) && typeof raw.version === "number" && raw.version > 1) { + return { inspection: { status: "unsafe", reason: "future-schema" } }; + } + if ( + !isRecord(raw) || + Object.keys(raw).some( + (key) => + !["version", "runId", "journalRevision", "previousDigest", "digest", "event"].includes( + key, + ), + ) || + raw.version !== 1 || + raw.runId !== checkpoint.runId || + raw.journalRevision !== revision + 1 || + raw.previousDigest !== previousDigest || + typeof raw.digest !== "string" || + !/^[a-f0-9]{64}$/u.test(raw.digest) || + !isRecord(raw.event) + ) { + return { inspection: { status: "corrupt" } }; + } + const event = raw.event as unknown as CreateImagesRunEventV1; + const digest = eventRecordDigest( + checkpoint.runId, + raw.journalRevision, + previousDigest, + event, + ); + if (digest !== raw.digest) return { inspection: { status: "corrupt" } }; + records.push(raw as unknown as RunEventLogRecordV1); + previousDigest = digest; + revision = raw.journalRevision; + } + const last = records[records.length - 1]; + const candidate = { + ...checkpoint, + journalRevision: revision, + updatedAt: last?.event.at ?? checkpoint.updatedAt, + events: [...checkpoint.events, ...records.map((record) => record.event)], + }; + const parsed = parsedJournal(candidate); + return parsed + ? { inspection: { status: "healthy", value: parsed }, tailDigest: previousDigest } + : { inspection: { status: "corrupt" } }; + } + + private async inspectEventLog( + target: string, + checkpoint: CreateImagesRunJournalV1, + ): Promise> { + const identityBeforeRead = await this.fileAuthorityIdentity(target); + if (identityBeforeRead.kind === "other") { + return { status: "unsafe", reason: "unsafe-storage" }; + } + let bytes: Buffer; + try { + bytes = await readRegularFile(target, MAX_EVENT_LOG_BYTES); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + const identityAfterRead = await this.fileAuthorityIdentity(target); + if ( + identityBeforeRead.kind !== "missing" || + !this.sameFileAuthorityIdentity(identityBeforeRead, identityAfterRead) + ) { + return { status: "corrupt" }; + } + this.cacheTail(target, { + bytes: 0, + digest: initialEventDigest(checkpoint), + identity: identityAfterRead, + }); + return { status: "healthy", value: checkpoint }; + } + if (["ELOOP", "EFTYPE", "ENXIO"].includes((error as NodeJS.ErrnoException).code ?? "")) { + return { status: "unsafe", reason: "unsafe-storage" }; + } + return { status: "corrupt" }; + } + const identityAfterRead = await this.fileAuthorityIdentity(target); + if ( + identityAfterRead.kind !== "file" || + !this.sameFileAuthorityIdentity(identityBeforeRead, identityAfterRead) || + BigInt(bytes.length) !== BigInt(identityAfterRead.size) + ) { + return identityAfterRead.kind === "other" + ? { status: "unsafe", reason: "unsafe-storage" } + : { status: "corrupt" }; + } + const parsed = this.parseEventLogBytes(bytes, checkpoint); + if (parsed.inspection.status === "healthy" && parsed.tailDigest) { + this.cacheTail(target, { + bytes: bytes.length, + digest: parsed.tailDigest, + identity: identityAfterRead, + }); + } + return parsed.inspection; + } + + private async inspectPending(target: string): Promise> { + const raw = await this.readJson(target, MAX_PENDING_BYTES); + if (raw.status !== "healthy") return raw; + if (futureVersion(raw.value, "version")) { + return { status: "unsafe", reason: "future-schema" }; + } + const pending = parsePending(raw.value); + if (!pending) return { status: "corrupt" }; + if (pending.kind === "append") return { status: "healthy", value: pending }; + const fingerprint = createImagesWorkflowSnapshotFingerprint(pending.next.workflowSnapshot); + return fingerprint === pending.next.workflowFingerprint + ? { status: "healthy", value: pending } + : { status: "corrupt" }; + } + + private async inspected(runId: string): Promise<{ + paths: ReturnType; + current: FileInspection; + lastKnownGood: FileInspection; + pending: FileInspection; + }> { + const paths = this.paths(runId); + try { + const info = await fs.lstat(paths.directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + return { + paths, + current: { status: "unsafe", reason: "unsafe-storage" }, + lastKnownGood: { status: "unsafe", reason: "unsafe-storage" }, + pending: { status: "unsafe", reason: "unsafe-storage" }, + }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return { + paths, + current: { status: "missing" }, + lastKnownGood: { status: "missing" }, + pending: { status: "missing" }, + }; + } + const [unboundCurrentCheckpoint, unboundLastKnownGoodCheckpoint, unboundPending] = + await Promise.all([ + this.inspectJournal(paths.current), + this.inspectJournal(paths.lastKnownGood), + this.inspectPending(paths.pending), + ]); + const bindJournal = ( + inspection: FileInspection, + ): FileInspection => + inspection.status === "healthy" && inspection.value.runId !== runId + ? { status: "corrupt" } + : inspection; + const currentCheckpoint = bindJournal(unboundCurrentCheckpoint); + const lastKnownGoodCheckpoint = bindJournal(unboundLastKnownGoodCheckpoint); + const pending: FileInspection = + unboundPending.status === "healthy" && unboundPending.value.runId !== runId + ? { status: "corrupt" } + : unboundPending; + const [current, lastKnownGood] = await Promise.all([ + currentCheckpoint.status === "healthy" + ? this.inspectEventLog(paths.currentEvents, currentCheckpoint.value) + : currentCheckpoint, + lastKnownGoodCheckpoint.status === "healthy" + ? this.inspectEventLog(paths.lastKnownGoodEvents, lastKnownGoodCheckpoint.value) + : lastKnownGoodCheckpoint, + ]); + return { paths, current, lastKnownGood, pending }; + } + + private async writeAtomic( + target: string, + value: unknown, + maxBytes: number, + canPublish: () => boolean = () => true, + ): Promise { + const directory = path.dirname(target); + const serialized = `${JSON.stringify(value, null, 2)}\n`; + if (Buffer.byteLength(serialized, "utf8") > maxBytes) { + throw new Error("Create Images run metadata exceeds its storage limit."); + } + const createdDirectory = await this.ensureDirectory(directory); + const staged = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`); + let publicationError: unknown; + try { + try { + const existing = await fs.lstat(target); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw new Error("Create Images run storage contains an unsafe file."); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await fs.writeFile(staged, serialized, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + const handle = await fs.open(staged, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + if (!canPublish()) throw new Error("The renderer document is no longer active."); + await fs.rename(staged, target); + await this.syncDirectory(directory); + } catch (error) { + publicationError = error; + } + await fs.rm(staged, { force: true }).catch(() => undefined); + if (createdDirectory) { + try { + await fs.rmdir(directory); + await this.syncDirectory(path.dirname(directory)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if ( + publicationError === undefined && + code !== "ENOENT" && + code !== "ENOTEMPTY" && + code !== "EEXIST" + ) { + publicationError = error; + } + } + } + if (publicationError !== undefined) throw publicationError; + } + + private async appendEventRecord( + target: string, + checkpointTarget: string, + base: CreateImagesRunJournalV1, + event: CreateImagesRunEventV1, + expectedCheckpointIdentity?: RunAuthorityFileIdentity, + expectedEventLogIdentity?: RunAuthorityFileIdentity, + ): Promise { + const directory = path.dirname(target); + await this.ensureDirectory(directory); + let previousDigest = initialEventDigest(base); + let created = false; + let existingBytes = 0; + const cachedTail = this.getCachedTail(target); + const checkpointIdentity = await this.fileAuthorityIdentity(checkpointTarget); + const eventLogIdentity = await this.fileAuthorityIdentity(target); + if (checkpointIdentity.kind !== "file" || eventLogIdentity.kind === "other") { + throw new CreateImagesRunJournalLoadError("unsafe", target); + } + if ( + (expectedCheckpointIdentity && + !this.sameFileAuthorityIdentity(expectedCheckpointIdentity, checkpointIdentity)) || + (expectedEventLogIdentity && + !this.sameFileAuthorityIdentity(expectedEventLogIdentity, eventLogIdentity)) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + if (eventLogIdentity.kind === "file") { + existingBytes = Number(eventLogIdentity.size); + if ( + cachedTail?.bytes === existingBytes && + this.sameFileAuthorityIdentity(cachedTail.identity, eventLogIdentity) + ) { + previousDigest = cachedTail.digest; + } else { + const checkpoint = await this.inspectJournal(checkpointTarget); + if (checkpoint.status !== "healthy") { + throw new CreateImagesRunJournalLoadError( + checkpoint.status === "unsafe" ? "unsafe" : "corrupt", + checkpointTarget, + ); + } + const reconstructed = await this.inspectEventLog(target, checkpoint.value); + if (reconstructed.status !== "healthy" || !identical(reconstructed.value, base)) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + const validatedTail = this.getCachedTail(target); + if ( + !validatedTail || + !this.sameFileAuthorityIdentity(validatedTail.identity, eventLogIdentity) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + previousDigest = validatedTail.digest; + } + } else { + created = true; + } + const journalRevision = event.sequence + 1; + const record: RunEventLogRecordV1 = { + version: 1, + runId: base.runId, + journalRevision, + previousDigest, + digest: eventRecordDigest(base.runId, journalRevision, previousDigest, event), + event, + }; + const serialized = `${JSON.stringify(record)}\n`; + const serializedBytes = Buffer.byteLength(serialized, "utf8"); + if (existingBytes + serializedBytes > MAX_EVENT_LOG_BYTES) { + throw new Error("Create Images run metadata exceeds its storage limit."); + } + if ( + !this.sameFileAuthorityIdentity( + checkpointIdentity, + await this.fileAuthorityIdentity(checkpointTarget), + ) || + !this.sameFileAuthorityIdentity(eventLogIdentity, await this.fileAuthorityIdentity(target)) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + const flags = created + ? constants.O_WRONLY | + constants.O_APPEND | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW + : constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW; + let handle: fs.FileHandle; + try { + handle = await fs.open(target, flags, 0o600); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + throw new CreateImagesRunJournalLoadError( + ["ELOOP", "EFTYPE", "ENXIO"].includes(code ?? "") ? "unsafe" : "corrupt", + target, + ); + } + let finalIdentity: RunAuthorityFileIdentity; + try { + const descriptorIdentity = this.authorityIdentityFromStats( + await handle.stat({ bigint: true }), + ); + if ( + descriptorIdentity.kind !== "file" || + (!created && !this.sameFileAuthorityIdentity(eventLogIdentity, descriptorIdentity)) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + await handle.writeFile(serialized, { encoding: "utf8" }); + await handle.sync(); + finalIdentity = this.authorityIdentityFromStats(await handle.stat({ bigint: true })); + if ( + finalIdentity.kind !== "file" || + Number(finalIdentity.size) !== existingBytes + serializedBytes + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + } finally { + await handle.close(); + } + const publishedIdentity = await this.fileAuthorityIdentity(target); + if ( + !this.sameFileAuthorityIdentity(finalIdentity, publishedIdentity) || + !this.sameFileAuthorityIdentity( + checkpointIdentity, + await this.fileAuthorityIdentity(checkpointTarget), + ) + ) { + throw new CreateImagesRunJournalLoadError( + publishedIdentity.kind === "other" ? "unsafe" : "corrupt", + target, + ); + } + this.cacheTail(target, { + bytes: existingBytes + serializedBytes, + digest: record.digest, + identity: publishedIdentity, + }); + if (created) await this.syncDirectory(directory); + return publishedIdentity; + } + + private async replaceTornEventLog( + target: string, + bytes: Buffer, + expectedTornIdentity: RunAuthorityFileIdentity, + tailDigest: string, + ): Promise { + if (bytes.length > MAX_EVENT_LOG_BYTES || expectedTornIdentity.kind !== "file") { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + const directory = path.dirname(target); + await this.ensureDirectory(directory); + const staged = path.join(directory, `.${path.basename(target)}.${randomUUID()}.tmp`); + try { + if ( + !this.sameFileAuthorityIdentity( + expectedTornIdentity, + await this.fileAuthorityIdentity(target), + ) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + await fs.writeFile(staged, bytes, { flag: "wx", mode: 0o600 }); + const stagedHandle = await fs.open(staged, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + await stagedHandle.sync(); + } finally { + await stagedHandle.close(); + } + if ( + !this.sameFileAuthorityIdentity( + expectedTornIdentity, + await this.fileAuthorityIdentity(target), + ) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + await fs.rename(staged, target); + await this.syncDirectory(directory); + } catch (error) { + await fs.rm(staged, { force: true }).catch(() => undefined); + throw error; + } + const identity = await this.fileAuthorityIdentity(target); + if (identity.kind !== "file" || Number(identity.size) !== bytes.length) { + throw new CreateImagesRunJournalLoadError("corrupt", target); + } + this.cacheTail(target, { bytes: bytes.length, digest: tailDigest, identity }); + return identity; + } + + private async replaceCheckpoint( + checkpointPath: string, + eventLogPath: string, + journal: CreateImagesRunJournalV1, + ): Promise { + await this.writeAtomic(checkpointPath, journal, CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + await this.removeDurably(eventLogPath); + this.cacheTail(eventLogPath, { + bytes: 0, + digest: initialEventDigest(journal), + identity: { kind: "missing" }, + }); + } + + private async removeDurably(target: string): Promise { + try { + await fs.rm(target); + await this.syncDirectory(path.dirname(target)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + + private async boundedEntries(directory: string, maxEntries: number): Promise { + const entries: Dirent[] = []; + const handle = await fs.opendir(directory); + for await (const entry of handle) { + entries.push(entry); + if (entries.length > maxEntries) { + throw new CreateImagesRunJournalLoadError("unsafe", directory); + } + } + return entries; + } + + private async inventory(force = false): Promise<{ + runIds: string[]; + aggregateBytes: number; + runBytes: Map; + }> { + if (!force && this.inventoryCache) return this.inventoryCache; + const runIds: string[] = []; + const runBytes = new Map(); + let aggregateBytes = 0; + for (const entry of await this.boundedEntries(this.runsPath(), this.limits.maxRunCount)) { + const entryPath = path.join(this.runsPath(), entry.name); + const info = await fs.lstat(entryPath); + if ( + !RUN_ID_PATTERN.test(entry.name) || + !entry.isDirectory() || + entry.isSymbolicLink() || + !info.isDirectory() || + info.isSymbolicLink() + ) { + throw new CreateImagesRunJournalLoadError("unsafe", entryPath); + } + runIds.push(entry.name); + let bytesForRun = 0; + let removedStagedFile = false; + for (const child of await this.boundedEntries( + entryPath, + RUN_FILES.size + MAX_STAGED_FILES_PER_RUN, + )) { + const childPath = path.join(entryPath, child.name); + const childInfo = await fs.lstat(childPath); + if (STAGED_FILE_PATTERN.test(child.name)) { + if ( + !child.isFile() || + child.isSymbolicLink() || + !childInfo.isFile() || + childInfo.isSymbolicLink() + ) { + throw new CreateImagesRunJournalLoadError("unsafe", childPath); + } + await fs.rm(childPath); + removedStagedFile = true; + continue; + } + if ( + !RUN_FILES.has(child.name) || + !child.isFile() || + child.isSymbolicLink() || + !childInfo.isFile() || + childInfo.isSymbolicLink() + ) { + throw new CreateImagesRunJournalLoadError("unsafe", childPath); + } + aggregateBytes += childInfo.size; + bytesForRun += childInfo.size; + if ( + !Number.isSafeInteger(aggregateBytes) || + aggregateBytes > this.limits.maxAggregateRunBytes + ) { + throw new Error("Create Images run storage has reached its aggregate byte limit."); + } + } + runBytes.set(entry.name, bytesForRun); + if (removedStagedFile) await this.syncDirectory(entryPath); + } + this.inventoryCache = { runIds: runIds.sort(), aggregateBytes, runBytes }; + return this.inventoryCache; + } + + private async refreshInventoryRun(runId: string): Promise { + if (!this.inventoryCache) return; + const oldBytes = this.inventoryCache.runBytes.get(runId) ?? 0; + let nextBytes = 0; + try { + for (const child of await this.boundedEntries( + this.runDirectory(runId), + RUN_FILES.size + MAX_STAGED_FILES_PER_RUN, + )) { + if (!RUN_FILES.has(child.name)) continue; + const info = await fs.lstat(path.join(this.runDirectory(runId), child.name)); + if (!info.isFile() || info.isSymbolicLink()) { + this.inventoryCache = undefined; + return; + } + nextBytes += info.size; + } + } catch { + this.inventoryCache = undefined; + return; + } + this.inventoryCache.aggregateBytes += nextBytes - oldBytes; + this.inventoryCache.runBytes.set(runId, nextBytes); + if (!this.inventoryCache.runIds.includes(runId)) { + this.inventoryCache.runIds.push(runId); + this.inventoryCache.runIds.sort(); + } + } + + private serializedBytes(value: unknown): number { + return Buffer.byteLength(`${JSON.stringify(value, null, 2)}\n`, "utf8"); + } + + private parseIndex(value: unknown): RunIndexV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => !["version", "revision", "entries", "degraded"].includes(key), + ) || + value.version !== 1 || + !Number.isSafeInteger(value.revision) || + (value.revision as number) < 1 || + !Array.isArray(value.entries) || + value.entries.length > this.limits.maxRunCount || + (value.degraded !== undefined && !Array.isArray(value.degraded)) + ) { + return undefined; + } + const statuses = new Set([ + "queued", + "running", + "cancel_requested", + "needs_attention", + "succeeded", + "failed", + "cancelled", + "interrupted", + ]); + const entries: RunIndexEntryV1[] = []; + const seen = new Set(); + for (const candidate of value.entries) { + if ( + !isRecord(candidate) || + Object.keys(candidate).some( + (key) => + ![ + "runId", + "workflowId", + "workflowRevision", + "journalRevision", + "status", + "createdAt", + "updatedAt", + "terminal", + "unresolvedAmbiguity", + // Accepted only for migration from the original derived index. + // References are authoritative in the journals and event logs; + // retaining them here made a 1,000-run index exceed 16 MiB. + "assetIds", + "health", + "recoveryReason", + "unsafeReason", + "canRecover", + "expectedJournalRevision", + ].includes(key), + ) || + typeof candidate.runId !== "string" || + !RUN_ID_PATTERN.test(candidate.runId) || + seen.has(candidate.runId) || + typeof candidate.workflowId !== "string" || + !RUN_ID_PATTERN.test(candidate.workflowId) || + !Number.isSafeInteger(candidate.workflowRevision) || + (candidate.workflowRevision as number) < 1 || + !Number.isSafeInteger(candidate.journalRevision) || + (candidate.journalRevision as number) < 1 || + typeof candidate.status !== "string" || + !statuses.has(candidate.status as CreateImagesRunProjection["status"]) || + typeof candidate.createdAt !== "string" || + !Number.isFinite(Date.parse(candidate.createdAt)) || + typeof candidate.updatedAt !== "string" || + !Number.isFinite(Date.parse(candidate.updatedAt)) || + typeof candidate.terminal !== "boolean" || + (candidate.unresolvedAmbiguity !== undefined && + typeof candidate.unresolvedAmbiguity !== "boolean") || + (candidate.assetIds !== undefined && + (!Array.isArray(candidate.assetIds) || + candidate.assetIds.length > 10_000 || + candidate.assetIds.some( + (assetId) => typeof assetId !== "string" || !/^[a-f0-9]{64}$/u.test(assetId), + ) || + new Set(candidate.assetIds).size !== candidate.assetIds.length)) + ) { + return undefined; + } + const health = candidate.health ?? "healthy"; + if (health !== "healthy" && health !== "recovery-required" && health !== "unsafe") + return undefined; + if (health === "recovery-required") { + if ( + ![ + "current-corrupt", + "current-missing", + "last-known-good-corrupt", + "last-known-good-missing", + "last-known-good-mismatch", + "pending-corrupt", + "pending-conflict", + ].includes(candidate.recoveryReason as string) || + !["from-last-known-good", "from-current", false].includes( + candidate.canRecover as never, + ) || + (candidate.expectedJournalRevision !== undefined && + (!Number.isSafeInteger(candidate.expectedJournalRevision) || + (candidate.expectedJournalRevision as number) < 1)) || + candidate.unsafeReason !== undefined + ) { + return undefined; + } + } else if (health === "unsafe") { + if ( + ![ + "current-future-schema", + "last-known-good-future-schema", + "pending-future-schema", + "unsafe-storage", + ].includes(candidate.unsafeReason as string) || + candidate.recoveryReason !== undefined || + candidate.canRecover !== undefined || + candidate.expectedJournalRevision !== undefined + ) { + return undefined; + } + } else if ( + candidate.recoveryReason !== undefined || + candidate.unsafeReason !== undefined || + candidate.canRecover !== undefined || + candidate.expectedJournalRevision !== undefined + ) { + return undefined; + } + seen.add(candidate.runId); + const { assetIds: _legacyAssetIds, ...metadata } = candidate; + entries.push({ + ...(metadata as unknown as RunIndexEntryV1), + health, + unresolvedAmbiguity: candidate.unresolvedAmbiguity ?? false, + }); + } + const degraded: RunUnassociatedDegradedEntryV1[] = []; + const degradedValues = (value.degraded ?? []) as unknown[]; + if (degradedValues.length + entries.length > this.limits.maxRunCount) return undefined; + for (const candidate of degradedValues) { + if ( + !isRecord(candidate) || + Object.keys(candidate).some( + (key) => + !["runId", "status", "recoveryReason", "unsafeReason", "canRecover"].includes(key), + ) || + typeof candidate.runId !== "string" || + !RUN_ID_PATTERN.test(candidate.runId) || + seen.has(candidate.runId) || + candidate.canRecover !== false + ) { + return undefined; + } + if ( + candidate.status === "recovery-required" && + [ + "current-corrupt", + "current-missing", + "last-known-good-corrupt", + "last-known-good-missing", + "last-known-good-mismatch", + "pending-corrupt", + "pending-conflict", + ].includes(candidate.recoveryReason as string) && + candidate.unsafeReason === undefined + ) { + degraded.push(candidate as unknown as RunUnassociatedDegradedEntryV1); + } else if ( + candidate.status === "unsafe" && + [ + "current-future-schema", + "last-known-good-future-schema", + "pending-future-schema", + "unsafe-storage", + ].includes(candidate.unsafeReason as string) && + candidate.recoveryReason === undefined + ) { + degraded.push(candidate as unknown as RunUnassociatedDegradedEntryV1); + } else { + return undefined; + } + seen.add(candidate.runId); + } + return { + version: 1, + revision: value.revision as number, + entries, + degraded, + }; + } + + private async quarantineCorruptIndex(): Promise { + const existing = await this.quarantinedIndexCount(); + if (existing >= MAX_INDEX_QUARANTINES) { + throw new CreateImagesRunJournalLoadError("corrupt", this.indexPath()); + } + const quarantine = path.join(this.root(), `run-index.corrupt.${randomUUID()}.json`); + await fs.rename(this.indexPath(), quarantine); + await this.syncDirectory(this.root()); + this.indexDiagnostic = "rebuilt-corrupt-index"; + this.clearIndexCache(); + } + + private async quarantinedIndexCount(): Promise { + let count = 0; + const handle = await fs.opendir(this.root()); + for await (const entry of handle) { + if (!INDEX_QUARANTINE_PATTERN.test(entry.name)) continue; + const info = await fs.lstat(path.join(this.root(), entry.name)); + if (!entry.isFile() || entry.isSymbolicLink() || !info.isFile() || info.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", this.root()); + } + count += 1; + if (count > MAX_INDEX_QUARANTINES) { + throw new CreateImagesRunJournalLoadError("corrupt", this.root()); + } + } + return count; + } + + private async loadIndex(recoverCorrupt = false): Promise { + if (this.indexCache) { + const identity = await this.fileAuthorityIdentity(this.indexPath()); + if (this.sameFileAuthorityIdentity(this.indexAuthorityCache, identity)) { + return this.indexCache; + } + this.clearIndexCache(); + } + const identityBeforeRead = await this.fileAuthorityIdentity(this.indexPath()); + const raw = await this.readJson(this.indexPath(), CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + const identityAfterRead = await this.fileAuthorityIdentity(this.indexPath()); + if (!this.sameFileAuthorityIdentity(identityBeforeRead, identityAfterRead)) { + this.clearIndexCache(); + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + if (raw.status === "missing") return undefined; + if (raw.status === "unsafe") { + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + if (raw.status !== "healthy") { + if (recoverCorrupt && raw.status === "corrupt") { + await this.quarantineCorruptIndex(); + return undefined; + } + throw new CreateImagesRunJournalLoadError("corrupt", this.indexPath()); + } + if (futureVersion(raw.value, "version")) { + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + const index = this.parseIndex(raw.value); + if (!index && recoverCorrupt) { + await this.quarantineCorruptIndex(); + return undefined; + } + if (!index) throw new CreateImagesRunJournalLoadError("corrupt", this.indexPath()); + if (identityAfterRead.kind !== "file") { + throw new CreateImagesRunJournalLoadError("unsafe", this.indexPath()); + } + this.indexCache = index; + this.indexAuthorityCache = identityAfterRead; + return index; + } + + private entryFor(journal: CreateImagesRunJournalV1): RunIndexEntryV1 { + const projection = projectCreateImagesRun(journal); + return { + runId: journal.runId, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + journalRevision: journal.journalRevision, + status: projection.status, + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + terminal: projection.terminal !== undefined, + unresolvedAmbiguity: hasUnresolvedCreateImagesRunAmbiguity(projection), + health: "healthy", + }; + } + + private entryForState( + runId: string, + state: Awaited>, + prior?: RunIndexEntryV1, + ): RunIndexEntryV1 | undefined { + const health = this.healthOf(runId, state); + if (health.status === "healthy" && state.current.status === "healthy") { + return this.entryFor(state.current.value); + } + const authority = + state.current.status === "healthy" + ? state.current.value + : state.lastKnownGood.status === "healthy" + ? state.lastKnownGood.value + : undefined; + const base = authority ? this.entryFor(authority) : prior; + if (!base) return undefined; + const { + recoveryReason: _recoveryReason, + unsafeReason: _unsafeReason, + canRecover: _canRecover, + expectedJournalRevision: _expectedJournalRevision, + ...cleanBase + } = base; + if (health.status === "unsafe") { + return { + ...cleanBase, + health: "unsafe", + unsafeReason: health.reason, + }; + } + if (health.status !== "recovery-required") return undefined; + return { + ...cleanBase, + health: "recovery-required", + recoveryReason: health.reason, + canRecover: health.canRecover, + ...(health.canRecover === "from-last-known-good" && + health.lastKnownGoodJournalRevision !== undefined + ? { expectedJournalRevision: health.lastKnownGoodJournalRevision } + : health.canRecover === "from-current" && health.currentJournalRevision !== undefined + ? { expectedJournalRevision: health.currentJournalRevision } + : {}), + }; + } + + private unassociatedDegradedForState( + runId: string, + state: Awaited>, + ): RunUnassociatedDegradedEntryV1 | undefined { + const health = this.healthOf(runId, state); + if (health.status === "unsafe") { + return { + runId, + status: "unsafe", + unsafeReason: health.reason, + canRecover: false, + }; + } + if (health.status === "recovery-required") { + return { + runId, + status: "recovery-required", + recoveryReason: health.reason, + canRecover: false, + }; + } + return undefined; + } + + private async publishIndex( + entries: RunIndexEntryV1[], + revision?: number, + degraded: RunUnassociatedDegradedEntryV1[] = this.indexCache?.degraded ?? [], + ): Promise { + const current = this.indexCache; + const next: RunIndexV1 = { + version: 1, + revision: revision ?? (current?.revision ?? 0) + 1, + entries: [...entries].sort((left, right) => left.runId.localeCompare(right.runId)), + degraded: [...degraded].sort((left, right) => left.runId.localeCompare(right.runId)), + }; + await this.durability.beforeIndexPublished?.(next.revision); + await this.writeAtomic(this.indexPath(), next, CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + await this.bindIndexCache(next); + this.indexDirty = false; + } + + private markIndexDirty(): void { + this.indexDirty = true; + this.clearIndexCache(); + } + + private async updateIndexEntry(journal: CreateImagesRunJournalV1): Promise { + const index = await this.indexed(); + const entries = index.entries.filter((entry) => entry.runId !== journal.runId); + entries.push(this.entryFor(journal)); + await this.publishIndex( + entries, + index.revision + 1, + index.degraded.filter((entry) => entry.runId !== journal.runId), + ); + } + + private async updateIndexState( + runId: string, + state: Awaited>, + ): Promise { + const index = await this.indexed(); + const prior = index.entries.find((candidate) => candidate.runId === runId); + const entry = this.entryForState(runId, state, prior); + const entries = index.entries.filter((candidate) => candidate.runId !== runId); + if (entry) entries.push(entry); + const degraded = index.degraded.filter((candidate) => candidate.runId !== runId); + if (!entry) { + const unassociated = this.unassociatedDegradedForState(runId, state); + if (unassociated) degraded.push(unassociated); + } + const sorted = entries.sort((left, right) => left.runId.localeCompare(right.runId)); + degraded.sort((left, right) => left.runId.localeCompare(right.runId)); + if ( + JSON.stringify(sorted) !== JSON.stringify(index.entries) || + JSON.stringify(degraded) !== JSON.stringify(index.degraded) + ) { + await this.publishIndex(sorted, index.revision + 1, degraded); + } + } + + private async enrichDegradedHealth( + health: CreateImagesRunJournalHealth, + ): Promise { + if ( + (health.status !== "recovery-required" && health.status !== "unsafe") || + health.workflowId !== undefined + ) { + return health; + } + const prior = (await this.indexed()).entries.find((entry) => entry.runId === health.runId); + return prior + ? { + ...health, + workflowId: prior.workflowId, + workflowRevision: prior.workflowRevision, + } + : health; + } + + private async rebuildIndex(runIds: readonly string[], prior?: RunIndexV1): Promise { + const entries: RunIndexEntryV1[] = []; + const degraded: RunUnassociatedDegradedEntryV1[] = []; + const priorEntries = new Map(prior?.entries.map((entry) => [entry.runId, entry]) ?? []); + for (const runId of runIds) { + const state = await this.inspected(runId); + const entry = this.entryForState(runId, state, priorEntries.get(runId)); + if (entry) entries.push(entry); + else { + const unassociated = this.unassociatedDegradedForState(runId, state); + if (unassociated) degraded.push(unassociated); + } + } + await this.publishIndex(entries, undefined, degraded); + } + + private async indexed(): Promise { + if (this.indexDirty) { + const prior = await this.loadIndex(true); + const { runIds } = await this.inventory(true); + await this.rebuildIndex(runIds, prior); + return this.indexCache as RunIndexV1; + } + const existing = await this.loadIndex(); + if (existing) return existing; + const { runIds } = await this.inventory(); + await this.rebuildIndex(runIds); + return this.indexCache as RunIndexV1; + } + + private pruneToken( + candidates: readonly CreateImagesTerminalPruneCandidate[], + assetIds: readonly string[], + ): string { + return createHash("sha256") + .update(JSON.stringify({ version: 1, candidates, assetIds }), "utf8") + .digest("hex"); + } + + private discardToken( + plan: Omit, + ): string { + return createHash("sha256").update(JSON.stringify(plan), "utf8").digest("hex"); + } + + private async degradedRecordFingerprint(runId: string): Promise { + const directory = this.runDirectory(runId); + const directoryInfo = await fs.lstat(directory); + if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", directory); + } + const entries = await fs.readdir(directory, { withFileTypes: true }); + if (entries.length > MAX_DISCARD_DIRECTORY_ENTRIES) { + throw new CreateImagesRunJournalLoadError("unsafe", directory); + } + const digest = createHash("sha256"); + let totalBytes = 0; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const target = path.join(directory, entry.name); + const info = await fs.lstat(target, { bigint: true }); + digest.update( + JSON.stringify({ + name: entry.name, + mode: info.mode.toString(), + size: info.size.toString(), + mtimeNs: info.mtimeNs.toString(), + ctimeNs: info.ctimeNs.toString(), + type: entry.isFile() + ? "file" + : entry.isSymbolicLink() + ? "symlink" + : entry.isDirectory() + ? "directory" + : "other", + }), + "utf8", + ); + if (entry.isFile() && !entry.isSymbolicLink()) { + const size = Number(info.size); + if (!Number.isSafeInteger(size) || size > MAX_PENDING_BYTES) { + throw new CreateImagesRunJournalLoadError("unsafe", target); + } + totalBytes += size; + if (totalBytes > MAX_DISCARD_FINGERPRINT_BYTES) { + throw new CreateImagesRunJournalLoadError("unsafe", directory); + } + digest.update(await readRegularFile(target, MAX_PENDING_BYTES)); + } else if (entry.isSymbolicLink()) { + digest.update(await fs.readlink(target), "utf8"); + } + } + return digest.digest("hex"); + } + + private parseDiscardManifest(value: unknown): DegradedRunDiscardManifestV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => + ![ + "version", + "runId", + "reason", + "association", + "workflowId", + "expectedCurrentJournalRevision", + "expectedLastKnownGoodJournalRevision", + "authorizationToken", + "recordFingerprint", + "createdAt", + ].includes(key), + ) || + value.version !== 1 || + typeof value.runId !== "string" || + !RUN_ID_PATTERN.test(value.runId) || + typeof value.reason !== "string" || + ![ + "current-corrupt", + "current-missing", + "last-known-good-corrupt", + "last-known-good-missing", + "last-known-good-mismatch", + "pending-corrupt", + "pending-conflict", + "current-future-schema", + "last-known-good-future-schema", + "pending-future-schema", + "unsafe-storage", + ].includes(value.reason) || + (value.association !== "workflow" && value.association !== "unassociated") || + (value.association === "workflow" + ? typeof value.workflowId !== "string" || !RUN_ID_PATTERN.test(value.workflowId) + : value.workflowId !== undefined) || + typeof value.recordFingerprint !== "string" || + !/^[a-f0-9]{64}$/u.test(value.recordFingerprint) || + typeof value.authorizationToken !== "string" || + !/^[a-f0-9]{64}$/u.test(value.authorizationToken) || + typeof value.createdAt !== "string" || + !Number.isFinite(Date.parse(value.createdAt)) + ) { + return undefined; + } + for (const revision of [ + value.expectedCurrentJournalRevision, + value.expectedLastKnownGoodJournalRevision, + ]) { + if (revision !== undefined && (!Number.isSafeInteger(revision) || (revision as number) < 1)) { + return undefined; + } + } + const withoutToken: Omit = { + version: 1, + runId: value.runId, + reason: value.reason as CreateImagesRunRecoveryReason | CreateImagesRunUnsafeReason, + association: value.association, + ...(value.workflowId === undefined ? {} : { workflowId: value.workflowId as string }), + ...(value.expectedCurrentJournalRevision === undefined + ? {} + : { + expectedCurrentJournalRevision: value.expectedCurrentJournalRevision as number, + }), + ...(value.expectedLastKnownGoodJournalRevision === undefined + ? {} + : { + expectedLastKnownGoodJournalRevision: + value.expectedLastKnownGoodJournalRevision as number, + }), + recordFingerprint: value.recordFingerprint, + }; + if (this.discardToken(withoutToken) !== value.authorizationToken) return undefined; + return { + ...withoutToken, + authorizationToken: value.authorizationToken, + createdAt: value.createdAt, + }; + } + + private async inspectDiscardManifest(): Promise> { + const raw = await this.readJson(this.discardPendingPath(), 64 * 1024); + if (raw.status !== "healthy") return raw; + if (futureVersion(raw.value, "version")) { + return { status: "unsafe", reason: "future-schema" }; + } + const manifest = this.parseDiscardManifest(raw.value); + return manifest ? { status: "healthy", value: manifest } : { status: "corrupt" }; + } + + private parsePruneManifest(value: unknown): TerminalPruneManifestV1 | undefined { + if ( + !isRecord(value) || + Object.keys(value).some( + (key) => !["version", "candidates", "token", "assetIds", "createdAt"].includes(key), + ) || + value.version !== 1 || + !Array.isArray(value.candidates) || + value.candidates.length < 1 || + value.candidates.length > MAX_PRUNE_BATCH_SIZE || + !Array.isArray(value.assetIds) || + value.assetIds.length > 10_000 || + value.assetIds.some( + (assetId) => typeof assetId !== "string" || !/^[a-f0-9]{64}$/u.test(assetId), + ) || + typeof value.token !== "string" || + !/^[a-f0-9]{64}$/u.test(value.token) || + typeof value.createdAt !== "string" || + !Number.isFinite(Date.parse(value.createdAt)) + ) { + return undefined; + } + const candidates: CreateImagesTerminalPruneCandidate[] = []; + const seen = new Set(); + for (const candidate of value.candidates) { + if ( + !isRecord(candidate) || + Object.keys(candidate).some((key) => !["runId", "journalRevision"].includes(key)) || + typeof candidate.runId !== "string" || + !RUN_ID_PATTERN.test(candidate.runId) || + seen.has(candidate.runId) || + !Number.isSafeInteger(candidate.journalRevision) || + (candidate.journalRevision as number) < 1 + ) { + return undefined; + } + seen.add(candidate.runId); + candidates.push({ + runId: candidate.runId, + journalRevision: candidate.journalRevision as number, + }); + } + const assetIds = [...(value.assetIds as string[])]; + if ( + new Set(assetIds).size !== assetIds.length || + value.token !== this.pruneToken(candidates, assetIds) + ) { + return undefined; + } + return { + version: 1, + candidates, + token: value.token, + assetIds, + createdAt: value.createdAt, + }; + } + + private async inspectPruneManifest(): Promise> { + const raw = await this.readJson(this.prunePendingPath(), 1024 * 1024); + if (raw.status !== "healthy") return raw; + if (futureVersion(raw.value, "version")) { + return { status: "unsafe", reason: "future-schema" }; + } + const manifest = this.parsePruneManifest(raw.value); + return manifest ? { status: "healthy", value: manifest } : { status: "corrupt" }; + } + + private async ensurePruneTombstones(): Promise { + if (!this.pruneStateLoaded) { + const state = await this.inspectPruneManifest(); + if (state.status === "unsafe" || state.status === "corrupt") { + throw new CreateImagesRunJournalLoadError( + state.status === "unsafe" ? "unsafe" : "corrupt", + this.prunePendingPath(), + ); + } + if (state.status === "healthy") { + for (const candidate of state.value.candidates) { + this.pruneTombstones.add(candidate.runId); + this.evictRunCaches(candidate.runId); + } + } + this.pruneStateLoaded = true; + } + if (!this.discardStateLoaded) { + const discard = await this.inspectDiscardManifest(); + if (discard.status === "unsafe" || discard.status === "corrupt") { + throw new CreateImagesRunJournalLoadError( + discard.status === "unsafe" ? "unsafe" : "corrupt", + this.discardPendingPath(), + ); + } + if (discard.status === "healthy") { + this.pruneTombstones.add(discard.value.runId); + this.evictRunCaches(discard.value.runId); + } + this.discardStateLoaded = true; + } + } + + private async buildDegradedRunDiscardPlan( + runId: string, + ): Promise { + const index = await this.indexed(); + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + if (health.status === "missing") return { status: "not-found" }; + if (health.status === "healthy") return { status: "not-degraded" }; + if (health.status === "recovery-required" && health.canRecover !== false) { + return { status: "recoverable" }; + } + const prior = index.entries.find((entry) => entry.runId === runId); + const workflowId = + "workflowId" in health && typeof health.workflowId === "string" + ? health.workflowId + : prior?.workflowId; + const withoutToken: Omit = { + version: 1, + runId, + reason: health.reason, + association: workflowId ? "workflow" : "unassociated", + ...(workflowId ? { workflowId } : {}), + ...(state.current.status === "healthy" + ? { + expectedCurrentJournalRevision: state.current.value.journalRevision, + } + : {}), + ...(state.lastKnownGood.status === "healthy" + ? { + expectedLastKnownGoodJournalRevision: state.lastKnownGood.value.journalRevision, + } + : {}), + recordFingerprint: await this.degradedRecordFingerprint(runId), + }; + return { + status: "ready", + plan: { + ...withoutToken, + authorizationToken: this.discardToken(withoutToken), + }, + }; + } + + private async assertWithinLimits( + runId: string, + replacements: ReadonlyMap, + additionalBytes = 0, + ): Promise { + const inventory = await this.inventory(); + const isNew = !inventory.runIds.includes(runId); + if (inventory.runIds.length + (isNew ? 1 : 0) > this.limits.maxRunCount) { + throw new Error("Create Images run storage has reached its run count limit."); + } + let projected = inventory.aggregateBytes; + for (const [target, replacement] of replacements) { + try { + const existing = await fs.lstat(target); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", target); + } + projected -= existing.size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (replacement !== undefined) projected += this.serializedBytes(replacement); + } + projected += additionalBytes; + if (!Number.isSafeInteger(projected) || projected > this.limits.maxAggregateRunBytes) { + throw new Error("Create Images run storage has reached its aggregate byte limit."); + } + } + + private healthOf( + runId: string, + state: Awaited>, + ): CreateImagesRunJournalHealth { + const identity = + state.current.status === "healthy" + ? { + workflowId: state.current.value.workflowId, + workflowRevision: state.current.value.workflowRevision, + } + : state.lastKnownGood.status === "healthy" + ? { + workflowId: state.lastKnownGood.value.workflowId, + workflowRevision: state.lastKnownGood.value.workflowRevision, + } + : {}; + if ( + state.current.status === "unsafe" || + state.lastKnownGood.status === "unsafe" || + state.pending.status === "unsafe" + ) { + return { + status: "unsafe", + runId, + ...identity, + reason: + (state.current.status === "unsafe" && state.current.reason === "unsafe-storage") || + (state.lastKnownGood.status === "unsafe" && + state.lastKnownGood.reason === "unsafe-storage") || + (state.pending.status === "unsafe" && state.pending.reason === "unsafe-storage") + ? "unsafe-storage" + : state.current.status === "unsafe" + ? "current-future-schema" + : state.lastKnownGood.status === "unsafe" + ? "last-known-good-future-schema" + : "pending-future-schema", + }; + } + if ( + state.current.status === "missing" && + state.lastKnownGood.status === "missing" && + state.pending.status === "missing" + ) { + return { status: "missing", runId }; + } + const revisions = { + ...(state.current.status === "healthy" + ? { currentJournalRevision: state.current.value.journalRevision } + : {}), + ...(state.lastKnownGood.status === "healthy" + ? { + lastKnownGoodJournalRevision: state.lastKnownGood.value.journalRevision, + } + : {}), + }; + if (state.pending.status === "corrupt") { + return { + status: "recovery-required", + runId, + reason: "pending-corrupt", + canRecover: false, + ...identity, + ...revisions, + }; + } + if (state.pending.status === "healthy") { + return { + status: "recovery-required", + runId, + reason: "pending-conflict", + canRecover: false, + ...identity, + ...revisions, + }; + } + if (state.current.status !== "healthy") { + return { + status: "recovery-required", + runId, + reason: state.current.status === "missing" ? "current-missing" : "current-corrupt", + canRecover: state.lastKnownGood.status === "healthy" ? "from-last-known-good" : false, + ...identity, + ...revisions, + }; + } + if (state.lastKnownGood.status !== "healthy") { + return { + status: "recovery-required", + runId, + reason: + state.lastKnownGood.status === "missing" + ? "last-known-good-missing" + : "last-known-good-corrupt", + canRecover: state.current.status === "healthy" ? "from-current" : false, + ...identity, + ...revisions, + }; + } + if (!identical(state.current.value, state.lastKnownGood.value)) { + return { + status: "recovery-required", + runId, + reason: "last-known-good-mismatch", + canRecover: false, + ...identity, + ...revisions, + }; + } + return { + status: "healthy", + runId, + journalRevision: state.current.value.journalRevision, + runStatus: projectCreateImagesRun(state.current.value).status, + }; + } + + private plausibleTornAppendIdentity( + original: RunAuthorityFileIdentity, + current: RunAuthorityFileIdentity, + ): boolean { + if (current.kind !== "file") return false; + if (original.kind === "missing") return BigInt(current.size) > 0n; + return ( + original.kind === "file" && + original.device === current.device && + original.inode === current.inode && + BigInt(current.size) > BigInt(original.size) + ); + } + + private async recoverTornEventAppend( + checkpointTarget: string, + eventLogTarget: string, + originalCheckpointIdentity: RunAuthorityFileIdentity, + originalEventLogIdentity: RunAuthorityFileIdentity, + pending: PendingRunAppendMutationV1, + ): Promise { + const checkpointIdentity = await this.fileAuthorityIdentity(checkpointTarget); + const tornIdentity = await this.fileAuthorityIdentity(eventLogTarget); + if ( + !this.sameFileAuthorityIdentity(originalCheckpointIdentity, checkpointIdentity) || + !this.plausibleTornAppendIdentity(originalEventLogIdentity, tornIdentity) + ) { + return false; + } + if (tornIdentity.kind !== "file") return false; + let bytes: Buffer; + try { + bytes = await readRegularFile(eventLogTarget, MAX_EVENT_LOG_BYTES); + } catch { + return false; + } + if ( + !this.sameFileAuthorityIdentity( + tornIdentity, + await this.fileAuthorityIdentity(eventLogTarget), + ) || + bytes.length !== Number(tornIdentity.size) + ) { + return false; + } + const originalBytes = + originalEventLogIdentity.kind === "file" ? Number(originalEventLogIdentity.size) : 0; + if (originalBytes < 0 || originalBytes >= bytes.length) return false; + const checkpoint = await this.inspectJournal(checkpointTarget); + if ( + checkpoint.status !== "healthy" || + checkpoint.value.journalRevision > pending.baseJournalRevision || + !this.sameFileAuthorityIdentity( + originalCheckpointIdentity, + await this.fileAuthorityIdentity(checkpointTarget), + ) + ) { + return false; + } + const prefix = bytes.subarray(0, originalBytes); + const parsedPrefix = this.parseEventLogBytes(prefix, checkpoint.value); + if ( + parsedPrefix.inspection.status !== "healthy" || + !parsedPrefix.tailDigest || + parsedPrefix.inspection.value.journalRevision !== pending.baseJournalRevision + ) { + return false; + } + let next: CreateImagesRunJournalV1; + try { + next = appendCreateImagesRunEvent(parsedPrefix.inspection.value, pending.event); + } catch { + return false; + } + if (journalDigest(next) !== pending.targetJournalDigest) return false; + const expected = serializedEventRecord( + parsedPrefix.inspection.value, + pending.event, + parsedPrefix.tailDigest, + ); + const suffix = bytes.subarray(originalBytes); + if ( + suffix.length === 0 || + suffix.length >= expected.bytes.length || + !suffix.equals(expected.bytes.subarray(0, suffix.length)) + ) { + return false; + } + await this.replaceTornEventLog( + eventLogTarget, + Buffer.concat([prefix, expected.bytes]), + tornIdentity, + expected.record.digest, + ); + return true; + } + + private async finishPending( + state: Awaited>, + invokeCrashSeams: boolean, + observedAuthority?: RunAuthorityIdentity, + ): Promise { + if (state.pending.status !== "healthy") return false; + const pending = state.pending.value; + if (pending.kind === "append") { + let authorityAtFinish = await this.runAuthorityIdentity(pending.runId); + if ( + observedAuthority && + !this.sameRunAuthorityIdentity(observedAuthority, authorityAtFinish) + ) { + return false; + } + const eventMatches = (journal: CreateImagesRunJournalV1): boolean => + JSON.stringify(journal.events[journal.events.length - 1]) === JSON.stringify(pending.event); + const targetMatches = (journal: CreateImagesRunJournalV1): boolean => + journal.journalRevision === pending.targetJournalRevision && + eventMatches(journal) && + journalDigest(journal) === pending.targetJournalDigest; + const refreshAfterRepair = async (): Promise => { + const authorityBeforeInspection = await this.runAuthorityIdentity(pending.runId); + const repairedState = await this.inspected(pending.runId); + const authorityAfterInspection = await this.runAuthorityIdentity(pending.runId); + if (!this.sameRunAuthorityIdentity(authorityBeforeInspection, authorityAfterInspection)) { + return false; + } + state = repairedState; + authorityAtFinish = authorityAfterInspection; + return true; + }; + const currentCanRecoverFromTornAppend = + state.current.status === "corrupt" && + state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.baseJournalRevision && + this.sameFileAuthorityIdentity( + pending.authority.lastKnownGood, + authorityAtFinish.lastKnownGood, + ) && + this.sameFileAuthorityIdentity( + pending.authority.lastKnownGoodEvents, + authorityAtFinish.lastKnownGoodEvents, + ); + if (currentCanRecoverFromTornAppend) { + const recovered = await this.recoverTornEventAppend( + state.paths.current, + state.paths.currentEvents, + pending.authority.current, + pending.authority.currentEvents, + pending, + ); + if (recovered && !(await refreshAfterRepair())) return false; + } + const lastKnownGoodCanRecoverFromTornAppend = + state.lastKnownGood.status === "corrupt" && + state.current.status === "healthy" && + targetMatches(state.current.value) && + this.sameFileAuthorityIdentity(pending.authority.current, authorityAtFinish.current); + if (lastKnownGoodCanRecoverFromTornAppend) { + const recovered = await this.recoverTornEventAppend( + state.paths.lastKnownGood, + state.paths.lastKnownGoodEvents, + pending.authority.lastKnownGood, + pending.authority.lastKnownGoodEvents, + pending, + ); + if (recovered && !(await refreshAfterRepair())) return false; + } + const currentIsBase = + state.current.status === "healthy" && + state.current.value.journalRevision === pending.baseJournalRevision; + const currentIsTarget = + state.current.status === "healthy" && targetMatches(state.current.value); + const lastKnownGoodIsBase = + state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.baseJournalRevision; + const lastKnownGoodIsTarget = + state.lastKnownGood.status === "healthy" && targetMatches(state.lastKnownGood.value); + const original = pending.authority; + const checkpointsUnchanged = + this.sameFileAuthorityIdentity(original.current, authorityAtFinish.current) && + this.sameFileAuthorityIdentity(original.lastKnownGood, authorityAtFinish.lastKnownGood); + const currentBaseLogUnchanged = this.sameFileAuthorityIdentity( + original.currentEvents, + authorityAtFinish.currentEvents, + ); + const lastKnownGoodBaseLogUnchanged = this.sameFileAuthorityIdentity( + original.lastKnownGoodEvents, + authorityAtFinish.lastKnownGoodEvents, + ); + if ( + !checkpointsUnchanged || + (currentIsBase && !currentBaseLogUnchanged) || + (lastKnownGoodIsBase && !lastKnownGoodBaseLogUnchanged) || + (!currentIsBase && !currentIsTarget) || + (!lastKnownGoodIsBase && !lastKnownGoodIsTarget) || + (currentIsBase && lastKnownGoodIsTarget) + ) { + return false; + } + let expectedAuthority = authorityAtFinish; + let next: CreateImagesRunJournalV1; + if ( + state.current.status === "healthy" && + state.current.value.journalRevision === pending.targetJournalRevision + ) { + if (!targetMatches(state.current.value)) { + return false; + } + next = state.current.value; + } else { + const base = + state.current.status === "healthy" && + state.current.value.journalRevision === pending.baseJournalRevision + ? state.current.value + : state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.baseJournalRevision + ? state.lastKnownGood.value + : undefined; + if (!base) return false; + try { + next = appendCreateImagesRunEvent(base, pending.event); + } catch { + return false; + } + if (state.current.status !== "healthy") { + return false; + } else { + const currentEvents = await this.appendEventRecord( + state.paths.currentEvents, + state.paths.current, + state.current.value, + pending.event, + expectedAuthority.current, + expectedAuthority.currentEvents, + ); + expectedAuthority = { ...expectedAuthority, currentEvents }; + } + if (invokeCrashSeams) await this.durability.afterCurrentPublished?.(pending.runId); + } + if ( + state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.targetJournalRevision + ) { + if (!identical(state.lastKnownGood.value, next)) return false; + } else if ( + state.lastKnownGood.status === "healthy" && + state.lastKnownGood.value.journalRevision === pending.baseJournalRevision + ) { + if ( + !this.sameRunAuthorityIdentity( + expectedAuthority, + await this.runAuthorityIdentity(pending.runId), + ) + ) { + return false; + } + const lastKnownGoodEvents = await this.appendEventRecord( + state.paths.lastKnownGoodEvents, + state.paths.lastKnownGood, + state.lastKnownGood.value, + pending.event, + expectedAuthority.lastKnownGood, + expectedAuthority.lastKnownGoodEvents, + ); + expectedAuthority = { ...expectedAuthority, lastKnownGoodEvents }; + } else if ( + state.lastKnownGood.status !== "unsafe" && + state.current.status === "healthy" && + state.current.value.journalRevision === pending.targetJournalRevision + ) { + return false; + } else { + return false; + } + if (invokeCrashSeams) await this.durability.afterLastKnownGoodPublished?.(pending.runId); + if ( + !this.sameRunAuthorityIdentity( + expectedAuthority, + await this.runAuthorityIdentity(pending.runId), + ) + ) { + return false; + } + await this.removeDurably(state.paths.pending); + return true; + } + if (state.current.status === "unsafe" || state.current.status === "corrupt") return false; + if (state.lastKnownGood.status === "unsafe" || state.lastKnownGood.status === "corrupt") + return false; + if (state.current.status === "missing") { + if (pending.baseJournalRevision !== null) return false; + await this.writeAtomic( + state.paths.current, + pending.next, + CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES, + ); + await this.removeDurably(state.paths.currentEvents); + if (invokeCrashSeams) await this.durability.afterCurrentPublished?.(pending.runId); + } else if (state.current.value.journalRevision === pending.targetJournalRevision) { + if (!identical(state.current.value, pending.next)) return false; + } else if ( + pending.baseJournalRevision !== null && + state.current.value.journalRevision === pending.baseJournalRevision + ) { + await this.writeAtomic( + state.paths.current, + pending.next, + CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES, + ); + await this.removeDurably(state.paths.currentEvents); + if (invokeCrashSeams) await this.durability.afterCurrentPublished?.(pending.runId); + } else { + return false; + } + await this.writeAtomic( + state.paths.lastKnownGood, + pending.next, + CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES, + ); + await this.removeDurably(state.paths.lastKnownGoodEvents); + if (invokeCrashSeams) await this.durability.afterLastKnownGoodPublished?.(pending.runId); + await this.removeDurably(state.paths.pending); + return true; + } + + private async reconcile(runId: string): Promise { + await this.ensurePruneTombstones(); + if (this.pruneTombstones.has(runId)) { + this.evictRunCaches(runId); + return; + } + if (this.getCachedJournal(runId)) { + try { + if (await this.cachedAuthorityIsCurrent(runId)) return; + } catch { + // Fall through to the authoritative parser so storage failures are + // classified consistently with uncached reads. + } + if (this.journalCache.has(runId)) { + // Cached state is an optimization, never mutation authority. Any + // identity change (including same-size writes with restored mtime) + // invalidates the journal and event-log tails before a full parse. + this.evictRunCaches(runId); + } + } + const authorityBeforeInspection = await this.runAuthorityIdentity(runId); + const state = await this.inspected(runId); + const authorityAfterInspection = await this.runAuthorityIdentity(runId); + if (!this.sameRunAuthorityIdentity(authorityBeforeInspection, authorityAfterInspection)) { + this.evictRunCaches(runId); + return; + } + if (state.pending.status === "healthy") { + await this.finishPending(state, false, authorityAfterInspection); + } + const reconciled = await this.inspected(runId); + if ( + this.healthOf(runId, reconciled).status === "healthy" && + reconciled.current.status === "healthy" + ) { + await this.cacheHealthyJournal(runId, reconciled.current.value); + } else { + this.evictJournal(runId); + } + } + + private cachedState( + runId: string, + ): Awaited> | undefined { + const journal = this.getCachedJournal(runId); + if (!journal) return undefined; + return { + paths: this.paths(runId), + current: { status: "healthy", value: journal }, + lastKnownGood: { status: "healthy", value: journal }, + pending: { status: "missing" }, + }; + } + + private async appendInternal( + runId: string, + expectedJournalRevision: number, + eventFactory: (journal: CreateImagesRunJournalV1) => CreateImagesRunEventV1, + ): Promise { + await this.reconcile(runId); + if (this.pruneTombstones.has(runId)) { + throw new CreateImagesRunJournalLoadError("corrupt", this.paths(runId).current); + } + let state = this.cachedState(runId); + let authority: RunAuthorityIdentity; + if (state) { + authority = await this.runAuthorityIdentity(runId); + if (!this.sameRunAuthorityIdentity(this.journalAuthorityCache.get(runId), authority)) { + this.evictRunCaches(runId); + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + } else { + const authorityBeforeInspection = await this.runAuthorityIdentity(runId); + state = await this.inspected(runId); + authority = await this.runAuthorityIdentity(runId); + if (!this.sameRunAuthorityIdentity(authorityBeforeInspection, authority)) { + this.evictRunCaches(runId); + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + } + const health = this.healthOf(runId, state); + if (health.status !== "healthy" || state.current.status !== "healthy") { + throw new CreateImagesRunJournalLoadError( + health.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + if (state.current.value.journalRevision !== expectedJournalRevision) { + throw new CreateImagesRunJournalRevisionConflictError( + runId, + expectedJournalRevision, + state.current.value.journalRevision, + ); + } + const next = appendCreateImagesRunEvent(state.current.value, eventFactory(state.current.value)); + const pending: PendingRunMutationV1 = { + version: PENDING_VERSION, + kind: "append", + runId, + baseJournalRevision: expectedJournalRevision, + targetJournalRevision: next.journalRevision, + stagedAt: next.updatedAt, + event: next.events[next.events.length - 1] as CreateImagesRunEventV1, + authority, + targetJournalDigest: journalDigest(next), + }; + const recordBytes = this.serializedBytes({ + version: 1, + runId, + journalRevision: next.journalRevision, + previousDigest: "0".repeat(64), + digest: "0".repeat(64), + event: pending.event, + }); + await this.assertWithinLimits( + runId, + new Map([[state.paths.pending, pending]]), + recordBytes * 2, + ); + await this.writeAtomic(state.paths.pending, pending, MAX_PENDING_BYTES); + await this.durability.afterPendingPublished?.(runId); + const pendingState = { + ...state, + pending: { status: "healthy", value: pending }, + } as Awaited>; + if (!(await this.finishPending(pendingState, true, authority))) { + this.evictRunCaches(runId); + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.pending); + } + await this.refreshInventoryRun(runId); + if ( + pending.event.type === "run-terminal" || + pending.event.type === "run-ambiguity-acknowledged" + ) { + try { + await this.updateIndexEntry(next); + } catch (error) { + // The journal is already authoritative. Never let a stale derived + // index authorize work after its publication failed. + this.markIndexDirty(); + await this.cacheHealthyJournal(runId, next); + throw error; + } + } + await this.cacheHealthyJournal(runId, next); + return next; + } + + async initialize(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + this.clearIndexCache(); + const loadedIndex = await this.loadIndex(true); + const prune = await this.inspectPruneManifest(); + if (prune.status === "unsafe" || prune.status === "corrupt") { + throw new CreateImagesRunJournalLoadError( + prune.status === "unsafe" ? "unsafe" : "corrupt", + this.prunePendingPath(), + ); + } + if (prune.status === "healthy") { + this.pruneStateLoaded = true; + for (const candidate of prune.value.candidates) { + this.pruneTombstones.add(candidate.runId); + this.evictRunCaches(candidate.runId); + } + await this.resumeTerminalPrune(prune.value); + } else { + this.pruneStateLoaded = true; + } + const discard = await this.inspectDiscardManifest(); + if (discard.status === "unsafe" || discard.status === "corrupt") { + throw new CreateImagesRunJournalLoadError( + discard.status === "unsafe" ? "unsafe" : "corrupt", + this.discardPendingPath(), + ); + } + if (discard.status === "healthy") { + this.discardStateLoaded = true; + this.pruneTombstones.add(discard.value.runId); + this.evictRunCaches(discard.value.runId); + await this.resumeDegradedRunDiscard(discard.value); + } else { + this.discardStateLoaded = true; + } + const { runIds } = await this.inventory(true); + const index = this.indexCache ?? loadedIndex; + const indexedEntries = new Map(index?.entries.map((entry) => [entry.runId, entry]) ?? []); + const results: CreateImagesRunJournalHealth[] = []; + const refreshedEntries = new Map(); + const refreshedDegraded = new Map(); + // The index is a derived history accelerator, never execution + // authority. Revalidate every bounded run directory before restart + // reconciliation so a stale terminal bit cannot hide queued work. + for (const runId of runIds) { + await this.reconcile(runId); + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + results.push(health); + const entry = this.entryForState(runId, state, indexedEntries.get(runId)); + if (entry) refreshedEntries.set(runId, entry); + else { + const degraded = this.unassociatedDegradedForState(runId, state); + if (degraded) refreshedDegraded.set(runId, degraded); + } + } + if (runIds.length > 0) await this.inventory(true); + const entries = [...refreshedEntries.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ); + const degraded = [...refreshedDegraded.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ); + if (!index) { + await this.publishIndex(entries, undefined, degraded); + } else if ( + JSON.stringify(entries) !== JSON.stringify(index.entries) || + JSON.stringify(degraded) !== JSON.stringify(index.degraded) + ) { + await this.publishIndex(entries, index.revision + 1, degraded); + } else { + // A complete authoritative scan proved that an earlier ambiguous + // write outcome already contains the exact derived state. + this.indexDirty = false; + } + return results; + }); + } + + async health(runId: string): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.reconcile(runId); + if (this.pruneTombstones.has(runId)) return { status: "missing", runId }; + const state = await this.inspected(runId); + const health = await this.enrichDegradedHealth(this.healthOf(runId, state)); + if (health.status === "healthy" && state.current.status === "healthy") { + await this.cacheHealthyJournal(runId, state.current.value); + } else { + this.evictJournal(runId); + } + if (health.status === "recovery-required" || health.status === "unsafe") { + await this.updateIndexState(runId, state); + } + return health; + }); + } + + async indexHealth(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const raw = await this.readJson(this.indexPath(), CREATE_IMAGES_MAX_RUN_JOURNAL_BYTES); + if (raw.status === "missing") { + return this.indexDirty + ? { + status: "degraded", + revision: 0, + entryCount: 0, + degradedEntryCount: 1, + diagnostic: "stale-derived-index", + } + : { status: "missing" }; + } + if (raw.status === "unsafe") return { status: "unsafe" }; + if (raw.status === "corrupt" || futureVersion(raw.value, "version")) { + return futureVersion(raw.status === "healthy" ? raw.value : undefined, "version") + ? { status: "unsafe" } + : { status: "corrupt" }; + } + const index = this.parseIndex(raw.value); + if (!index) return { status: "corrupt" }; + const quarantinedIndexCount = await this.quarantinedIndexCount(); + const degradedEntryCount = + index.degraded.length + index.entries.filter((entry) => entry.health !== "healthy").length; + const details = { + revision: index.revision, + entryCount: index.entries.length + index.degraded.length, + ...(this.indexDirty + ? { diagnostic: "stale-derived-index" as const } + : this.indexDiagnostic + ? { diagnostic: this.indexDiagnostic } + : {}), + ...(quarantinedIndexCount > 0 ? { quarantinedIndexCount } : {}), + }; + return degradedEntryCount > 0 || this.indexDirty + ? { + status: "degraded", + ...details, + degradedEntryCount: Math.max(1, degradedEntryCount), + } + : { status: "healthy", ...details }; + }); + } + + async terminalPruneStatus(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const state = await this.inspectPruneManifest(); + if (state.status === "missing") return { status: "none" }; + if (state.status === "unsafe") return { status: "unsafe" }; + if (state.status === "corrupt") return { status: "corrupt" }; + const { createdAt: _createdAt, ...plan } = state.value; + return { status: "pending", plan }; + }); + } + + async get(runId: string): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.reconcile(runId); + if (this.pruneTombstones.has(runId)) return undefined; + const cached = this.getCachedJournal(runId); + if (cached) return cached; + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + if (health.status === "missing") return undefined; + if (health.status !== "healthy" || state.current.status !== "healthy") { + throw new CreateImagesRunJournalLoadError( + health.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + return state.current.value; + }); + } + + async start( + input: CreateImagesRunStartInput, + isRendererCurrent: () => boolean, + ): Promise { + validateRunId(input.runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.reconcile(input.runId); + if (this.pruneTombstones.has(input.runId)) { + throw new CreateImagesRunJournalRevisionConflictError(input.runId, null, null); + } + const state = await this.inspected(input.runId); + const health = this.healthOf(input.runId, state); + if (health.status !== "missing") { + throw new CreateImagesRunJournalRevisionConflictError( + input.runId, + null, + state.current.status === "healthy" ? state.current.value.journalRevision : null, + ); + } + const next = createCreateImagesRunJournal({ + ...input, + workflowFingerprint: createImagesWorkflowSnapshotFingerprint(input.workflowSnapshot), + }); + const pending: PendingRunMutationV1 = { + version: PENDING_VERSION, + runId: input.runId, + baseJournalRevision: null, + targetJournalRevision: 1, + stagedAt: input.createdAt, + next, + }; + await this.assertWithinLimits( + input.runId, + new Map([ + [state.paths.pending, pending], + [state.paths.current, next], + [state.paths.lastKnownGood, next], + ]), + ); + await this.writeAtomic(state.paths.pending, pending, MAX_PENDING_BYTES, isRendererCurrent); + await this.durability.afterPendingPublished?.(input.runId); + const pendingState = await this.inspected(input.runId); + if (!(await this.finishPending(pendingState, true))) { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.pending); + } + await this.refreshInventoryRun(input.runId); + try { + await this.updateIndexEntry(next); + } catch (error) { + this.markIndexDirty(); + await this.cacheHealthyJournal(input.runId, next); + throw error; + } + await this.cacheHealthyJournal(input.runId, next); + return next; + }); + } + + async append( + runId: string, + expectedJournalRevision: number, + event: CreateImagesRunEventV1, + ): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + try { + return await this.appendInternal(runId, expectedJournalRevision, () => event); + } catch (error) { + this.evictJournal(runId); + throw error; + } + }); + } + + async requestCancellation( + runId: string, + expectedJournalRevision: number, + input: { at: string; reason: CreateImagesCancellationReason }, + ): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + try { + return await this.appendInternal(runId, expectedJournalRevision, (journal) => ({ + type: "run-cancel-requested", + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: input.at, + reason: input.reason, + })); + } catch (error) { + this.evictJournal(runId); + throw error; + } + }); + } + + async reconciliationCandidates(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const runIds = (await this.indexed()).entries + .filter( + (entry) => + entry.health === "healthy" && !entry.terminal && !this.pruneTombstones.has(entry.runId), + ) + .map((entry) => entry.runId); + const candidates: CreateImagesRunJournalV1[] = []; + for (const runId of runIds) { + await this.reconcile(runId); + const state = await this.inspected(runId); + if ( + this.healthOf(runId, state).status === "healthy" && + state.current.status === "healthy" && + !projectCreateImagesRun(state.current.value).terminal + ) { + candidates.push(state.current.value); + } + } + return candidates.sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.runId.localeCompare(right.runId), + ); + }); + } + + async terminalHistory(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const summaries = (await this.indexed()).entries + .filter( + ( + entry, + ): entry is RunIndexEntryV1 & { + status: CreateImagesRunTerminalStatus; + } => + entry.health === "healthy" && entry.terminal && !this.pruneTombstones.has(entry.runId), + ) + .map( + ({ + runId, + workflowId, + workflowRevision, + journalRevision, + status, + createdAt, + updatedAt, + }) => ({ + runId, + workflowId, + workflowRevision, + journalRevision, + status, + createdAt, + updatedAt, + }), + ); + return summaries.sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || right.runId.localeCompare(left.runId), + ); + }); + } + + async referenceInventory(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const pruneManifest = await this.inspectPruneManifest(); + const discardManifest = await this.inspectDiscardManifest(); + if ( + pruneManifest.status === "unsafe" || + pruneManifest.status === "corrupt" || + discardManifest.status === "unsafe" || + discardManifest.status === "corrupt" + ) { + return { complete: false, records: [] }; + } + const { runIds } = await this.inventory(); + const records: CreateImagesRunReferenceInventory["records"] = []; + let complete = + (pruneManifest.status === "missing" || pruneManifest.status === "healthy") && + discardManifest.status === "missing"; + for (const runId of runIds) { + await this.reconcile(runId); + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + if (health.status !== "healthy") complete = false; + const candidates = [ + state.current.status === "healthy" ? state.current.value : undefined, + state.lastKnownGood.status === "healthy" ? state.lastKnownGood.value : undefined, + state.pending.status === "healthy" && state.pending.value.kind !== "append" + ? state.pending.value.next + : undefined, + ].filter((candidate): candidate is CreateImagesRunJournalV1 => candidate !== undefined); + const assetIds = new Set(); + for (const candidate of candidates) { + for (const assetId of referencedAssetIds(candidate)) assetIds.add(assetId); + } + if ( + state.pending.status === "healthy" && + state.pending.value.kind === "append" && + (state.pending.value.event.type === "node-output-published" || + state.pending.value.event.type === "node-succeeded") + ) { + for (const assetId of state.pending.value.event.outputAssetIds) assetIds.add(assetId); + } + records.push({ runId, assetIds: [...assetIds].sort() }); + } + if (pruneManifest.status === "healthy") { + const retainedByManifest = new Set(pruneManifest.value.assetIds); + const firstRunId = pruneManifest.value.candidates[0]?.runId; + if (firstRunId) { + const existing = records.find((record) => record.runId === firstRunId); + if (existing) { + for (const assetId of existing.assetIds) retainedByManifest.add(assetId); + existing.assetIds = [...retainedByManifest].sort(); + } else { + records.push({ + runId: firstRunId, + assetIds: [...retainedByManifest].sort(), + }); + } + } + } + return { complete, records }; + }); + } + + private async buildTerminalPrunePlan( + requested: readonly CreateImagesTerminalPruneCandidate[], + ): Promise { + if (requested.length < 1 || requested.length > MAX_PRUNE_BATCH_SIZE) { + throw new Error("Create Images terminal prune batch is outside its bounded limit."); + } + const candidates = [...requested].sort((left, right) => left.runId.localeCompare(right.runId)); + if (new Set(candidates.map((candidate) => candidate.runId)).size !== candidates.length) { + throw new Error("Create Images terminal prune candidates must be unique."); + } + const assetIds = new Set(); + for (const candidate of candidates) { + validateRunId(candidate.runId); + if (!Number.isSafeInteger(candidate.journalRevision) || candidate.journalRevision < 1) { + throw new Error("Invalid Create Images terminal prune revision."); + } + const state = await this.inspected(candidate.runId); + const health = this.healthOf(candidate.runId, state); + if (health.status !== "healthy" || state.current.status !== "healthy") { + throw new CreateImagesRunJournalLoadError( + health.status === "unsafe" ? "unsafe" : "corrupt", + state.paths.current, + ); + } + if (state.current.value.journalRevision !== candidate.journalRevision) { + throw new CreateImagesRunJournalRevisionConflictError( + candidate.runId, + candidate.journalRevision, + state.current.value.journalRevision, + ); + } + const projection = projectCreateImagesRun(state.current.value); + if (!projection.terminal) { + throw new Error("Only terminal Create Images runs can be retired."); + } + if (hasUnresolvedCreateImagesRunAmbiguity(projection)) { + throw new Error( + "Unresolved Create Images submissions must be acknowledged before retirement.", + ); + } + for (const assetId of referencedAssetIds(state.current.value)) assetIds.add(assetId); + if (assetIds.size > 10_000) { + throw new Error("Create Images terminal prune references exceed the bounded limit."); + } + } + const sortedAssetIds = [...assetIds].sort(); + return { + version: 1, + candidates, + token: this.pruneToken(candidates, sortedAssetIds), + assetIds: sortedAssetIds, + }; + } + + async planTerminalPrune( + requested: readonly CreateImagesTerminalPruneCandidate[], + ): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + const pending = await this.inspectPruneManifest(); + if (pending.status !== "missing") { + throw new CreateImagesRunJournalLoadError( + pending.status === "unsafe" ? "unsafe" : "corrupt", + this.prunePendingPath(), + ); + } + return this.buildTerminalPrunePlan(requested); + }); + } + + private async resumeTerminalPrune( + manifest: TerminalPruneManifestV1, + ): Promise { + for (const candidate of manifest.candidates) { + this.pruneTombstones.add(candidate.runId); + this.evictRunCaches(candidate.runId); + } + const retiredRoot = path.join(this.root(), RETIRED_RUNS_DIRECTORY); + const retiredBatch = path.join(retiredRoot, manifest.token); + await this.ensureDirectory(retiredRoot); + await this.ensureDirectory(retiredBatch); + const index = await this.indexed(); + const indexedRunIds = new Set(index.entries.map((entry) => entry.runId)); + for (const candidate of manifest.candidates) { + const source = this.runDirectory(candidate.runId); + const destination = path.join(retiredBatch, candidate.runId); + const [sourceInfo, destinationInfo] = await Promise.all([ + fs.lstat(source).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }), + fs.lstat(destination).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }), + ]); + if (sourceInfo && destinationInfo) { + throw new CreateImagesRunJournalLoadError("unsafe", source); + } + if (sourceInfo) { + if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", source); + } + await fs.rename(source, destination); + await this.syncDirectory(this.runsPath()); + await this.syncDirectory(retiredBatch); + await this.durability.afterRunRetired?.(candidate.runId); + } else if (destinationInfo) { + if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", destination); + } + } else if (indexedRunIds.has(candidate.runId)) { + throw new CreateImagesRunJournalLoadError("corrupt", source); + } + } + + const removed = new Set(manifest.candidates.map((candidate) => candidate.runId)); + if (index.entries.some((entry) => removed.has(entry.runId))) { + await this.publishIndex( + index.entries.filter((entry) => !removed.has(entry.runId)), + index.revision + 1, + ); + } + await this.durability.beforeRetiredDelete?.(manifest.token); + await fs.rm(retiredBatch, { recursive: true, force: true }); + await this.syncDirectory(retiredRoot); + await this.durability.afterRetiredDelete?.(manifest.token); + await this.removeDurably(this.prunePendingPath()); + this.inventoryCache = undefined; + for (const candidate of manifest.candidates) { + this.evictRunCaches(candidate.runId); + this.pruneTombstones.delete(candidate.runId); + } + return { + removedRunIds: manifest.candidates.map((candidate) => candidate.runId), + releasedAssetIds: [...manifest.assetIds], + }; + } + + async pruneTerminalRuns( + plan: CreateImagesTerminalPrunePlan, + ): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + let manifestState = await this.inspectPruneManifest(); + let manifest: TerminalPruneManifestV1; + if (manifestState.status === "healthy") { + manifest = manifestState.value; + if ( + manifest.token !== plan.token || + JSON.stringify(manifest.candidates) !== JSON.stringify(plan.candidates) || + JSON.stringify(manifest.assetIds) !== JSON.stringify(plan.assetIds) + ) { + throw new Error("Another Create Images terminal prune is already in progress."); + } + } else if (manifestState.status === "missing") { + const verified = await this.buildTerminalPrunePlan(plan.candidates); + if ( + verified.token !== plan.token || + JSON.stringify(verified.assetIds) !== JSON.stringify(plan.assetIds) + ) { + throw new Error("Create Images terminal prune authorization is stale."); + } + manifest = { ...verified, createdAt: new Date().toISOString() }; + await this.writeAtomic(this.prunePendingPath(), manifest, 1024 * 1024); + this.pruneStateLoaded = true; + for (const candidate of manifest.candidates) { + this.pruneTombstones.add(candidate.runId); + this.evictRunCaches(candidate.runId); + } + await this.durability.afterPruneManifestPublished?.(manifest.token); + manifestState = { status: "healthy", value: manifest }; + } else { + throw new CreateImagesRunJournalLoadError( + manifestState.status === "unsafe" ? "unsafe" : "corrupt", + this.prunePendingPath(), + ); + } + + return this.resumeTerminalPrune(manifest); + }); + } + + async planDegradedRunDiscard(runId: string): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + if (this.pruneTombstones.has(runId)) return { status: "not-found" }; + const pending = await this.inspectDiscardManifest(); + if (pending.status !== "missing") { + throw new CreateImagesRunJournalLoadError( + pending.status === "unsafe" ? "unsafe" : "corrupt", + this.discardPendingPath(), + ); + } + return this.buildDegradedRunDiscardPlan(runId); + }); + } + + private async resumeDegradedRunDiscard( + manifest: DegradedRunDiscardManifestV1, + ): Promise { + this.pruneTombstones.add(manifest.runId); + this.evictRunCaches(manifest.runId); + const discardedRoot = path.join(this.root(), DISCARDED_RUNS_DIRECTORY); + const discardedBatch = path.join(discardedRoot, manifest.authorizationToken); + const source = this.runDirectory(manifest.runId); + const destination = path.join(discardedBatch, manifest.runId); + await this.ensureDirectory(discardedRoot); + await this.ensureDirectory(discardedBatch); + const index = await this.indexed(); + const [sourceInfo, destinationInfo] = await Promise.all([ + fs.lstat(source).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }), + fs.lstat(destination).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + }), + ]); + if (sourceInfo && destinationInfo) { + throw new CreateImagesRunJournalLoadError("unsafe", source); + } + if (sourceInfo) { + if (!sourceInfo.isDirectory() || sourceInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", source); + } + const verified = await this.buildDegradedRunDiscardPlan(manifest.runId); + if ( + verified.status !== "ready" || + verified.plan.authorizationToken !== manifest.authorizationToken || + verified.plan.recordFingerprint !== manifest.recordFingerprint || + verified.plan.reason !== manifest.reason || + verified.plan.association !== manifest.association || + verified.plan.workflowId !== manifest.workflowId || + verified.plan.expectedCurrentJournalRevision !== manifest.expectedCurrentJournalRevision || + verified.plan.expectedLastKnownGoodJournalRevision !== + manifest.expectedLastKnownGoodJournalRevision + ) { + throw new CreateImagesRunJournalRevisionConflictError( + manifest.runId, + manifest.expectedCurrentJournalRevision ?? null, + verified.status === "ready" + ? (verified.plan.expectedCurrentJournalRevision ?? null) + : null, + ); + } + await fs.rename(source, destination); + await this.syncDirectory(this.runsPath()); + await this.syncDirectory(discardedBatch); + await this.durability.afterDegradedRunRetired?.(manifest.runId); + } else if (destinationInfo) { + if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) { + throw new CreateImagesRunJournalLoadError("unsafe", destination); + } + } else if ( + index.entries.some((entry) => entry.runId === manifest.runId) || + index.degraded.some((entry) => entry.runId === manifest.runId) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", source); + } + + if ( + index.entries.some((entry) => entry.runId === manifest.runId) || + index.degraded.some((entry) => entry.runId === manifest.runId) + ) { + try { + await this.publishIndex( + index.entries.filter((entry) => entry.runId !== manifest.runId), + index.revision + 1, + index.degraded.filter((entry) => entry.runId !== manifest.runId), + ); + } catch (error) { + this.markIndexDirty(); + throw error; + } + } + await fs.rm(discardedBatch, { recursive: true, force: true }); + await this.syncDirectory(discardedRoot); + await this.durability.afterDiscardedRunDeleted?.(manifest.authorizationToken); + await this.removeDurably(this.discardPendingPath()); + this.inventoryCache = undefined; + this.evictRunCaches(manifest.runId); + this.pruneTombstones.delete(manifest.runId); + return { + runId: manifest.runId, + ...(manifest.workflowId ? { workflowId: manifest.workflowId } : {}), + }; + } + + async discardDegradedRun( + input: CreateImagesDegradedRunDiscardRequest, + ): Promise { + validateRunId(input.runId); + if (!/^[a-f0-9]{64}$/u.test(input.authorizationToken)) { + throw new Error("Invalid Create Images degraded-run discard authorization."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + let pending = await this.inspectDiscardManifest(); + if (this.pruneTombstones.has(input.runId) && pending.status === "missing") { + return { status: "not-found" }; + } + let manifest: DegradedRunDiscardManifestV1; + if (pending.status === "healthy") { + manifest = pending.value; + if ( + manifest.runId !== input.runId || + manifest.authorizationToken !== input.authorizationToken || + manifest.expectedCurrentJournalRevision !== input.expectedCurrentJournalRevision || + manifest.expectedLastKnownGoodJournalRevision !== + input.expectedLastKnownGoodJournalRevision + ) { + return { status: "conflict" }; + } + } else if (pending.status === "missing") { + const planned = await this.buildDegradedRunDiscardPlan(input.runId); + if (planned.status !== "ready") return planned; + if ( + planned.plan.authorizationToken !== input.authorizationToken || + planned.plan.expectedCurrentJournalRevision !== input.expectedCurrentJournalRevision || + planned.plan.expectedLastKnownGoodJournalRevision !== + input.expectedLastKnownGoodJournalRevision + ) { + return { status: "conflict" }; + } + manifest = { ...planned.plan, createdAt: new Date().toISOString() }; + await this.writeAtomic(this.discardPendingPath(), manifest, 64 * 1024); + this.discardStateLoaded = true; + this.pruneTombstones.add(manifest.runId); + this.evictRunCaches(manifest.runId); + await this.durability.afterDiscardManifestPublished?.(manifest.authorizationToken); + pending = { status: "healthy", value: manifest }; + } else { + throw new CreateImagesRunJournalLoadError( + pending.status === "unsafe" ? "unsafe" : "corrupt", + this.discardPendingPath(), + ); + } + return { + status: "discarded", + result: await this.resumeDegradedRunDiscard(manifest), + }; + }); + } + + async recoverFromLastKnownGood( + runId: string, + expectedJournalRevision: number, + ): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + if (this.pruneTombstones.has(runId)) { + throw new CreateImagesRunJournalLoadError("corrupt", this.paths(runId).current); + } + const state = await this.inspected(runId); + if (state.pending.status !== "missing" || state.lastKnownGood.status !== "healthy") { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + if (state.lastKnownGood.value.journalRevision !== expectedJournalRevision) { + throw new CreateImagesRunJournalRevisionConflictError( + runId, + expectedJournalRevision, + state.lastKnownGood.value.journalRevision, + ); + } + if (state.current.status === "unsafe") { + throw new CreateImagesRunJournalLoadError("unsafe", state.paths.current); + } + if ( + state.current.status === "healthy" && + identical(state.current.value, state.lastKnownGood.value) + ) { + return state.current.value; + } + await this.assertWithinLimits( + runId, + new Map([[state.paths.current, state.lastKnownGood.value]]), + ); + await this.replaceCheckpoint( + state.paths.current, + state.paths.currentEvents, + state.lastKnownGood.value, + ); + await this.refreshInventoryRun(runId); + await this.updateIndexEntry(state.lastKnownGood.value); + return state.lastKnownGood.value; + }); + } + + async recoverLastKnownGoodFromCurrent( + runId: string, + expectedJournalRevision: number, + ): Promise { + validateRunId(runId); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + if (this.pruneTombstones.has(runId)) { + throw new CreateImagesRunJournalLoadError("corrupt", this.paths(runId).lastKnownGood); + } + const state = await this.inspected(runId); + if (state.pending.status !== "missing" || state.current.status !== "healthy") { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.lastKnownGood); + } + if (state.current.value.journalRevision !== expectedJournalRevision) { + throw new CreateImagesRunJournalRevisionConflictError( + runId, + expectedJournalRevision, + state.current.value.journalRevision, + ); + } + if (state.lastKnownGood.status === "unsafe") { + throw new CreateImagesRunJournalLoadError("unsafe", state.paths.lastKnownGood); + } + if ( + state.lastKnownGood.status === "healthy" && + identical(state.current.value, state.lastKnownGood.value) + ) { + return state.current.value; + } + await this.assertWithinLimits( + runId, + new Map([[state.paths.lastKnownGood, state.current.value]]), + ); + await this.replaceCheckpoint( + state.paths.lastKnownGood, + state.paths.lastKnownGoodEvents, + state.current.value, + ); + await this.refreshInventoryRun(runId); + await this.updateIndexEntry(state.current.value); + return state.current.value; + }); + } + + async healthPage(cursor?: string, limit = 100): Promise { + if (cursor !== undefined) validateRunId(cursor); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_HEALTH_PAGE_SIZE) { + throw new Error("Invalid Create Images run health page size."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const { runIds } = await this.inventory(); + const start = cursor === undefined ? 0 : runIds.findIndex((runId) => runId > cursor); + if (start < 0) return { records: [] }; + const selected = runIds.slice(start, start + limit); + const records: CreateImagesRunJournalHealth[] = []; + for (const runId of selected) { + await this.reconcile(runId); + records.push( + this.pruneTombstones.has(runId) + ? { status: "missing", runId } + : this.healthOf(runId, await this.inspected(runId)), + ); + } + const last = selected[selected.length - 1]; + return { + records, + ...(last && start + selected.length < runIds.length ? { nextCursor: last } : {}), + }; + }); + } + + async recoveryCandidates(limit = 100): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_HEALTH_PAGE_SIZE) { + throw new Error("Invalid Create Images run recovery candidate limit."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries + .filter( + ( + entry, + ): entry is RunIndexEntryV1 & { + recoveryReason: CreateImagesRunRecoveryReason; + } => + entry.health === "recovery-required" && + entry.recoveryReason !== undefined && + !this.pruneTombstones.has(entry.runId), + ) + .slice(0, limit) + .map((entry) => this.recoveryCandidateFromEntry(entry)); + }); + } + + private recoveryCandidateFromEntry( + entry: RunIndexEntryV1 & { recoveryReason: CreateImagesRunRecoveryReason }, + ): CreateImagesRunRecoveryCandidate { + return { + runId: entry.runId, + workflowId: entry.workflowId, + workflowRevision: entry.workflowRevision, + reason: entry.recoveryReason, + canRecover: entry.canRecover ?? false, + ...(entry.expectedJournalRevision !== undefined + ? { expectedJournalRevision: entry.expectedJournalRevision } + : {}), + }; + } + + private degradedCandidateFromEntry( + entry: RunIndexEntryV1, + ): CreateImagesRunDegradedCandidate | undefined { + if (entry.health === "recovery-required" && entry.recoveryReason) { + return { + status: "recovery-required", + ...this.recoveryCandidateFromEntry({ + ...entry, + recoveryReason: entry.recoveryReason, + }), + }; + } + if (entry.health === "unsafe" && entry.unsafeReason) { + return { + status: "unsafe", + runId: entry.runId, + workflowId: entry.workflowId, + workflowRevision: entry.workflowRevision, + reason: entry.unsafeReason, + }; + } + return undefined; + } + + /** + * Bounded, path-free diagnostics for every degraded run known to the + * derived index. Workflow-less records are deliberately not authorizable. + */ + async degradedRuns( + limit = this.limits.maxRunCount, + ): Promise { + if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.limits.maxRunCount) { + throw new Error("Invalid Create Images degraded run limit."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const index = await this.indexed(); + const associated = index.entries + .map((entry) => this.degradedCandidateFromEntry(entry)) + .filter( + (candidate): candidate is CreateImagesRunDegradedCandidate => + candidate !== undefined && !this.pruneTombstones.has(candidate.runId), + ); + const unassociated = index.degraded + .filter((entry) => !this.pruneTombstones.has(entry.runId)) + .map( + (entry): CreateImagesRunStorageDegradedRecord => ({ + status: entry.status, + runId: entry.runId, + reason: + entry.status === "unsafe" + ? (entry.unsafeReason as CreateImagesRunUnsafeReason) + : (entry.recoveryReason as CreateImagesRunRecoveryReason), + canRecover: false, + }), + ); + return [...associated, ...unassociated] + .sort((left, right) => left.runId.localeCompare(right.runId)) + .slice(0, limit); + }); + } + + async degradedRunCount(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const index = await this.indexed(); + return ( + index.entries.filter( + (entry) => entry.health !== "healthy" && !this.pruneTombstones.has(entry.runId), + ).length + index.degraded.filter((entry) => !this.pruneTombstones.has(entry.runId)).length + ); + }); + } + + async refreshWorkflowDegradedMetadata( + workflowId: string, + runIds: readonly string[], + ): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + if ( + runIds.length > MAX_PRUNE_BATCH_SIZE || + new Set(runIds).size !== runIds.length || + runIds.some((runId) => !RUN_ID_PATTERN.test(runId)) + ) { + throw new Error("Invalid bounded Create Images recovery refresh."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const index = await this.indexed(); + const refreshed = new Map(index.entries.map((entry) => [entry.runId, entry])); + const degraded = new Map(index.degraded.map((entry) => [entry.runId, entry])); + for (const runId of runIds) { + await this.reconcile(runId); + if (this.pruneTombstones.has(runId)) { + refreshed.delete(runId); + degraded.delete(runId); + continue; + } + const state = await this.inspected(runId); + const entry = this.entryForState(runId, state, refreshed.get(runId)); + if (entry) { + refreshed.set(runId, entry); + degraded.delete(runId); + } else { + refreshed.delete(runId); + const unassociated = this.unassociatedDegradedForState(runId, state); + if (unassociated) degraded.set(runId, unassociated); + } + } + const entries = [...refreshed.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ); + const degradedEntries = [...degraded.values()].sort((left, right) => + left.runId.localeCompare(right.runId), + ); + if ( + JSON.stringify(entries) !== JSON.stringify(index.entries) || + JSON.stringify(degradedEntries) !== JSON.stringify(index.degraded) + ) { + await this.publishIndex(entries, index.revision + 1, degradedEntries); + } + return entries + .filter( + (entry) => entry.workflowId === workflowId && !this.pruneTombstones.has(entry.runId), + ) + .map((entry) => this.degradedCandidateFromEntry(entry)) + .filter( + (candidate): candidate is CreateImagesRunDegradedCandidate => candidate !== undefined, + ); + }); + } + + async refreshWorkflowRecoveryMetadata( + workflowId: string, + runIds: readonly string[], + ): Promise { + const degraded = await this.refreshWorkflowDegradedMetadata(workflowId, runIds); + return degraded + .filter( + ( + candidate, + ): candidate is Extract< + CreateImagesRunDegradedCandidate, + { status: "recovery-required" } + > => candidate.status === "recovery-required", + ) + .map(({ status: _status, ...candidate }) => candidate); + } + + async workflowDegradedCandidates( + workflowId: string, + limit = this.limits.maxRunCount, + ): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > this.limits.maxRunCount) { + throw new Error("Invalid Create Images degraded run limit."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries + .filter( + (entry) => + entry.workflowId === workflowId && + entry.health !== "healthy" && + !this.pruneTombstones.has(entry.runId), + ) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.runId.localeCompare(right.runId), + ) + .map((entry) => this.degradedCandidateFromEntry(entry)) + .filter( + (candidate): candidate is CreateImagesRunDegradedCandidate => candidate !== undefined, + ) + .slice(0, limit); + }); + } + + /** + * Revalidates start authority from the bounded on-disk run inventory. + * The derived index is used only as an association hint for records whose + * two authoritative checkpoints are damaged; its status bits never admit a + * new run. + */ + async auditWorkflowAdmission(workflowId: string): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const prior = await this.loadIndex(true); + const { runIds } = await this.inventory(true); + const inventoryRunIds = new Set(runIds); + const priorEntries = new Map(prior?.entries.map((entry) => [entry.runId, entry]) ?? []); + for (const entry of prior?.entries ?? []) { + if ( + entry.workflowId === workflowId && + !inventoryRunIds.has(entry.runId) && + !this.pruneTombstones.has(entry.runId) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", this.runDirectory(entry.runId)); + } + } + if ( + (prior?.degraded ?? []).some( + (entry) => !inventoryRunIds.has(entry.runId) && !this.pruneTombstones.has(entry.runId), + ) + ) { + throw new CreateImagesRunJournalLoadError("corrupt", this.runsPath()); + } + + const audit: CreateImagesWorkflowAdmissionAudit = { + hasDegradedAuthority: false, + hasNonterminalRun: false, + hasUnresolvedAmbiguity: false, + }; + const entries: RunIndexEntryV1[] = []; + const degraded: RunUnassociatedDegradedEntryV1[] = []; + for (const runId of runIds) { + if (this.pruneTombstones.has(runId)) continue; + await this.reconcile(runId); + const state = await this.inspected(runId); + const health = this.healthOf(runId, state); + if (health.status === "missing") { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + const entry = this.entryForState(runId, state, priorEntries.get(runId)); + if (entry) { + entries.push(entry); + if (entry.health !== "healthy") { + // A derived-index association is useful for recovery UI, but it + // cannot scope admission when neither checkpoint still proves + // identity. Unassociated damage therefore blocks every workflow. + const trustedWorkflowId = "workflowId" in health ? health.workflowId : undefined; + if (trustedWorkflowId === undefined || trustedWorkflowId === workflowId) { + audit.hasDegradedAuthority = true; + } + } else if (entry.workflowId === workflowId) { + audit.hasNonterminalRun ||= !entry.terminal; + audit.hasUnresolvedAmbiguity ||= entry.unresolvedAmbiguity; + } + } else { + const unassociated = this.unassociatedDegradedForState(runId, state); + if (!unassociated) { + throw new CreateImagesRunJournalLoadError("corrupt", state.paths.current); + } + degraded.push(unassociated); + audit.hasDegradedAuthority = true; + } + if (health.status === "healthy" && state.current.status === "healthy") { + await this.cacheHealthyJournal(runId, state.current.value); + } else { + this.evictJournal(runId); + } + } + + entries.sort((left, right) => left.runId.localeCompare(right.runId)); + degraded.sort((left, right) => left.runId.localeCompare(right.runId)); + if ( + !prior || + JSON.stringify(entries) !== JSON.stringify(prior.entries) || + JSON.stringify(degraded) !== JSON.stringify(prior.degraded) + ) { + try { + await this.publishIndex(entries, prior ? prior.revision + 1 : undefined, degraded); + } catch (error) { + this.markIndexDirty(); + throw error; + } + } else { + this.indexDirty = false; + } + return audit; + }); + } + + async hasUnresolvedAmbiguity(workflowId: string): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries.some( + (entry) => + entry.workflowId === workflowId && + entry.health === "healthy" && + entry.unresolvedAmbiguity && + !this.pruneTombstones.has(entry.runId), + ); + }); + } + + async hasNonterminalRun(workflowId: string): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries.some( + (entry) => + entry.workflowId === workflowId && + entry.health === "healthy" && + !entry.terminal && + !this.pruneTombstones.has(entry.runId), + ); + }); + } + + async hasUnassociatedDegradedRuns(): Promise { + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).degraded.some( + (entry) => !this.pruneTombstones.has(entry.runId), + ); + }); + } + + async workflowRecoveryCandidates( + workflowId: string, + limit = 100, + ): Promise { + if (!RUN_ID_PATTERN.test(workflowId)) throw new Error("Invalid Create Images workflow ID."); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_HEALTH_PAGE_SIZE) { + throw new Error("Invalid Create Images run recovery candidate limit."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + return (await this.indexed()).entries + .filter( + ( + entry, + ): entry is RunIndexEntryV1 & { + recoveryReason: CreateImagesRunRecoveryReason; + } => + entry.workflowId === workflowId && + entry.health === "recovery-required" && + entry.recoveryReason !== undefined && + !this.pruneTombstones.has(entry.runId), + ) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || left.runId.localeCompare(right.runId), + ) + .slice(0, limit) + .map((entry) => this.recoveryCandidateFromEntry(entry)); + }); + } + + async terminalRetentionCandidates( + query: CreateImagesTerminalRetentionQuery, + ): Promise { + if ( + !Number.isSafeInteger(query.keepLatest) || + query.keepLatest < 0 || + query.keepLatest > 1_000 + ) { + throw new Error("Invalid Create Images terminal retention keep count."); + } + const limit = query.limit ?? MAX_PRUNE_BATCH_SIZE; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_PRUNE_BATCH_SIZE) { + throw new Error("Invalid Create Images terminal retention candidate limit."); + } + if (query.workflowId !== undefined && !RUN_ID_PATTERN.test(query.workflowId)) { + throw new Error("Invalid Create Images workflow ID."); + } + if (query.olderThan !== undefined && !Number.isFinite(Date.parse(query.olderThan))) { + throw new Error("Invalid Create Images terminal retention cutoff."); + } + return serializedAtRoot(this.root(), async () => { + await this.prepare(); + await this.ensurePruneTombstones(); + const entries = (await this.indexed()).entries + .filter( + (entry) => + entry.health === "healthy" && + entry.terminal && + !entry.unresolvedAmbiguity && + !this.pruneTombstones.has(entry.runId) && + (query.workflowId === undefined || entry.workflowId === query.workflowId), + ) + .sort( + (left, right) => + right.updatedAt.localeCompare(left.updatedAt) || right.runId.localeCompare(left.runId), + ); + const retainedByWorkflow = new Map(); + const candidates: CreateImagesTerminalRetentionCandidate[] = []; + const plannedAssetIds = new Set(); + for (const entry of entries) { + const retentionKey = query.workflowId === undefined ? "__global__" : entry.workflowId; + const retained = retainedByWorkflow.get(retentionKey) ?? 0; + if (retained < query.keepLatest) { + retainedByWorkflow.set(retentionKey, retained + 1); + continue; + } + if (query.olderThan !== undefined && entry.updatedAt >= query.olderThan) continue; + const state = await this.inspected(entry.runId); + const health = this.healthOf(entry.runId, state); + if (health.status !== "healthy" || state.current.status !== "healthy") { + await this.updateIndexState(entry.runId, state); + continue; + } + const assetIds = referencedAssetIds(state.current.value); + const nextAssetIds = new Set(plannedAssetIds); + for (const assetId of assetIds) nextAssetIds.add(assetId); + // The crash-resumable prune manifest is intentionally bounded. A + // high-output history is retired in multiple authorized batches. + if (nextAssetIds.size > 10_000) { + if (candidates.length === 0) { + throw new Error("A Create Images run exceeds the bounded prune reference limit."); + } + break; + } + plannedAssetIds.clear(); + for (const assetId of nextAssetIds) plannedAssetIds.add(assetId); + candidates.push({ + runId: entry.runId, + workflowId: entry.workflowId, + journalRevision: entry.journalRevision, + updatedAt: entry.updatedAt, + assetIds, + }); + if (candidates.length === limit) break; + } + return candidates; + }); + } + + cacheStats(): CreateImagesRunCacheStats { + return { + journalCount: this.journalCache.size, + journalBytes: this.journalCacheBytes, + tailCount: this.eventLogTailCache.size, + tailBytes: this.tailCacheBytes, + }; + } +} + +// Keep the shared contract's version visible to main-only feature-surface tests. +export const CREATE_IMAGES_RUN_STORE_SCHEMA_VERSION = CREATE_IMAGES_RUN_JOURNAL_VERSION; diff --git a/main/services/create-images/run-publication-binding-core.ts b/main/services/create-images/run-publication-binding-core.ts new file mode 100644 index 00000000..0b6754b8 --- /dev/null +++ b/main/services/create-images/run-publication-binding-core.ts @@ -0,0 +1,16 @@ +export interface CreateImagesRunOwnerSnapshot { + status: string; + activeRun?: { runId: string }; +} + +/** + * A transient/busy snapshot proves nothing about run liveness. Renderer + * disconnect ownership may be released only by an authoritative ready list + * that proves this exact run is no longer active. + */ +export function shouldReleaseCreateImagesRunOwner( + runId: string, + snapshot: CreateImagesRunOwnerSnapshot, +): boolean { + return snapshot.status === "ready" && snapshot.activeRun?.runId !== runId; +} diff --git a/main/services/create-images/run-service.test.ts b/main/services/create-images/run-service.test.ts new file mode 100644 index 00000000..565501e6 --- /dev/null +++ b/main/services/create-images/run-service.test.ts @@ -0,0 +1,2742 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test, { type TestContext } from "node:test"; +import type { AuthResult } from "@earendil-works/pi-ai"; +import { createWorkflowCoordinatorPlan } from "./scheduler-core.js"; +import { + projectCreateImagesRun, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; +import type { + AssetIngestRequest, + AssetIngestResult, + AssetMetadataDto, + ContentAddressedAssetStore, +} from "./asset-store-core.js"; +import { + DeterministicMockImageProvider, + type MockImageProviderScript, +} from "./mock-image-provider-core.js"; +import { + CreateImagesRunJournalStore, + type CreateImagesRunJournalDurability, +} from "./run-journal-store.js"; +import { + CreateImagesRunService, + CREATE_IMAGES_MAX_ACTIVE_RUNS, + evaluateCreateImagesWorkflowDeletion, + type CreateImagesRunReferenceAuthority, + type CreateImagesRunReferenceReservation, +} from "./run-service.js"; +import { WorkflowManifestStore } from "./workflow-manifest-store.js"; +import { GeminiImageProvider } from "./providers/gemini-image-provider-core.js"; +import type { CreateImagesWorkspaceState } from "./workspace-store.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; +const DURABLE_ASSET_ID = "a".repeat(64); + +test("workflow deletion requires an authoritative empty run lifecycle", () => { + const unavailableMessage = ( + decision: ReturnType, + ): string => { + assert.equal(decision.status, "unavailable"); + return decision.status === "unavailable" ? decision.message : ""; + }; + const empty = { + status: "ready" as const, + authoritative: true as const, + history: [], + recoveries: [], + }; + assert.deepEqual(evaluateCreateImagesWorkflowDeletion(empty), { status: "allowed" }); + assert.deepEqual(evaluateCreateImagesWorkflowDeletion({ status: "not-found" }), { + status: "not-found", + }); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ status: "unavailable", message: "busy" }), + ), + /could not be verified safely/u, + ); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ + ...empty, + activeRun: {} as never, + }), + ), + /Stop the active image run/u, + ); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ + ...empty, + latestTerminalRun: {} as never, + }), + ), + /retained run history/u, + ); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ + ...empty, + history: [{} as never], + }), + ), + /retained run history/u, + ); + assert.match( + unavailableMessage( + evaluateCreateImagesWorkflowDeletion({ + ...empty, + recoveries: [ + { + status: "unsafe", + workflowId: "workflow-1", + runId: "run-1", + reason: "unsafe-storage", + }, + ], + }), + ), + /retained run recovery records/u, + ); +}); + +async function temporaryRoot(t: TestContext): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-run-service-")); + t.after(() => fs.rm(root, { force: true, recursive: true })); + return root; +} + +function workflow(outputCount: 1 | 2 | 3 | 4 = 1): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "workflow-1", + title: "Run service", + revision: 1, + createdAt: NOW, + updatedAt: NOW, + nodes: [ + { + id: "prompt-1", + type: "prompt", + position: { x: 0, y: 0 }, + data: { text: "A tiny durable image" }, + }, + { + id: "generate-1", + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: outputCount, + }, + }, + { id: "output-1", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + edges: [ + { + id: "edge-prompt", + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + { + id: "edge-output", + source: "generate-1", + sourcePort: "images", + target: "output-1", + targetPort: "images", + }, + ], + assetRefs: [], + settings: { concurrency: 1 }, + }; +} + +class FakeAssets { + readonly available = new Map(); + readonly bytesById = new Map(); + readonly publicationOrder: string[] = []; + readonly runReferences = new Map(); + failIngest = false; + ingestGate?: Promise; + ingestStarted = false; + + async ingest( + source: AsyncIterable, + request: AssetIngestRequest, + ): Promise { + if (this.failIngest) throw new Error("simulated durable asset publication failure"); + this.ingestStarted = true; + await this.ingestGate; + const chunks: Uint8Array[] = []; + let byteLength = 0; + for await (const chunk of source) { + chunks.push(chunk); + byteLength += chunk.byteLength; + } + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const assetId = createHash("sha256").update(bytes).digest("hex"); + const asset: AssetMetadataDto = { + assetId, + mediaType: "image/png", + byteLength, + width: Number(request.generationMetadata?.width ?? 1), + height: Number(request.generationMetadata?.height ?? 1), + createdAt: NOW, + origin: request.origin, + ...(request.generationMetadata + ? { generationMetadata: structuredClone(request.generationMetadata) } + : {}), + referenceCount: 0, + thumbnailSizes: [], + }; + this.available.set(assetId, asset); + this.bytesById.set(assetId, bytes); + this.publicationOrder.push(assetId); + return { + asset, + deduplicated: false, + quotaWarning: false, + totalAssetBytes: [...this.available.values()].reduce( + (total, candidate) => total + candidate.byteLength, + 0, + ), + }; + } + + async getAvailable(assetId: string): Promise { + return this.available.get(assetId); + } + + async acquirePreviewLease(assetId: string, ownerId: string) { + if (!this.available.has(assetId)) throw new Error("asset unavailable"); + return { token: `${ownerId}-${assetId.slice(0, 16)}`, assetId, expiresAt: Date.now() + 60_000 }; + } + + async readPreview(token: string) { + const assetId = [...this.available.keys()].find((candidate) => + token.endsWith(candidate.slice(0, 16)), + ); + const asset = assetId ? this.available.get(assetId) : undefined; + const bytes = assetId ? this.bytesById.get(assetId) : undefined; + if (!asset || !bytes) throw new Error("preview unavailable"); + return { asset, bytes: bytes.slice() }; + } + + async releasePreviewLease(): Promise { + return true; + } + + async list(): Promise { + return [...this.available.values()]; + } + + async replaceReferences( + owner: { kind: "workflow" | "run"; id: string }, + assetIds: readonly string[], + ): Promise { + if (owner.kind === "run") this.runReferences.set(owner.id, [...assetIds]); + } +} + +class FakeReferences implements CreateImagesRunReferenceAuthority { + readonly reservations: CreateImagesRunReferenceReservation[] = []; + readonly committed = new Map>(); + readonly order: string[] = []; + reconcileCount = 0; + onReserve?: (reservation: CreateImagesRunReferenceReservation) => void | Promise; + + async reserveRun( + runId: string, + assetIds: readonly string[], + ): Promise { + const reservation = { runId, next: new Set(assetIds), active: true }; + this.reservations.push(reservation); + this.order.push(`reserve:${runId}`); + await this.onReserve?.(reservation); + return reservation; + } + + async commitRun(reservation: CreateImagesRunReferenceReservation): Promise { + reservation.active = false; + this.committed.set(reservation.runId, new Set(reservation.next)); + this.order.push(`commit:${reservation.runId}`); + } + + async releaseRunReservations(runId: string): Promise { + for (const reservation of this.reservations) { + if (reservation.runId === runId) reservation.active = false; + } + this.order.push(`release:${runId}`); + } + + async reconcileRuns(store: CreateImagesRunJournalStore): Promise { + this.reconcileCount += 1; + const inventory = await store.referenceInventory(); + this.committed.clear(); + for (const record of inventory.records) { + this.committed.set(record.runId, new Set(record.assetIds)); + } + for (const reservation of this.reservations) reservation.active = false; + return inventory.complete; + } + + isRunAssetReferenced(runId: string, assetId: string): boolean { + return ( + (this.committed.get(runId)?.has(assetId) ?? false) || + this.reservations.some( + (reservation) => + reservation.active && reservation.runId === runId && reservation.next.has(assetId), + ) + ); + } +} + +interface Harness { + root: string; + workflows: WorkflowManifestStore; + assets: FakeAssets; + references: FakeReferences; + journals: CreateImagesRunJournalStore; + service: CreateImagesRunService; +} + +async function harness( + t: TestContext, + options: { + document?: WorkflowDocumentV1; + script?: MockImageProviderScript; + createRunId?: () => string; + onScript?: () => void; + now?: () => number; + shutdownTimeoutMs?: number; + journalDurability?: CreateImagesRunJournalDurability; + resolveGeminiAuth?: () => Promise; + createGeminiProvider?: () => GeminiImageProvider; + workspaceStatus?: () => Promise<{ configured: boolean; state: CreateImagesWorkspaceState }>; + workspaceRequired?: boolean; + } = {}, +): Promise { + const root = await temporaryRoot(t); + const workflows = new WorkflowManifestStore(() => root); + await workflows.create(options.document ?? workflow()); + const assets = new FakeAssets(); + const references = new FakeReferences(); + const journals = new CreateImagesRunJournalStore(() => root, options.journalDurability); + let now = Date.parse(NOW); + const service = new CreateImagesRunService({ + rootResolver: () => root, + workflows, + assets: assets as unknown as ContentAddressedAssetStore, + references, + journalStore: journals, + resolveGeminiAuth: options.resolveGeminiAuth, + createGeminiProvider: options.createGeminiProvider, + workspaceStatus: options.workspaceStatus, + workspaceRequired: options.workspaceRequired, + now: options.now ?? (() => now++), + createRunId: options.createRunId ?? (() => "run-1"), + shutdownTimeoutMs: options.shutdownTimeoutMs, + mockScript: (nodeIds) => { + options.onScript?.(); + return ( + options.script ?? { + nodes: Object.fromEntries( + nodeIds.map((nodeId) => [ + nodeId, + [ + { + outcome: "success" as const, + delayMs: 0, + width: 8, + height: 8, + seed: 7, + }, + ], + ]), + ), + } + ); + }, + }); + return { root, workflows, assets, references, journals, service }; +} + +test("does not start provider work when a configured workspace fails the fast preflight", async (t) => { + let providerStarted = false; + const context = await harness(t, { + workspaceStatus: async () => ({ configured: true, state: "drifted" }), + onScript: () => { + providerStarted = true; + }, + }); + const result = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(result.status, "unavailable"); + assert.match(result.message, /workspace is not ready/u); + assert.equal(providerStarted, false); +}); + +test("does not start provider work before the required first-open workspace is configured", async (t) => { + let providerStarted = false; + const context = await harness(t, { + workspaceRequired: true, + workspaceStatus: async () => ({ configured: false, state: "ready" }), + onScript: () => { + providerStarted = true; + }, + }); + const result = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(result.status, "unavailable"); + assert.match(result.message, /workspace is not ready/u); + assert.equal(providerStarted, false); + assert.equal(await context.journals.get("run-1"), undefined); +}); + +async function waitForJournal( + store: CreateImagesRunJournalStore, + runId: string, + predicate: (journal: CreateImagesRunJournalV1) => boolean, +): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + const journal = await store.get(runId); + if (journal && predicate(journal)) return journal; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error(`Timed out waiting for ${runId}.`); +} + +async function waitForTerminal( + store: CreateImagesRunJournalStore, + runId: string, +): Promise { + return waitForJournal( + store, + runId, + (journal) => projectCreateImagesRun(journal).terminal !== undefined, + ); +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error("Timed out waiting for run-service state."); +} + +async function waitForAsync(predicate: () => Promise): Promise { + for (let attempt = 0; attempt < 500; attempt += 1) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 2)); + } + throw new Error("Timed out waiting for async run-service state."); +} + +async function seedRestartRun( + store: CreateImagesRunJournalStore, + input: { + runId: string; + providerJobId?: string; + durableOutputAssetIds?: string[]; + }, +): Promise { + const plan = createWorkflowCoordinatorPlan(workflow(), { kind: "all" }); + let journal = await store.start( + { + runId: input.runId, + workflowSnapshot: plan.snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, values]) => [nodeId, [...values]]), + ), + }, + createdAt: NOW, + }, + () => true, + ); + const append = async (event: CreateImagesRunEventV1): Promise => { + journal = await store.append(journal.runId, journal.journalRevision, event); + }; + const base = () => ({ + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: NOW, + }); + await append({ ...base(), type: "run-started" }); + await append({ ...base(), type: "node-started", nodeId: "prompt-1" }); + await append({ + ...base(), + type: "node-output-published", + nodeId: "prompt-1", + outputAssetIds: [], + }); + await append({ + ...base(), + type: "node-succeeded", + nodeId: "prompt-1", + outputAssetIds: [], + }); + await append({ ...base(), type: "node-started", nodeId: "generate-1" }); + await append({ + ...base(), + type: "node-submission-prepared", + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-restart-node-0001", + providerId: "local-mock", + modelId: "deterministic-v1", + }); + if (input.providerJobId) { + await append({ + ...base(), + type: "node-submission-accepted", + nodeId: "generate-1", + attempt: 1, + providerJobId: input.providerJobId, + }); + } + if (input.durableOutputAssetIds) { + await append({ + ...base(), + type: "node-output-published", + nodeId: "generate-1", + outputAssetIds: [...input.durableOutputAssetIds], + }); + } + return journal; +} + +test("successful runs publish assets before journal success and commit durable references", async (t) => { + const context = await harness(t); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "succeeded"); + const generated = projection.nodes["generate-1"]; + assert.equal(generated?.outputAssetIds.length, 1); + const assetId = generated?.outputAssetIds[0]; + assert.ok(assetId); + assert.equal(context.assets.available.has(assetId), true); + assert.equal(context.references.isRunAssetReferenced(journal.runId, assetId), true); + assert.deepEqual(context.assets.runReferences.get(journal.runId), [assetId]); + const acceptedIndex = journal.events.findIndex( + (event) => event.type === "node-submission-accepted", + ); + const publishedIndex = journal.events.findIndex( + (event) => event.type === "node-output-published" && event.nodeId === "generate-1", + ); + const succeededIndex = journal.events.findIndex( + (event) => event.type === "node-succeeded" && event.nodeId === "generate-1", + ); + assert.ok( + acceptedIndex >= 0 && publishedIndex > acceptedIndex && succeededIndex > publishedIndex, + ); + assert.ok(context.references.reconcileCount >= 2); +}); + +test("stop durably journals cancellation before terminal node cancellation", async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + delayMs: 60_000, + width: 8, + height: 8, + seed: 4, + lateCompletionAfterCancel: true, + }, + ], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForJournal(context.journals, started.run.runId, (journal) => + journal.events.some((event) => event.type === "node-submission-accepted"), + ); + const stopping = await context.service.stop("workflow-1", started.run.runId, "user"); + assert.equal(stopping.status, "stopping"); + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(projectCreateImagesRun(journal).status, "cancelled"); + const cancellationIndex = journal.events.findIndex( + (event) => event.type === "run-cancel-requested", + ); + const cancelledNodeIndex = journal.events.findIndex((event) => event.type === "node-cancelled"); + assert.ok(cancellationIndex >= 0 && cancelledNodeIndex > cancellationIndex); + assert.equal( + journal.events.some( + (event) => event.type === "node-output-published" && event.nodeId === "generate-1", + ), + false, + ); +}); + +test("app-quit cancellation remains journal-monotonic when the wall clock rolls backward", async (t) => { + let now = Date.parse(NOW) + 120_000; + const context = await harness(t, { + now: () => now, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8 }], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const beforeRollback = await waitForJournal(context.journals, started.run.runId, (journal) => + journal.events.some((event) => event.type === "node-submission-accepted"), + ); + now = Date.parse(NOW) - 120_000; + await context.service.stopAll("app-quit"); + const terminal = await waitForTerminal(context.journals, started.run.runId); + assert.ok(Date.parse(terminal.updatedAt) >= Date.parse(beforeRollback.updatedAt)); + assert.equal(projectCreateImagesRun(terminal).cancellation?.reason, "app-quit"); + let previous = Date.parse(terminal.createdAt); + for (const event of terminal.events) { + assert.ok(Date.parse(event.at) >= previous); + previous = Date.parse(event.at); + } +}); + +test("ambiguous submissions become explicit needs-attention terminal history", async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [ + { + outcome: "ambiguous-submit", + delayMs: 0, + durableRemoteJob: false, + error: "connection lost after submission", + }, + ], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal(projection.nodes["output-1"]?.status, "blocked"); + assert.equal(JSON.stringify(journal).includes("connection lost"), false); + assert.equal((await context.service.list("workflow-1")).status, "ready"); +}); + +test("a delayed durable cancellation cannot erase a prepared submission ambiguity", async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [ + { + outcome: "ambiguous-submit", + delayMs: 60_000, + durableRemoteJob: false, + }, + ], + }, + }, + }); + let releaseCancellation: () => void = () => undefined; + let markCancellationEntered: () => void = () => undefined; + const cancellationGate = new Promise((resolve) => { + releaseCancellation = resolve; + }); + const cancellationEntered = new Promise((resolve) => { + markCancellationEntered = resolve; + }); + const requestCancellation = context.journals.requestCancellation.bind(context.journals); + context.journals.requestCancellation = async (...args) => { + markCancellationEntered(); + await cancellationGate; + return requestCancellation(...args); + }; + + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForJournal(context.journals, started.run.runId, (journal) => + journal.events.some((event) => event.type === "node-submission-prepared"), + ); + const stopping = context.service.stop("workflow-1", started.run.runId, "app-quit"); + await cancellationEntered; + const beforeDurableCancel = await context.journals.get(started.run.runId); + assert.equal(projectCreateImagesRun(beforeDurableCancel!).cancellation, undefined); + releaseCancellation(); + assert.equal((await stopping).status, "stopping"); + + const terminal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(terminal); + assert.equal(projection.cancellation?.reason, "app-quit"); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal(projection.nodes["output-1"]?.status, "blocked"); + assert.equal( + terminal.events.some( + (event) => event.type === "node-cancelled" && event.nodeId === "generate-1", + ), + false, + ); +}); + +test("unresolved ambiguity blocks admission until a CAS-bound audit acknowledgement", async (t) => { + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `ambiguity-${++runNumber}`, + script: { + nodes: { + "generate-1": [ + { + outcome: "ambiguous-submit", + delayMs: 0, + durableRemoteJob: false, + error: "connection lost after submission", + }, + ], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const needsAttention = await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + assert.equal(runNumber, 1); + + assert.deepEqual( + await context.service.resolveRunAmbiguity({ + workflowId: "workflow-1", + runId: needsAttention.runId, + expectedJournalRevision: needsAttention.journalRevision + 1, + resolution: "acknowledge-unresolved-submission", + }), + { + status: "conflict", + expectedJournalRevision: needsAttention.journalRevision + 1, + currentJournalRevision: needsAttention.journalRevision, + }, + ); + const resolved = await context.service.resolveRunAmbiguity({ + workflowId: "workflow-1", + runId: needsAttention.runId, + expectedJournalRevision: needsAttention.journalRevision, + resolution: "acknowledge-unresolved-submission", + }); + assert.equal(resolved.status, "resolved"); + if (resolved.status !== "resolved") return; + assert.equal(resolved.run.status, "needs_attention"); + assert.equal(resolved.run.ambiguityResolution?.kind, "acknowledged-unresolved-submission"); + assert.equal( + resolved.authoritativeList.history[0]?.ambiguityResolution?.kind, + "acknowledged-unresolved-submission", + ); + const acknowledged = await context.journals.get(needsAttention.runId); + assert.equal( + acknowledged?.events.filter((candidate) => candidate.type === "run-ambiguity-acknowledged") + .length, + 1, + ); + const stale = await context.service.resolveRunAmbiguity({ + workflowId: "workflow-1", + runId: needsAttention.runId, + expectedJournalRevision: needsAttention.journalRevision, + resolution: "acknowledge-unresolved-submission", + }); + assert.equal(stale.status, "conflict"); + const already = await context.service.resolveRunAmbiguity({ + workflowId: "workflow-1", + runId: needsAttention.runId, + expectedJournalRevision: resolved.run.journalRevision, + resolution: "acknowledge-unresolved-submission", + }); + assert.equal(already.status, "already-resolved"); + + let journalReads = 0; + const originalGet = context.journals.get.bind(context.journals); + context.journals.get = async (runId) => { + journalReads += 1; + return originalGet(runId); + }; + const admitted = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + context.journals.get = originalGet; + assert.equal(admitted.status, "started"); + assert.equal(journalReads, 0); + if (admitted.status === "started") { + await waitForTerminal(context.journals, admitted.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + } +}); + +test("a failed terminal index publication keeps direct run admission closed", async (t) => { + let rejectIndex = false; + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `dirty-index-${++runNumber}`, + journalDurability: { + beforeIndexPublished: async () => { + if (rejectIndex) throw new Error("simulated terminal index failure"); + }, + }, + script: { + nodes: { + "generate-1": [ + { + outcome: "ambiguous-submit", + delayMs: 25, + durableRemoteJob: false, + error: "connection lost after submission", + }, + ], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + rejectIndex = true; + const terminal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(projectCreateImagesRun(terminal).status, "needs_attention"); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + const blockedWhileDirty = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blockedWhileDirty.status, "unavailable"); + assert.equal(runNumber, 1); + + rejectIndex = false; + const blockedAfterRebuild = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blockedAfterRebuild.status, "unavailable"); + assert.equal(runNumber, 1); +}); + +test("same-process current and recovery corruption blocks a second workflow run", async (t) => { + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `authority-run-${++runNumber}`, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + const directory = path.join(context.root, "runs", started.run.runId); + await Promise.all([ + fs.writeFile(path.join(directory, "run.json"), "{broken-current", "utf8"), + fs.writeFile(path.join(directory, "run.last-known-good.json"), "{broken-recovery", "utf8"), + ]); + + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + assert.equal(runNumber, 1); +}); + +test("same-process future index replacement blocks run allocation and executor launch", async (t) => { + let runNumber = 0; + let providerConstructions = 0; + const context = await harness(t, { + createRunId: () => `index-identity-run-${++runNumber}`, + onScript: () => (providerConstructions += 1), + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); + + const indexPath = path.join(context.root, "run-index.json"); + const replacementPath = path.join(context.root, "run-index.future-replacement.json"); + const futureBytes = '{"version":2,"revision":99,"entries":[],"degraded":[]}\n'; + await fs.writeFile(replacementPath, futureBytes, "utf8"); + await fs.rename(replacementPath, indexPath); + + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); + assert.equal(await fs.readFile(indexPath, "utf8"), futureBytes); +}); + +for (const authoritativeFile of [ + "run.json", + "run.last-known-good.json", + "run.events.jsonl", + "run.last-known-good.events.jsonl", +] as const) { + test(`same-process ${authoritativeFile} tampering blocks executor admission`, async (t) => { + let runNumber = 0; + let providerConstructions = 0; + const context = await harness(t, { + createRunId: () => `tamper-${authoritativeFile.replace(/\./gu, "-")}-${++runNumber}`, + onScript: () => (providerConstructions += 1), + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + assert.equal(providerConstructions, 1); + + const target = path.join(context.root, "runs", started.run.runId, authoritativeFile); + const before = await fs.stat(target); + const bytes = await fs.readFile(target); + bytes[0] = bytes[0] === 0x7b ? 0x5b : bytes[0] === 0x5b ? 0x7b : bytes[0] ^ 1; + await fs.writeFile(target, bytes); + await fs.utimes(target, before.atime, before.mtime); + + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); + }); +} + +test("post-pending authority replacement aborts before provider execution", async (t) => { + let executorCalls = 0; + let tampered = false; + const originalExecute = DeterministicMockImageProvider.prototype.execute; + DeterministicMockImageProvider.prototype.execute = async function (...args) { + executorCalls += 1; + return originalExecute.apply(this, args); + }; + t.after(() => { + DeterministicMockImageProvider.prototype.execute = originalExecute; + }); + + let root = ""; + const context = await harness(t, { + createRunId: () => "post-pending-authority-run", + journalDurability: { + afterPendingPublished: async (runId) => { + const directory = path.join(root, "runs", runId); + const pending = JSON.parse( + await fs.readFile(path.join(directory, "run.pending.json"), "utf8"), + ) as { kind?: string; event?: { type?: string } }; + if (pending.kind !== "append" || pending.event?.type !== "run-started") return; + const target = path.join(directory, "run.json"); + const before = await fs.stat(target); + const bytes = await fs.readFile(target); + bytes[0] = bytes[0] === 0x7b ? 0x5b : bytes[0] ^ 1; + await fs.writeFile(target, bytes); + await fs.utimes(target, before.atime, before.mtime); + assert.equal((await fs.stat(target)).size, before.size); + tampered = true; + }, + }, + }); + root = context.root; + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + + assert.equal(tampered, true); + assert.equal(executorCalls, 0); + assert.equal(context.assets.publicationOrder.length, 0); + const health = await context.journals.health(started.run.runId); + assert.equal(health.status, "recovery-required"); + if (health.status === "recovery-required") { + assert.equal(health.reason, "pending-conflict"); + assert.equal(health.canRecover, false); + } + await fs.access(path.join(root, "runs", started.run.runId, "run.pending.json")); +}); + +test("torn prepared-submission append is recovered without provider execution or resubmit", async (t) => { + let executorCalls = 0; + const originalExecute = DeterministicMockImageProvider.prototype.execute; + DeterministicMockImageProvider.prototype.execute = async function (...args) { + executorCalls += 1; + return originalExecute.apply(this, args); + }; + t.after(() => { + DeterministicMockImageProvider.prototype.execute = originalExecute; + }); + + let root = ""; + let torn = false; + const context = await harness(t, { + createRunId: () => "torn-prepared-submission-run", + journalDurability: { + afterPendingPublished: async (runId) => { + const directory = path.join(root, "runs", runId); + const pending = JSON.parse( + await fs.readFile(path.join(directory, "run.pending.json"), "utf8"), + ) as { kind?: string; event?: CreateImagesRunEventV1 }; + if (pending.kind !== "append" || pending.event?.type !== "node-submission-prepared") { + return; + } + const checkpoint = JSON.parse( + await fs.readFile(path.join(directory, "run.json"), "utf8"), + ) as CreateImagesRunJournalV1; + const eventLogPath = path.join(directory, "run.events.jsonl"); + const eventLog = await fs.readFile(eventLogPath, "utf8"); + const lines = eventLog.trimEnd().split("\n"); + const previousDigest = (JSON.parse(lines[lines.length - 1] as string) as { digest: string }) + .digest; + const event = pending.event; + const journalRevision = event.sequence + 1; + const digest = createHash("sha256") + .update(JSON.stringify({ runId, journalRevision, previousDigest, event }), "utf8") + .digest("hex"); + const record = Buffer.from( + `${JSON.stringify({ + version: 1, + runId, + journalRevision, + previousDigest, + digest, + event, + })}\n`, + "utf8", + ); + assert.ok(checkpoint.journalRevision < journalRevision); + await fs.appendFile(eventLogPath, record.subarray(0, Math.floor(record.length / 2))); + torn = true; + throw new Error("simulated process loss during prepared-submission append"); + }, + }, + }); + root = context.root; + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + + assert.equal(torn, true); + assert.equal(executorCalls, 0); + assert.equal(context.assets.publicationOrder.length, 0); + const journal = await context.journals.get(started.run.runId); + assert.ok(journal?.events.some((event) => event.type === "node-submission-prepared")); + assert.equal( + journal?.events.some((event) => event.type === "node-submission-accepted"), + false, + ); + assert.equal(journal && projectCreateImagesRun(journal).status, "needs_attention"); +}); + +test("a failed start index publication reconciles its durable run before retry", async (t) => { + let rejectIndex = false; + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `start-index-${++runNumber}`, + journalDurability: { + beforeIndexPublished: async () => { + if (rejectIndex) throw new Error("simulated start index failure"); + }, + }, + }); + await context.service.initialize(); + rejectIndex = true; + await assert.rejects( + context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ), + /simulated start index failure/u, + ); + const authoritative = await context.journals.get("start-index-1"); + assert.equal(projectCreateImagesRun(authoritative!).terminal?.status, "interrupted"); + assert.equal(projectCreateImagesRun(authoritative!).cancellation, undefined); + assert.equal(runNumber, 1); + + rejectIndex = false; + const retry = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(retry.status, "started"); + assert.equal(runNumber, 2); + if (retry.status === "started") await waitForTerminal(context.journals, retry.run.runId); +}); + +test("same-process launch reconciliation retains ownership until it can interrupt the orphan", async (t) => { + let runNumber = 0; + let scriptCalls = 0; + const context = await harness(t, { + createRunId: () => `launch-orphan-${++runNumber}`, + onScript: () => (scriptCalls += 1), + }); + const originalAppend = context.journals.append.bind(context.journals); + let failedAppendAttempts = 0; + context.journals.append = async (...args) => { + if (failedAppendAttempts < 2) { + failedAppendAttempts += 1; + throw new Error(`simulated transient append failure ${failedAppendAttempts}`); + } + return originalAppend(...args); + }; + + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitFor(() => failedAppendAttempts === 2); + assert.deepEqual( + (await context.service.activeRuns()).map((run) => run.runId), + [started.run.runId], + ); + const orphan = await context.journals.get(started.run.runId); + assert.ok(orphan); + assert.equal(projectCreateImagesRun(orphan).terminal, undefined); + assert.equal(scriptCalls, 1); + + context.journals.append = originalAppend; + const admitted = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(admitted.status, "started"); + assert.equal(runNumber, 2); + assert.equal(scriptCalls, 2); + const interrupted = await context.journals.get(started.run.runId); + assert.ok(interrupted); + const interruptedProjection = projectCreateImagesRun(interrupted); + assert.equal(interruptedProjection.status, "interrupted"); + assert.equal(interruptedProjection.cancellation, undefined); + assert.equal(interruptedProjection.nodes["prompt-1"]?.errorCode, "interrupted"); + assert.equal( + interrupted?.events.some( + (event) => event.type === "node-submission-prepared" || event.type === "node-started", + ), + false, + ); + if (admitted.status === "started") await waitForTerminal(context.journals, admitted.run.runId); +}); + +test("failed-launch callers bound a deferred publication-tail reconciliation", async (t) => { + const never = new Promise(() => undefined); + let rejectLaunchMutation: (error: Error) => void = () => undefined; + let launchMutationReached: () => void = () => undefined; + const launchMutation = new Promise((_resolve, reject) => { + rejectLaunchMutation = reject; + }); + const reachedLaunchMutation = new Promise((resolve) => { + launchMutationReached = resolve; + }); + let runNumber = 0; + let providerConstructions = 0; + const context = await harness(t, { + shutdownTimeoutMs: 30, + createRunId: () => `publication-orphan-${++runNumber}`, + onScript: () => (providerConstructions += 1), + }); + const originalAppend = context.journals.append.bind(context.journals); + let holdFirstLaunchMutation = true; + context.journals.append = async (...args) => { + if (!holdFirstLaunchMutation) return originalAppend(...args); + holdFirstLaunchMutation = false; + launchMutationReached(); + return launchMutation; + }; + + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await reachedLaunchMutation; + const internals = context.service as unknown as { + activeByRun: Map< + string, + { + needsReconciliation?: boolean; + publicationTail: Promise; + } + >; + }; + const active = internals.activeByRun.get(started.run.runId); + assert.ok(active); + active.publicationTail = never; + rejectLaunchMutation(new Error("simulated launch failure before publication join")); + await waitFor(() => active.needsReconciliation === true); + + const listStartedAt = Date.now(); + assert.equal((await context.service.list("workflow-1")).status, "unavailable"); + assert.ok(Date.now() - listStartedAt < 500, "list exceeded its publication-tail deadline"); + const startStartedAt = Date.now(); + assert.equal( + ( + await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ) + ).status, + "unavailable", + ); + assert.ok(Date.now() - startStartedAt < 500, "start exceeded its publication-tail deadline"); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); +}); + +test("failed-launch reconciliation bounds list, start, stop, and quit around deferred authority", async (t) => { + const never = new Promise(() => undefined); + let healthReached: () => void = () => undefined; + const reachedHealth = new Promise((resolve) => { + healthReached = resolve; + }); + let runNumber = 0; + let providerConstructions = 0; + const context = await harness(t, { + shutdownTimeoutMs: 30, + createRunId: () => `bounded-orphan-${++runNumber}`, + onScript: () => (providerConstructions += 1), + }); + const originalAppend = context.journals.append.bind(context.journals); + let rejectFirstLaunchMutation = true; + context.journals.append = async (...args) => { + if (rejectFirstLaunchMutation) { + rejectFirstLaunchMutation = false; + throw new Error("simulated launch mutation failure"); + } + return originalAppend(...args); + }; + context.journals.health = async () => { + healthReached(); + return never as never; + }; + + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await reachedHealth; + const internals = context.service as unknown as { + activeByRun: Map< + string, + { + mutationTail: Promise; + publicationTail: Promise; + } + >; + }; + const active = internals.activeByRun.get(started.run.runId); + assert.ok(active); + active.mutationTail = never; + active.publicationTail = never; + + const listStartedAt = Date.now(); + assert.equal((await context.service.list("workflow-1")).status, "unavailable"); + assert.ok(Date.now() - listStartedAt < 500, "list exceeded its reconciliation deadline"); + + const startStartedAt = Date.now(); + assert.equal( + ( + await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ) + ).status, + "unavailable", + ); + assert.ok(Date.now() - startStartedAt < 500, "start exceeded its reconciliation deadline"); + assert.equal(runNumber, 1); + assert.equal(providerConstructions, 1); + + const stopStartedAt = Date.now(); + assert.equal( + (await context.service.stop("workflow-1", started.run.runId, "user")).status, + "unavailable", + ); + assert.ok(Date.now() - stopStartedAt < 500, "stop exceeded its durable deadline"); + + const quitStartedAt = Date.now(); + assert.deepEqual(await context.service.stopAll("app-quit"), { + status: "blocked", + failedRunIds: [started.run.runId], + }); + assert.ok(Date.now() - quitStartedAt < 500, "stopAll exceeded its durable deadline"); + const durable = await context.journals.get(started.run.runId); + assert.equal( + durable?.events.some( + (event) => event.type === "node-submission-prepared" || event.type === "node-started", + ), + false, + ); +}); + +test("asset publication failure is terminal and reconciliation releases its reservation", async (t) => { + const context = await harness(t); + context.assets.failIngest = true; + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "failed"); + assert.equal(projection.nodes["generate-1"]?.errorCode, "output-publication-failed"); + assert.ok(context.references.reservations.length > 0); + await waitFor(() => context.references.reservations.every((reservation) => !reservation.active)); + assert.equal( + context.references.reservations.some((reservation) => reservation.active), + false, + ); + assert.equal(context.references.isRunAssetReferenced(journal.runId, "a".repeat(64)), false); +}); + +test("prepared restart becomes needs-attention without executing or resubmitting", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { + onScript: () => (scriptCalls += 1), + now: () => Date.parse(NOW) - 60_000, + }); + const plan = createWorkflowCoordinatorPlan(workflow(), { kind: "all" }); + let journal = await context.journals.start( + { + runId: "run-restart", + workflowSnapshot: plan.snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, values]) => [nodeId, [...values]]), + ), + }, + createdAt: NOW, + }, + () => true, + ); + const append = async (event: CreateImagesRunEventV1) => { + journal = await context.journals.append(journal.runId, journal.journalRevision, event); + }; + const base = () => ({ + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: NOW, + }); + await append({ ...base(), type: "run-started" }); + await append({ ...base(), type: "node-started", nodeId: "prompt-1" }); + await append({ + ...base(), + type: "node-output-published", + nodeId: "prompt-1", + outputAssetIds: [], + }); + await append({ + ...base(), + type: "node-succeeded", + nodeId: "prompt-1", + outputAssetIds: [], + }); + await append({ ...base(), type: "node-started", nodeId: "generate-1" }); + await append({ + ...base(), + type: "node-submission-prepared", + nodeId: "generate-1", + attempt: 1, + idempotencyKey: "idem-restart-node-0001", + providerId: "local-mock", + modelId: "deterministic-v1", + }); + + await context.service.initialize(); + const reconciled = await context.journals.get("run-restart"); + assert.ok(reconciled); + assert.equal(reconciled && projectCreateImagesRun(reconciled).status, "needs_attention"); + assert.equal(scriptCalls, 0); + assert.equal( + reconciled?.events.filter((event) => event.type === "node-submission-prepared").length, + 1, + ); + assert.equal( + reconciled?.events.some((event) => event.type === "node-submission-accepted"), + false, + ); + assert.ok(Date.parse(reconciled?.updatedAt ?? "") >= Date.parse(NOW)); +}); + +test("accepted restart reconciles the durable mock job and truthfully interrupts lost local work", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { + onScript: () => (scriptCalls += 1), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + remoteJobId: "accepted-restart-job", + durableRemoteJob: true, + width: 8, + height: 8, + seed: 37, + }, + ], + }, + }, + }); + await seedRestartRun(context.journals, { + runId: "run-accepted-restart", + providerJobId: "accepted-restart-job", + }); + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, "run-accepted-restart"); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "interrupted"); + assert.equal(projection.nodes["generate-1"]?.status, "succeeded"); + assert.equal(projection.nodes["generate-1"]?.outputAssetIds.length, 1); + assert.equal(projection.nodes["output-1"]?.status, "failed"); + assert.equal(projection.nodes["output-1"]?.errorCode, "interrupted"); + assert.equal(projection.cancellation, undefined); + assert.equal(scriptCalls, 1); + assert.equal( + journal.events.filter((event) => event.type === "node-submission-prepared").length, + 1, + ); + assert.equal( + journal.events.filter((event) => event.type === "node-submission-accepted").length, + 1, + ); + assert.equal( + journal.events.filter( + (event) => event.type === "node-output-published" && event.nodeId === "generate-1", + ).length, + 1, + ); + assert.equal(context.assets.publicationOrder.length, 1); +}); + +test("restart cancellation remains authoritative over an accepted provider job", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { + onScript: () => (scriptCalls += 1), + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + remoteJobId: "accepted-cancelled-job", + durableRemoteJob: true, + width: 8, + height: 8, + }, + ], + }, + }, + }); + const accepted = await seedRestartRun(context.journals, { + runId: "run-accepted-cancelled", + providerJobId: "accepted-cancelled-job", + }); + await context.journals.requestCancellation(accepted.runId, accepted.journalRevision, { + at: NOW, + reason: "app-quit", + }); + + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, "run-accepted-cancelled"); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.cancellation?.reason, "app-quit"); + assert.equal(projection.terminal?.status, "cancelled"); + assert.equal(projection.nodes["generate-1"]?.status, "cancelled"); + assert.equal(scriptCalls, 0); + assert.equal(context.assets.publicationOrder.length, 0); + assert.equal( + journal.events.some( + (candidate) => + candidate.type === "node-output-published" && candidate.nodeId === "generate-1", + ), + false, + ); +}); + +test("restart keeps a prepared submission ambiguous even after durable cancellation", async (t) => { + const context = await harness(t); + const prepared = await seedRestartRun(context.journals, { + runId: "run-prepared-cancelled", + }); + await context.journals.requestCancellation(prepared.runId, prepared.journalRevision, { + at: NOW, + reason: "app-quit", + }); + + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, prepared.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.cancellation?.reason, "app-quit"); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal(projection.nodes["output-1"]?.status, "blocked"); + assert.equal( + journal.events.some( + (event) => event.type === "node-cancelled" && event.nodeId === "generate-1", + ), + false, + ); +}); + +test("restart finalizes a node only from its durably published output boundary", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { onScript: () => (scriptCalls += 1) }); + await seedRestartRun(context.journals, { + runId: "run-output-published", + providerJobId: "accepted-output-job", + durableOutputAssetIds: [DURABLE_ASSET_ID, DURABLE_ASSET_ID], + }); + context.assets.available.set(DURABLE_ASSET_ID, { + assetId: DURABLE_ASSET_ID, + mediaType: "image/png", + byteLength: 1, + width: 1, + height: 1, + createdAt: NOW, + origin: { + kind: "provider", + providerId: "local-mock", + modelId: "deterministic-v1", + runId: "run-output-published", + }, + referenceCount: 1, + thumbnailSizes: [], + }); + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, "run-output-published"); + const node = projectCreateImagesRun(journal).nodes["generate-1"]; + assert.equal(node?.status, "succeeded"); + assert.deepEqual(node?.outputAssetIds, [DURABLE_ASSET_ID, DURABLE_ASSET_ID]); + assert.equal(scriptCalls, 0); + assert.equal(context.assets.publicationOrder.length, 0); + assert.equal( + journal.events.filter( + (event) => event.type === "node-output-published" && event.nodeId === "generate-1", + ).length, + 1, + ); + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status === "ready") { + assert.equal( + listed.history.find((entry) => entry.runId === "run-output-published")?.outputCount, + 2, + ); + } +}); + +test("restart refuses to finalize a published output whose durable asset is missing", async (t) => { + let scriptCalls = 0; + const context = await harness(t, { onScript: () => (scriptCalls += 1) }); + await seedRestartRun(context.journals, { + runId: "run-output-missing", + providerJobId: "accepted-output-job", + durableOutputAssetIds: [DURABLE_ASSET_ID], + }); + await context.service.initialize(); + const journal = await waitForTerminal(context.journals, "run-output-missing"); + const node = projectCreateImagesRun(journal).nodes["generate-1"]; + assert.equal(node?.status, "failed"); + assert.equal(node?.errorCode, "output-publication-failed"); + assert.equal(scriptCalls, 0); +}); + +test("service revalidates stale terminal metadata and reconciles queued work before new admission", async (t) => { + const context = await harness(t, { createRunId: () => "new-run" }); + const plan = createWorkflowCoordinatorPlan(workflow(), { kind: "all" }); + await context.journals.start( + { + runId: "concealed-queued", + workflowSnapshot: plan.snapshot, + plan: { + scope: { kind: "all" }, + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, values]) => [nodeId, [...values]]), + ), + }, + createdAt: NOW, + }, + () => true, + ); + const indexPath = path.join(context.root, "run-index.json"); + const index = JSON.parse(await fs.readFile(indexPath, "utf8")) as { + entries: Array>; + }; + index.entries[0]!.status = "succeeded"; + index.entries[0]!.terminal = true; + await fs.writeFile(indexPath, `${JSON.stringify(index)}\n`, "utf8"); + + await context.service.initialize(); + const concealed = await context.journals.get("concealed-queued"); + assert.ok(concealed); + const concealedProjection = projectCreateImagesRun(concealed!); + assert.equal(concealedProjection.terminal?.status, "interrupted"); + assert.equal(concealedProjection.cancellation, undefined); + assert.equal(concealedProjection.nodes["prompt-1"]?.errorCode, "interrupted"); + assert.equal( + concealed!.events.some( + (event) => + event.type === "run-cancel-requested" || + event.type === "run-started" || + event.type === "node-started", + ), + false, + ); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status === "started") { + await context.service.stop("workflow-1", started.run.runId, "user"); + await waitForTerminal(context.journals, started.run.runId); + } +}); + +test("production run service distinguishes every mock submission crash boundary", async (t) => { + await t.test( + "crash before send is the only automatically retryable exception boundary", + async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [ + { outcome: "crash-before-send" }, + { outcome: "success", width: 8, height: 8, seed: 12 }, + ], + }, + }, + }); + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(projectCreateImagesRun(journal).status, "succeeded"); + assert.equal( + journal.events.filter((event) => event.type === "node-submission-prepared").length, + 2, + ); + }, + ); + + for (const [outcome, acceptedCount] of [ + ["accepted-before-response", 0], + ["crash-after-send", 1], + ] as const) { + await t.test(outcome, async (t) => { + const context = await harness(t, { + script: { nodes: { "generate-1": [{ outcome }] } }, + }); + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const projection = projectCreateImagesRun(journal); + assert.equal(projection.status, "needs_attention"); + assert.equal(projection.nodes["generate-1"]?.status, "ambiguous"); + assert.equal( + journal.events.filter((event) => event.type === "node-submission-accepted").length, + acceptedCount, + ); + const attempts = projection.nodes["generate-1"]?.attempts ?? []; + assert.equal( + attempts[attempts.length - 1]?.submission, + outcome === "accepted-before-response" ? "ambiguous" : "accepted", + ); + }); + } +}); + +test("production run coordination rejects out-of-order mock completion but tolerates duplicates", async (t) => { + for (const [label, script, expected] of [ + ["duplicate", { outcome: "success", duplicateSubmittedEvent: true }, "succeeded"], + ["out-of-order", { outcome: "success", outOfOrderCompletionEvent: true }, "needs_attention"], + ] as const) { + await t.test(label, async (t) => { + const context = await harness(t, { + script: { + nodes: { "generate-1": [{ ...script, width: 8, height: 8 }] }, + }, + }); + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(projectCreateImagesRun(journal).status, expected); + }); + } +}); + +test("snapshot input assets are reserved before journal publication and committed before launch", async (t) => { + const document = workflow(); + document.nodes.push({ + id: "image-input-1", + type: "image-input", + position: { x: 0, y: 100 }, + data: { assetId: DURABLE_ASSET_ID }, + }); + document.assetRefs = [DURABLE_ASSET_ID]; + const context = await harness(t, { + document, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8 }], + }, + }, + }); + context.assets.available.set(DURABLE_ASSET_ID, { + assetId: DURABLE_ASSET_ID, + mediaType: "image/png", + byteLength: 1, + width: 1, + height: 1, + createdAt: NOW, + origin: { kind: "import" }, + referenceCount: 1, + thumbnailSizes: [], + }); + let publicationChecks = 0; + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => { + publicationChecks += 1; + assert.equal(context.references.isRunAssetReferenced("run-1", DURABLE_ASSET_ID), true); + return true; + }, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + assert.equal(publicationChecks, 1); + assert.deepEqual(context.references.order.slice(0, 3), [ + "reserve:run-1", + "commit:run-1", + "release:run-1", + ]); + assert.equal(context.references.isRunAssetReferenced("run-1", DURABLE_ASSET_ID), true); + assert.deepEqual(context.assets.runReferences.get("run-1"), [DURABLE_ASSET_ID]); + await context.service.stop("workflow-1", "run-1", "user"); + await waitForTerminal(context.journals, "run-1"); +}); + +test("run detail, recovery, and active-run reads stay workflow-authorized and path-free", async (t) => { + const context = await harness(t, { + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8 }], + }, + }, + }); + await context.workflows.create({ + ...workflow(), + id: "workflow-2", + title: "Another workflow", + }); + assert.deepEqual(await context.service.list("missing-workflow"), { + status: "not-found", + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + assert.deepEqual( + (await context.service.activeRuns()).map((run) => run.runId), + [started.run.runId], + ); + assert.equal((await context.service.get("workflow-1", started.run.runId)).status, "ready"); + assert.equal((await context.service.get("workflow-2", started.run.runId)).status, "not-found"); + await context.service.stop("workflow-1", started.run.runId, "user"); + await waitForTerminal(context.journals, started.run.runId); + await fs.writeFile( + path.join(context.root, "runs", started.run.runId, "run.json"), + "{broken", + "utf8", + ); + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status !== "ready") return; + assert.equal(listed.authoritative, true); + assert.equal( + listed.history.some((entry) => entry.runId === started.run.runId), + false, + ); + assert.equal(listed.recoveries.length, 1); + const recovery = listed.recoveries[0]; + assert.equal(recovery?.status, "recovery-required"); + if (!recovery || recovery.status !== "recovery-required") return; + assert.equal(recovery.workflowId, "workflow-1"); + assert.equal(recovery.recoverySource, "last-known-good"); + assert.equal(JSON.stringify(recovery).includes(context.root), false); + assert.equal( + (await context.service.get("workflow-1", started.run.runId)).status, + "recovery-required", + ); + const expected = recovery.expectedCandidateJournalRevision; + assert.ok(expected); + const conflict = await context.service.recover( + "workflow-1", + started.run.runId, + "last-known-good", + (expected ?? 1) + 1, + ); + assert.equal(conflict.status, "conflict"); + const recovered = await context.service.recover( + "workflow-1", + started.run.runId, + "last-known-good", + expected ?? 1, + ); + assert.equal(recovered.status, "recovered"); + assert.equal((await context.service.get("workflow-1", started.run.runId)).status, "ready"); + assert.deepEqual(await context.service.activeRuns(), []); +}); + +test("recovery can durably rebuild last-known-good from a healthy current journal", async (t) => { + const context = await harness(t); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForTerminal(context.journals, started.run.runId); + await fs.writeFile( + path.join(context.root, "runs", started.run.runId, "run.last-known-good.json"), + "{broken", + "utf8", + ); + + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status !== "ready") return; + const recovery = listed.recoveries[0]; + assert.equal(recovery?.status, "recovery-required"); + if (!recovery || recovery.status !== "recovery-required") return; + assert.equal(recovery.recoverySource, "current"); + assert.ok(recovery.expectedCandidateJournalRevision); + const recovered = await context.service.recover( + "workflow-1", + started.run.runId, + "current", + recovery.expectedCandidateJournalRevision ?? 1, + ); + assert.equal(recovered.status, "recovered"); + assert.equal((await context.service.get("workflow-1", started.run.runId)).status, "ready"); +}); + +test("future-schema and both-corrupt runs remain workflow-authorized list and detail records", async (t) => { + let nextRun = 0; + const context = await harness(t, { + createRunId: () => `degraded-${++nextRun}`, + }); + for (let index = 0; index < 2; index += 1) { + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status === "started") { + await waitForTerminal(context.journals, started.run.runId); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + } + } + + const futurePath = path.join(context.root, "runs", "degraded-1", "run.json"); + const future = JSON.parse(await fs.readFile(futurePath, "utf8")) as Record; + future.version = 2; + await fs.writeFile(futurePath, `${JSON.stringify(future)}\n`, "utf8"); + await Promise.all([ + fs.writeFile( + path.join(context.root, "runs", "degraded-2", "run.json"), + "{broken-current", + "utf8", + ), + fs.writeFile( + path.join(context.root, "runs", "degraded-2", "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status !== "ready") return; + assert.deepEqual( + listed.recoveries.map((recovery) => ({ + runId: recovery.runId, + status: recovery.status, + })), + [ + { runId: "degraded-1", status: "unsafe" }, + { runId: "degraded-2", status: "recovery-required" }, + ], + ); + const corrupt = listed.recoveries.find((recovery) => recovery.runId === "degraded-2"); + assert.equal(corrupt?.status, "recovery-required"); + if (corrupt?.status === "recovery-required") assert.equal(corrupt.recoverySource, undefined); + assert.equal((await context.service.get("workflow-1", "degraded-1")).status, "unsafe"); + assert.equal((await context.service.get("workflow-1", "degraded-2")).status, "recovery-required"); + assert.equal((await context.service.get("other-workflow", "degraded-1")).status, "not-found"); + assert.equal( + (await context.service.recover("workflow-1", "degraded-1", "current", 1)).status, + "unsafe", + ); +}); + +test("stopAll durably cancels but does not wait forever for stalled publication", async (t) => { + const context = await harness(t, { shutdownTimeoutMs: 250 }); + context.assets.ingestGate = new Promise(() => undefined); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitFor(() => context.assets.ingestStarted); + const startedAt = Date.now(); + const stopped = await context.service.stopAll("app-quit"); + assert.ok(Date.now() - startedAt < 1_000, "stopAll exceeded its bounded deadline"); + assert.deepEqual(stopped, { + status: "safe-to-quit", + unsettledRunIds: [started.run.runId], + }); + const journal = await context.journals.get(started.run.runId); + assert.equal(projectCreateImagesRun(journal!).cancellation?.reason, "app-quit"); +}); + +test("stopAll closes admission before joining an in-flight durable start", async (t) => { + let releaseAdmission: () => void = () => undefined; + let markPending: () => void = () => undefined; + const admissionGate = new Promise((resolve) => { + releaseAdmission = resolve; + }); + const pendingReached = new Promise((resolve) => { + markPending = resolve; + }); + let pauseFirstStart = true; + let runNumber = 0; + const context = await harness(t, { + shutdownTimeoutMs: 300, + createRunId: () => `admission-run-${++runNumber}`, + journalDurability: { + afterPendingPublished: async (runId) => { + if (runId !== "admission-run-1" || !pauseFirstStart) return; + pauseFirstStart = false; + markPending(); + await admissionGate; + }, + }, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8 }], + }, + }, + }); + const crossingStart = context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + await pendingReached; + const quitStartedAt = Date.now(); + assert.deepEqual(await context.service.stopAll("app-quit"), { + status: "blocked", + failedRunIds: [], + }); + assert.ok(Date.now() - quitStartedAt < 1_000); + + const laterStart = context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + releaseAdmission(); + assert.equal((await crossingStart).status, "started"); + assert.equal((await laterStart).status, "unavailable"); + assert.equal((await context.service.stopAll("app-quit")).status, "safe-to-quit"); +}); + +test("stop and stopAll report blocked when durable cancellation rejects", async (t) => { + const context = await harness(t, { shutdownTimeoutMs: 30 }); + context.assets.ingestGate = new Promise(() => undefined); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitFor(() => context.assets.ingestStarted); + context.journals.requestCancellation = async () => { + throw new Error("simulated cancellation journal rejection"); + }; + + const stopped = await context.service.stop("workflow-1", started.run.runId, "user"); + assert.equal(stopped.status, "unavailable"); + const quitStartedAt = Date.now(); + assert.deepEqual(await context.service.stopAll("app-quit"), { + status: "blocked", + failedRunIds: [started.run.runId], + }); + assert.ok(Date.now() - quitStartedAt < 500); + const journal = await context.journals.get(started.run.runId); + assert.equal(projectCreateImagesRun(journal!).cancellation, undefined); + assert.equal(projectCreateImagesRun(journal!).terminal, undefined); +}); + +test("after-pending cancellation hangs block quit without a follow-up store read", async (t) => { + let blockCancellation = false; + const never = new Promise(() => undefined); + const context = await harness(t, { + shutdownTimeoutMs: 30, + journalDurability: { + afterPendingPublished: async () => { + if (blockCancellation) await never; + }, + }, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 1_000, width: 8, height: 8 }], + }, + }, + }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitForJournal(context.journals, started.run.runId, (journal) => + journal.events.some((candidate) => candidate.type === "node-submission-accepted"), + ); + blockCancellation = true; + + const quitStartedAt = Date.now(); + assert.deepEqual(await context.service.stopAll("app-quit"), { + status: "blocked", + failedRunIds: [started.run.runId], + }); + assert.ok(Date.now() - quitStartedAt < 500); + const inspectStartedAt = Date.now(); + assert.deepEqual( + (await context.service.activeRuns()).map((run) => run.runId), + [started.run.runId], + ); + assert.ok(Date.now() - inspectStartedAt < 100); + const pending = JSON.parse( + await fs.readFile( + path.join(context.root, "runs", started.run.runId, "run.pending.json"), + "utf8", + ), + ) as { event?: { type?: string } }; + assert.equal(pending.event?.type, "run-cancel-requested"); +}); + +test("run history retention requires a fresh CAS plan and reconciles released run references", async (t) => { + const context = await harness(t); + const candidates = [ + { + runId: "old-run-1", + workflowId: "workflow-1", + journalRevision: 7, + updatedAt: NOW, + assetIds: [DURABLE_ASSET_ID], + }, + ]; + const token = "b".repeat(64); + context.references.committed.set("old-run-1", new Set([DURABLE_ASSET_ID])); + context.assets.runReferences.set("old-run-1", [DURABLE_ASSET_ID]); + context.journals.terminalRetentionCandidates = async (query) => { + assert.deepEqual(query, { keepLatest: 100, limit: 100 }); + return candidates; + }; + context.journals.planTerminalPrune = async (requested) => { + assert.deepEqual(requested, candidates); + return { + version: 1, + candidates: [{ runId: "old-run-1", journalRevision: 7 }], + token, + assetIds: [DURABLE_ASSET_ID], + }; + }; + let pruned = false; + context.journals.pruneTerminalRuns = async (plan) => { + assert.equal(plan.token, token); + pruned = true; + return { + removedRunIds: ["old-run-1"], + releasedAssetIds: [DURABLE_ASSET_ID], + }; + }; + + assert.deepEqual(await context.service.planHistoryPrune(100), { + status: "ready", + scope: "all-workflows", + mayReleaseUniqueOutputs: true, + authorizationToken: token, + keepLatest: 100, + candidateRunCount: 1, + releasedAssetCount: 1, + }); + assert.equal((await context.service.pruneHistory(100, "c".repeat(64))).status, "conflict"); + assert.equal(pruned, false); + assert.deepEqual(await context.service.pruneHistory(100, token), { + status: "pruned", + removedRunCount: 1, + releasedAssetCount: 1, + }); + assert.equal(pruned, true); + assert.deepEqual(context.assets.runReferences.get("old-run-1"), []); + assert.equal(context.references.isRunAssetReferenced("old-run-1", DURABLE_ASSET_ID), false); +}); + +test("service requires a fresh explicit plan before discarding irrecoverable run authority", async (t) => { + const context = await harness(t, { createRunId: () => "replacement-run" }); + await context.service.initialize(); + const plan = createWorkflowCoordinatorPlan(workflow(), { kind: "all" }); + await context.journals.start( + { + runId: "damaged-run", + workflowSnapshot: plan.snapshot, + plan: { + scope: structuredClone(plan.scope), + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, dependencies]) => [ + nodeId, + [...dependencies], + ]), + ), + }, + createdAt: NOW, + }, + () => true, + ); + await Promise.all([ + fs.writeFile( + path.join(context.root, "runs", "damaged-run", "run.json"), + "{broken-current", + "utf8", + ), + fs.writeFile( + path.join(context.root, "runs", "damaged-run", "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + assert.notEqual((await context.journals.health("damaged-run")).status, "healthy"); + const blocked = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(blocked.status, "unavailable"); + + const planned = await context.service.planDegradedRunDiscard("damaged-run"); + assert.equal(planned.status, "ready"); + if (planned.status !== "ready") return; + assert.equal(planned.mayLoseOutputs, true); + assert.equal(planned.mayDuplicateProviderWork, true); + assert.deepEqual( + await context.service.discardDegradedRun({ + runId: planned.runId, + authorizationToken: "f".repeat(64), + confirmed: true, + }), + { status: "conflict" }, + ); + const discarded = await context.service.discardDegradedRun({ + runId: planned.runId, + authorizationToken: planned.authorizationToken, + ...(planned.expectedCurrentJournalRevision === undefined + ? {} + : { + expectedCurrentJournalRevision: planned.expectedCurrentJournalRevision, + }), + ...(planned.expectedLastKnownGoodJournalRevision === undefined + ? {} + : { + expectedLastKnownGoodJournalRevision: planned.expectedLastKnownGoodJournalRevision, + }), + confirmed: true, + }); + assert.equal(discarded.status, "discarded"); + if (discarded.status === "discarded") { + assert.equal(discarded.authoritativeList?.status, "ready"); + } + assert.equal(await context.journals.degradedRunCount(), 0); + const admitted = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(admitted.status, "started"); + await context.service.stopAll("app-quit"); +}); + +test("main admission rejects a forged rejoining downstream path without creating a journal", async (t) => { + const document = workflow(); + const firstGeneration = document.nodes.find((node) => node.id === "generate-1")!; + document.nodes = [ + document.nodes.find((node) => node.id === "prompt-1")!, + { ...structuredClone(firstGeneration), id: "generation-a" }, + { ...structuredClone(firstGeneration), id: "generation-b" }, + { + id: "gallery", + type: "output-gallery", + position: { x: 200, y: 0 }, + data: {}, + }, + ]; + document.edges = [ + { + id: "prompt-a", + source: "prompt-1", + sourcePort: "text", + target: "generation-a", + targetPort: "prompt", + }, + { + id: "prompt-b", + source: "prompt-1", + sourcePort: "text", + target: "generation-b", + targetPort: "prompt", + }, + { + id: "images-a", + source: "generation-a", + sourcePort: "images", + target: "gallery", + targetPort: "images", + }, + { + id: "images-b", + source: "generation-b", + sourcePort: "images", + target: "gallery", + targetPort: "images", + }, + ]; + let allocatedRunIds = 0; + const context = await harness(t, { + document, + createRunId: () => `forged-run-${++allocatedRunIds}`, + }); + const result = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { + kind: "from-node", + nodeId: "prompt-1", + downstreamPath: ["generation-a", "gallery"], + }, + }, + () => true, + ); + assert.equal(result.status, "invalid"); + assert.equal(allocatedRunIds, 0); + assert.deepEqual(await context.journals.reconciliationCandidates(), []); + assert.deepEqual(await context.journals.terminalHistory(), []); +}); + +test("start enforces exact workflow revision, renderer liveness, and one active run per workflow", async (t) => { + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `run-${++runNumber}`, + script: { + nodes: { + "generate-1": [{ outcome: "success", delayMs: 60_000, width: 8, height: 8, seed: 9 }], + }, + }, + }); + const conflict = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 2, scope: { kind: "all" } }, + () => true, + ); + assert.deepEqual(conflict, { + status: "conflict", + expectedRevision: 2, + currentRevision: 1, + }); + let staleChecks = 0; + await assert.rejects( + context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => { + staleChecks += 1; + return false; + }, + ), + /no longer active/u, + ); + assert.equal(staleChecks, 1); + assert.equal( + context.references.reservations.find((reservation) => reservation.runId === "run-1")?.active, + false, + ); + assert.equal((await context.journals.health("run-1")).status, "missing"); + + const starts = await Promise.all([ + context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ), + context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ), + ]); + assert.deepEqual(starts.map((result) => result.status).sort(), ["already-running", "started"]); + const running = starts.find((result) => result.status === "started"); + assert.ok(running?.status === "started"); + await context.service.stop("workflow-1", running.run.runId, "user"); + await waitForTerminal(context.journals, running.run.runId); +}); + +test("multi-image output persists every unique durable asset and counts the batch once", async (t) => { + const context = await harness(t, { document: workflow(3) }); + const started = await context.service.start( + { workflowId: "workflow-1", expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + const outputAssetIds = projectCreateImagesRun(journal).nodes["generate-1"]?.outputAssetIds ?? []; + assert.equal(outputAssetIds.length, 3); + assert.equal(new Set(outputAssetIds).size, 3); + assert.ok(outputAssetIds.every((assetId) => context.assets.available.has(assetId))); + assert.deepEqual(context.assets.runReferences.get(journal.runId), [...outputAssetIds].sort()); + const listed = await context.service.list("workflow-1"); + assert.equal(listed.status, "ready"); + if (listed.status === "ready") assert.equal(listed.history[0]?.outputCount, 3); +}); + +test("active-run admission is globally capped before a fifth journal is published", async (t) => { + let runNumber = 0; + const context = await harness(t, { + createRunId: () => `run-cap-${++runNumber}`, + script: { + nodes: { + "generate-1": [ + { + outcome: "success", + delayMs: 60_000, + width: 8, + height: 8, + seed: 11, + }, + ], + }, + }, + }); + const workflowIds = Array.from( + { length: CREATE_IMAGES_MAX_ACTIVE_RUNS + 1 }, + (_, index) => `workflow-${index + 1}`, + ); + for (const workflowId of workflowIds.slice(1)) { + await context.workflows.create({ + ...workflow(), + id: workflowId, + title: `Workflow ${workflowId}`, + }); + } + const results = await Promise.all( + workflowIds.map((workflowId) => + context.service.start( + { workflowId, expectedRevision: 1, scope: { kind: "all" } }, + () => true, + ), + ), + ); + assert.equal(results.filter((result) => result.status === "started").length, 4); + assert.equal(results.filter((result) => result.status === "unavailable").length, 1); + assert.equal((await context.journals.initialize()).length, CREATE_IMAGES_MAX_ACTIVE_RUNS); + await context.service.stopAll("app-quit"); +}); + +test("Gemini launch requires one-shot main consent and durably binds provider authority", async (t) => { + const secret = "AIzaSy_PHASE4_TEST_KEY_NEVER_PERSIST"; + const png = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + let fetchCount = 0; + let runCount = 0; + const context = await harness(t, { + createRunId: () => `gemini-run-${++runCount}`, + resolveGeminiAuth: async () => ({ auth: { apiKey: secret }, source: "test API key" }), + createGeminiProvider: () => + new GeminiImageProvider({ + fetch: (async () => { + fetchCount += 1; + return new Response( + JSON.stringify({ + id: "interactions/phase4-test", + status: "completed", + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", data: png }], + }, + ], + usage: { total_input_tokens: 3, total_output_tokens: 4, total_tokens: 7 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof globalThis.fetch, + }), + }); + + const withoutConsent = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + executionMode: "gemini", + }, + () => true, + ); + assert.equal(withoutConsent.status, "invalid"); + assert.equal(runCount, 0); + assert.equal(fetchCount, 0); + + const prepared = await context.service.prepareGeminiRun({ + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }); + assert.equal(prepared.status, "ready"); + if (prepared.status !== "ready") return; + assert.equal(prepared.plan.accounting.initialRequestCount, 1); + assert.equal(prepared.plan.accounting.maximumAttempts, 1); + assert.equal(prepared.plan.accounting.dataLeavesDevice, true); + assert.equal(prepared.plan.estimate.kind, "unavailable"); + assert.doesNotMatch(JSON.stringify(prepared), new RegExp(secret, "u")); + + const consent = { + version: 1 as const, + authorizationId: prepared.plan.authorizationId, + consentFingerprint: prepared.plan.consentFingerprint, + token: prepared.plan.token, + reviewed: true as const, + }; + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + executionMode: "gemini", + providerConsent: consent, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(fetchCount, 1); + assert.equal( + projectCreateImagesRun(journal).terminal?.status, + "succeeded", + JSON.stringify({ + projection: projectCreateImagesRun(journal), + assets: [...context.assets.available.values()], + reservations: context.references.reservations.map((item) => [...item.next]), + }), + ); + assert.equal(journal.providerAuthorization?.executionMode, "gemini"); + assert.equal(journal.providerAuthorization?.maximumAttempts, 1); + assert.equal(journal.providerAuthorization?.credentialRecordId.startsWith("google-"), true); + const serialized = JSON.stringify(journal); + assert.doesNotMatch(serialized, new RegExp(secret, "u")); + assert.doesNotMatch(serialized, new RegExp(prepared.plan.token, "u")); + assert.ok( + journal.events.some( + (event) => + event.type === "node-submission-prepared" && + event.providerId === "gemini" && + event.modelId === "gemini-3.1-flash-image", + ), + ); + const generatedAsset = [...context.assets.available.values()][0]; + assert.equal( + generatedAsset?.origin.kind === "provider" ? generatedAsset.origin.providerId : undefined, + "gemini", + ); + await waitForAsync(async () => (await context.service.activeRuns()).length === 0); + + const replay = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + executionMode: "gemini", + providerConsent: consent, + }, + () => true, + ); + assert.equal(replay.status, "invalid"); + assert.equal(runCount, 1); + assert.equal(fetchCount, 1); +}); + +test("Gemini consent accounts for a durable reference and submits only its bounded bytes", async (t) => { + const pngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + const pngBytes = Uint8Array.from(Buffer.from(pngBase64, "base64")); + const assetId = createHash("sha256").update(pngBytes).digest("hex"); + const document = workflow(); + document.nodes.splice(1, 0, { + id: "reference-1", + type: "image-input", + position: { x: 0, y: 100 }, + data: { assetId, label: "Reference" }, + }); + document.edges.splice(1, 0, { + id: "edge-reference", + source: "reference-1", + sourcePort: "image", + target: "generate-1", + targetPort: "references", + }); + document.assetRefs = [assetId]; + let requestBody = ""; + const context = await harness(t, { + document, + createRunId: () => "gemini-reference-run", + resolveGeminiAuth: async () => ({ auth: { apiKey: "reference-test-key" }, source: "test" }), + createGeminiProvider: () => + new GeminiImageProvider({ + fetch: (async (_url, init) => { + requestBody = String(init?.body ?? ""); + return new Response( + JSON.stringify({ + status: "completed", + steps: [ + { + type: "model_output", + content: [{ type: "image", mime_type: "image/png", data: pngBase64 }], + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof globalThis.fetch, + }), + }); + const reference: AssetMetadataDto = { + assetId, + mediaType: "image/png", + byteLength: pngBytes.byteLength, + width: 1, + height: 1, + createdAt: NOW, + origin: { kind: "import" }, + referenceCount: 1, + thumbnailSizes: [], + }; + context.assets.available.set(assetId, reference); + context.assets.bytesById.set(assetId, pngBytes); + const prepared = await context.service.prepareGeminiRun({ + workflowId: document.id, + expectedRevision: document.revision, + scope: { kind: "all" }, + }); + assert.equal(prepared.status, "ready"); + if (prepared.status !== "ready") return; + assert.equal(prepared.plan.accounting.referenceImageCount, 1); + assert.equal(prepared.plan.accounting.referenceImageBytes, pngBytes.byteLength); + const started = await context.service.start( + { + workflowId: document.id, + expectedRevision: document.revision, + scope: { kind: "all" }, + executionMode: "gemini", + providerConsent: { + version: 1, + authorizationId: prepared.plan.authorizationId, + consentFingerprint: prepared.plan.consentFingerprint, + token: prepared.plan.token, + reviewed: true, + }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + assert.equal( + projectCreateImagesRun(await waitForTerminal(context.journals, started.run.runId)).terminal + ?.status, + "succeeded", + ); + assert.match(requestBody, new RegExp(pngBase64.replace(/[+]/gu, "\\+"), "u")); + assert.doesNotMatch(requestBody, /reference-test-key/u); +}); + +test("Gemini credential drift after durable preparation fails before transport", async (t) => { + let authReads = 0; + let fetchCount = 0; + const context = await harness(t, { + createRunId: () => "gemini-drift-run", + resolveGeminiAuth: async () => ({ + auth: { apiKey: authReads++ < 2 ? "reviewed-key" : "changed-key" }, + source: "test", + }), + createGeminiProvider: () => + new GeminiImageProvider({ + fetch: (async () => { + fetchCount += 1; + throw new Error("must not execute"); + }) as typeof globalThis.fetch, + }), + }); + const prepared = await context.service.prepareGeminiRun({ + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + }); + assert.equal(prepared.status, "ready"); + if (prepared.status !== "ready") return; + const started = await context.service.start( + { + workflowId: "workflow-1", + expectedRevision: 1, + scope: { kind: "all" }, + executionMode: "gemini", + providerConsent: { + version: 1, + authorizationId: prepared.plan.authorizationId, + consentFingerprint: prepared.plan.consentFingerprint, + token: prepared.plan.token, + reviewed: true, + }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const journal = await waitForTerminal(context.journals, started.run.runId); + assert.equal(fetchCount, 0); + assert.equal(projectCreateImagesRun(journal).terminal?.status, "failed"); + assert.ok(journal.events.some((event) => event.type === "node-submission-prepared")); + assert.equal( + journal.events.some((event) => event.type === "node-submission-accepted"), + false, + ); +}); diff --git a/main/services/create-images/run-service.ts b/main/services/create-images/run-service.ts new file mode 100644 index 00000000..5b24447a --- /dev/null +++ b/main/services/create-images/run-service.ts @@ -0,0 +1,2676 @@ +import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto"; +import type { AuthResult } from "@earendil-works/pi-ai"; +import { CREATE_IMAGES_NODE_DEFINITIONS } from "../../../renderer/shared/create-images/ports.js"; +import { + hasUnresolvedCreateImagesRunAmbiguity, + projectCreateImagesRun, + type CreateImagesCancellationReason, + type CreateImagesRunEventV1, + type CreateImagesRunJournalV1, + type CreateImagesRunProviderAuthorizationV1, +} from "../../../renderer/shared/create-images/run-contract.js"; +import type { + CreateImagesDegradedRunDiscardPlanResult, + CreateImagesDegradedRunDiscardResult, + CreateImagesDiscardDegradedRunRequest, + CreateImagesRunDetailResult, + CreateImagesRunAmbiguityResolutionResult, + CreateImagesRunListResult, + CreateImagesRunHistoryPrunePlanResult, + CreateImagesRunHistoryPruneResult, + CreateImagesRunMutationResult, + CreateImagesPrepareRunResult, + CreateImagesProviderConsentPlanView, + CreateImagesRunRecoveryMutationResult, + CreateImagesRunRecoveryRequiredView, + CreateImagesRunRecoveryView, + CreateImagesRunUnsafeRecoveryView, + CreateImagesRunView, + CreateImagesResolveRunAmbiguityRequest, + CreateImagesTerminalRunView, +} from "../../../renderer/shared/create-images/ipc.js"; +import type { WorkflowRunScope } from "../../../renderer/shared/create-images/execution.js"; +import { CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY } from "../../../renderer/shared/create-images/retry-policy.js"; +import type { WorkflowNodeV1 } from "../../../renderer/shared/create-images/schema.js"; +import type { ContentAddressedAssetStore } from "./asset-store-core.js"; +import { + admitCreateImagesProviderExecution, + createCreateImagesMainCredentialBinding, + createCreateImagesProviderCapabilitySnapshot, + CreateImagesProviderAdmissionError, + CreateImagesProviderAdmissionGate, + prepareCreateImagesProviderExecutionConsent, + type CreateImagesMainCredentialBindingV1, + type CreateImagesProviderConsentAuthority, + type CreateImagesProviderConsentClaimV1, + type CreateImagesProviderExecutionConsentPlanV1, + type CreateImagesProviderInvocationFactsV1, +} from "./image-provider-execution-core.js"; +import type { + ImageGenerationReference, + ValidatedImageGenerationRequest, +} from "./provider-contract.js"; +import { + GeminiImageProvider, + type GeminiImageProviderErrorCode, + type GeminiImageProviderOutput, +} from "./providers/gemini-image-provider-core.js"; +import { + DeterministicMockImageProvider, + MockProviderEventCoordinator, + type MockImageOutputBatch, + type MockImageProviderScript, +} from "./mock-image-provider-core.js"; +import { + CoordinatorCancellationRequest, + createWorkflowCoordinatorPlan, + reconcileRestartNode, + runWorkflowCoordinator, + type CoordinatorClock, + type CoordinatorErrorCode, + type CoordinatorDurability, + type CoordinatorNodeExecutionContext, +} from "./scheduler-core.js"; +import { + CreateImagesRunJournalLoadError, + CreateImagesRunJournalRevisionConflictError, + CreateImagesRunJournalStore, + type CreateImagesRunJournalHealth, + type CreateImagesRunStartInput, + type CreateImagesWorkflowAdmissionAudit, +} from "./run-journal-store.js"; +import type { WorkflowManifestStore } from "./workflow-manifest-store.js"; +import type { CreateImagesWorkspaceState } from "./workspace-store.js"; + +interface DurableNodeOutput { + kind: "images" | "text"; + assetIds: string[]; + text?: string; +} + +type CreateImagesRunExecution = { mode: "local-mock" } | { mode: "gemini"; auth: AuthResult }; + +export interface CreateImagesRunReferenceReservation { + runId: string; + next: ReadonlySet; + active: boolean; +} + +export interface CreateImagesRunReferenceAuthority { + reserveRun( + runId: string, + assetIds: readonly string[], + ): Promise; + commitRun(reservation: CreateImagesRunReferenceReservation): Promise; + releaseRunReservations(runId: string): Promise; + reconcileRuns(store: CreateImagesRunJournalStore): Promise; + isRunAssetReferenced(runId: string, assetId: string): boolean; +} + +interface ActiveRun { + runId: string; + workflowId: string; + journal: CreateImagesRunJournalV1; + execution: CreateImagesRunExecution; + controller: AbortController; + mutationTail: Promise; + publicationTail: Promise; + publishedOutputs: Map; + reservations: Map; + cancelDurable: Promise; + resolveCancelDurable(): void; + cancellationRequest?: Promise; + needsReconciliation?: boolean; + reconciliationAttempt?: Promise; + ownershipReleaseAttempt?: Promise; + settled: Promise; +} + +export type CreateImagesRunStopAllResult = + | { status: "safe-to-quit"; unsettledRunIds: string[] } + | { status: "blocked"; failedRunIds: string[] }; + +export type CreateImagesWorkflowDeletionDecision = + | { status: "allowed" } + | { status: "not-found" } + | { status: "unavailable"; message: string }; + +export function evaluateCreateImagesWorkflowDeletion( + snapshot: CreateImagesRunListResult, +): CreateImagesWorkflowDeletionDecision { + if (snapshot.status === "not-found") return { status: "not-found" }; + if (snapshot.status !== "ready" || snapshot.authoritative !== true) { + return { + status: "unavailable", + message: "Run history could not be verified safely. No workflow was deleted.", + }; + } + if (snapshot.activeRun) { + return { + status: "unavailable", + message: "Stop the active image run before deleting this workflow.", + }; + } + if (snapshot.recoveries.length > 0) { + return { + status: "unavailable", + message: + "This workflow has retained run recovery records. Resolve or retain them; workflow deletion is unavailable while those records remain.", + }; + } + if (snapshot.latestTerminalRun || snapshot.history.length > 0) { + return { + status: "unavailable", + message: + "This workflow has retained run history. Workflow deletion is unavailable while those records remain; Aiden will not remove them implicitly.", + }; + } + return { status: "allowed" }; +} + +export interface CreateImagesRunServiceOptions { + rootResolver: () => string; + workflows: WorkflowManifestStore; + assets: ContentAddressedAssetStore; + references: CreateImagesRunReferenceAuthority; + journalStore?: CreateImagesRunJournalStore; + now?: () => number; + createRunId?: () => string; + mockScript?: (nodeIds: readonly string[]) => MockImageProviderScript; + resolveGeminiAuth?: () => Promise; + createGeminiProvider?: () => GeminiImageProvider; + /** Fast root/config check before a run can start provider work. */ + workspaceStatus?: () => Promise<{ configured: boolean; state: CreateImagesWorkspaceState }>; + workspaceRequired?: boolean; + shutdownTimeoutMs?: number; +} + +export interface CreateImagesRunStartRequest { + workflowId: string; + expectedRevision: number; + scope: WorkflowRunScope; + executionMode?: "local-mock" | "gemini"; + providerConsent?: CreateImagesProviderConsentClaimV1; +} + +export interface CreateImagesPrepareGeminiRunRequest { + workflowId: string; + expectedRevision: number; + scope: WorkflowRunScope; +} + +interface PendingGeminiConsent { + mainPlan: CreateImagesProviderExecutionConsentPlanV1; + scope: WorkflowRunScope; +} + +const TERMINAL_RUN_STATUSES = new Set([ + "succeeded", + "failed", + "cancelled", + "interrupted", + "needs_attention", +]); +export const CREATE_IMAGES_MAX_ACTIVE_RUNS = 4; +const CREATE_IMAGES_MAX_CACHED_WORKFLOW_HISTORIES = 64; +const CREATE_IMAGES_GEMINI_RETRY_POLICY = Object.freeze({ + maxRetriesPerNode: 0, + baseDelayMs: 0, + maxDelayMs: 0, + maxTotalDelayMs: 0, + jitterRatio: 0, + retryRemoteNotSubmitted: false, + retryRemoteIdempotent: false, +}); +const CREATE_IMAGES_GEMINI_CATALOG_REVISION = 1; +const CREATE_IMAGES_GEMINI_CATALOG_OBSERVED_AT = "2026-08-11T00:00:00.000Z"; +const CREATE_IMAGES_GEMINI_CONSENT_LIFETIME_MS = 15 * 60_000; +const CREATE_IMAGES_MAX_PENDING_GEMINI_CONSENTS = 32; +const CREATE_IMAGES_GEMINI_ESTIMATE_SOURCE_FINGERPRINT = createHash("sha256") + .update("google-gemini-interactions-pricing-unavailable-2026-08-11") + .digest("hex"); + +function realClock(now: () => number): CoordinatorClock { + return { + now, + sleep(delayMs, signal) { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timeout = setTimeout(finish, delayMs); + const abort = () => { + clearTimeout(timeout); + signal.removeEventListener("abort", abort); + reject(signal.reason); + }; + function finish(): void { + signal.removeEventListener("abort", abort); + resolve(); + } + signal.addEventListener("abort", abort, { once: true }); + }); + }, + }; +} + +function defaultMockScript(nodeIds: readonly string[]): MockImageProviderScript { + return { + nodes: Object.fromEntries( + nodeIds.map((nodeId, index) => [ + nodeId, + [ + { + outcome: "success" as const, + delayMs: 450 + (index % 3) * 150, + seed: index + 1, + width: 96, + height: 96, + }, + ], + ]), + ), + }; +} + +function isDurableNodeOutput(value: unknown): value is DurableNodeOutput { + return ( + typeof value === "object" && + value !== null && + (value as DurableNodeOutput).kind !== undefined && + Array.isArray((value as DurableNodeOutput).assetIds) + ); +} + +type ProviderImageOutputBatch = MockImageOutputBatch | GeminiImageProviderOutput; + +function geminiCoordinatorErrorCode(code: GeminiImageProviderErrorCode): CoordinatorErrorCode { + if (code === "refused") return "provider-refused"; + if ( + code === "response-too-large" || + code === "response-malformed" || + code === "response-mime-mismatch" || + code === "incomplete" + ) { + return "output-invalid"; + } + return code === "rate-limited" ? "rate-limited" : "provider-unavailable"; +} + +function isProviderImageOutputBatch(value: unknown): value is ProviderImageOutputBatch { + return ( + typeof value === "object" && + value !== null && + Array.isArray((value as ProviderImageOutputBatch).images) && + (value as ProviderImageOutputBatch).images.length > 0 && + (value as ProviderImageOutputBatch).images.every( + (image) => image?.bytes instanceof Uint8Array && typeof image.metadata === "object", + ) + ); +} + +function assetIdsFrom(value: unknown): string[] { + return isDurableNodeOutput(value) ? [...value.assetIds] : []; +} + +async function* bytesOf(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +function iso(atMs: number): string { + return new Date(atMs).toISOString(); +} + +function createEventBase(journal: CreateImagesRunJournalV1, atMs: number) { + const durableAtMs = Date.parse(journal.updatedAt); + return { + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + runId: journal.runId, + sequence: journal.events.length + 1, + at: iso(Math.max(atMs, durableAtMs)), + }; +} + +function runView(journal: CreateImagesRunJournalV1): CreateImagesRunView { + const projection = projectCreateImagesRun(journal); + const nodes = new Map(journal.workflowSnapshot.nodes.map((node) => [node.id, node])); + return { + runId: journal.runId, + workflowId: journal.workflowId, + workflowRevision: journal.workflowRevision, + journalRevision: journal.journalRevision, + status: projection.status, + lastSequence: projection.lastSequence, + scope: structuredClone(journal.plan.scope), + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + executionMode: journal.providerAuthorization ? "gemini" : "local-mock", + ...(projection.ambiguityResolution + ? { ambiguityResolution: { ...projection.ambiguityResolution } } + : {}), + nodes: journal.plan.orderedNodeIds.map((nodeId) => { + const node = nodes.get(nodeId); + const projected = projection.nodes[nodeId]!; + const attempt = projected.attempts[projected.attempts.length - 1]; + return { + nodeId, + label: `${node ? CREATE_IMAGES_NODE_DEFINITIONS[node.type].title : "Workflow node"} · ${nodeId}`, + status: projected.status, + attempt: attempt?.attempt ?? 0, + outputAssetIds: [...projected.outputAssetIds], + ...(projected.errorCode ? { errorCode: projected.errorCode } : {}), + ...(attempt?.retry ? { retrySafety: attempt.retry.safety } : {}), + }; + }), + }; +} + +function terminalView(journal: CreateImagesRunJournalV1): CreateImagesTerminalRunView | undefined { + const projection = projectCreateImagesRun(journal); + if (!projection.terminal) return undefined; + const providerNodeIds = new Set( + journal.workflowSnapshot.nodes + .filter((node) => node.type === "generate-image") + .map((node) => node.id), + ); + const outputCount = [...providerNodeIds].reduce( + (total, nodeId) => total + (projection.nodes[nodeId]?.outputAssetIds.length ?? 0), + 0, + ); + const geminiModelId = journal.workflowSnapshot.nodes.find( + (node) => node.type === "generate-image", + )?.data.modelId; + const isGemini = journal.providerAuthorization !== undefined; + return { + runId: journal.runId, + workflowRevision: journal.workflowRevision, + status: projection.terminal.status, + scope: structuredClone(journal.plan.scope), + createdAt: journal.createdAt, + updatedAt: journal.updatedAt, + executionMode: isGemini ? "gemini" : "local-mock", + providerLabel: isGemini ? "Google Gemini" : "Aiden local mock", + modelLabel: isGemini ? (geminiModelId ?? "Gemini image model") : "Deterministic Phase 3", + costLabel: isGemini ? "Provider cost not reported" : "$0.00 mock", + ...(projection.ambiguityResolution + ? { ambiguityResolution: { ...projection.ambiguityResolution } } + : {}), + requestCount: [...providerNodeIds].reduce( + (total, nodeId) => total + (projection.nodes[nodeId]?.attempts.length ?? 0), + 0, + ), + outputCount, + completedNodeCount: Object.values(projection.nodes).filter( + (node) => node.status === "succeeded", + ).length, + totalNodeCount: journal.plan.orderedNodeIds.length, + }; +} + +function recoveryRequiredView( + health: Pick< + Extract, + | "workflowId" + | "runId" + | "reason" + | "canRecover" + | "currentJournalRevision" + | "lastKnownGoodJournalRevision" + > & { expectedJournalRevision?: number }, +): CreateImagesRunRecoveryRequiredView | undefined { + if (!health.workflowId) return undefined; + const recoverySource = + health.canRecover === "from-last-known-good" + ? "last-known-good" + : health.canRecover === "from-current" + ? "current" + : undefined; + const expectedCandidateJournalRevision = + health.expectedJournalRevision ?? + (recoverySource === "last-known-good" + ? health.lastKnownGoodJournalRevision + : recoverySource === "current" + ? health.currentJournalRevision + : undefined); + return { + status: "recovery-required", + workflowId: health.workflowId, + runId: health.runId, + reason: health.reason, + ...(recoverySource && expectedCandidateJournalRevision !== undefined + ? { recoverySource, expectedCandidateJournalRevision } + : {}), + ...(health.currentJournalRevision === undefined + ? {} + : { currentJournalRevision: health.currentJournalRevision }), + ...(health.lastKnownGoodJournalRevision === undefined + ? {} + : { lastKnownGoodJournalRevision: health.lastKnownGoodJournalRevision }), + }; +} + +function unsafeRecoveryView( + health: Pick< + Extract, + "workflowId" | "runId" | "reason" + >, +): CreateImagesRunUnsafeRecoveryView | undefined { + if (!health.workflowId) return undefined; + return { + status: "unsafe", + workflowId: health.workflowId, + runId: health.runId, + reason: health.reason, + }; +} + +export class CreateImagesRunService { + readonly journals: CreateImagesRunJournalStore; + private readonly now: () => number; + private readonly createRunId: () => string; + private readonly mockScript: (nodeIds: readonly string[]) => MockImageProviderScript; + private readonly shutdownTimeoutMs: number; + private readonly activeByWorkflow = new Map(); + private readonly activeByRun = new Map(); + private startAdmissionTail: Promise = Promise.resolve(); + private shutdownAdmissionBarrier = false; + private readonly listeners = new Set<(workflowId: string) => void>(); + private readonly terminalCache = new Map< + string, + { + history: CreateImagesTerminalRunView[]; + latestTerminalRun?: CreateImagesRunView; + } + >(); + private initializePromise: Promise | undefined; + private readonly providerConsentAuthority: CreateImagesProviderConsentAuthority = { + secret: randomBytes(32), + }; + private readonly pendingGeminiConsents = new Map(); + private readonly providerAdmissionGate = new CreateImagesProviderAdmissionGate([ + { + providerId: "gemini", + maxConcurrency: 2, + maxStartsPerWindow: 500, + windowMs: 60_000, + minimumStartIntervalMs: 0, + }, + ]); + + constructor(private readonly options: CreateImagesRunServiceOptions) { + this.journals = options.journalStore ?? new CreateImagesRunJournalStore(options.rootResolver); + this.now = options.now ?? Date.now; + this.createRunId = options.createRunId ?? randomUUID; + this.mockScript = options.mockScript ?? defaultMockScript; + this.shutdownTimeoutMs = options.shutdownTimeoutMs ?? 5_000; + if (!Number.isSafeInteger(this.shutdownTimeoutMs) || this.shutdownTimeoutMs < 1) { + throw new Error("Invalid Create Images shutdown timeout."); + } + } + + subscribe(listener: (workflowId: string) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(workflowId: string): void { + for (const listener of this.listeners) { + try { + listener(workflowId); + } catch { + // Publication is already durable. Observers cannot roll it back. + } + } + } + + private cacheTerminalHistory( + workflowId: string, + value: { + history: CreateImagesTerminalRunView[]; + latestTerminalRun?: CreateImagesRunView; + }, + ): void { + this.terminalCache.delete(workflowId); + this.terminalCache.set(workflowId, value); + while (this.terminalCache.size > CREATE_IMAGES_MAX_CACHED_WORKFLOW_HISTORIES) { + const oldest = this.terminalCache.keys().next().value as string | undefined; + if (!oldest) break; + this.terminalCache.delete(oldest); + } + } + + private async recoveryViewsForWorkflow( + workflowId: string, + ): Promise { + const candidates = await this.journals.workflowDegradedCandidates(workflowId); + return this.recoveryViews(candidates); + } + + private recoveryViews( + candidates: Awaited>, + ): CreateImagesRunRecoveryView[] { + const recoveries: CreateImagesRunRecoveryView[] = []; + for (const candidate of candidates) { + const view = + candidate.status === "unsafe" + ? unsafeRecoveryView(candidate) + : recoveryRequiredView(candidate); + if (view) recoveries.push(view); + } + return recoveries; + } + + async initialize(): Promise { + this.initializePromise ??= (async () => { + await this.journals.initialize(); + await this.options.references.reconcileRuns(this.journals); + for (const journal of await this.journals.reconciliationCandidates()) { + const projection = projectCreateImagesRun(journal); + if (projection.terminal) continue; + await this.reconcileAfterRestart(journal); + } + await this.options.references.reconcileRuns(this.journals); + })(); + try { + await this.initializePromise; + } catch (error) { + this.initializePromise = undefined; + throw error; + } + } + + private recoveryActive(journal: CreateImagesRunJournalV1): ActiveRun { + let resolveCancelDurable: () => void = () => undefined; + const cancelDurable = new Promise((resolve) => { + resolveCancelDurable = resolve; + }); + return { + runId: journal.runId, + workflowId: journal.workflowId, + journal, + execution: { mode: "local-mock" }, + controller: new AbortController(), + mutationTail: Promise.resolve(), + publicationTail: Promise.resolve(), + publishedOutputs: new Map(), + reservations: new Map(), + cancelDurable, + resolveCancelDurable, + settled: Promise.resolve(), + }; + } + + private async reconcileAfterRestart(initial: CreateImagesRunJournalV1): Promise { + const active = this.recoveryActive(initial); + const durability = this.durability(active); + const nodes = new Map(initial.workflowSnapshot.nodes.map((node) => [node.id, node])); + let provider: DeterministicMockImageProvider | undefined; + const providerForAcceptedJob = (): DeterministicMockImageProvider => { + provider ??= new DeterministicMockImageProvider({ + clock: realClock(this.now), + script: this.mockScript( + initial.workflowSnapshot.nodes + .filter((node) => node.type === "generate-image") + .map((node) => node.id), + ), + }); + return provider; + }; + const appendNode = async ( + nodeId: string, + status: "succeeded" | "failed" | "cancelled" | "blocked" | "ambiguous", + attempt: number, + errorCode?: + | "execution-failed" + | "interrupted" + | "output-publication-failed" + | "cancelled" + | "submission-ambiguous", + ): Promise => + durability.appendEvent({ + workflowId: active.workflowId, + workflowRevision: active.journal.workflowRevision, + runId: active.runId, + sequence: projectCreateImagesRun(active.journal).lastSequence + 1, + atMs: Math.max(this.now(), Date.parse(active.journal.updatedAt)), + kind: "node", + nodeId, + status, + attempt, + ...(errorCode ? { errorCode } : {}), + }); + let recoveryInterrupted = false; + + for (const nodeId of initial.plan.orderedNodeIds) { + let projection = projectCreateImagesRun(active.journal); + const nodeRun = projection.nodes[nodeId]; + if ( + !nodeRun || + ["succeeded", "failed", "cancelled", "blocked", "ambiguous"].includes(nodeRun.status) + ) { + continue; + } + if (nodeRun.status === "queued") continue; + const node = nodes.get(nodeId); + const attempt = nodeRun.attempts[nodeRun.attempts.length - 1]; + if (nodeRun.durableOutputAssetIds !== undefined) { + const decision = reconcileRestartNode({ + phase: "output-publishing", + lane: node?.type === "generate-image" ? "remote" : "local", + durableOutputAvailable: true, + }); + if (decision.category !== "resume-output-publication") { + throw new Error("Durable output publication did not produce a resumable decision."); + } + const uniqueAssetIds = [...new Set(nodeRun.durableOutputAssetIds)]; + const availability = await Promise.all( + uniqueAssetIds.map((assetId) => this.options.assets.getAvailable(assetId)), + ); + if (availability.some((asset) => asset === undefined)) { + await appendNode(nodeId, "failed", attempt?.attempt ?? 0, "output-publication-failed"); + continue; + } + active.publishedOutputs.set(nodeId, { + kind: node?.type === "prompt" ? "text" : "images", + assetIds: [...nodeRun.durableOutputAssetIds], + }); + await appendNode(nodeId, "succeeded", attempt?.attempt ?? 0); + continue; + } + if ( + node?.type === "generate-image" && + attempt && + (attempt.submission === "prepared" || attempt.submission === "ambiguous") + ) { + const decision = reconcileRestartNode({ + phase: "remote-submitting", + lane: "remote", + }); + if (decision.category !== "ambiguous-submit") { + throw new Error("Prepared submission did not produce an ambiguous restart decision."); + } + await appendNode(nodeId, "ambiguous", attempt.attempt, "submission-ambiguous"); + continue; + } + if (projection.cancellation) { + const decision = reconcileRestartNode({ + phase: "cancel-requested", + lane: node?.type === "generate-image" ? "remote" : "local", + ...(attempt?.providerJobId ? { remoteJobId: attempt.providerJobId } : {}), + }); + if (decision.category !== "reconcile-cancel" && decision.category !== "finalize-cancel") { + throw new Error("Durable cancellation did not produce a cancellation restart decision."); + } + await appendNode(nodeId, "cancelled", attempt?.attempt ?? 0, "cancelled"); + continue; + } + if (node?.type !== "generate-image" || !attempt) { + if (nodeRun.status === "running") { + recoveryInterrupted = true; + await appendNode(nodeId, "failed", attempt?.attempt ?? 0, "interrupted"); + } + continue; + } + if (attempt.submission !== "accepted") { + recoveryInterrupted = true; + await appendNode(nodeId, "failed", attempt.attempt, "interrupted"); + continue; + } + if (active.journal.providerAuthorization) { + await appendNode(nodeId, "ambiguous", attempt.attempt, "submission-ambiguous"); + continue; + } + const decision = reconcileRestartNode({ + phase: "remote-submitted", + lane: "remote", + ...(attempt.providerJobId ? { remoteJobId: attempt.providerJobId } : {}), + }); + if (decision.category !== "reconcile-remote-job" || !decision.remoteJobId) { + await appendNode(nodeId, "ambiguous", attempt.attempt, "submission-ambiguous"); + continue; + } + const result = providerForAcceptedJob().reconcileAccepted({ + runId: active.runId, + node, + attempt: attempt.attempt, + idempotencyKey: attempt.idempotencyKey, + remoteJobId: decision.remoteJobId, + }); + if (result.kind === "success") { + await durability.publishOutput({ + workflowId: active.workflowId, + workflowRevision: active.journal.workflowRevision, + runId: active.runId, + nodeId, + output: result.output, + }); + await appendNode(nodeId, "succeeded", attempt.attempt); + } else if (result.kind === "ambiguous-submit") { + await appendNode(nodeId, "ambiguous", attempt.attempt, "submission-ambiguous"); + } else { + await appendNode(nodeId, "failed", attempt.attempt, "execution-failed"); + } + } + + let projection = projectCreateImagesRun(active.journal); + for (const nodeId of active.journal.plan.orderedNodeIds) { + const nodeRun = projection.nodes[nodeId]; + if (!nodeRun || nodeRun.status !== "queued") continue; + const upstream = active.journal.plan.dependencies[nodeId] ?? []; + if ( + upstream.some((dependency) => + ["failed", "cancelled", "blocked", "ambiguous"].includes( + projection.nodes[dependency]?.status ?? "", + ), + ) + ) { + await appendNode(nodeId, "blocked", 0); + projection = projectCreateImagesRun(active.journal); + continue; + } + if (projection.cancellation) { + await appendNode(nodeId, "cancelled", 0, "cancelled"); + } else { + recoveryInterrupted = true; + await appendNode(nodeId, "failed", 0, "interrupted"); + } + projection = projectCreateImagesRun(active.journal); + } + const finalProjection = projectCreateImagesRun(active.journal); + const values = Object.values(finalProjection.nodes); + const terminalStatus = values.some((node) => node.status === "ambiguous") + ? "needs_attention" + : finalProjection.cancellation + ? "cancelled" + : recoveryInterrupted + ? "interrupted" + : values.every((node) => node.status === "succeeded") + ? "succeeded" + : values.some((node) => node.status === "failed" || node.status === "blocked") + ? "failed" + : "interrupted"; + await this.mutateJournal(active, (journal) => + this.journals.append(journal.runId, journal.journalRevision, { + ...createEventBase(journal, this.now()), + type: "run-terminal", + status: terminalStatus, + }), + ); + await active.publicationTail; + await active.mutationTail; + await this.options.references.releaseRunReservations(active.runId).catch(() => undefined); + this.terminalCache.delete(active.workflowId); + await this.options.references.reconcileRuns(this.journals); + this.notify(active.workflowId); + } + + private async releaseActiveOwnershipIfTerminalOrDegradedCore( + active: ActiveRun, + ): Promise { + let releasable = projectCreateImagesRun(active.journal).terminal !== undefined; + if (!releasable) { + try { + const health = await this.journals.health(active.runId); + if (health.status === "healthy") { + const journal = await this.journals.get(active.runId); + if (!journal) return false; + active.journal = journal; + releasable = projectCreateImagesRun(journal).terminal !== undefined; + } else { + releasable = health.status === "recovery-required" || health.status === "unsafe"; + } + } catch { + return false; + } + } + if (!releasable) return false; + + await active.publicationTail; + await active.mutationTail; + await this.options.references.releaseRunReservations(active.runId).catch(() => undefined); + await this.options.references.reconcileRuns(this.journals).catch(() => undefined); + this.activeByRun.delete(active.runId); + if (this.activeByWorkflow.get(active.workflowId) === active) { + this.activeByWorkflow.delete(active.workflowId); + } + active.needsReconciliation = false; + this.notify(active.workflowId); + return true; + } + + private async releaseActiveOwnershipIfTerminalOrDegraded( + active: ActiveRun, + deadline = Date.now() + this.shutdownTimeoutMs, + ): Promise { + if (!active.ownershipReleaseAttempt) { + const operation = this.releaseActiveOwnershipIfTerminalOrDegradedCore(active); + const attempt = operation.finally(() => { + if (active.ownershipReleaseAttempt === attempt) { + active.ownershipReleaseAttempt = undefined; + } + }); + active.ownershipReleaseAttempt = attempt; + } + const attempt = active.ownershipReleaseAttempt; + if (!(await this.waitUntilDeadline(attempt, deadline))) return false; + return attempt.catch(() => false); + } + + private async reconcileFailedLaunch( + active: ActiveRun, + deadline = Date.now() + this.shutdownTimeoutMs, + ): Promise { + if (!active.needsReconciliation) return true; + + const joinedExistingAttempt = active.reconciliationAttempt !== undefined; + if (!active.reconciliationAttempt) { + const operation = (async () => { + try { + await active.publicationTail; + await active.mutationTail; + const health = await this.journals.health(active.runId); + if (health.status === "healthy") { + const journal = await this.journals.get(active.runId); + if (journal) { + active.journal = journal; + if (!projectCreateImagesRun(journal).terminal) { + await this.reconcileAfterRestart(journal); + } + } + } + } catch { + // Keep ownership of healthy nonterminal work. A later list or + // admission can join this single attempt, but never resubmits work. + } + await this.releaseActiveOwnershipIfTerminalOrDegraded(active); + })(); + const attempt = operation.finally(() => { + if (active.reconciliationAttempt === attempt) active.reconciliationAttempt = undefined; + }); + active.reconciliationAttempt = attempt; + } + if (!(await this.waitUntilDeadline(active.reconciliationAttempt, deadline))) return false; + // A foreground caller that merely joined an earlier failed attempt gets + // one fresh reconciliation opportunity within the same deadline. The + // recovery path never invokes the live executor or resubmits provider work. + if (joinedExistingAttempt && active.needsReconciliation && !active.reconciliationAttempt) { + return this.reconcileFailedLaunch(active, deadline); + } + return true; + } + + private mutateJournal( + active: ActiveRun, + mutate: (journal: CreateImagesRunJournalV1) => Promise, + ): Promise { + const operation = active.mutationTail.then(async () => { + active.journal = await mutate(active.journal); + this.notify(active.workflowId); + }); + active.mutationTail = operation.catch(() => undefined); + return operation; + } + + private durability(active: ActiveRun): CoordinatorDurability { + const append = (create: (journal: CreateImagesRunJournalV1) => CreateImagesRunEventV1) => + this.mutateJournal(active, (journal) => + this.journals.append(journal.runId, journal.journalRevision, create(journal)), + ); + return { + persistPlan: async (record) => { + if ( + record.runId !== active.runId || + record.workflowId !== active.workflowId || + record.workflowRevision !== active.journal.workflowRevision || + record.plan.workflowId !== active.journal.workflowId || + record.plan.workflowRevision !== active.journal.workflowRevision || + JSON.stringify(record.plan.scope) !== JSON.stringify(active.journal.plan.scope) || + JSON.stringify(record.plan.snapshot) !== + JSON.stringify(active.journal.workflowSnapshot) || + JSON.stringify(record.plan.orderedNodeIds) !== + JSON.stringify(active.journal.plan.orderedNodeIds) || + JSON.stringify(record.plan.dependencies) !== + JSON.stringify(active.journal.plan.dependencies) + ) { + throw new Error("The coordinator plan does not match the durable run snapshot."); + } + }, + persistCancelIntent: async (intent) => { + await this.mutateJournal(active, (journal) => + projectCreateImagesRun(journal).cancellation + ? Promise.resolve(journal) + : this.journals.requestCancellation(journal.runId, journal.journalRevision, { + at: createEventBase(journal, this.now()).at, + reason: intent.reason, + }), + ); + active.resolveCancelDurable(); + }, + persistSubmissionPrepared: (record) => { + const node = active.journal.workflowSnapshot.nodes.find( + (candidate) => candidate.id === record.nodeId, + ); + if (active.execution.mode === "gemini" && node?.type !== "generate-image") { + throw new Error("Gemini submission preparation requires a generation node."); + } + const modelId = + active.execution.mode === "gemini" && node?.type === "generate-image" + ? node.data.modelId + : "deterministic-v1"; + if (!modelId) throw new Error("Gemini submission preparation requires a curated model."); + return append((journal) => ({ + ...createEventBase(journal, this.now()), + type: "node-submission-prepared", + nodeId: record.nodeId, + attempt: record.attempt, + idempotencyKey: record.idempotencyKey, + providerId: active.execution.mode === "gemini" ? "gemini" : "local-mock", + modelId, + })); + }, + persistRemoteJob: (record) => + append((journal) => ({ + ...createEventBase(journal, this.now()), + type: "node-submission-accepted", + nodeId: record.nodeId, + attempt: record.attempt, + providerJobId: record.remoteJobId, + })), + publishOutput: async (record) => { + const durable = await this.publishNodeOutput(active, record.nodeId, record.output); + active.publishedOutputs.set(record.nodeId, durable); + await append((journal) => ({ + ...createEventBase(journal, this.now()), + type: "node-output-published", + nodeId: record.nodeId, + outputAssetIds: [...durable.assetIds], + })); + return durable; + }, + appendEvent: async (event) => { + if (event.kind === "remote-job" || (event.kind === "node" && event.status === "queued")) { + return; + } + if (event.kind === "run") { + if (event.status !== "running") this.terminalCache.delete(active.workflowId); + await append( + (journal) => + ({ + ...createEventBase(journal, event.atMs), + type: event.status === "running" ? "run-started" : "run-terminal", + ...(event.status === "running" ? {} : { status: event.status }), + }) as CreateImagesRunEventV1, + ); + return; + } + if (event.status === "running") { + if (event.attempt === 1) { + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-started", + nodeId: event.nodeId, + })); + } + return; + } + if (event.status === "retry_wait") { + const safety = event.retrySafety; + if (safety !== "confirmed-not-submitted" && safety !== "same-idempotency-key") { + throw new Error("Remote retries require a durable safety classification."); + } + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-retry-scheduled", + nodeId: event.nodeId, + attempt: event.attempt, + errorCode: event.errorCode ?? "execution-failed", + delayMs: event.retryDelayMs ?? 0, + retrySafety: safety, + })); + return; + } + if (event.status === "ambiguous") { + const projection = projectCreateImagesRun(active.journal); + const attempts = projection.nodes[event.nodeId]?.attempts ?? []; + const attempt = attempts[attempts.length - 1]; + if (attempt?.submission === "prepared") { + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-submission-ambiguous", + nodeId: event.nodeId, + attempt: event.attempt, + })); + } + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-ambiguous", + nodeId: event.nodeId, + attempt: event.attempt, + })); + return; + } + if (event.status === "succeeded") { + const output = active.publishedOutputs.get(event.nodeId); + if (!output) { + throw new Error("A node cannot succeed before its output is durably published."); + } + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-succeeded", + nodeId: event.nodeId, + outputAssetIds: [...output.assetIds], + })); + const reservation = active.reservations.get(event.nodeId); + if (reservation) { + try { + await this.options.references.commitRun(reservation); + } catch (error) { + if (!(await this.options.references.reconcileRuns(this.journals))) throw error; + } + active.reservations.delete(event.nodeId); + await this.options.assets + .replaceReferences({ kind: "run", id: active.runId }, [...reservation.next].sort()) + .catch(() => undefined); + } + return; + } + if (event.status === "failed") { + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-failed", + nodeId: event.nodeId, + errorCode: event.errorCode ?? "execution-failed", + })); + return; + } + if (event.status === "cancelled") { + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-cancelled", + nodeId: event.nodeId, + })); + return; + } + const projection = projectCreateImagesRun(active.journal); + const upstreamNodeIds = (active.journal.plan.dependencies[event.nodeId] ?? []).filter( + (nodeId) => + ["failed", "cancelled", "blocked", "ambiguous"].includes( + projection.nodes[nodeId]?.status ?? "", + ), + ); + await append((journal) => ({ + ...createEventBase(journal, event.atMs), + type: "node-blocked", + nodeId: event.nodeId, + upstreamNodeIds, + })); + }, + }; + } + + private async publishNodeOutput( + active: ActiveRun, + nodeId: string, + output: unknown, + ): Promise { + const operation = active.publicationTail.then(() => + this.publishNodeOutputInternal(active, nodeId, output), + ); + active.publicationTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + private cumulativeRunAssetIds(active: ActiveRun, nextAssetIds: readonly string[]): string[] { + const cumulative = new Set(nextAssetIds); + for (const published of active.publishedOutputs.values()) { + for (const assetId of published.assetIds) cumulative.add(assetId); + } + return [...cumulative].sort(); + } + + private async publishNodeOutputInternal( + active: ActiveRun, + nodeId: string, + output: unknown, + ): Promise { + if (isDurableNodeOutput(output)) { + if (output.assetIds.length === 0) { + return { ...output, assetIds: [] }; + } + const reservation = await this.options.references.reserveRun( + active.runId, + this.cumulativeRunAssetIds(active, output.assetIds), + ); + active.reservations.set(nodeId, reservation); + return { ...output, assetIds: [...output.assetIds] }; + } + if (!isProviderImageOutputBatch(output)) { + throw new Error("The node executor returned an unsupported output."); + } + const batch = output; + const remoteMetadata = + batch.metadata.source === "gemini-interactions" ? batch.metadata : undefined; + const remote = remoteMetadata !== undefined; + const providerId = remote ? "gemini" : "local-mock"; + const modelId = remoteMetadata?.modelId ?? "deterministic-v1"; + const expectedIds = batch.images.map((image) => + createHash("sha256").update(image.bytes).digest("hex"), + ); + const reservation = await this.options.references.reserveRun( + active.runId, + this.cumulativeRunAssetIds(active, expectedIds), + ); + active.reservations.set(nodeId, reservation); + const assetIds: string[] = []; + for (const [index, image] of batch.images.entries()) { + const ingested = await this.options.assets.ingest(bytesOf(image.bytes), { + origin: { + kind: "provider", + providerId, + modelId, + runId: active.runId, + }, + declaredMimeType: image.metadata.mimeType, + generationMetadata: { + source: image.metadata.source, + mock: !remote, + outputIndex: index, + width: image.metadata.width, + height: image.metadata.height, + ...(image.metadata.source === "deterministic-local-mock" + ? { seed: image.metadata.seed } + : { + modelId: image.metadata.modelId, + ...(remoteMetadata?.interactionId + ? { interactionId: remoteMetadata.interactionId } + : {}), + ...(remoteMetadata?.usage?.totalInputTokens === undefined + ? {} + : { inputTokens: remoteMetadata.usage.totalInputTokens }), + ...(remoteMetadata?.usage?.totalOutputTokens === undefined + ? {} + : { outputTokens: remoteMetadata.usage.totalOutputTokens }), + ...(remoteMetadata?.usage?.totalThoughtTokens === undefined + ? {} + : { thoughtTokens: remoteMetadata.usage.totalThoughtTokens }), + ...(remoteMetadata?.usage?.totalTokens === undefined + ? {} + : { totalTokens: remoteMetadata.usage.totalTokens }), + }), + }, + }); + if (ingested.asset.assetId !== expectedIds[index]) { + throw new Error("The durable asset digest differs from the validated mock output."); + } + assetIds.push(ingested.asset.assetId); + } + return { kind: "images", assetIds }; + } + + private async executeNode( + provider: DeterministicMockImageProvider, + providerEvents: MockProviderEventCoordinator, + context: CoordinatorNodeExecutionContext, + ) { + if (context.node.type === "generate-image") { + const result = await provider.execute(context); + if ( + result.kind === "success" && + providerEvents.acceptedTerminalKind({ + runId: context.runId, + nodeId: context.node.id, + attempt: context.attempt, + }) !== "completed" + ) { + return { + kind: "ambiguous-submit" as const, + error: "The mock provider completion was not accepted by the ordered event reducer.", + }; + } + return result; + } + if (context.node.type === "prompt") { + return { + kind: "success" as const, + output: { + kind: "text", + text: context.node.data.text, + assetIds: [], + } satisfies DurableNodeOutput, + }; + } + if (context.node.type === "image-input") { + const assetId = context.node.data.assetId; + if (!assetId || !(await this.options.assets.getAvailable(assetId))) { + return { + kind: "failure" as const, + error: "The referenced image is unavailable.", + retrySafety: "never" as const, + }; + } + return { + kind: "success" as const, + output: { + kind: "images", + assetIds: [assetId], + } satisfies DurableNodeOutput, + }; + } + const assetIds = new Set(); + for (const value of context.dependencyOutputs.values()) { + for (const assetId of assetIdsFrom(value)) assetIds.add(assetId); + } + return { + kind: "success" as const, + output: { + kind: "images", + assetIds: [...assetIds], + } satisfies DurableNodeOutput, + }; + } + + private async executeGeminiNode( + active: ActiveRun, + provider: GeminiImageProvider, + context: CoordinatorNodeExecutionContext, + ) { + if (context.node.type !== "generate-image") { + return this.executeNode( + new DeterministicMockImageProvider({ clock: realClock(this.now), script: { nodes: {} } }), + new MockProviderEventCoordinator(), + context, + ); + } + if (active.execution.mode !== "gemini") { + return { + kind: "failure" as const, + error: "Gemini execution authority is unavailable.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + if (!this.options.resolveGeminiAuth || !active.journal.providerAuthorization) { + return { + kind: "failure" as const, + error: "Gemini credential authority is unavailable after durable preparation.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + let currentAuth: AuthResult; + try { + currentAuth = await this.options.resolveGeminiAuth(); + const binding = this.geminiCredentialBinding(currentAuth); + if ( + binding.recordId !== active.journal.providerAuthorization.credentialRecordId || + binding.revision !== active.journal.providerAuthorization.credentialRevision + ) { + return { + kind: "failure" as const, + error: "The reviewed Gemini credential changed before submission.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + } catch { + return { + kind: "failure" as const, + error: "The reviewed Gemini credential is no longer available.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + const promptOutputs = [...context.dependencyOutputs.values()].filter( + (value): value is DurableNodeOutput & { text: string } => + isDurableNodeOutput(value) && + value.kind === "text" && + typeof value.text === "string" && + value.text.trim().length > 0, + ); + if (promptOutputs.length !== 1) { + return { + kind: "failure" as const, + error: "Gemini generation requires exactly one durable prompt input.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + const referenceIds = [ + ...new Set( + [...context.dependencyOutputs.values()].flatMap((value) => + isDurableNodeOutput(value) && value.kind === "images" ? value.assetIds : [], + ), + ), + ]; + const ownerId = `provider-${createHash("sha256") + .update(active.runId) + .update("\0") + .update(context.node.id) + .digest("hex") + .slice(0, 32)}`; + const leases: string[] = []; + try { + const references: ImageGenerationReference[] = []; + for (const assetId of referenceIds) { + const lease = await this.options.assets.acquirePreviewLease(assetId, ownerId, 60_000); + leases.push(lease.token); + const preview = await this.options.assets.readPreview(lease.token, ownerId); + if (!["image/png", "image/jpeg", "image/webp"].includes(preview.asset.mediaType)) { + return { + kind: "failure" as const, + error: "A reference image has an unsupported media type.", + retrySafety: "confirmed-not-submitted" as const, + }; + } + references.push({ + assetId, + bytes: preview.bytes, + mimeType: preview.asset.mediaType as "image/png" | "image/jpeg" | "image/webp", + }); + } + const request: ValidatedImageGenerationRequest = { + providerId: "gemini", + modelId: context.node.data.modelId ?? "", + prompt: promptOutputs[0]!.text, + aspectRatio: context.node.data.aspectRatio, + imageSize: context.node.data.imageSize, + outputMime: context.node.data.outputMime, + count: context.node.data.count, + references, + }; + const gate = this.providerAdmissionGate.tryAcquire("gemini", this.now()); + if (gate.status === "deferred") { + return { + kind: "rate-limited" as const, + providerErrorCode: "rate-limited" as const, + error: "Gemini request admission is temporarily busy. Review and start a new run later.", + retrySafety: "never" as const, + retryAfterMs: gate.retryAfterMs, + }; + } + try { + const result = await provider.execute(currentAuth, request, { + runId: active.runId, + nodeId: context.node.id, + signal: context.signal, + }); + if (result.kind === "success") { + const acceptanceId = `gemini-sync-${createHash("sha256") + .update(result.output.metadata.interactionId ?? "no-interaction-id") + .update("\0") + .update(active.runId) + .update("\0") + .update(context.node.id) + .digest("hex")}`; + try { + await context.recordRemoteJobId(acceptanceId); + } catch { + return { + kind: "ambiguous-submit" as const, + providerErrorCode: "submission-ambiguous" as const, + error: "Gemini completed, but Aiden could not durably bind the response.", + }; + } + return result; + } + return result.kind === "failure" || result.kind === "rate-limited" + ? { ...result, errorCode: geminiCoordinatorErrorCode(result.providerErrorCode) } + : result; + } finally { + this.providerAdmissionGate.release(gate.lease); + } + } catch { + return { + kind: "failure" as const, + error: "Aiden could not prepare the bounded Gemini image request.", + retrySafety: "confirmed-not-submitted" as const, + }; + } finally { + await Promise.all( + leases.map((token) => + this.options.assets.releasePreviewLease(token, ownerId).catch(() => false), + ), + ); + } + } + + private launch(active: ActiveRun, nodes: readonly WorkflowNodeV1[]): void { + active.settled = Promise.resolve() + .then(async () => { + const remoteNodeIds = nodes + .filter((node) => node.type === "generate-image") + .map((node) => node.id); + const clock = realClock(this.now); + const providerEvents = new MockProviderEventCoordinator(); + const provider = new DeterministicMockImageProvider({ + clock, + script: this.mockScript(remoteNodeIds), + onProviderEvent: (event) => { + providerEvents.observe(event); + }, + }); + const plan = createWorkflowCoordinatorPlan( + active.journal.workflowSnapshot, + active.journal.plan.scope, + ); + const geminiProvider = + active.execution.mode === "gemini" + ? (this.options.createGeminiProvider?.() ?? new GeminiImageProvider()) + : undefined; + await runWorkflowCoordinator(plan, { + runId: active.runId, + localConcurrency: 4, + remoteConcurrency: + active.execution.mode === "gemini" + ? Math.min(active.journal.workflowSnapshot.settings.concurrency, 2) + : active.journal.workflowSnapshot.settings.concurrency, + clock, + jitter: { sample: () => 0.5 }, + retryPolicy: + active.execution.mode === "gemini" + ? CREATE_IMAGES_GEMINI_RETRY_POLICY + : CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY, + durability: this.durability(active), + signal: active.controller.signal, + executeNode: (context) => + geminiProvider + ? this.executeGeminiNode(active, geminiProvider, context) + : this.executeNode(provider, providerEvents, context), + }); + }) + .catch(async () => { + active.needsReconciliation = true; + await this.reconcileFailedLaunch(active); + }) + .finally(async () => { + await this.releaseActiveOwnershipIfTerminalOrDegraded(active); + }); + } + + private currentGeminiCapability(modelId: string) { + const provider = this.options.createGeminiProvider?.() ?? new GeminiImageProvider(); + const model = provider.listModels().find((candidate) => candidate.id === modelId); + if (!model) { + throw new CreateImagesProviderAdmissionError( + "capability-drift", + "The reviewed Gemini model is no longer in Aiden's release catalog.", + ); + } + return createCreateImagesProviderCapabilitySnapshot({ + catalogRevision: CREATE_IMAGES_GEMINI_CATALOG_REVISION, + observedAt: CREATE_IMAGES_GEMINI_CATALOG_OBSERVED_AT, + model, + transport: { + kind: "synchronous", + supportsIdempotency: false, + supportsReconciliation: false, + }, + }); + } + + private geminiCredentialBinding(auth: AuthResult): CreateImagesMainCredentialBindingV1 { + const apiKey = auth.auth.apiKey; + if (typeof apiKey !== "string" || apiKey.length === 0) { + throw new CreateImagesProviderAdmissionError( + "credential-required", + "A compatible Google Gemini API key is required.", + ); + } + const recordId = `google-${createHmac("sha256", this.providerConsentAuthority.secret) + .update("aiden-create-images-google-api-key-v1\0") + .update(apiKey) + .digest("hex")}`; + return createCreateImagesMainCredentialBinding({ + providerId: "gemini", + recordId, + revision: 1, + authKind: "api-key", + }); + } + + private async geminiInvocations(plan: ReturnType): Promise<{ + capability: ReturnType; + invocations: CreateImagesProviderInvocationFactsV1[]; + }> { + const nodes = new Map(plan.snapshot.nodes.map((node) => [node.id, node])); + const generationNodes = plan.orderedNodeIds + .map((nodeId) => nodes.get(nodeId)) + .filter( + (node): node is Extract => + node?.type === "generate-image", + ); + if (generationNodes.length === 0) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "This run scope contains no Gemini generation request.", + ); + } + const modelIds = new Set(generationNodes.map((node) => node.data.modelId)); + if (modelIds.size !== 1 || generationNodes[0]?.data.modelId === undefined) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "One reviewed Gemini run must use exactly one curated model.", + ); + } + const capability = this.currentGeminiCapability(generationNodes[0].data.modelId); + const invocations: CreateImagesProviderInvocationFactsV1[] = []; + for (const node of generationNodes) { + const dependencies = (plan.dependencies[node.id] ?? []) + .map((nodeId) => nodes.get(nodeId)) + .filter((candidate): candidate is WorkflowNodeV1 => candidate !== undefined); + const prompts = dependencies.filter( + (candidate): candidate is Extract => + candidate.type === "prompt", + ); + if (prompts.length !== 1 || !prompts[0]!.data.text.trim()) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Each Gemini request requires exactly one non-empty prompt input.", + ); + } + if (dependencies.some((candidate) => candidate.type === "generate-image")) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "Chained cloud generations require a separate reviewed run in this release.", + ); + } + const referenceNodes = dependencies.filter( + (candidate): candidate is Extract => + candidate.type === "image-input" && candidate.data.assetId !== undefined, + ); + const referenceAssets = await Promise.all( + referenceNodes.map((candidate) => + this.options.assets.getAvailable(candidate.data.assetId!), + ), + ); + if (referenceAssets.some((asset) => asset === undefined)) { + throw new CreateImagesProviderAdmissionError( + "invalid-input", + "A reviewed Gemini reference image is unavailable.", + ); + } + invocations.push({ + nodeId: node.id, + promptBytes: Buffer.byteLength(prompts[0]!.data.text, "utf8"), + referenceImageCount: referenceAssets.length, + referenceImageBytes: referenceAssets.reduce( + (total, asset) => total + (asset?.byteLength ?? 0), + 0, + ), + requestedOutputs: node.data.count, + aspectRatio: node.data.aspectRatio, + imageSize: node.data.imageSize, + outputMime: node.data.outputMime, + }); + } + return { capability, invocations }; + } + + private purgeExpiredGeminiConsents(): void { + const now = this.now(); + for (const [authorizationId, pending] of this.pendingGeminiConsents) { + if (Date.parse(pending.mainPlan.expiresAt) < now) { + this.pendingGeminiConsents.delete(authorizationId); + } + } + while (this.pendingGeminiConsents.size >= CREATE_IMAGES_MAX_PENDING_GEMINI_CONSENTS) { + const oldest = this.pendingGeminiConsents.keys().next().value as string | undefined; + if (!oldest) break; + this.pendingGeminiConsents.delete(oldest); + } + } + + private async prepareGeminiInternal( + input: CreateImagesPrepareGeminiRunRequest, + ): Promise { + await this.initialize(); + const workflow = await this.options.workflows.get(input.workflowId); + if (!workflow) return { status: "not-found", message: "The workflow no longer exists." }; + if (workflow.revision !== input.expectedRevision) { + return { + status: "conflict", + expectedRevision: input.expectedRevision, + currentRevision: workflow.revision, + }; + } + const audit = await this.journals.auditWorkflowAdmission(workflow.id); + if ( + audit.hasDegradedAuthority || + audit.hasNonterminalRun || + audit.hasUnresolvedAmbiguity || + this.activeByWorkflow.has(workflow.id) + ) { + return { + status: "unavailable", + message: "Resolve the workflow's retained run state before reviewing a new cloud run.", + }; + } + if (!this.options.resolveGeminiAuth) { + return { + status: "unavailable", + message: "Google Gemini image execution is unavailable in this Aiden runtime.", + }; + } + try { + const plan = createWorkflowCoordinatorPlan(workflow, input.scope); + const { capability, invocations } = await this.geminiInvocations(plan); + const auth = await this.options.resolveGeminiAuth(); + const credentialBinding = this.geminiCredentialBinding(auth); + const createdAtMs = this.now(); + const prepared = prepareCreateImagesProviderExecutionConsent( + { + authorizationId: randomUUID(), + workflowId: workflow.id, + workflowRevision: workflow.revision, + executionMode: "gemini", + capability, + credentialBinding, + invocations, + maximumAttempts: invocations.length, + estimate: { + kind: "unavailable", + estimatedAt: iso(createdAtMs), + sourceFingerprint: CREATE_IMAGES_GEMINI_ESTIMATE_SOURCE_FINGERPRINT, + }, + createdAt: iso(createdAtMs), + expiresAt: iso(createdAtMs + CREATE_IMAGES_GEMINI_CONSENT_LIFETIME_MS), + }, + this.providerConsentAuthority, + ); + this.purgeExpiredGeminiConsents(); + this.pendingGeminiConsents.set(prepared.mainPlan.authorizationId, { + mainPlan: prepared.mainPlan, + scope: structuredClone(plan.scope), + }); + return { + status: "ready", + plan: prepared.rendererPlan as CreateImagesProviderConsentPlanView, + }; + } catch (error) { + return { + status: error instanceof CreateImagesProviderAdmissionError ? "invalid" : "unavailable", + message: + error instanceof Error + ? error.message + : "Aiden could not prepare a bounded Gemini consent plan.", + }; + } + } + + async prepareGeminiRun( + input: CreateImagesPrepareGeminiRunRequest, + ): Promise { + const previous = this.startAdmissionTail; + const operation = previous.then(() => this.prepareGeminiInternal(input)); + this.startAdmissionTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + private async startInternal( + input: CreateImagesRunStartRequest, + isRendererCurrent: () => boolean, + ): Promise { + if (this.shutdownAdmissionBarrier) { + return { + status: "unavailable", + message: "Aiden is preparing to quit and is not accepting new image runs.", + }; + } + await this.initialize(); + if (this.shutdownAdmissionBarrier) { + return { + status: "unavailable", + message: "Aiden is preparing to quit and is not accepting new image runs.", + }; + } + const orphaned = this.activeByWorkflow.get(input.workflowId); + if ( + orphaned?.needsReconciliation && + !(await this.reconcileFailedLaunch(orphaned, Date.now() + this.shutdownTimeoutMs)) + ) { + return { + status: "unavailable", + message: "The previous durable run is still being reconciled. No new work was started.", + }; + } + const existing = this.activeByWorkflow.get(input.workflowId); + if (existing) + return projectCreateImagesRun(existing.journal).terminal + ? { + status: "unavailable", + message: "The previous durable run is still releasing its ownership.", + } + : { + status: "already-running", + run: runView(existing.journal), + }; + if (this.activeByRun.size >= CREATE_IMAGES_MAX_ACTIVE_RUNS) { + return { + status: "unavailable", + message: `Create Images supports at most ${CREATE_IMAGES_MAX_ACTIVE_RUNS} active runs at once.`, + }; + } + const workflow = await this.options.workflows.get(input.workflowId); + if (!workflow) return { status: "not-found", message: "The workflow no longer exists." }; + if (workflow.revision !== input.expectedRevision) { + return { + status: "conflict", + expectedRevision: input.expectedRevision, + currentRevision: workflow.revision, + }; + } + let admissionAudit: CreateImagesWorkflowAdmissionAudit; + try { + admissionAudit = await this.journals.auditWorkflowAdmission(workflow.id); + } catch { + return { + status: "unavailable", + message: "Run authority could not be revalidated safely. No new image run was started.", + }; + } + if (admissionAudit.hasDegradedAuthority) { + return { + status: "unavailable", + message: + "Resolve the workflow's damaged or unsupported run records before starting another run.", + }; + } + if (admissionAudit.hasNonterminalRun) { + return { + status: "unavailable", + message: "A previous durable run must be reconciled before another image run can start.", + }; + } + if (admissionAudit.hasUnresolvedAmbiguity) { + return { + status: "unavailable", + message: + "A previous submission is unresolved. Acknowledge its duplicate-generation risk before starting another run.", + }; + } + let plan; + try { + plan = createWorkflowCoordinatorPlan(workflow, input.scope); + } catch (error) { + return { + status: "invalid", + message: error instanceof Error ? error.message : "The workflow cannot run.", + }; + } + if (this.options.workspaceStatus) { + const workspace = await this.options.workspaceStatus(); + if ( + (this.options.workspaceRequired && !workspace.configured) || + (workspace.configured && workspace.state !== "ready") + ) { + return { + status: "unavailable", + message: + "The configured Create Images workspace is not ready. Reconnect it before starting this run.", + }; + } + } + if ( + input.executionMode !== undefined && + input.executionMode !== "local-mock" && + input.executionMode !== "gemini" + ) { + return { status: "invalid", message: "The execution mode is unsupported." }; + } + let execution: CreateImagesRunExecution = { mode: "local-mock" }; + let providerAuthorization: CreateImagesRunProviderAuthorizationV1 | undefined; + let consumedAuthorizationId: string | undefined; + if (input.executionMode === "gemini") { + const claim = input.providerConsent; + if (!claim) { + return { + status: "invalid", + message: "Review the current Gemini consent plan before starting this run.", + }; + } + this.purgeExpiredGeminiConsents(); + const pending = this.pendingGeminiConsents.get(claim.authorizationId); + if ( + !pending || + pending.mainPlan.workflowId !== workflow.id || + pending.mainPlan.workflowRevision !== workflow.revision || + JSON.stringify(pending.scope) !== JSON.stringify(plan.scope) + ) { + return { + status: "invalid", + message: "The reviewed Gemini consent no longer matches this exact saved run scope.", + }; + } + if (!this.options.resolveGeminiAuth) { + return { + status: "unavailable", + message: "Google Gemini image execution is unavailable in this Aiden runtime.", + }; + } + try { + const auth = await this.options.resolveGeminiAuth(); + const credentialBinding = this.geminiCredentialBinding(auth); + const capability = this.currentGeminiCapability(pending.mainPlan.capability.model.id); + const authorization = admitCreateImagesProviderExecution({ + mainPlan: pending.mainPlan, + claim, + authority: this.providerConsentAuthority, + currentCapability: capability, + currentCredential: credentialBinding, + now: iso(this.now()), + }); + const generationNodeIds = plan.orderedNodeIds.filter( + (nodeId) => + plan.snapshot.nodes.find((candidate) => candidate.id === nodeId)?.type === + "generate-image", + ); + if ( + JSON.stringify(authorization.invocations.map((invocation) => invocation.nodeId)) !== + JSON.stringify(generationNodeIds) + ) { + throw new CreateImagesProviderAdmissionError( + "forged-consent", + "The reviewed Gemini requests no longer match the immutable run plan.", + ); + } + execution = { mode: "gemini", auth }; + providerAuthorization = { + version: 1, + executionMode: "gemini", + authorizationId: authorization.authorizationId, + consentFingerprint: authorization.consentFingerprint, + capabilityFingerprint: authorization.capability.fingerprint, + credentialRecordId: authorization.credentialBinding!.recordId, + credentialRevision: authorization.credentialBinding!.revision, + initialRequestCount: authorization.accounting.initialRequestCount, + expectedOutputCount: authorization.accounting.expectedOutputCount, + maximumAttempts: authorization.accounting.maximumAttempts, + createdAt: pending.mainPlan.createdAt, + expiresAt: authorization.expiresAt, + }; + consumedAuthorizationId = authorization.authorizationId; + } catch (error) { + return { + status: error instanceof CreateImagesProviderAdmissionError ? "invalid" : "unavailable", + message: + error instanceof Error + ? error.message + : "The reviewed Gemini authorization could not be admitted safely.", + }; + } + } else if (input.providerConsent) { + return { status: "invalid", message: "Local mock runs cannot carry cloud consent." }; + } + const runId = this.createRunId(); + const createdAt = iso(this.now()); + const start: CreateImagesRunStartInput = { + runId, + workflowSnapshot: plan.snapshot, + plan: { + scope: structuredClone(plan.scope), + orderedNodeIds: [...plan.orderedNodeIds], + dependencies: Object.fromEntries( + Object.entries(plan.dependencies).map(([nodeId, values]) => [nodeId, [...values]]), + ), + }, + ...(providerAuthorization ? { providerAuthorization } : {}), + createdAt, + }; + const inputReservation = await this.options.references.reserveRun( + runId, + plan.snapshot.assetRefs, + ); + let journal: CreateImagesRunJournalV1; + try { + if (consumedAuthorizationId) { + this.pendingGeminiConsents.delete(consumedAuthorizationId); + } + journal = await this.journals.start(start, isRendererCurrent); + } catch (error) { + const authoritative = await this.journals.get(runId).catch(() => undefined); + if (authoritative && !projectCreateImagesRun(authoritative).terminal) { + await this.reconcileAfterRestart(authoritative).catch(() => undefined); + } + await this.options.references.releaseRunReservations(runId).catch(() => undefined); + await this.options.references.reconcileRuns(this.journals).catch(() => undefined); + throw error; + } + try { + await this.options.references.commitRun(inputReservation); + } catch (error) { + if (!(await this.options.references.reconcileRuns(this.journals))) throw error; + } + await this.options.assets + .replaceReferences({ kind: "run", id: runId }, [...new Set(plan.snapshot.assetRefs)].sort()) + .catch(() => undefined); + await this.options.references.releaseRunReservations(runId).catch(() => undefined); + let resolveCancelDurable: () => void = () => undefined; + const cancelDurable = new Promise((resolve) => { + resolveCancelDurable = resolve; + }); + const active: ActiveRun = { + runId, + workflowId: workflow.id, + journal, + execution, + controller: new AbortController(), + mutationTail: Promise.resolve(), + publicationTail: Promise.resolve(), + publishedOutputs: new Map(), + reservations: new Map(), + cancelDurable, + resolveCancelDurable, + settled: Promise.resolve(), + }; + this.activeByRun.set(runId, active); + this.activeByWorkflow.set(workflow.id, active); + this.notify(workflow.id); + this.launch(active, plan.snapshot.nodes); + return { status: "started", run: runView(journal) }; + } + + async start( + input: CreateImagesRunStartRequest, + isRendererCurrent: () => boolean, + ): Promise { + const previous = this.startAdmissionTail; + const operation = previous.then(() => this.startInternal(input, isRendererCurrent)); + const tail = operation.then( + () => undefined, + () => undefined, + ); + this.startAdmissionTail = tail; + return operation; + } + + async deleteWorkflowIfRunLifecycleEmpty( + workflowId: string, + deleteWorkflow: () => Promise, + ): Promise< + | { status: "allowed"; value: Result } + | Exclude + > { + const previous = this.startAdmissionTail; + const operation = previous.then(async () => { + let snapshot: CreateImagesRunListResult; + let authority: CreateImagesWorkflowAdmissionAudit; + try { + authority = await this.journals.auditWorkflowAdmission(workflowId); + snapshot = await this.list(workflowId); + } catch { + return { + status: "unavailable" as const, + message: "Run history could not be verified safely. No workflow was deleted.", + }; + } + const decision = evaluateCreateImagesWorkflowDeletion(snapshot); + if (decision.status !== "allowed") return decision; + if (authority.hasDegradedAuthority) { + return { + status: "unavailable" as const, + message: + "Damaged or unassociated run recovery authority prevents Aiden from proving this workflow is safe to delete. No workflow was deleted.", + }; + } + if (authority.hasNonterminalRun) { + return { + status: "unavailable" as const, + message: + "A durable nonterminal image run must be reconciled before deleting this workflow. No workflow was deleted.", + }; + } + if (authority.hasUnresolvedAmbiguity) { + return { + status: "unavailable" as const, + message: + "An unresolved image submission must remain reviewable before deleting this workflow. No workflow was deleted.", + }; + } + return { status: "allowed" as const, value: await deleteWorkflow() }; + }); + this.startAdmissionTail = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + async stop( + workflowId: string, + runId: string, + reason: CreateImagesCancellationReason, + ): Promise { + await this.initialize(); + const active = this.activeByRun.get(runId); + if (!active || active.workflowId !== workflowId) { + const journal = await this.journals.get(runId); + if (!journal || journal.workflowId !== workflowId) { + return { + status: "not-found", + message: "The workflow run no longer exists.", + }; + } + return TERMINAL_RUN_STATUSES.has(projectCreateImagesRun(journal).status) + ? { + status: "unavailable", + message: "This workflow run is already finished.", + } + : { + status: "unavailable", + message: "This run must be reconciled before it can stop.", + }; + } + if (!active.controller.signal.aborted) { + active.controller.abort(new CoordinatorCancellationRequest(reason)); + } + this.beginActiveCancellation(active, reason); + const outcome = await this.waitForActiveStop(active, Date.now() + this.shutdownTimeoutMs); + if (outcome === "blocked") { + return { + status: "unavailable", + message: "The cancellation request was not durably saved. Aiden will keep this run open.", + }; + } + if (outcome === "terminal") { + return { + status: "unavailable", + message: "This workflow run is already finished.", + }; + } + return { + status: "stopping", + // Avoid a post-deadline store read: an interrupted pending publication + // may still own the store's serialization lock. + run: runView(active.journal), + }; + } + + private beginActiveCancellation(active: ActiveRun, reason: CreateImagesCancellationReason): void { + if (!active.controller.signal.aborted) { + active.controller.abort(new CoordinatorCancellationRequest(reason)); + } + if (projectCreateImagesRun(active.journal).cancellation) { + active.resolveCancelDurable(); + return; + } + if (active.cancellationRequest) return; + const request = this.mutateJournal(active, (journal) => + projectCreateImagesRun(journal).cancellation + ? Promise.resolve(journal) + : this.journals.requestCancellation(journal.runId, journal.journalRevision, { + at: createEventBase(journal, this.now()).at, + reason, + }), + ); + active.cancellationRequest = request; + void request.then( + () => { + active.resolveCancelDurable(); + if (active.cancellationRequest === request) active.cancellationRequest = undefined; + }, + () => { + // The bounded waiter below turns this into an observable failure. The + // reference is cleared so a later explicit stop may safely retry. + if (active.cancellationRequest === request) active.cancellationRequest = undefined; + }, + ); + } + + private async waitForActiveStop( + active: ActiveRun, + deadline: number, + ): Promise<"durable" | "terminal" | "blocked"> { + const projection = projectCreateImagesRun(active.journal); + if (projection.cancellation) return "durable"; + if (projection.terminal) return "terminal"; + const remaining = deadline - Date.now(); + if (remaining <= 0) return "blocked"; + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + active.cancelDurable.then(() => "durable" as const), + active.settled.then(() => { + const settled = projectCreateImagesRun(active.journal); + return settled.cancellation + ? ("durable" as const) + : settled.terminal + ? ("terminal" as const) + : ("blocked" as const); + }), + new Promise<"blocked">((resolve) => { + timeout = setTimeout(() => resolve("blocked"), remaining); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + + private async waitUntilDeadline(promise: Promise, deadline: number): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) return false; + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), remaining); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + + async stopAll(reason: "app-quit"): Promise { + this.shutdownAdmissionBarrier = true; + const deadline = Date.now() + this.shutdownTimeoutMs; + const admittedBeforeShutdown = this.startAdmissionTail; + if (!(await this.waitUntilDeadline(admittedBeforeShutdown, deadline))) { + return { status: "blocked", failedRunIds: [] }; + } + const activeRuns = [...this.activeByRun.values()]; + const alreadyDurable = activeRuns.every((active) => { + const projection = projectCreateImagesRun(active.journal); + return projection.cancellation !== undefined || projection.terminal !== undefined; + }); + for (const active of activeRuns) this.beginActiveCancellation(active, reason); + const outcomes = await Promise.all( + activeRuns.map((active) => this.waitForActiveStop(active, deadline)), + ); + const failedRunIds = activeRuns + .filter((_active, index) => outcomes[index] === "blocked") + .map((active) => active.runId) + .sort(); + if (failedRunIds.length > 0) return { status: "blocked", failedRunIds }; + if (alreadyDurable) { + return { + status: "safe-to-quit", + unsettledRunIds: activeRuns + .filter((active) => this.activeByRun.has(active.runId)) + .map((active) => active.runId) + .sort(), + }; + } + const remaining = deadline - Date.now(); + if (remaining > 0) { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + Promise.allSettled(activeRuns.map((active) => active.settled)).then(() => undefined), + new Promise((resolve) => { + timeout = setTimeout(resolve, remaining); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + } + return { + status: "safe-to-quit", + unsettledRunIds: activeRuns + .filter((active) => this.activeByRun.has(active.runId)) + .map((active) => active.runId) + .sort(), + }; + } + + /** Reopens admission only after main has explicitly abandoned a quit attempt. */ + resumeRunAdmissionsAfterCancelledShutdown(): void { + this.shutdownAdmissionBarrier = false; + } + + async activeRuns(): Promise { + await this.initialize(); + const views = [...this.activeByRun.values()].map((active) => runView(active.journal)); + return views.sort( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.runId.localeCompare(right.runId), + ); + } + + async list(workflowId: string): Promise { + await this.initialize(); + if (!(await this.options.workflows.get(workflowId))) return { status: "not-found" }; + const orphaned = this.activeByWorkflow.get(workflowId); + if ( + orphaned?.needsReconciliation && + !(await this.reconcileFailedLaunch(orphaned, Date.now() + this.shutdownTimeoutMs)) + ) { + return { + status: "unavailable", + message: "The durable run is still being reconciled. Try again shortly.", + retryAfterMs: this.shutdownTimeoutMs, + }; + } + const active = this.activeByWorkflow.get(workflowId); + let cached = this.terminalCache.get(workflowId); + let refreshedRecoveries: CreateImagesRunRecoveryView[] | undefined; + if (!cached) { + const summaries = (await this.journals.terminalHistory()) + .filter((summary) => summary.workflowId === workflowId) + .slice(0, 100); + refreshedRecoveries = this.recoveryViews( + await this.journals.refreshWorkflowDegradedMetadata( + workflowId, + summaries.map((summary) => summary.runId), + ), + ); + const refreshedRecoveryIds = new Set(refreshedRecoveries.map((recovery) => recovery.runId)); + const history: CreateImagesTerminalRunView[] = []; + let latestTerminalRun: CreateImagesRunView | undefined; + for (const summary of summaries) { + if (refreshedRecoveryIds.has(summary.runId)) continue; + const journal = await this.journals.get(summary.runId); + if (!journal) continue; + const view = terminalView(journal); + if (view) { + history.push(view); + latestTerminalRun ??= runView(journal); + } + } + cached = { history, ...(latestTerminalRun ? { latestTerminalRun } : {}) }; + this.cacheTerminalHistory(workflowId, cached); + } else { + this.cacheTerminalHistory(workflowId, cached); + } + const recoveries = refreshedRecoveries ?? (await this.recoveryViewsForWorkflow(workflowId)); + const recoveryRunIds = new Set(recoveries.map((recovery) => recovery.runId)); + const history = cached.history.filter((entry) => !recoveryRunIds.has(entry.runId)); + const latestTerminalRun = + cached.latestTerminalRun && !recoveryRunIds.has(cached.latestTerminalRun.runId) + ? cached.latestTerminalRun + : undefined; + return { + status: "ready", + authoritative: true, + ...(active ? { activeRun: runView(active.journal) } : {}), + ...(!active && latestTerminalRun + ? { latestTerminalRun: structuredClone(latestTerminalRun) } + : {}), + history: structuredClone(history), + recoveries, + }; + } + + async get(workflowId: string, runId: string): Promise { + await this.initialize(); + if (!(await this.options.workflows.get(workflowId))) return { status: "not-found" }; + const health = await this.journals.health(runId); + if (health.status === "missing") return { status: "not-found" }; + if (health.status === "unsafe") { + const recovery = unsafeRecoveryView(health); + return recovery?.workflowId === workflowId + ? { + status: "unsafe", + recovery, + message: "This run uses an unsupported schema or unsafe storage and is read-only.", + } + : { status: "not-found" }; + } + if (health.status === "recovery-required") { + const recovery = recoveryRequiredView(health); + return recovery?.workflowId === workflowId + ? { status: "recovery-required", recovery } + : { status: "not-found" }; + } + const journal = await this.journals.get(runId); + return journal?.workflowId === workflowId + ? { status: "ready", run: runView(journal) } + : { status: "not-found" }; + } + + async resolveRunAmbiguity( + input: CreateImagesResolveRunAmbiguityRequest, + ): Promise { + await this.initialize(); + if ( + input.resolution !== "acknowledge-unresolved-submission" || + !(await this.options.workflows.get(input.workflowId)) + ) { + return { status: "not-found" }; + } + const health = await this.journals.health(input.runId); + if (health.status === "missing") return { status: "not-found" }; + if (health.status !== "healthy") { + return { + status: "unavailable", + message: "This run must be recovered before its unresolved submission can be acknowledged.", + }; + } + let journal = await this.journals.get(input.runId); + if (!journal || journal.workflowId !== input.workflowId) { + return { status: "not-found" }; + } + if (journal.journalRevision !== input.expectedJournalRevision) { + return { + status: "conflict", + expectedJournalRevision: input.expectedJournalRevision, + currentJournalRevision: journal.journalRevision, + }; + } + let projection = projectCreateImagesRun(journal); + const wasAlreadyResolved = projection.ambiguityResolution !== undefined; + if (!wasAlreadyResolved && !hasUnresolvedCreateImagesRunAmbiguity(projection)) { + return { status: "not-ambiguous" }; + } + if (!wasAlreadyResolved) { + try { + journal = await this.journals.append(journal.runId, input.expectedJournalRevision, { + ...createEventBase(journal, this.now()), + type: "run-ambiguity-acknowledged", + expectedNeedsAttentionJournalRevision: input.expectedJournalRevision, + }); + } catch (error) { + if (error instanceof CreateImagesRunJournalRevisionConflictError) { + if (error.actualJournalRevision === null) return { status: "not-found" }; + return { + status: "conflict", + expectedJournalRevision: input.expectedJournalRevision, + currentJournalRevision: error.actualJournalRevision, + }; + } + if (error instanceof CreateImagesRunJournalLoadError) { + return { + status: "unavailable", + message: "The unresolved submission acknowledgement could not be saved safely.", + }; + } + throw error; + } + projection = projectCreateImagesRun(journal); + if (!projection.ambiguityResolution) { + return { + status: "unavailable", + message: "The unresolved submission acknowledgement was not durably projected.", + }; + } + this.terminalCache.delete(input.workflowId); + this.notify(input.workflowId); + } + const authoritativeList = await this.list(input.workflowId); + if (authoritativeList.status !== "ready") { + return { + status: "unavailable", + message: "The updated run history could not be loaded safely.", + }; + } + return { + status: wasAlreadyResolved ? "already-resolved" : "resolved", + run: runView(journal), + authoritativeList, + }; + } + + async planDegradedRunDiscard(runId: string): Promise { + await this.initialize(); + if (this.activeByRun.has(runId)) { + return { + status: "unavailable", + message: "An active run cannot be discarded.", + }; + } + try { + const planned = await this.journals.planDegradedRunDiscard(runId); + if (planned.status !== "ready") return planned; + const { recordFingerprint: _recordFingerprint, version: _version, ...safe } = planned.plan; + return { + status: "ready", + ...safe, + mayLoseOutputs: true, + mayDuplicateProviderWork: true, + }; + } catch { + return { + status: "unavailable", + message: "The damaged run record could not be authorized for discard safely.", + }; + } + } + + async discardDegradedRun( + input: CreateImagesDiscardDegradedRunRequest, + ): Promise { + await this.initialize(); + if (this.activeByRun.has(input.runId)) { + return { + status: "unavailable", + message: "An active run cannot be discarded.", + }; + } + const referencedAssetCount = (await this.options.assets.list()).filter((asset) => + this.options.references.isRunAssetReferenced(input.runId, asset.assetId), + ).length; + try { + const discarded = await this.journals.discardDegradedRun({ + runId: input.runId, + authorizationToken: input.authorizationToken, + ...(input.expectedCurrentJournalRevision === undefined + ? {} + : { + expectedCurrentJournalRevision: input.expectedCurrentJournalRevision, + }), + ...(input.expectedLastKnownGoodJournalRevision === undefined + ? {} + : { + expectedLastKnownGoodJournalRevision: input.expectedLastKnownGoodJournalRevision, + }), + }); + if (discarded.status !== "discarded") return discarded; + const assetReferencesReleased = await this.options.assets + .replaceReferences({ kind: "run", id: input.runId }, []) + .then( + () => true, + () => false, + ); + const releasedAssetCount = assetReferencesReleased ? referencedAssetCount : 0; + await this.options.references.reconcileRuns(this.journals).catch(() => false); + const workflowId = discarded.result.workflowId; + if (!workflowId) { + return { status: "discarded", runId: input.runId, releasedAssetCount }; + } + this.terminalCache.delete(workflowId); + this.notify(workflowId); + const authoritativeList = await this.list(workflowId); + return { + status: "discarded", + runId: input.runId, + releasedAssetCount, + authoritativeList, + }; + } catch { + return { + status: "unavailable", + message: "The damaged run record was preserved because discard could not finish safely.", + }; + } + } + + async planHistoryPrune(keepLatest: number): Promise { + await this.initialize(); + const candidates = await this.journals.terminalRetentionCandidates({ + keepLatest, + limit: 100, + }); + if (candidates.length === 0) return { status: "nothing-to-prune" }; + const plan = await this.journals.planTerminalPrune(candidates); + return { + status: "ready", + scope: "all-workflows", + mayReleaseUniqueOutputs: true, + authorizationToken: plan.token, + keepLatest, + candidateRunCount: plan.candidates.length, + releasedAssetCount: plan.assetIds.length, + }; + } + + async pruneHistory( + keepLatest: number, + authorizationToken: string, + ): Promise { + await this.initialize(); + const candidates = await this.journals.terminalRetentionCandidates({ + keepLatest, + limit: 100, + }); + if (candidates.length === 0) return { status: "nothing-to-prune" }; + const affectedWorkflowIds = new Set(candidates.map((candidate) => candidate.workflowId)); + const plan = await this.journals.planTerminalPrune(candidates); + if (plan.token !== authorizationToken) { + return { + status: "conflict", + message: "Run history changed after confirmation. Review the updated cleanup plan.", + }; + } + const result = await this.journals.pruneTerminalRuns(plan); + for (const runId of result.removedRunIds) { + await this.options.assets + .replaceReferences({ kind: "run", id: runId }, []) + .catch(() => undefined); + } + await this.options.references.reconcileRuns(this.journals); + for (const workflowId of affectedWorkflowIds) { + this.terminalCache.delete(workflowId); + this.notify(workflowId); + } + return { + status: "pruned", + removedRunCount: result.removedRunIds.length, + releasedAssetCount: result.releasedAssetIds.length, + }; + } + + async recover( + workflowId: string, + runId: string, + source: "last-known-good" | "current", + expectedCandidateJournalRevision: number, + ): Promise { + await this.initialize(); + if (!(await this.options.workflows.get(workflowId))) return { status: "not-found" }; + if (this.activeByRun.has(runId)) { + return { + status: "unavailable", + message: "An active run cannot be recovered.", + }; + } + const health = await this.journals.health(runId); + if (health.status === "missing") return { status: "not-found" }; + if (health.status === "unsafe") { + const recovery = unsafeRecoveryView(health); + return recovery?.workflowId === workflowId + ? { + status: "unsafe", + recovery, + message: + "This run uses an unsupported schema or unsafe storage and cannot be recovered by this version.", + } + : { status: "not-found" }; + } + if (health.status === "healthy") { + const journal = await this.journals.get(runId); + return journal?.workflowId === workflowId + ? { status: "recovered", run: runView(journal) } + : { status: "not-found" }; + } + const recovery = recoveryRequiredView(health); + if (!recovery || recovery.workflowId !== workflowId) return { status: "not-found" }; + const expectedSource = + health.canRecover === "from-last-known-good" + ? "last-known-good" + : health.canRecover === "from-current" + ? "current" + : undefined; + const currentCandidateJournalRevision = + expectedSource === "last-known-good" + ? health.lastKnownGoodJournalRevision + : expectedSource === "current" + ? health.currentJournalRevision + : undefined; + if (!expectedSource || currentCandidateJournalRevision === undefined) { + return { status: "recovery-required", recovery }; + } + if ( + expectedSource !== source || + currentCandidateJournalRevision !== expectedCandidateJournalRevision + ) { + return { + status: "conflict", + source, + expectedCandidateJournalRevision, + currentCandidateJournalRevision, + }; + } + let journal: CreateImagesRunJournalV1; + try { + journal = + source === "last-known-good" + ? await this.journals.recoverFromLastKnownGood(runId, expectedCandidateJournalRevision) + : await this.journals.recoverLastKnownGoodFromCurrent( + runId, + expectedCandidateJournalRevision, + ); + } catch (error) { + if (error instanceof CreateImagesRunJournalRevisionConflictError) { + return { + status: "conflict", + source, + expectedCandidateJournalRevision, + ...(error.actualJournalRevision === null + ? {} + : { + currentCandidateJournalRevision: error.actualJournalRevision, + }), + }; + } + if (error instanceof CreateImagesRunJournalLoadError) { + const latest = await this.journals.health(runId); + if (latest.status === "unsafe") { + const latestRecovery = unsafeRecoveryView(latest); + if (latestRecovery?.workflowId === workflowId) { + return { + status: "unsafe", + recovery: latestRecovery, + message: + "This run uses an unsupported schema or unsafe storage and cannot be recovered by this version.", + }; + } + } + if (latest.status === "recovery-required") { + const latestRecovery = recoveryRequiredView(latest); + if (latestRecovery?.workflowId === workflowId) { + return { status: "recovery-required", recovery: latestRecovery }; + } + } + return { + status: "unavailable", + message: "The run record could not be recovered safely.", + }; + } + throw error; + } + if (journal.workflowId !== workflowId) return { status: "not-found" }; + if (!projectCreateImagesRun(journal).terminal) { + await this.reconcileAfterRestart(journal); + journal = (await this.journals.get(runId)) ?? journal; + } + this.terminalCache.delete(workflowId); + await this.options.references.reconcileRuns(this.journals); + this.notify(workflowId); + return { status: "recovered", run: runView(journal) }; + } + + async isRunAssetReferenced(workflowId: string, runId: string, assetId: string): Promise { + const journal = await this.journals.get(runId); + return ( + journal?.workflowId === workflowId && + Object.values(projectCreateImagesRun(journal).nodes).some((node) => + node.outputAssetIds.includes(assetId), + ) + ); + } +} diff --git a/main/services/create-images/scheduler-core.test.ts b/main/services/create-images/scheduler-core.test.ts new file mode 100644 index 00000000..acb30b16 --- /dev/null +++ b/main/services/create-images/scheduler-core.test.ts @@ -0,0 +1,989 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { WorkflowExecutionPlan } from "../../../renderer/shared/create-images/execution.js"; +import type { + GenerateImageNodeV1, + PromptNodeV1, + WorkflowDocumentV1, + WorkflowNodeV1, +} from "../../../renderer/shared/create-images/schema.js"; +import { + CoordinatorCancellationRequest, + createCoordinatorEventCursor, + createWorkflowCoordinatorPlan, + reconcileRestartNode, + reduceCoordinatorEvent, + rendererDisconnectDecision, + runWorkflowCoordinator, + type CoordinatorClock, + type CoordinatorDurability, + type CoordinatorEvent, + type CoordinatorEventPayload, + type CoordinatorEventReduction, + type CoordinatorRetryPolicy, +} from "./scheduler-core.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; + +function prompt(id: string): PromptNodeV1 { + return { id, type: "prompt", position: { x: 0, y: 0 }, data: { text: id } }; +} + +function generate(id: string): GenerateImageNodeV1 { + return { + id, + type: "generate-image", + position: { x: 100, y: 0 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }; +} + +function documentWith( + nodes: WorkflowNodeV1[], + edges: WorkflowDocumentV1["edges"] = [], +): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "workflow-1", + title: "Coordinator test", + revision: 7, + createdAt: NOW, + updatedAt: NOW, + nodes, + edges, + assetRefs: [], + settings: { concurrency: 1 }, + }; +} + +function edge(id: string, source: string, sourcePort: string, target: string, targetPort: string) { + return { id, source, sourcePort, target, targetPort }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function settleUntil(predicate: () => boolean): Promise { + for (let index = 0; index < 50 && !predicate(); index += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal( + predicate(), + true, + "The deterministic coordinator did not reach the expected point.", + ); +} + +class TestClock implements CoordinatorClock { + time = 1_000; + readonly sleeps: number[] = []; + + now(): number { + this.time += 1; + return this.time; + } + + async sleep(delayMs: number, signal: AbortSignal): Promise { + this.sleeps.push(delayMs); + if (signal.aborted) throw signal.reason; + this.time += delayMs; + } +} + +const POLICY: CoordinatorRetryPolicy = { + maxRetriesPerNode: 2, + baseDelayMs: 100, + maxDelayMs: 1_000, + maxTotalDelayMs: 5_000, + jitterRatio: 0, + retryRemoteNotSubmitted: true, + retryRemoteIdempotent: true, +}; + +function harness() { + const log: string[] = []; + const events: CoordinatorEvent[] = []; + const durability: CoordinatorDurability = { + async persistPlan() { + log.push("plan"); + }, + async appendEvent(event) { + log.push(`event:${event.kind}:${"status" in event ? event.status : "job"}`); + events.push(event); + }, + async persistCancelIntent() { + log.push("cancel-intent"); + }, + async persistSubmissionPrepared(record) { + log.push(`prepared:${record.nodeId}:${record.attempt}:${record.idempotencyKey}`); + }, + async persistRemoteJob(record) { + log.push(`remote-job:${record.nodeId}`); + }, + async publishOutput(record) { + log.push(`publish:${record.nodeId}`); + return record.output; + }, + }; + return { log, events, durability }; +} + +function options(durability: CoordinatorDurability, clock = new TestClock()) { + return { + runId: "run-1", + localConcurrency: 1, + remoteConcurrency: 1, + clock, + jitter: { sample: () => 0.5 }, + retryPolicy: POLICY, + durability, + } as const; +} + +test("coordinator uses stable plan order with independent local and remote gates", async () => { + const document = documentWith( + [prompt("prompt-a"), prompt("prompt-b"), generate("generate-a"), generate("generate-b")], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "prompt-b", "text", "generate-b", "prompt"), + ], + ); + const plan = createWorkflowCoordinatorPlan(document, { kind: "all" }); + const gates = new Map(plan.orderedNodeIds.map((nodeId) => [nodeId, deferred()])); + const started: string[] = []; + const idempotencyKeys = new Map(); + const active = { local: 0, remote: 0 }; + const maximum = { local: 0, remote: 0 }; + const { durability, log } = harness(); + const run = runWorkflowCoordinator(plan, { + ...options(durability), + executeNode: async ({ node, lane, idempotencyKey, recordRemoteJobId }) => { + log.push(`execute:${node.id}`); + active[lane] += 1; + maximum[lane] = Math.max(maximum[lane], active[lane]); + started.push(node.id); + if (lane === "remote") { + idempotencyKeys.set(node.id, idempotencyKey ?? ""); + await recordRemoteJobId(`job-${node.id}`); + } + await gates.get(node.id)?.promise; + active[lane] -= 1; + return { kind: "success", output: node.id }; + }, + }); + await settleUntil(() => started.length >= 1); + assert.deepEqual(started, ["prompt-a"]); + gates.get("prompt-a")?.resolve(); + await settleUntil(() => started.length >= 3); + assert.deepEqual(started, ["prompt-a", "prompt-b", "generate-a"]); + gates.get("prompt-b")?.resolve(); + gates.get("generate-a")?.resolve(); + await settleUntil(() => started.length >= 4); + assert.deepEqual(started, ["prompt-a", "prompt-b", "generate-a", "generate-b"]); + gates.get("generate-b")?.resolve(); + const result = await run; + assert.deepEqual(maximum, { local: 1, remote: 1 }); + assert.notEqual(idempotencyKeys.get("generate-a"), idempotencyKeys.get("generate-b")); + assert.match(idempotencyKeys.get("generate-a") ?? "", /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u); + assert.ok( + log.findIndex((entry) => entry.startsWith("prepared:generate-a:1:")) < + log.indexOf("execute:generate-a"), + ); + assert.equal(result.status, "succeeded"); + assert.deepEqual(result.nodeStatuses, { + "prompt-a": "succeeded", + "prompt-b": "succeeded", + "generate-a": "succeeded", + "generate-b": "succeeded", + }); + let cursor = createCoordinatorEventCursor( + { + workflowId: result.workflowId, + workflowRevision: result.workflowRevision, + runId: result.runId, + }, + plan.orderedNodeIds, + ); + for (const event of result.events) { + const reduced = reduceCoordinatorEvent(cursor, event); + assert.equal(reduced.accepted, true); + if (reduced.accepted) cursor = reduced.cursor; + } + assert.equal(cursor.runStatus, "succeeded"); +}); + +test("coordinator snapshots plan inputs and publishes output before durable success", async () => { + const document = documentWith([prompt("prompt-a")]); + const plan = createWorkflowCoordinatorPlan(document, { kind: "all" }); + document.nodes[0] = prompt("mutated-node"); + const observed: string[] = []; + const { durability, log } = harness(); + const result = await runWorkflowCoordinator(plan, { + ...options(durability), + executeNode: async ({ node }) => { + observed.push(node.id); + return { kind: "success", output: "durable-output" }; + }, + }); + assert.deepEqual(observed, ["prompt-a"]); + assert.ok(log.indexOf("plan") < log.indexOf("event:run:running")); + assert.ok(log.indexOf("publish:prompt-a") < log.lastIndexOf("event:node:succeeded")); + assert.equal(result.outputs.get("prompt-a"), "durable-output"); + assert.equal(Object.isFrozen(plan.snapshot), true); +}); + +test("only durable publication projections enter outputs and downstream dependencies", async () => { + const graph = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const rawPrompt = { bytes: Uint8Array.from([1, 2, 3]) }; + const rawImage = { bytes: Uint8Array.from([4, 5, 6]) }; + const durablePrompt = { kind: "text", value: "durable prompt" }; + const durableImage = { kind: "assets", assetIds: ["a".repeat(64)] }; + const observedDependencies: unknown[] = []; + const { durability } = harness(); + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(graph, { kind: "all" }), + { + ...options({ + ...durability, + async publishOutput(record) { + return record.nodeId === "prompt-a" ? durablePrompt : durableImage; + }, + }), + executeNode: async ({ lane, dependencyOutputs }) => { + if (lane === "local") return { kind: "success", output: rawPrompt }; + observedDependencies.push(dependencyOutputs.get("prompt-a")); + return { kind: "success", output: rawImage }; + }, + }, + ); + assert.deepEqual(observedDependencies, [durablePrompt]); + assert.deepEqual(result.outputs.get("prompt-a"), durablePrompt); + assert.deepEqual(result.outputs.get("generate-a"), durableImage); + assert.notEqual(result.outputs.get("prompt-a"), rawPrompt); + assert.notEqual(result.outputs.get("generate-a"), rawImage); +}); + +test("failure and node cancellation block required descendants while independent work succeeds", async () => { + const graph = documentWith( + [ + prompt("prompt-a"), + generate("generate-a"), + { id: "output-a", type: "output", position: { x: 200, y: 0 }, data: {} }, + prompt("independent"), + ], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "generate-a", "images", "output-a", "images"), + ], + ); + const { durability } = harness(); + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(graph, { kind: "all" }), + { + ...options(durability), + localConcurrency: 2, + executeNode: async ({ node }) => + node.id === "generate-a" + ? { kind: "failure", error: "mock refused", retrySafety: "never" } + : { kind: "success", output: node.id }, + }, + ); + assert.equal(result.status, "failed"); + assert.equal(result.nodeStatuses["generate-a"], "failed"); + assert.equal(result.nodeStatuses["output-a"], "blocked"); + assert.equal(result.nodeStatuses.independent, "succeeded"); +}); + +test("run-from-here executes required ancestors and only the explicitly selected downstream path", async () => { + const graph = documentWith( + [ + prompt("prompt-a"), + generate("generate-a"), + { id: "output-a", type: "output", position: { x: 200, y: 0 }, data: {} }, + prompt("independent"), + ], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "generate-a", "images", "output-a", "images"), + ], + ); + const runScope = async (plan: WorkflowExecutionPlan) => { + const { durability } = harness(); + const executed: string[] = []; + const result = await runWorkflowCoordinator(plan, { + ...options(durability), + executeNode: async ({ node }) => { + executed.push(node.id); + return { kind: "success", output: node.id }; + }, + }); + return { executed, result }; + }; + const selected = await runScope( + createWorkflowCoordinatorPlan(graph, { + kind: "from-node", + nodeId: "generate-a", + }), + ); + assert.deepEqual(selected.executed, ["prompt-a", "generate-a"]); + assert.deepEqual(Object.keys(selected.result.nodeStatuses), ["prompt-a", "generate-a"]); + + const downstream = await runScope( + createWorkflowCoordinatorPlan(graph, { + kind: "from-node", + nodeId: "generate-a", + downstreamPath: ["output-a"], + }), + ); + assert.deepEqual(downstream.executed, ["prompt-a", "generate-a", "output-a"]); +}); + +test("main coordinator planning rejects a forged path whose rejoin hides sibling work", () => { + const graph = documentWith( + [ + prompt("start"), + generate("generate-a"), + generate("generate-b"), + { + id: "gallery", + type: "output-gallery", + position: { x: 200, y: 0 }, + data: {}, + }, + ], + [ + edge("prompt-a", "start", "text", "generate-a", "prompt"), + edge("prompt-b", "start", "text", "generate-b", "prompt"), + edge("image-a", "generate-a", "images", "gallery", "images"), + edge("image-b", "generate-b", "images", "gallery", "images"), + ], + ); + assert.throws( + () => + createWorkflowCoordinatorPlan(graph, { + kind: "from-node", + nodeId: "start", + downstreamPath: ["generate-a", "gallery"], + }), + /additional branch work/u, + ); +}); + +test("bounded retry uses injected clock and explicit safety classifications", async () => { + const clock = new TestClock(); + const { durability } = harness(); + let attempts = 0; + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(documentWith([prompt("prompt-a")]), { + kind: "all", + }), + { + ...options(durability, clock), + executeNode: async () => { + attempts += 1; + return attempts < 3 + ? { + kind: "rate-limited", + error: "slow down", + retrySafety: "local-safe", + } + : { kind: "success", output: "done" }; + }, + }, + ); + assert.equal(attempts, 3); + assert.deepEqual(clock.sleeps, [100, 200]); + assert.equal(result.retryDelayMs, 300); + assert.deepEqual( + result.events + .filter((event) => event.kind === "node" && event.nodeId === "prompt-a") + .map((event) => (event.kind === "node" ? [event.status, event.attempt] : [])), + [ + ["queued", 0], + ["running", 1], + ["retry_wait", 1], + ["running", 2], + ["retry_wait", 2], + ["running", 3], + ["succeeded", 3], + ], + ); +}); + +test("ambiguous remote submission is terminal and can never be auto-retried", async () => { + const graph = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const { durability, log } = harness(); + let remoteAttempts = 0; + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(graph, { kind: "all" }), + { + ...options(durability), + executeNode: async ({ lane }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + remoteAttempts += 1; + return { kind: "ambiguous-submit", error: "accepted, response lost" }; + }, + }, + ); + assert.equal(remoteAttempts, 1); + assert.equal(result.nodeStatuses["generate-a"], "ambiguous"); + assert.equal(result.status, "needs_attention"); + assert.equal(result.retryDelayMs, 0); + assert.ok(log.some((entry) => entry.startsWith("prepared:generate-a:1:"))); + assert.equal(log.includes("remote-job:generate-a"), false); +}); + +test("unknown remote exceptions after durable preparation are ambiguous, while typed non-submission can retry", async () => { + const graph = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const plan = createWorkflowCoordinatorPlan(graph, { kind: "all" }); + const unknownHarness = harness(); + let unknownAttempts = 0; + const unknown = await runWorkflowCoordinator(plan, { + ...options(unknownHarness.durability), + executeNode: async ({ lane }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + unknownAttempts += 1; + throw new Error("socket closed after send"); + }, + }); + assert.equal(unknownAttempts, 1); + assert.equal(unknown.status, "needs_attention"); + assert.equal(unknown.nodeStatuses["generate-a"], "ambiguous"); + assert.equal(unknown.retryDelayMs, 0); + + const safeHarness = harness(); + let safeAttempts = 0; + const safe = await runWorkflowCoordinator(plan, { + ...options(safeHarness.durability), + executeNode: async ({ lane }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + safeAttempts += 1; + return safeAttempts === 1 + ? { + kind: "failure", + error: "failed before transport", + retrySafety: "confirmed-not-submitted", + } + : { kind: "success", output: "image" }; + }, + }); + assert.equal(safeAttempts, 2); + assert.equal(safe.status, "succeeded"); +}); + +test("remote retry requires confirmed non-submission or the exact bounded idempotency class", async () => { + const graph = documentWith( + [prompt("prompt-a"), generate("generate-a")], + [edge("edge-a", "prompt-a", "text", "generate-a", "prompt")], + ); + const plan = createWorkflowCoordinatorPlan(graph, { kind: "all" }); + const runCase = async ( + firstRemoteResult: + | { + kind: "rate-limited"; + error: string; + retrySafety: "confirmed-not-submitted" | "same-idempotency-key"; + retryAfterMs?: number; + idempotencyKey?: string; + } + | { + kind: "failure"; + error: string; + retrySafety: "same-idempotency-key"; + idempotencyKey?: string; + }, + retryPolicy: CoordinatorRetryPolicy = POLICY, + ) => { + const { durability } = harness(); + const clock = new TestClock(); + let remoteAttempts = 0; + const attemptKeys: string[] = []; + const result = await runWorkflowCoordinator(plan, { + ...options(durability, clock), + retryPolicy, + executeNode: async ({ lane, idempotencyKey }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + remoteAttempts += 1; + attemptKeys.push(idempotencyKey ?? ""); + if (remoteAttempts !== 1) return { kind: "success", output: "image" }; + return firstRemoteResult.idempotencyKey === "USE_CONTEXT_KEY" + ? { ...firstRemoteResult, idempotencyKey } + : firstRemoteResult; + }, + }); + return { result, remoteAttempts, clock, attemptKeys }; + }; + + const disabled = await runCase( + { + kind: "rate-limited", + error: "not submitted", + retrySafety: "confirmed-not-submitted", + }, + { ...POLICY, retryRemoteNotSubmitted: false }, + ); + assert.equal(disabled.remoteAttempts, 1); + assert.equal(disabled.result.status, "failed"); + + const missingKey = await runCase({ + kind: "failure", + error: "accepted under a key", + retrySafety: "same-idempotency-key", + }); + assert.equal(missingKey.remoteAttempts, 1); + + const sameKey = await runCase({ + kind: "rate-limited", + error: "retry same request", + retrySafety: "same-idempotency-key", + idempotencyKey: "USE_CONTEXT_KEY", + retryAfterMs: 250, + }); + assert.equal(sameKey.remoteAttempts, 2); + assert.equal(sameKey.result.status, "succeeded"); + assert.deepEqual(sameKey.clock.sleeps, [250]); + assert.equal(sameKey.attemptKeys.length, 2); + assert.equal(sameKey.attemptKeys[0], sameKey.attemptKeys[1]); + + const freshKey = await runCase({ + kind: "rate-limited", + error: "definitely not submitted", + retrySafety: "confirmed-not-submitted", + }); + assert.equal(freshKey.remoteAttempts, 2); + assert.notEqual(freshKey.attemptKeys[0], freshKey.attemptKeys[1]); + + const excessiveRetryAfter = await runCase({ + kind: "rate-limited", + error: "retry much later", + retrySafety: "confirmed-not-submitted", + retryAfterMs: 1_001, + }); + assert.equal(excessiveRetryAfter.remoteAttempts, 1); + assert.deepEqual(excessiveRetryAfter.clock.sleeps, []); + + const { durability } = harness(); + let contradictoryAttempts = 0; + const contradictory = await runWorkflowCoordinator(plan, { + ...options(durability), + executeNode: async ({ lane, recordRemoteJobId }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + contradictoryAttempts += 1; + await recordRemoteJobId("accepted-job-1"); + return { + kind: "rate-limited", + error: "incorrect classification", + retrySafety: "confirmed-not-submitted", + }; + }, + }); + assert.equal(contradictoryAttempts, 1); + assert.equal(contradictory.status, "needs_attention"); + assert.equal(contradictory.nodeStatuses["generate-a"], "ambiguous"); + + for (const acceptedResult of [ + { + kind: "rate-limited" as const, + error: "polling was rate limited", + retrySafety: "same-idempotency-key" as const, + }, + { + kind: "failure" as const, + error: "accepted job lookup failed", + retrySafety: "same-idempotency-key" as const, + }, + ]) { + const acceptedHarness = harness(); + let acceptedAttempts = 0; + const accepted = await runWorkflowCoordinator(plan, { + ...options(acceptedHarness.durability), + executeNode: async ({ lane, idempotencyKey, recordRemoteJobId }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + acceptedAttempts += 1; + await recordRemoteJobId("accepted-same-key-job"); + return { ...acceptedResult, idempotencyKey }; + }, + }); + assert.equal(acceptedAttempts, 1); + assert.equal(accepted.status, "needs_attention"); + assert.equal(accepted.nodeStatuses["generate-a"], "ambiguous"); + assert.deepEqual( + accepted.events + .filter((event) => event.kind === "node" && event.nodeId === "generate-a") + .map((event) => (event.kind === "node" ? event.status : undefined)), + ["queued", "running", "ambiguous"], + ); + assert.equal( + acceptedHarness.log.some((entry) => entry.includes("retry_wait")), + false, + ); + } +}); + +test("durable cancellation precedes abort/provider cancel and suppresses late completion", async () => { + const graph = documentWith( + [ + prompt("prompt-a"), + generate("generate-a"), + { id: "output-a", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "generate-a", "images", "output-a", "images"), + ], + ); + const controller = new AbortController(); + const remoteStarted = deferred(); + const late = deferred(); + const { durability, log } = harness(); + const run = runWorkflowCoordinator(createWorkflowCoordinatorPlan(graph, { kind: "all" }), { + ...options(durability), + signal: controller.signal, + cancelRemoteJob: async (record) => { + log.push(`cancel-remote:${record.nodeId}`); + }, + executeNode: async ({ lane, signal, recordRemoteJobId }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + await recordRemoteJobId("remote-job-1"); + signal.addEventListener("abort", () => log.push("executor-abort"), { + once: true, + }); + remoteStarted.resolve(); + return late.promise; + }, + }); + await remoteStarted.promise; + controller.abort(new CoordinatorCancellationRequest("renderer-disconnected")); + const result = await Promise.race([ + run, + new Promise((_, reject) => + setTimeout(() => reject(new Error("non-cooperative cancel did not terminalize")), 100), + ), + ]); + const intentIndex = log.indexOf("cancel-intent"); + assert.ok(intentIndex >= 0); + assert.ok(intentIndex < log.indexOf("executor-abort")); + assert.ok(intentIndex < log.indexOf("cancel-remote:generate-a")); + assert.equal(result.nodeStatuses["generate-a"], "cancelled"); + assert.equal(result.nodeStatuses["output-a"], "blocked"); + assert.equal(result.outputs.has("generate-a"), false); +}); + +test("a prepared remote submission remains ambiguous when cancellation becomes durable", async () => { + const graph = documentWith( + [ + prompt("prompt-a"), + generate("generate-a"), + { id: "output-a", type: "output", position: { x: 200, y: 0 }, data: {} }, + ], + [ + edge("edge-a", "prompt-a", "text", "generate-a", "prompt"), + edge("edge-b", "generate-a", "images", "output-a", "images"), + ], + ); + const controller = new AbortController(); + const remoteStarted = deferred(); + const { durability, log } = harness(); + const run = runWorkflowCoordinator(createWorkflowCoordinatorPlan(graph, { kind: "all" }), { + ...options(durability), + signal: controller.signal, + executeNode: async ({ lane, signal }) => { + if (lane === "local") return { kind: "success", output: "prompt" }; + remoteStarted.resolve(); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + return { kind: "cancelled" }; + }, + }); + await remoteStarted.promise; + controller.abort(new CoordinatorCancellationRequest("app-quit")); + const result = await run; + + assert.equal(result.status, "needs_attention"); + assert.equal(result.nodeStatuses["generate-a"], "ambiguous"); + assert.equal(result.nodeStatuses["output-a"], "blocked"); + assert.ok( + log.findIndex((entry) => entry.startsWith("prepared:generate-a:1:")) < + log.indexOf("cancel-intent"), + ); + assert.equal( + result.events.some( + (event) => + event.kind === "node" && event.nodeId === "generate-a" && event.status === "cancelled", + ), + false, + ); +}); + +test("durable cancellation during output publication keeps the output on a cancelled run", async () => { + const controller = new AbortController(); + const { durability, log, events } = harness(); + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(documentWith([prompt("prompt-a")]), { kind: "all" }), + { + ...options({ + ...durability, + async persistCancelIntent() { + log.push("cancel-intent"); + }, + async publishOutput(record) { + log.push("publish-start"); + controller.abort(new CoordinatorCancellationRequest("app-quit")); + await new Promise((resolve) => setImmediate(resolve)); + log.push("publish-end"); + return record.output; + }, + }), + signal: controller.signal, + executeNode: async () => ({ kind: "success", output: "durable prompt" }), + }, + ); + + assert.deepEqual(log.slice(log.indexOf("publish-start"), log.indexOf("publish-end") + 1), [ + "publish-start", + "cancel-intent", + "publish-end", + ]); + assert.equal(result.status, "cancelled"); + assert.equal(result.nodeStatuses["prompt-a"], "succeeded"); + assert.equal(result.outputs.get("prompt-a"), "durable prompt"); + const terminalEvent = events[events.length - 1]; + assert.equal(terminalEvent?.kind, "run"); + assert.equal(terminalEvent?.kind === "run" ? terminalEvent.status : undefined, "cancelled"); +}); + +test("typed abort reasons durably distinguish user, renderer disconnect, and app quit", async () => { + for (const [expected, reason] of [ + ["user", new Error("untrusted renderer text")], + ["renderer-disconnected", new CoordinatorCancellationRequest("renderer-disconnected")], + ["app-quit", new CoordinatorCancellationRequest("app-quit")], + ] as const) { + const controller = new AbortController(); + controller.abort(reason); + const persisted: string[] = []; + const { durability } = harness(); + const result = await runWorkflowCoordinator( + createWorkflowCoordinatorPlan(documentWith([prompt("prompt-a")]), { + kind: "all", + }), + { + ...options({ + ...durability, + async persistCancelIntent(intent) { + persisted.push(intent.reason); + }, + }), + signal: controller.signal, + executeNode: async () => { + throw new Error("A pre-cancelled run must not execute nodes."); + }, + }, + ); + assert.deepEqual(persisted, [expected]); + assert.equal(result.status, "cancelled"); + } +}); + +test("event reducer rejects cross-run, duplicate, out-of-order, invalid, and late events", () => { + const identity = { + workflowId: "workflow-1", + workflowRevision: 7, + runId: "run-1", + }; + let cursor = createCoordinatorEventCursor(identity, ["node-a"]); + const event = (sequence: number, value: CoordinatorEventPayload): CoordinatorEvent => + ({ ...identity, sequence, atMs: sequence, ...value }) as CoordinatorEvent; + const rejectionReason = (reduction: CoordinatorEventReduction) => { + assert.equal(reduction.accepted, false); + return reduction.accepted ? undefined : reduction.reason; + }; + const started = reduceCoordinatorEvent(cursor, event(1, { kind: "run", status: "running" })); + assert.equal(started.accepted, true); + if (!started.accepted) return; + cursor = started.cursor; + assert.equal( + rejectionReason(reduceCoordinatorEvent(cursor, event(2, { kind: "run", status: "failed" }))), + "invalid-transition", + ); + assert.equal( + rejectionReason(reduceCoordinatorEvent(cursor, event(1, { kind: "run", status: "running" }))), + "duplicate-or-stale", + ); + assert.equal( + rejectionReason( + reduceCoordinatorEvent( + cursor, + event(3, { + kind: "node", + nodeId: "node-a", + status: "queued", + attempt: 0, + }), + ), + ), + "out-of-order", + ); + assert.equal( + rejectionReason( + reduceCoordinatorEvent(cursor, { + ...event(2, { + kind: "node", + nodeId: "node-a", + status: "queued", + attempt: 0, + }), + runId: "other", + }), + ), + "wrong-run", + ); + for (const next of [ + event(2, { kind: "node", nodeId: "node-a", status: "queued", attempt: 0 }), + event(3, { kind: "node", nodeId: "node-a", status: "running", attempt: 1 }), + event(4, { + kind: "node", + nodeId: "node-a", + status: "succeeded", + attempt: 1, + }), + event(5, { kind: "run", status: "succeeded" }), + ]) { + const reduced = reduceCoordinatorEvent(cursor, next); + assert.equal(reduced.accepted, true); + if (reduced.accepted) cursor = reduced.cursor; + } + assert.equal( + rejectionReason( + reduceCoordinatorEvent( + cursor, + event(6, { + kind: "node", + nodeId: "node-a", + status: "failed", + attempt: 1, + }), + ), + ), + "late-after-terminal", + ); +}); + +test("renderer lifecycle and every restart phase have explicit no-auto-submit decisions", () => { + assert.equal( + rendererDisconnectDecision("document-1", { + kind: "route-change", + documentId: "document-1", + }), + "continue-and-resubscribe", + ); + assert.equal( + rendererDisconnectDecision("document-1", { + kind: "document-destroyed", + documentId: "document-1", + }), + "request-best-effort-cancel", + ); + assert.equal( + rendererDisconnectDecision("document-1", { + kind: "document-destroyed", + documentId: "document-2", + }), + "ignore", + ); + const decisions = [ + reconcileRestartNode({ phase: "never-started", lane: "local" }), + reconcileRestartNode({ phase: "local-running", lane: "local" }), + reconcileRestartNode({ phase: "remote-submitting", lane: "remote" }), + reconcileRestartNode({ + phase: "remote-submitting", + lane: "remote", + remoteJobId: "job-1", + }), + reconcileRestartNode({ phase: "remote-submitted", lane: "remote" }), + reconcileRestartNode({ + phase: "remote-submitted", + lane: "remote", + remoteJobId: "job-1", + }), + reconcileRestartNode({ + phase: "output-publishing", + lane: "remote", + durableOutputAvailable: true, + }), + reconcileRestartNode({ + phase: "cancel-requested", + lane: "remote", + remoteJobId: "job-1", + }), + reconcileRestartNode({ phase: "terminal", lane: "remote" }), + ]; + assert.ok(decisions.every((decision) => decision.autoSubmit === false)); + assert.deepEqual( + decisions.map((decision) => decision.category), + [ + "await-explicit-resume", + "mark-interrupted", + "ambiguous-submit", + "reconcile-remote-job", + "ambiguous-submit", + "reconcile-remote-job", + "resume-output-publication", + "reconcile-cancel", + "terminal", + ], + ); +}); + +test("altered and invalid plans, retry policies, and concurrency fail before execution", async () => { + const plan = createWorkflowCoordinatorPlan(documentWith([prompt("prompt-a")]), { kind: "all" }); + const forged = { + ...plan, + orderedNodeIds: ["missing"], + } as WorkflowExecutionPlan; + const { durability } = harness(); + await assert.rejects( + runWorkflowCoordinator(forged, { + ...options(durability), + executeNode: async () => ({ kind: "success", output: undefined }), + }), + /altered/u, + ); + await assert.rejects( + runWorkflowCoordinator(plan, { + ...options(durability), + remoteConcurrency: 5, + executeNode: async () => ({ kind: "success", output: undefined }), + }), + /between 1 and 4/u, + ); + await assert.rejects( + runWorkflowCoordinator(plan, { + ...options(durability), + retryPolicy: { ...POLICY, maxRetriesPerNode: 6 }, + executeNode: async () => ({ kind: "success", output: undefined }), + }), + /Retry count/u, + ); +}); diff --git a/main/services/create-images/scheduler-core.ts b/main/services/create-images/scheduler-core.ts new file mode 100644 index 00000000..55968fd6 --- /dev/null +++ b/main/services/create-images/scheduler-core.ts @@ -0,0 +1,1176 @@ +import { createHash } from "node:crypto"; +import { + planWorkflowExecution, + type WorkflowExecutionPlan, + type WorkflowRunScope, +} from "../../../renderer/shared/create-images/execution.js"; +import { CREATE_IMAGES_NODE_DEFINITIONS } from "../../../renderer/shared/create-images/ports.js"; +import type { + WorkflowDocumentV1, + WorkflowNodeV1, +} from "../../../renderer/shared/create-images/schema.js"; + +const OPAQUE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u; +const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{15,191}$/u; +const PROVIDER_JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u; +const MAX_RETRIES = 5; +const MAX_RETRY_DELAY_MS = 5 * 60_000; +const MAX_TOTAL_RETRY_DELAY_MS = 10 * 60_000; + +export type CoordinatorExecutionLane = "local" | "remote"; + +export type CoordinatorNodeStatus = + | "queued" + | "running" + | "retry_wait" + | "succeeded" + | "failed" + | "cancelled" + | "blocked" + | "ambiguous"; + +export type CoordinatorRunStatus = + | "pending" + | "running" + | "succeeded" + | "failed" + | "cancelled" + | "needs_attention"; + +export type CoordinatorErrorCode = + | "cancelled" + | "dependency-unschedulable" + | "execution-failed" + | "interrupted" + | "output-invalid" + | "output-publication-failed" + | "provider-refused" + | "provider-unavailable" + | "rate-limited" + | "submission-ambiguous" + | "upstream-blocked"; + +export type CoordinatorRetrySafety = + | "never" + | "local-safe" + | "confirmed-not-submitted" + | "same-idempotency-key"; + +export type CoordinatorAttemptResult = + | { kind: "success"; output: unknown } + | { + kind: "failure"; + error: string; + retrySafety: CoordinatorRetrySafety; + idempotencyKey?: string; + errorCode?: CoordinatorErrorCode; + } + | { + kind: "rate-limited"; + error: string; + retrySafety: CoordinatorRetrySafety; + retryAfterMs?: number; + idempotencyKey?: string; + errorCode?: CoordinatorErrorCode; + } + | { kind: "cancelled"; error?: string } + | { kind: "ambiguous-submit"; error: string }; + +export interface CoordinatorRunIdentity { + workflowId: string; + workflowRevision: number; + runId: string; +} + +interface CoordinatorEventBase extends CoordinatorRunIdentity { + sequence: number; + atMs: number; +} + +export type CoordinatorEvent = + | (CoordinatorEventBase & { + kind: "run"; + status: Exclude; + }) + | (CoordinatorEventBase & { + kind: "node"; + nodeId: string; + status: CoordinatorNodeStatus; + attempt: number; + errorCode?: CoordinatorErrorCode; + retryDelayMs?: number; + retrySafety?: CoordinatorRetrySafety; + }) + | (CoordinatorEventBase & { + kind: "remote-job"; + nodeId: string; + attempt: number; + remoteJobId: string; + }); + +export type CoordinatorEventPayload = + | { + kind: "run"; + status: Exclude; + } + | { + kind: "node"; + nodeId: string; + status: CoordinatorNodeStatus; + attempt: number; + errorCode?: CoordinatorErrorCode; + retryDelayMs?: number; + retrySafety?: CoordinatorRetrySafety; + } + | { + kind: "remote-job"; + nodeId: string; + attempt: number; + remoteJobId: string; + }; + +export interface CoordinatorClock { + now(): number; + sleep(delayMs: number, signal: AbortSignal): Promise; +} + +export interface CoordinatorJitter { + /** A deterministic sample in the inclusive range 0 through 1. */ + sample(): number; +} + +export interface CoordinatorRetryPolicy { + maxRetriesPerNode: number; + baseDelayMs: number; + maxDelayMs: number; + maxTotalDelayMs: number; + jitterRatio: number; + retryRemoteNotSubmitted: boolean; + retryRemoteIdempotent: boolean; +} + +export interface CoordinatorPlanRecord extends CoordinatorRunIdentity { + plan: WorkflowExecutionPlan; + localConcurrency: number; + remoteConcurrency: number; +} + +export interface CoordinatorCancelIntent extends CoordinatorRunIdentity { + reason: "user" | "renderer-disconnected" | "app-quit"; + remoteJobs: Readonly>; +} + +export class CoordinatorCancellationRequest extends Error { + readonly cancellationReason: CoordinatorCancelIntent["reason"]; + + constructor(cancellationReason: CoordinatorCancelIntent["reason"]) { + if (!["user", "renderer-disconnected", "app-quit"].includes(cancellationReason)) { + throw new Error("The Create Images cancellation reason is invalid."); + } + super(`Create Images cancellation requested: ${cancellationReason}.`); + this.name = "CoordinatorCancellationRequest"; + this.cancellationReason = cancellationReason; + } +} + +export interface CoordinatorRemoteJobRecord extends CoordinatorRunIdentity { + nodeId: string; + attempt: number; + /** Stable for same-idempotency-key retries; fresh after confirmed non-submission. */ + idempotencyKey: string; + remoteJobId: string; +} + +export interface CoordinatorSubmissionPreparedRecord extends CoordinatorRunIdentity { + nodeId: string; + attempt: number; + idempotencyKey: string; +} + +export interface CoordinatorDurability { + persistPlan(record: CoordinatorPlanRecord): Promise; + appendEvent(event: CoordinatorEvent): Promise; + persistCancelIntent(intent: CoordinatorCancelIntent): Promise; + /** Must journal the idempotency key before executeNode may submit remote work. */ + persistSubmissionPrepared(record: CoordinatorSubmissionPreparedRecord): Promise; + persistRemoteJob(record: CoordinatorRemoteJobRecord): Promise; + publishOutput( + record: CoordinatorRunIdentity & { nodeId: string; output: unknown }, + ): Promise; +} + +export interface CoordinatorNodeExecutionContext extends CoordinatorRunIdentity { + node: WorkflowNodeV1; + lane: CoordinatorExecutionLane; + attempt: number; + /** Stable for same-idempotency-key retries; fresh after confirmed non-submission. */ + idempotencyKey?: string; + signal: AbortSignal; + dependencyOutputs: ReadonlyMap; + recordRemoteJobId(remoteJobId: string): Promise; +} + +export interface RunWorkflowCoordinatorOptions { + runId: string; + localConcurrency: number; + remoteConcurrency: number; + clock: CoordinatorClock; + jitter: CoordinatorJitter; + retryPolicy: CoordinatorRetryPolicy; + durability: CoordinatorDurability; + signal?: AbortSignal; + executeNode(context: CoordinatorNodeExecutionContext): Promise; + cancelRemoteJob?(record: CoordinatorRemoteJobRecord): Promise; + onEvent?(event: CoordinatorEvent): void; +} + +export interface WorkflowCoordinatorResult extends CoordinatorRunIdentity { + status: Exclude; + nodeStatuses: Readonly>; + outputs: ReadonlyMap; + events: readonly CoordinatorEvent[]; + retryDelayMs: number; +} + +type SettledNode = { + nodeId: string; + lane: CoordinatorExecutionLane; + result: CoordinatorAttemptResult; +}; + +function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const child of Object.values(value)) deepFreeze(child); + return Object.freeze(value); +} + +function assertOpaqueId(value: string, label: string): void { + if (!OPAQUE_ID_PATTERN.test(value)) throw new Error(`${label} must be an opaque identifier.`); +} + +function assertProviderJobId(value: string): void { + if (!PROVIDER_JOB_ID_PATTERN.test(value)) { + throw new Error("Remote job ID must be a bounded provider identifier."); + } +} + +function assertConcurrency(value: number, label: string): void { + if (!Number.isInteger(value) || value < 1 || value > 4) { + throw new Error(`${label} concurrency must be between 1 and 4.`); + } +} + +function validatedPolicy(policy: CoordinatorRetryPolicy): CoordinatorRetryPolicy { + if ( + !Number.isInteger(policy.maxRetriesPerNode) || + policy.maxRetriesPerNode < 0 || + policy.maxRetriesPerNode > MAX_RETRIES + ) { + throw new Error(`Retry count must be between 0 and ${MAX_RETRIES}.`); + } + for (const [label, value, maximum] of [ + ["base delay", policy.baseDelayMs, MAX_RETRY_DELAY_MS], + ["maximum delay", policy.maxDelayMs, MAX_RETRY_DELAY_MS], + ["total delay", policy.maxTotalDelayMs, MAX_TOTAL_RETRY_DELAY_MS], + ] as const) { + if (!Number.isInteger(value) || value < 0 || value > maximum) { + throw new Error(`Retry ${label} must be an integer between 0 and ${maximum}.`); + } + } + if (policy.maxDelayMs < policy.baseDelayMs) { + throw new Error("Retry maximum delay cannot be smaller than the base delay."); + } + if (!Number.isFinite(policy.jitterRatio) || policy.jitterRatio < 0 || policy.jitterRatio > 1) { + throw new Error("Retry jitter ratio must be between 0 and 1."); + } + return deepFreeze({ ...policy }); +} + +function immutableVerifiedPlan(plan: WorkflowExecutionPlan): WorkflowExecutionPlan { + const rebuilt = planWorkflowExecution(plan.snapshot, plan.scope); + if ( + rebuilt.workflowId !== plan.workflowId || + rebuilt.workflowRevision !== plan.workflowRevision || + JSON.stringify(rebuilt.orderedNodeIds) !== JSON.stringify(plan.orderedNodeIds) || + JSON.stringify(rebuilt.dependencies) !== JSON.stringify(plan.dependencies) + ) { + throw new Error("The Create Images execution plan is stale or has been altered."); + } + return rebuilt; +} + +export function createWorkflowCoordinatorPlan( + document: WorkflowDocumentV1, + scope: WorkflowRunScope, +): WorkflowExecutionPlan { + return planWorkflowExecution(document, scope); +} + +function laneFor(node: WorkflowNodeV1): CoordinatorExecutionLane { + return CREATE_IMAGES_NODE_DEFINITIONS[node.type].execution; +} + +function terminalNode(status: CoordinatorNodeStatus): boolean { + return ["succeeded", "failed", "cancelled", "blocked", "ambiguous"].includes(status); +} + +function dependencyBlocks(status: CoordinatorNodeStatus | undefined): boolean { + return ( + status === "failed" || status === "cancelled" || status === "blocked" || status === "ambiguous" + ); +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim() + ? error.message + : "The node executor failed without a usable error."; +} + +function retryIsAllowed( + lane: CoordinatorExecutionLane, + result: Extract, + policy: CoordinatorRetryPolicy, + expectedIdempotencyKey: string | undefined, +): boolean { + if (lane === "local") return result.retrySafety === "local-safe"; + if (result.retrySafety === "confirmed-not-submitted") return policy.retryRemoteNotSubmitted; + return ( + result.retrySafety === "same-idempotency-key" && + policy.retryRemoteIdempotent && + typeof result.idempotencyKey === "string" && + IDEMPOTENCY_KEY_PATTERN.test(result.idempotencyKey) && + result.idempotencyKey === expectedIdempotencyKey + ); +} + +function idempotencyKeyFor( + identity: CoordinatorRunIdentity, + nodeId: string, + generation: number, +): string { + const digest = createHash("sha256") + .update(identity.workflowId) + .update("\0") + .update(String(identity.workflowRevision)) + .update("\0") + .update(identity.runId) + .update("\0") + .update(nodeId) + .update("\0") + .update(String(generation)) + .digest("hex"); + return `aiden-ci-${digest}`; +} + +function retryDelay( + retryIndex: number, + result: Extract, + policy: CoordinatorRetryPolicy, + jitter: CoordinatorJitter, +): number { + const exponential = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** retryIndex); + const retryAfter = result.kind === "rate-limited" ? (result.retryAfterMs ?? 0) : 0; + if (!Number.isInteger(retryAfter) || retryAfter < 0 || retryAfter > policy.maxDelayMs) return -1; + const floor = Math.max(exponential, retryAfter); + const sample = jitter.sample(); + if (!Number.isFinite(sample) || sample < 0 || sample > 1) { + throw new Error("The injected retry jitter sample must be between 0 and 1."); + } + const factor = 1 + policy.jitterRatio * (sample * 2 - 1); + return Math.min(policy.maxDelayMs, Math.max(retryAfter, Math.max(0, Math.round(floor * factor)))); +} + +function finalRunStatus( + statuses: ReadonlyMap, +): WorkflowCoordinatorResult["status"] { + const values = [...statuses.values()]; + if (values.some((status) => status === "ambiguous")) return "needs_attention"; + if (values.some((status) => status === "failed")) return "failed"; + if (values.some((status) => status === "cancelled")) return "cancelled"; + return "succeeded"; +} + +export async function runWorkflowCoordinator( + inputPlan: WorkflowExecutionPlan, + options: RunWorkflowCoordinatorOptions, +): Promise { + assertOpaqueId(options.runId, "Run ID"); + assertConcurrency(options.localConcurrency, "Local"); + assertConcurrency(options.remoteConcurrency, "Remote"); + const policy = validatedPolicy(options.retryPolicy); + const plan = immutableVerifiedPlan(inputPlan); + const identity = deepFreeze({ + workflowId: plan.workflowId, + workflowRevision: plan.workflowRevision, + runId: options.runId, + }); + const nodes = new Map(plan.snapshot.nodes.map((node) => [node.id, node])); + const statuses = new Map(); + const outputs = new Map(); + const nodeAttempts = new Map(); + const events: CoordinatorEvent[] = []; + const active = new Map>(); + const controllers = new Map(); + const remoteJobs = new Map(); + const uncertainRemoteSubmissions = new Set(); + const remoteCancelIssued = new Set(); + let sequence = 0; + let lastAtMs = 0; + let totalRetryDelayMs = 0; + let acceptSettlements = true; + let admissionStopped = false; + let cancelRequested = false; + let planPersisted = false; + let cancelPersisted = false; + let cancelFailure: unknown; + let cancelWakeResolve!: () => void; + const cancelWake = new Promise((resolve) => { + cancelWakeResolve = resolve; + }); + let cancelTask: Promise | undefined; + let eventQueue: Promise = Promise.resolve(); + + const emit = async (event: CoordinatorEventPayload): Promise => { + const now = options.clock.now(); + if (!Number.isFinite(now) || now < 0) + throw new Error("The coordinator clock returned an invalid time."); + lastAtMs = Math.max(lastAtMs, now); + sequence += 1; + const durableEvent = deepFreeze({ + ...identity, + ...event, + sequence, + atMs: lastAtMs, + } as CoordinatorEvent); + const append = eventQueue.then(() => options.durability.appendEvent(durableEvent)); + eventQueue = append.catch(() => undefined); + await append; + events.push(durableEvent); + options.onEvent?.(durableEvent); + }; + + const cancelRemote = async (record: CoordinatorRemoteJobRecord): Promise => { + if (!options.cancelRemoteJob || remoteCancelIssued.has(record.nodeId)) return; + remoteCancelIssued.add(record.nodeId); + await options.cancelRemoteJob(record); + }; + + const cancellationReason = (): CoordinatorCancelIntent["reason"] => + options.signal?.reason instanceof CoordinatorCancellationRequest + ? options.signal.reason.cancellationReason + : "user"; + + const beginCancel = (): void => { + if (cancelTask) return; + cancelRequested = true; + admissionStopped = true; + if (!planPersisted) return; + cancelTask = (async () => { + try { + const remoteJobSnapshot = Object.fromEntries( + [...remoteJobs.entries()].map(([nodeId, record]) => [nodeId, record.remoteJobId]), + ); + await options.durability.persistCancelIntent( + deepFreeze({ + ...identity, + reason: cancellationReason(), + remoteJobs: remoteJobSnapshot, + }), + ); + cancelPersisted = true; + acceptSettlements = false; + for (const controller of controllers.values()) controller.abort(options.signal?.reason); + await Promise.allSettled([...remoteJobs.values()].map(cancelRemote)); + } catch (error) { + cancelFailure = error; + } finally { + cancelWakeResolve(); + } + })(); + }; + + const abortListener = (): void => beginCancel(); + if (options.signal?.aborted) { + cancelRequested = true; + admissionStopped = true; + } else options.signal?.addEventListener("abort", abortListener, { once: true }); + + try { + await options.durability.persistPlan( + deepFreeze({ + ...identity, + plan, + localConcurrency: options.localConcurrency, + remoteConcurrency: options.remoteConcurrency, + }), + ); + planPersisted = true; + if (cancelRequested) beginCancel(); + await emit({ kind: "run", status: "running" }); + for (const nodeId of plan.orderedNodeIds) { + statuses.set(nodeId, "queued"); + await emit({ kind: "node", nodeId, status: "queued", attempt: 0 }); + } + + const execute = async ( + nodeId: string, + lane: CoordinatorExecutionLane, + ): Promise => { + const node = nodes.get(nodeId); + if (!node) { + return { + nodeId, + lane, + result: { + kind: "failure", + error: "The planned node no longer exists.", + retrySafety: "never", + }, + }; + } + const controller = new AbortController(); + controllers.set(nodeId, controller); + let attempt = 1; + let idempotencyGeneration = 1; + let idempotencyKey = + lane === "remote" ? idempotencyKeyFor(identity, nodeId, idempotencyGeneration) : undefined; + let nodeRetryDelayMs = 0; + const dependencies = plan.dependencies[nodeId] ?? []; + const dependencyOutputs = new Map(); + for (const dependency of dependencies) { + if (outputs.has(dependency)) dependencyOutputs.set(dependency, outputs.get(dependency)); + } + + while (true) { + if (controller.signal.aborted) return { nodeId, lane, result: { kind: "cancelled" } }; + nodeAttempts.set(nodeId, attempt); + await emit({ kind: "node", nodeId, status: "running", attempt }); + if (lane === "remote" && idempotencyKey) { + await options.durability.persistSubmissionPrepared( + deepFreeze({ ...identity, nodeId, attempt, idempotencyKey }), + ); + uncertainRemoteSubmissions.add(nodeId); + } + let result: CoordinatorAttemptResult; + try { + result = await options.executeNode({ + ...identity, + node, + lane, + attempt, + ...(idempotencyKey ? { idempotencyKey } : {}), + signal: controller.signal, + dependencyOutputs, + recordRemoteJobId: async (remoteJobId) => { + if (lane !== "remote") throw new Error("Only remote nodes can record provider jobs."); + if (!idempotencyKey) + throw new Error("Remote jobs require a prepared idempotency key."); + assertProviderJobId(remoteJobId); + const existing = remoteJobs.get(nodeId); + if (existing && existing.remoteJobId !== remoteJobId) { + throw new Error("A node attempt cannot replace its durable remote job ID."); + } + if (existing) return; + const record = deepFreeze({ + ...identity, + nodeId, + attempt, + idempotencyKey, + remoteJobId, + }); + await options.durability.persistRemoteJob(record); + remoteJobs.set(nodeId, record); + uncertainRemoteSubmissions.delete(nodeId); + await emit({ kind: "remote-job", nodeId, attempt, remoteJobId }); + if (cancelPersisted) await cancelRemote(record); + }, + }); + } catch (error) { + result = + lane === "remote" + ? { + kind: "ambiguous-submit", + error: errorMessage(error), + } + : { + kind: "failure", + error: errorMessage(error), + retrySafety: "never", + }; + } + if (controller.signal.aborted) return { nodeId, lane, result: { kind: "cancelled" } }; + if (result.kind !== "failure" && result.kind !== "rate-limited") { + return { nodeId, lane, result }; + } + if ( + remoteJobs.has(nodeId) && + (result.retrySafety === "confirmed-not-submitted" || + result.retrySafety === "same-idempotency-key") + ) { + return { + nodeId, + lane, + result: { + kind: "ambiguous-submit", + error: + "A durable remote job must be reconciled and cannot enter the submission retry path.", + }, + }; + } + const retriesUsed = attempt - 1; + if ( + retriesUsed >= policy.maxRetriesPerNode || + !retryIsAllowed(lane, result, policy, idempotencyKey) + ) { + return { nodeId, lane, result }; + } + const delayMs = retryDelay(retriesUsed, result, policy, options.jitter); + if ( + delayMs < 0 || + totalRetryDelayMs + delayMs > policy.maxTotalDelayMs || + nodeRetryDelayMs + delayMs > policy.maxTotalDelayMs + ) { + return { nodeId, lane, result }; + } + totalRetryDelayMs += delayMs; + nodeRetryDelayMs += delayMs; + // A retry-scheduled event durably seals the preceding prepared + // submission with an explicit safe-retry classification. The next + // attempt becomes uncertain only after its own prepared record lands. + uncertainRemoteSubmissions.delete(nodeId); + await emit({ + kind: "node", + nodeId, + status: "retry_wait", + attempt, + errorCode: + result.errorCode ?? + (result.kind === "rate-limited" ? "rate-limited" : "execution-failed"), + retryDelayMs: delayMs, + retrySafety: result.retrySafety, + }); + try { + await options.clock.sleep(delayMs, controller.signal); + } catch (error) { + if (controller.signal.aborted) return { nodeId, lane, result: { kind: "cancelled" } }; + return { + nodeId, + lane, + result: { + kind: "failure", + error: errorMessage(error), + retrySafety: "never", + }, + }; + } + attempt += 1; + if (lane === "remote" && result.retrySafety === "confirmed-not-submitted") { + idempotencyGeneration += 1; + idempotencyKey = idempotencyKeyFor(identity, nodeId, idempotencyGeneration); + } + } + }; + + while ([...statuses.values()].some((status) => !terminalNode(status))) { + if (cancelFailure) throw cancelFailure; + if (cancelPersisted) { + for (const nodeId of plan.orderedNodeIds) { + const status = statuses.get(nodeId); + if (status === "running" || status === "retry_wait") { + const submissionIsUncertain = uncertainRemoteSubmissions.has(nodeId); + statuses.set(nodeId, submissionIsUncertain ? "ambiguous" : "cancelled"); + await emit({ + kind: "node", + nodeId, + status: submissionIsUncertain ? "ambiguous" : "cancelled", + attempt: nodeAttempts.get(nodeId) ?? 0, + errorCode: submissionIsUncertain ? "submission-ambiguous" : "cancelled", + }); + } + } + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + const blocked = dependencies.some((dependency) => + dependencyBlocks(statuses.get(dependency)), + ); + statuses.set(nodeId, blocked ? "blocked" : "cancelled"); + await emit({ + kind: "node", + nodeId, + status: blocked ? "blocked" : "cancelled", + attempt: 0, + errorCode: blocked ? "upstream-blocked" : "cancelled", + }); + } + active.clear(); + break; + } + + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + if (dependencies.some((dependency) => dependencyBlocks(statuses.get(dependency)))) { + statuses.set(nodeId, "blocked"); + await emit({ + kind: "node", + nodeId, + status: "blocked", + attempt: 0, + errorCode: "upstream-blocked", + }); + } + } + + if (!admissionStopped) { + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) !== "queued") continue; + const dependencies = plan.dependencies[nodeId] ?? []; + if (!dependencies.every((dependency) => statuses.get(dependency) === "succeeded")) + continue; + const node = nodes.get(nodeId); + if (!node) continue; + const lane = laneFor(node); + const activeInLane = [...active.keys()].filter((activeId) => { + const activeNode = nodes.get(activeId); + return activeNode ? laneFor(activeNode) === lane : false; + }).length; + const limit = lane === "local" ? options.localConcurrency : options.remoteConcurrency; + if (activeInLane >= limit) continue; + statuses.set(nodeId, "running"); + const task = execute(nodeId, lane).catch( + (error): SettledNode => ({ + nodeId, + lane, + result: { + kind: "failure", + error: errorMessage(error), + retrySafety: "never", + }, + }), + ); + active.set(nodeId, task); + } + } + + if (active.size === 0) { + if (admissionStopped && cancelTask) { + await cancelWake; + continue; + } + for (const nodeId of plan.orderedNodeIds) { + if (statuses.get(nodeId) === "queued") { + statuses.set(nodeId, "blocked"); + await emit({ + kind: "node", + nodeId, + status: "blocked", + attempt: 0, + errorCode: "dependency-unschedulable", + }); + } + } + continue; + } + + const settled = await Promise.race([...active.values(), cancelWake.then(() => undefined)]); + if (!settled) continue; + active.delete(settled.nodeId); + controllers.delete(settled.nodeId); + if (!acceptSettlements) continue; + const result = settled.result; + if (result.kind === "success") { + try { + const durableOutput = await options.durability.publishOutput({ + ...identity, + nodeId: settled.nodeId, + output: result.output, + }); + outputs.set(settled.nodeId, durableOutput); + statuses.set(settled.nodeId, "succeeded"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "succeeded", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + }); + } catch { + statuses.set(settled.nodeId, "failed"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "failed", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + errorCode: "output-publication-failed", + }); + } + } else if (result.kind === "ambiguous-submit") { + statuses.set(settled.nodeId, "ambiguous"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "ambiguous", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + errorCode: "submission-ambiguous", + }); + } else if (result.kind === "cancelled") { + statuses.set(settled.nodeId, "cancelled"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "cancelled", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + errorCode: "cancelled", + }); + } else { + statuses.set(settled.nodeId, "failed"); + await emit({ + kind: "node", + nodeId: settled.nodeId, + status: "failed", + attempt: nodeAttempts.get(settled.nodeId) ?? 0, + errorCode: + result.errorCode ?? + (result.kind === "rate-limited" ? "rate-limited" : "execution-failed"), + }); + } + } + + if (cancelTask) { + await cancelTask; + if (cancelFailure) throw cancelFailure; + } + const completedStatus = finalRunStatus(statuses); + const status = + completedStatus === "needs_attention" + ? completedStatus + : cancelPersisted + ? "cancelled" + : completedStatus; + await emit({ kind: "run", status }); + return deepFreeze({ + ...identity, + status, + nodeStatuses: Object.fromEntries(statuses), + outputs, + events, + retryDelayMs: totalRetryDelayMs, + }); + } finally { + options.signal?.removeEventListener("abort", abortListener); + } +} + +export interface CoordinatorEventCursor extends CoordinatorRunIdentity { + lastSequence: number; + runStatus: CoordinatorRunStatus; + nodeStatuses: Readonly>; + nodeAttempts: Readonly>; +} + +export type CoordinatorEventRejection = + | "wrong-run" + | "duplicate-or-stale" + | "out-of-order" + | "late-after-terminal" + | "unknown-node" + | "invalid-transition" + | "attempt-regression"; + +export type CoordinatorEventReduction = + | { accepted: true; cursor: CoordinatorEventCursor } + | { + accepted: false; + cursor: CoordinatorEventCursor; + reason: CoordinatorEventRejection; + }; + +export function createCoordinatorEventCursor( + identity: CoordinatorRunIdentity, + nodeIds: readonly string[], +): CoordinatorEventCursor { + assertOpaqueId(identity.workflowId, "Workflow ID"); + assertOpaqueId(identity.runId, "Run ID"); + if (!Number.isInteger(identity.workflowRevision) || identity.workflowRevision < 0) { + throw new Error("Workflow revision must be a non-negative integer."); + } + const unique = new Set(nodeIds); + if (unique.size !== nodeIds.length || nodeIds.some((nodeId) => !OPAQUE_ID_PATTERN.test(nodeId))) { + throw new Error("Event cursors require unique opaque node IDs."); + } + return deepFreeze({ + ...identity, + lastSequence: 0, + runStatus: "pending", + nodeStatuses: Object.fromEntries(nodeIds.map((nodeId) => [nodeId, undefined])), + nodeAttempts: Object.fromEntries(nodeIds.map((nodeId) => [nodeId, 0])), + }); +} + +const NODE_TRANSITIONS: Readonly> = { + unseen: ["queued"], + queued: ["running", "cancelled", "blocked"], + running: ["retry_wait", "succeeded", "failed", "cancelled", "ambiguous"], + retry_wait: ["running", "cancelled"], + succeeded: [], + failed: [], + cancelled: [], + blocked: [], + ambiguous: [], +}; + +export function reduceCoordinatorEvent( + cursor: CoordinatorEventCursor, + event: CoordinatorEvent, +): CoordinatorEventReduction { + if ( + cursor.workflowId !== event.workflowId || + cursor.workflowRevision !== event.workflowRevision || + cursor.runId !== event.runId + ) { + return { accepted: false, cursor, reason: "wrong-run" }; + } + if (event.sequence <= cursor.lastSequence) { + return { accepted: false, cursor, reason: "duplicate-or-stale" }; + } + if (event.sequence !== cursor.lastSequence + 1) { + return { accepted: false, cursor, reason: "out-of-order" }; + } + if (!["pending", "running"].includes(cursor.runStatus)) { + return { accepted: false, cursor, reason: "late-after-terminal" }; + } + if (event.kind === "run") { + if (cursor.runStatus === "pending" && event.status !== "running") { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + if (cursor.runStatus === "running" && event.status === "running") { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + if (cursor.runStatus === "running") { + const entries = Object.entries(cursor.nodeStatuses); + if ( + entries.some(([, status]) => status === undefined || !terminalNode(status)) || + finalRunStatus(new Map(entries as Array<[string, CoordinatorNodeStatus]>)) !== event.status + ) { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + } + return { + accepted: true, + cursor: deepFreeze({ + ...cursor, + lastSequence: event.sequence, + runStatus: event.status, + }), + }; + } + if (!Object.prototype.hasOwnProperty.call(cursor.nodeStatuses, event.nodeId)) { + return { accepted: false, cursor, reason: "unknown-node" }; + } + const previousAttempt = cursor.nodeAttempts[event.nodeId] ?? 0; + if (!Number.isInteger(event.attempt) || event.attempt < 0 || event.attempt < previousAttempt) { + return { accepted: false, cursor, reason: "attempt-regression" }; + } + if (event.kind === "remote-job") { + if ( + !PROVIDER_JOB_ID_PATTERN.test(event.remoteJobId) || + cursor.nodeStatuses[event.nodeId] !== "running" || + event.attempt !== previousAttempt + ) { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + return { + accepted: true, + cursor: deepFreeze({ ...cursor, lastSequence: event.sequence }), + }; + } + const previous = cursor.nodeStatuses[event.nodeId]; + const retryContractValid = + event.status !== "retry_wait" || + (Number.isInteger(event.retryDelayMs) && + (event.retryDelayMs ?? -1) >= 0 && + (event.retryDelayMs ?? MAX_RETRY_DELAY_MS + 1) <= MAX_RETRY_DELAY_MS && + event.retrySafety !== undefined && + event.retrySafety !== "never" && + event.errorCode !== undefined); + const nonRetryContractValid = + event.status === "retry_wait" || + (event.retryDelayMs === undefined && event.retrySafety === undefined); + const failureCodeRequired = ["failed", "cancelled", "blocked", "ambiguous"].includes( + event.status, + ); + if ( + !retryContractValid || + !nonRetryContractValid || + (failureCodeRequired && event.errorCode === undefined) || + (!failureCodeRequired && event.status !== "retry_wait" && event.errorCode !== undefined) + ) { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + if (!(NODE_TRANSITIONS[previous ?? "unseen"] ?? []).includes(event.status)) { + return { accepted: false, cursor, reason: "invalid-transition" }; + } + if (event.status === "running" && event.attempt !== previousAttempt + 1) { + return { accepted: false, cursor, reason: "attempt-regression" }; + } + if ( + event.status !== "running" && + event.status !== "queued" && + event.attempt !== previousAttempt + ) { + return { accepted: false, cursor, reason: "attempt-regression" }; + } + return { + accepted: true, + cursor: deepFreeze({ + ...cursor, + lastSequence: event.sequence, + nodeStatuses: { ...cursor.nodeStatuses, [event.nodeId]: event.status }, + nodeAttempts: { + ...cursor.nodeAttempts, + [event.nodeId]: event.status === "running" ? event.attempt : previousAttempt, + }, + }), + }; +} + +export type RendererLifecycleEvent = + | { kind: "route-change"; documentId: string } + | { kind: "document-destroyed"; documentId: string }; + +export type RendererDisconnectDecision = + | "continue-and-resubscribe" + | "request-best-effort-cancel" + | "ignore"; + +export function rendererDisconnectDecision( + runOwnerDocumentId: string, + event: RendererLifecycleEvent, +): RendererDisconnectDecision { + assertOpaqueId(runOwnerDocumentId, "Run owner document ID"); + assertOpaqueId(event.documentId, "Renderer document ID"); + if (event.documentId !== runOwnerDocumentId) return "ignore"; + return event.kind === "route-change" ? "continue-and-resubscribe" : "request-best-effort-cancel"; +} + +export type RestartNodePhase = + | "never-started" + | "local-running" + | "remote-submitting" + | "remote-submitted" + | "output-publishing" + | "cancel-requested" + | "terminal"; + +export interface RestartNodeRecord { + phase: RestartNodePhase; + lane: CoordinatorExecutionLane; + remoteJobId?: string; + durableOutputAvailable?: boolean; +} + +export type RestartReconciliationCategory = + | "await-explicit-resume" + | "mark-interrupted" + | "ambiguous-submit" + | "reconcile-remote-job" + | "resume-output-publication" + | "reconcile-cancel" + | "finalize-cancel" + | "terminal"; + +export interface RestartReconciliationDecision { + category: RestartReconciliationCategory; + autoSubmit: false; + maySubmitAfterExplicitApproval: boolean; + remoteJobId?: string; +} + +export function reconcileRestartNode(record: RestartNodeRecord): RestartReconciliationDecision { + if (record.remoteJobId !== undefined) assertProviderJobId(record.remoteJobId); + if (record.remoteJobId !== undefined && record.lane !== "remote") { + throw new Error("Only remote restart records can contain provider job IDs."); + } + if (record.phase === "local-running" && record.lane !== "local") { + throw new Error("A local-running restart record must use the local lane."); + } + if ( + (record.phase === "remote-submitting" || record.phase === "remote-submitted") && + record.lane !== "remote" + ) { + throw new Error("Remote submission restart records must use the remote lane."); + } + switch (record.phase) { + case "never-started": + return { + category: "await-explicit-resume", + autoSubmit: false, + maySubmitAfterExplicitApproval: true, + }; + case "local-running": + return { + category: "mark-interrupted", + autoSubmit: false, + maySubmitAfterExplicitApproval: true, + }; + case "remote-submitting": + if (record.remoteJobId) { + return { + category: "reconcile-remote-job", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + remoteJobId: record.remoteJobId, + }; + } + return { + category: "ambiguous-submit", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + }; + case "remote-submitted": + if (!record.remoteJobId) { + return { + category: "ambiguous-submit", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + }; + } + return { + category: "reconcile-remote-job", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + remoteJobId: record.remoteJobId, + }; + case "output-publishing": + return record.durableOutputAvailable + ? { + category: "resume-output-publication", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + } + : { + category: "mark-interrupted", + autoSubmit: false, + maySubmitAfterExplicitApproval: true, + }; + case "cancel-requested": + return record.remoteJobId + ? { + category: "reconcile-cancel", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + remoteJobId: record.remoteJobId, + } + : { + category: "finalize-cancel", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + }; + case "terminal": + return { + category: "terminal", + autoSubmit: false, + maySubmitAfterExplicitApproval: false, + }; + } +} From e9038091045184e8e0f852d236abc87deea1202c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 20 Aug 2026 00:42:05 -0400 Subject: [PATCH 004/110] feat(create-images): add archive and compatibility workflows Implement hostile native archive import/export, clean Node Banana graph conversion, packaged canvas acceptance, and cross-phase production integration contracts. Verify path stripping, exact assets, recovery, GC protection, IPC isolation, and feature gating. --- .../native-archive-service.test.ts | 182 ++ .../create-images/native-archive-service.ts | 567 +++++++ .../node-banana-import-service.test.ts | 165 ++ .../node-banana-import-service.ts | 216 +++ .../packaged-canvas-acceptance-core.test.ts | 199 +++ .../packaged-canvas-acceptance-core.ts | 493 ++++++ .../packaged-canvas-acceptance-runner.ts | 1467 +++++++++++++++++ .../phase-three-integration.test.ts | 775 +++++++++ .../phase-two-integration.test.ts | 456 +++++ .../phase-zero-contracts.test.ts | 245 +++ 10 files changed, 4765 insertions(+) create mode 100644 main/services/create-images/native-archive-service.test.ts create mode 100644 main/services/create-images/native-archive-service.ts create mode 100644 main/services/create-images/node-banana-import-service.test.ts create mode 100644 main/services/create-images/node-banana-import-service.ts create mode 100644 main/services/create-images/packaged-canvas-acceptance-core.test.ts create mode 100644 main/services/create-images/packaged-canvas-acceptance-core.ts create mode 100644 main/services/create-images/packaged-canvas-acceptance-runner.ts create mode 100644 main/services/create-images/phase-three-integration.test.ts create mode 100644 main/services/create-images/phase-two-integration.test.ts create mode 100644 main/services/create-images/phase-zero-contracts.test.ts diff --git a/main/services/create-images/native-archive-service.test.ts b/main/services/create-images/native-archive-service.test.ts new file mode 100644 index 00000000..99adda0c --- /dev/null +++ b/main/services/create-images/native-archive-service.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pipeline } from "node:stream/promises"; +import test from "node:test"; +import * as yazl from "yazl"; +import { CREATE_IMAGES_ARCHIVE_MANIFEST_PATH } from "../../../renderer/shared/create-images/archive.js"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { CreateImagesService } from "./create-images-service.js"; +import { CreateImagesNativeArchiveError } from "./native-archive-service.js"; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + return concat(u32(data.byteLength), typeBytes, data, u32(crc32(concat(typeBytes, data)))); +} + +function makePng(): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(1)); + header.set(u32(1), 4); + header[8] = 8; + header[9] = 6; + return concat( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, 0, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +async function* chunks(bytes: Uint8Array): AsyncGenerator { + yield bytes; +} + +function service(root: string): CreateImagesService { + return new CreateImagesService(root, { + assetStore: { + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { bytes: makePng(), width: 1, height: 1, mediaType: "image/png" as const }; + }, + }, + now: () => Date.parse("2026-08-19T12:00:00.000Z"), + }, + }); +} + +async function writeDuplicateManifestArchive(filePath: string): Promise { + const zip = new yazl.ZipFile(); + zip.addBuffer(Buffer.from("{}"), CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, { compress: false }); + zip.addBuffer(Buffer.from("{}"), CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, { compress: false }); + const writing = pipeline(zip.outputStream, createWriteStream(filePath, { mode: 0o600 })); + zip.end(); + await writing; +} + +test("native archive export/import round-trips a workflow and referenced image without paths", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-native-archive-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const image = await createImages.assets.ingest(chunks(makePng()), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "reference.png", + }); + const workflow = createStarterWorkflow({ + workflowId: "archive-source", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-19T12:00:00.000Z", + }); + workflow.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 0, y: 320 }, + data: { assetId: image.asset.assetId, label: "Reference" }, + }); + workflow.assetRefs = [image.asset.assetId]; + await createImages.mutateWorkflow(workflow.id, workflow.assetRefs, () => + createImages.workflows.create(workflow), + ); + + const archivePath = path.join(root, "exported.aiden-images"); + const exported = await createImages.archives.exportToFile({ + workflowId: workflow.id, + expectedRevision: 1, + destination: archivePath, + }); + assert.deepEqual(exported, { + workflowId: workflow.id, + revision: 1, + fileName: "exported.aiden-images", + assetCount: 1, + }); + assert.equal(Object.prototype.hasOwnProperty.call(exported, "filePath"), false); + assert.ok((await fs.stat(archivePath)).size > image.asset.byteLength); + + const imported = await createImages.archives.importFromFile(archivePath); + assert.notEqual(imported.workflow.id, workflow.id); + assert.equal(imported.workflow.revision, 1); + assert.deepEqual(imported.workflow.assetRefs, [image.asset.assetId]); + assert.equal(imported.importedAssetCount, 1); + assert.equal(imported.sourceFileName, "exported.aiden-images"); + assert.equal(Object.prototype.hasOwnProperty.call(imported, "filePath"), false); + assert.ok(await createImages.workflows.get(imported.workflow.id)); + assert.equal( + (await createImages.assets.getAvailable(image.asset.assetId))?.assetId, + image.asset.assetId, + ); +}); + +test("native archive import rejects invalid bytes without publishing a workflow", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-native-archive-invalid-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const archivePath = path.join(root, "hostile.aiden-images"); + await fs.writeFile(archivePath, "not a zip", { mode: 0o600 }); + const before = await createImages.workflows.list(); + await assert.rejects( + createImages.archives.importFromFile(archivePath), + (error: unknown) => + error instanceof CreateImagesNativeArchiveError && error.code === "archive_invalid", + ); + assert.deepEqual(await createImages.workflows.list(), before); +}); + +test("native archive import rejects a duplicate manifest before publication", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-native-archive-duplicate-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const archivePath = path.join(root, "duplicate-manifest.aiden-images"); + await writeDuplicateManifestArchive(archivePath); + const before = await createImages.workflows.list(); + + await assert.rejects( + createImages.archives.importFromFile(archivePath), + (error: unknown) => + error instanceof CreateImagesNativeArchiveError && error.code === "archive_invalid", + ); + assert.deepEqual(await createImages.workflows.list(), before); +}); diff --git a/main/services/create-images/native-archive-service.ts b/main/services/create-images/native-archive-service.ts new file mode 100644 index 00000000..9806071a --- /dev/null +++ b/main/services/create-images/native-archive-service.ts @@ -0,0 +1,567 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants, createReadStream, createWriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import * as yauzl from "yauzl"; +import * as yazl from "yazl"; +import { + CREATE_IMAGES_ARCHIVE_EXTENSION, + CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + CREATE_IMAGES_ARCHIVE_MAX_ENTRIES, + CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES, + CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES, + CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + CREATE_IMAGES_ARCHIVE_FORMAT, + CREATE_IMAGES_ARCHIVE_VERSION, + parseCreateImagesArchiveManifestBytes, + validateCreateImagesArchiveBootstrap, + validateCreateImagesArchiveExtractedEntries, + validateCreateImagesArchiveInventory, + validateCreateImagesArchiveWorkflowAssets, + type CreateImagesArchiveExtractedEntry, + type CreateImagesArchiveInventoryEntry, + type CreateImagesArchiveManifestV1, + type CreateImagesArchiveValidatedAsset, +} from "../../../renderer/shared/create-images/archive.js"; +import { + CREATE_IMAGES_MAX_WORKFLOW_BYTES, + parseWorkflowDocument, + type WorkflowDocumentV1, +} from "../../../renderer/shared/create-images/schema.js"; +import type { ContentAddressedAssetStore } from "./asset-store-core.js"; +import type { WorkflowManifestStore } from "./workflow-manifest-store.js"; + +const ARCHIVE_QUARANTINE_DIRECTORY = "archive-quarantine"; +const UNIX_FILE_TYPE_MASK = 0o170000; +const UNIX_DIRECTORY = 0o040000; +const UNIX_SYMLINK = 0o120000; + +export type CreateImagesNativeArchiveErrorCode = + | "archive_invalid" + | "archive_io" + | "archive_revision_conflict" + | "archive_workflow_missing"; + +export class CreateImagesNativeArchiveError extends Error { + constructor( + readonly code: CreateImagesNativeArchiveErrorCode, + message: string, + ) { + super(message); + this.name = "CreateImagesNativeArchiveError"; + } +} + +export interface CreateImagesNativeArchiveDependencies { + rootDirectory: string; + workflows: WorkflowManifestStore; + assets: ContentAddressedAssetStore; + publishImportedWorkflow( + workflow: WorkflowDocumentV1, + isCurrent: () => boolean, + ): Promise; + now?: () => number; + randomId?: () => string; +} + +export interface CreateImagesNativeArchiveExportResult { + workflowId: string; + revision: number; + fileName: string; + assetCount: number; +} + +export interface CreateImagesNativeArchiveImportResult { + workflow: WorkflowDocumentV1; + sourceFileName: string; + importedAssetCount: number; +} + +interface QuarantinedEntry { + path: string; + filePath: string; + extracted: CreateImagesArchiveExtractedEntry; +} + +const CRC32_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < table.length; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) { + value = (value >>> 1) ^ (value & 1 ? 0xedb8_8320 : 0); + } + table[index] = value >>> 0; + } + return table; +})(); + +class Crc32Accumulator { + private value = 0xffff_ffff; + + update(bytes: Uint8Array): void { + for (const byte of bytes) { + this.value = (this.value >>> 8) ^ CRC32_TABLE[(this.value ^ byte) & 0xff]!; + } + } + + digest(): number { + return (this.value ^ 0xffff_ffff) >>> 0; + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function ensurePrivateDirectory(directory: string): Promise { + const created = await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new CreateImagesNativeArchiveError("archive_io", "Archive storage is unavailable."); + } + await fs.chmod(directory, 0o700); + if (created !== undefined) await syncDirectory(path.dirname(directory)); +} + +function serializeWorkflow(workflow: WorkflowDocumentV1): Buffer { + const bytes = Buffer.from(`${JSON.stringify(workflow, null, 2)}\n`, "utf8"); + if (bytes.byteLength < 1 || bytes.byteLength > CREATE_IMAGES_MAX_WORKFLOW_BYTES) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "The workflow exceeds the native archive limit.", + ); + } + return bytes; +} + +function inventoryKind(entry: yauzl.Entry): CreateImagesArchiveInventoryEntry["kind"] { + const unixMode = (entry.externalFileAttributes >>> 16) & 0xffff; + const fileType = unixMode & UNIX_FILE_TYPE_MASK; + if (fileType === UNIX_SYMLINK) return "symlink"; + if (entry.fileName.endsWith("/") || fileType === UNIX_DIRECTORY) return "directory"; + return "file"; +} + +function inventoryEntry(entry: yauzl.Entry): CreateImagesArchiveInventoryEntry { + return { + path: entry.fileName, + kind: inventoryKind(entry), + encrypted: entry.isEncrypted(), + compressionMethod: entry.compressionMethod, + compressedBytes: entry.compressedSize, + uncompressedBytes: entry.uncompressedSize, + crc32: entry.crc32 >>> 0, + }; +} + +async function boundedEntryBytes( + zip: yauzl.ZipFile, + entry: yauzl.Entry, + maximumBytes: number, +): Promise { + const stream = await zip.openReadStreamPromise(entry); + const chunks: Buffer[] = []; + let total = 0; + for await (const raw of stream) { + const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as Uint8Array); + total += chunk.byteLength; + if (total > maximumBytes) { + stream.destroy(); + throw new CreateImagesNativeArchiveError("archive_invalid", "Archive entry is too large."); + } + chunks.push(chunk); + } + return Buffer.concat(chunks, total); +} + +async function extractEntry( + zip: yauzl.ZipFile, + entry: yauzl.Entry, + inventory: CreateImagesArchiveInventoryEntry, + filePath: string, +): Promise { + const stream = await zip.openReadStreamPromise(entry); + const handle = await fs.open(filePath, "wx", 0o600); + const digest = createHash("sha256"); + const crc = new Crc32Accumulator(); + let byteLength = 0; + try { + for await (const raw of stream) { + const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as Uint8Array); + byteLength += chunk.byteLength; + if ( + byteLength > inventory.uncompressedBytes || + byteLength > CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES + ) { + stream.destroy(); + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "Archive entry exceeded its declared size.", + ); + } + digest.update(chunk); + crc.update(chunk); + let offset = 0; + while (offset < chunk.byteLength) { + const result = await handle.write(chunk, offset, chunk.byteLength - offset, null); + if (result.bytesWritten < 1) throw new Error("Archive extraction made no progress."); + offset += result.bytesWritten; + } + } + await handle.sync(); + } finally { + await handle.close(); + } + return { + path: inventory.path, + byteLength, + crc32: crc.digest(), + sha256: digest.digest("hex"), + }; +} + +async function copyAssetToStage(source: string, destination: string): Promise { + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const sourceHandle = await fs.open(source, constants.O_RDONLY | noFollow); + const destinationHandle = await fs.open(destination, "wx", 0o600); + try { + const info = await sourceHandle.stat(); + if (!info.isFile()) throw new Error("The asset is not a regular file."); + await pipeline( + sourceHandle.createReadStream({ autoClose: true }), + destinationHandle.createWriteStream({ autoClose: true }), + ); + } finally { + await Promise.allSettled([sourceHandle.close(), destinationHandle.close()]); + } + const durable = await fs.open(destination, "r"); + try { + await durable.sync(); + } finally { + await durable.close(); + } +} + +async function writeZipAtomically( + zip: yazl.ZipFile, + destination: string, +): Promise { + const directory = path.dirname(destination); + const temp = path.join(directory, `.${path.basename(destination)}.${randomUUID()}.tmp`); + try { + const output = createWriteStream(temp, { flags: "wx", mode: 0o600 }); + zip.end(); + await pipeline(zip.outputStream as Readable, output); + const handle = await fs.open(temp, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } catch (error) { + await fs.rm(temp, { force: true }).catch(() => undefined); + throw error; + } + try { + await fs.rename(temp, destination); + await fs.chmod(destination, 0o600); + await syncDirectory(directory); + } catch (error) { + await fs.rm(temp, { force: true }).catch(() => undefined); + throw error; + } +} + +function safeArchiveDestination(destination: string): string { + if (!path.isAbsolute(destination) || destination.includes("\0")) { + throw new CreateImagesNativeArchiveError("archive_io", "The archive destination is invalid."); + } + return destination.endsWith(CREATE_IMAGES_ARCHIVE_EXTENSION) + ? destination + : `${destination}${CREATE_IMAGES_ARCHIVE_EXTENSION}`; +} + +export class CreateImagesNativeArchiveService { + private readonly now: () => number; + private readonly randomId: () => string; + + constructor(private readonly dependencies: CreateImagesNativeArchiveDependencies) { + this.now = dependencies.now ?? Date.now; + this.randomId = dependencies.randomId ?? randomUUID; + } + + private async operationDirectory(): Promise { + const root = path.join(this.dependencies.rootDirectory, ARCHIVE_QUARANTINE_DIRECTORY); + await ensurePrivateDirectory(root); + return fs.mkdtemp(path.join(root, "operation-")); + } + + async exportToFile(input: { + workflowId: string; + expectedRevision: number; + destination: string; + }): Promise { + const destination = safeArchiveDestination(input.destination); + const workflow = await this.dependencies.workflows.get(input.workflowId); + if (!workflow) { + throw new CreateImagesNativeArchiveError( + "archive_workflow_missing", + "The workflow no longer exists.", + ); + } + if (workflow.revision !== input.expectedRevision) { + throw new CreateImagesNativeArchiveError( + "archive_revision_conflict", + "The workflow changed before export.", + ); + } + const operationDirectory = await this.operationDirectory(); + try { + const workflowBytes = serializeWorkflow(workflow); + const exportedAt = new Date(this.now()).toISOString(); + const assets: CreateImagesArchiveManifestV1["assets"] = []; + const stagedAssets: Array<{ path: string; filePath: string }> = []; + for (const [index, assetId] of workflow.assetRefs.entries()) { + await this.dependencies.assets.withAssetFile(assetId, async ({ filePath, asset }) => { + const extension = asset.mediaType === "image/png" ? "png" : "jpg"; + const archivePath = `assets/${assetId}.${extension}`; + const stagedPath = path.join(operationDirectory, `asset-${index}.${extension}`); + await copyAssetToStage(filePath, stagedPath); + const validated = await this.dependencies.assets.validateQuarantinedAssetFile(stagedPath, { + declaredMimeType: asset.mediaType, + displayName: `asset.${extension}`, + }); + if ( + validated.sha256 !== assetId || + validated.mediaType !== asset.mediaType || + validated.byteLength !== asset.byteLength || + validated.width !== asset.width || + validated.height !== asset.height + ) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "A referenced image changed before export.", + ); + } + assets.push({ + assetId, + sha256: assetId, + path: archivePath, + mediaType: asset.mediaType, + byteLength: asset.byteLength, + width: asset.width, + height: asset.height, + }); + stagedAssets.push({ path: archivePath, filePath: stagedPath }); + }); + } + const manifest: CreateImagesArchiveManifestV1 = { + format: CREATE_IMAGES_ARCHIVE_FORMAT, + version: CREATE_IMAGES_ARCHIVE_VERSION, + exportedAt, + workflow: { + path: CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + sha256: createHash("sha256").update(workflowBytes).digest("hex"), + byteLength: workflowBytes.byteLength, + }, + assets, + }; + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + if (manifestBytes.byteLength > CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "The native archive manifest is too large.", + ); + } + const zip = new yazl.ZipFile(); + const zipOptions = { compress: false, mode: 0o100600, mtime: new Date(exportedAt) }; + zip.addBuffer(manifestBytes, CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, zipOptions); + zip.addBuffer(workflowBytes, CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, zipOptions); + for (const asset of stagedAssets) zip.addFile(asset.filePath, asset.path, zipOptions); + await writeZipAtomically(zip, destination); + return { + workflowId: workflow.id, + revision: workflow.revision, + fileName: path.basename(destination), + assetCount: assets.length, + }; + } catch (error) { + if (error instanceof CreateImagesNativeArchiveError) throw error; + throw new CreateImagesNativeArchiveError("archive_io", "The workflow could not be exported."); + } finally { + await fs.rm(operationDirectory, { recursive: true, force: true }).catch(() => undefined); + } + } + + async importFromFile( + source: string, + isCurrent: () => boolean = () => true, + ): Promise { + if (!path.isAbsolute(source) || !source.endsWith(CREATE_IMAGES_ARCHIVE_EXTENSION)) { + throw new CreateImagesNativeArchiveError("archive_invalid", "Choose an .aiden-images file."); + } + const operationDirectory = await this.operationDirectory(); + let zip: yauzl.ZipFile | undefined; + try { + zip = await yauzl.openPromise(source, { + autoClose: false, + lazyEntries: true, + strictFileNames: true, + validateEntrySizes: true, + }); + if (zip.entryCount > CREATE_IMAGES_ARCHIVE_MAX_ENTRIES) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "The archive contains too many entries.", + ); + } + const entries: yauzl.Entry[] = []; + for await (const entry of zip.eachEntry()) { + entries.push(entry); + if (entries.length > CREATE_IMAGES_ARCHIVE_MAX_ENTRIES) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "The archive contains too many entries.", + ); + } + } + const inventory = entries.map(inventoryEntry); + if (validateCreateImagesArchiveBootstrap(inventory).length > 0) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive is unsafe."); + } + const manifestIndex = inventory.findIndex( + (entry) => entry.path === CREATE_IMAGES_ARCHIVE_MANIFEST_PATH, + ); + const manifestBytes = await boundedEntryBytes( + zip, + entries[manifestIndex]!, + CREATE_IMAGES_ARCHIVE_MAX_MANIFEST_BYTES, + ); + const manifestResult = parseCreateImagesArchiveManifestBytes( + manifestBytes, + inventory[manifestIndex]!, + ); + if (!manifestResult.success) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive manifest is invalid."); + } + const manifest = manifestResult.value; + if (validateCreateImagesArchiveInventory(manifest, inventory).length > 0) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive inventory is invalid."); + } + + const quarantined: QuarantinedEntry[] = []; + let extractedTotal = 0; + for (const [index, entry] of entries.entries()) { + const filePath = path.join(operationDirectory, `entry-${index}.bin`); + const extracted = await extractEntry(zip, entry, inventory[index]!, filePath); + extractedTotal += extracted.byteLength; + if (extractedTotal > CREATE_IMAGES_ARCHIVE_MAX_TOTAL_BYTES) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive is too large."); + } + quarantined.push({ path: entry.fileName, filePath, extracted }); + } + if ( + validateCreateImagesArchiveExtractedEntries( + manifest, + inventory, + quarantined.map((entry) => entry.extracted), + ).length > 0 + ) { + throw new CreateImagesNativeArchiveError("archive_invalid", "Archive contents are invalid."); + } + + const workflowEntry = quarantined.find( + (entry) => entry.path === CREATE_IMAGES_ARCHIVE_WORKFLOW_PATH, + ); + if (!workflowEntry) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The workflow entry is missing."); + } + const workflowBytes = await fs.readFile(workflowEntry.filePath); + let workflowValue: unknown; + try { + workflowValue = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(workflowBytes)); + } catch { + throw new CreateImagesNativeArchiveError("archive_invalid", "The workflow JSON is invalid."); + } + const parsedWorkflow = parseWorkflowDocument(workflowValue); + if (!parsedWorkflow.success) { + throw new CreateImagesNativeArchiveError("archive_invalid", "The workflow schema is invalid."); + } + + const validatedAssets: CreateImagesArchiveValidatedAsset[] = []; + for (const expected of manifest.assets) { + const archived = quarantined.find((entry) => entry.path === expected.path); + if (!archived) { + throw new CreateImagesNativeArchiveError("archive_invalid", "An image entry is missing."); + } + const actual = await this.dependencies.assets.validateQuarantinedAssetFile( + archived.filePath, + { declaredMimeType: expected.mediaType, displayName: path.basename(expected.path) }, + ); + if (actual.sha256 !== expected.assetId) { + throw new CreateImagesNativeArchiveError("archive_invalid", "An image digest is invalid."); + } + validatedAssets.push({ + assetId: expected.assetId, + mediaType: actual.mediaType, + byteLength: actual.byteLength, + width: actual.width, + height: actual.height, + }); + } + if ( + validateCreateImagesArchiveWorkflowAssets( + manifest, + parsedWorkflow.value, + validatedAssets, + ).length > 0 + ) { + throw new CreateImagesNativeArchiveError( + "archive_invalid", + "Workflow image references are invalid.", + ); + } + + for (const expected of manifest.assets) { + const archived = quarantined.find((entry) => entry.path === expected.path)!; + const result = await this.dependencies.assets.ingest(createReadStream(archived.filePath), { + origin: { kind: "import" }, + declaredMimeType: expected.mediaType, + displayName: path.basename(expected.path), + validationDisplayName: path.basename(expected.path), + }); + if (result.asset.assetId !== expected.assetId) { + throw new CreateImagesNativeArchiveError("archive_invalid", "An imported image changed."); + } + } + + const now = new Date(this.now()).toISOString(); + const imported: WorkflowDocumentV1 = { + ...structuredClone(parsedWorkflow.value), + id: this.randomId(), + revision: 1, + createdAt: now, + updatedAt: now, + }; + const published = await this.dependencies.publishImportedWorkflow(imported, isCurrent); + return { + workflow: published, + sourceFileName: path.basename(source), + importedAssetCount: manifest.assets.length, + }; + } catch (error) { + if (error instanceof CreateImagesNativeArchiveError) throw error; + throw new CreateImagesNativeArchiveError("archive_invalid", "The archive could not be imported."); + } finally { + zip?.close(); + await fs.rm(operationDirectory, { recursive: true, force: true }).catch(() => undefined); + } + } +} diff --git a/main/services/create-images/node-banana-import-service.test.ts b/main/services/create-images/node-banana-import-service.test.ts new file mode 100644 index 00000000..07302b89 --- /dev/null +++ b/main/services/create-images/node-banana-import-service.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { CreateImagesService } from "./create-images-service.js"; +import { CreateImagesNodeBananaServiceError } from "./node-banana-import-service.js"; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.byteLength; + } + return result; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + return concat(u32(data.byteLength), typeBytes, data, u32(crc32(concat(typeBytes, data)))); +} + +function makePng(): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(1)); + header.set(u32(1), 4); + header[8] = 8; + header[9] = 6; + return concat( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, 0, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +function service(root: string): CreateImagesService { + return new CreateImagesService(root, { + assetStore: { + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { bytes: makePng(), width: 1, height: 1, mediaType: "image/png" as const }; + }, + }, + now: () => Date.parse("2026-08-19T12:00:00.000Z"), + }, + }); +} + +test("Node Banana file import externalizes validated images and reports every rewritten node", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-node-banana-import-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const source = path.join(root, "node-banana.json"); + const png = Buffer.from(makePng()).toString("base64"); + await fs.writeFile( + source, + JSON.stringify({ + version: 1, + name: "Imported edit", + directoryPath: "/private/source", + providers: { gemini: { apiKey: "do-not-import" } }, + nodes: [ + { + id: "image-1", + type: "imageInput", + position: { x: 0, y: 100 }, + data: { + filename: "reference.png", + image: `data:image/png;base64,${png}`, + imageRef: "/private/source/reference.png", + }, + }, + { + id: "prompt-1", + type: "prompt", + position: { x: 0, y: 300 }, + data: { prompt: "Make it yellow" }, + }, + { + id: "generate-1", + type: "nanoBanana", + position: { x: 360, y: 180 }, + data: { + aspectRatio: "1:1", + resolution: "1K", + model: "gemini-3.1-flash-image-preview", + apiKey: "do-not-import", + }, + }, + { + id: "output-1", + type: "output", + position: { x: 720, y: 180 }, + data: { image: `data:image/png;base64,${png}` }, + }, + { id: "video-1", type: "generateVideo", position: {}, data: {} }, + ], + edges: [ + { source: "image-1", target: "generate-1", targetHandle: "image" }, + { source: "prompt-1", target: "generate-1", targetHandle: "text" }, + { source: "generate-1", target: "output-1", targetHandle: "image" }, + ], + }), + { mode: 0o600 }, + ); + + const result = await createImages.nodeBananaImports.importFromFile(source); + assert.equal(result.sourceFileName, "node-banana.json"); + assert.equal(result.importedAssetCount, 1); + assert.equal(result.report.importedEmbeddedImageCount, 1); + assert.equal(result.report.skippedNodeCount, 1); + assert.equal(result.report.entries.length, 5); + assert.equal(Object.prototype.hasOwnProperty.call(result, "filePath"), false); + const imageNode = result.workflow.nodes.find((node) => node.type === "image-input"); + assert.equal(imageNode?.type, "image-input"); + assert.equal(result.workflow.assetRefs.length, 1); + assert.equal(imageNode?.data.assetId, result.workflow.assetRefs[0]); + assert.ok(await createImages.assets.getAvailable(result.workflow.assetRefs[0]!)); + assert.ok(await createImages.workflows.get(result.workflow.id)); + const serialized = JSON.stringify(result.workflow); + assert.equal(serialized.includes("do-not-import"), false); + assert.equal(serialized.includes("/private/source"), false); + assert.equal(serialized.includes("data:image"), false); +}); + +test("Node Banana file import rejects invalid JSON without publishing a workflow", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-node-banana-invalid-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const createImages = service(root); + await createImages.initialize(); + const source = path.join(root, "invalid.json"); + await fs.writeFile(source, "{broken", { mode: 0o600 }); + const before = await createImages.workflows.list(); + await assert.rejects( + createImages.nodeBananaImports.importFromFile(source), + (error: unknown) => error instanceof CreateImagesNodeBananaServiceError, + ); + assert.deepEqual(await createImages.workflows.list(), before); +}); diff --git a/main/services/create-images/node-banana-import-service.ts b/main/services/create-images/node-banana-import-service.ts new file mode 100644 index 00000000..b72b958b --- /dev/null +++ b/main/services/create-images/node-banana-import-service.ts @@ -0,0 +1,216 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + convertNodeBananaWorkflow, + CreateImagesNodeBananaImportError, + type CreateImagesNodeBananaImportReport, +} from "../../../renderer/shared/create-images/node-banana-import.js"; +import { + CREATE_IMAGES_MAX_WORKFLOW_BYTES, + parseWorkflowDocument, + type WorkflowDocumentV1, +} from "../../../renderer/shared/create-images/schema.js"; +import { decodeUtf8, readRegularFile } from "../regular-file-read.js"; +import type { ContentAddressedAssetStore } from "./asset-store-core.js"; +import { ingestCreateImagesImageFile } from "./electron-asset-import.js"; + +const COMPATIBILITY_QUARANTINE_DIRECTORY = "compatibility-import-quarantine"; + +export type CreateImagesNodeBananaServiceErrorCode = "invalid" | "io"; + +export class CreateImagesNodeBananaServiceError extends Error { + constructor( + readonly code: CreateImagesNodeBananaServiceErrorCode, + message: string, + ) { + super(message); + this.name = "CreateImagesNodeBananaServiceError"; + } +} + +export interface CreateImagesNodeBananaImportDependencies { + rootDirectory: string; + assets: ContentAddressedAssetStore; + publishImportedWorkflow( + workflow: WorkflowDocumentV1, + isCurrent: () => boolean, + ): Promise; + now?: () => number; + randomId?: () => string; +} + +export interface CreateImagesNodeBananaImportResult { + workflow: WorkflowDocumentV1; + sourceFileName: string; + importedAssetCount: number; + report: CreateImagesNodeBananaImportReport; +} + +async function syncDirectory(directory: string): Promise { + const handle = await fs.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function ensurePrivateDirectory(directory: string): Promise { + const created = await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + const info = await fs.lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new CreateImagesNodeBananaServiceError("io", "Compatibility import is unavailable."); + } + await fs.chmod(directory, 0o700); + if (created !== undefined) await syncDirectory(path.dirname(directory)); +} + +function extensionFor(mediaType: string): string { + const subtype = mediaType.slice("image/".length).toLowerCase(); + if (subtype === "jpeg" || subtype === "pjpeg") return "jpg"; + if (subtype === "svg+xml") return "svg"; + const safe = subtype.replace(/[^a-z0-9]/gu, "").slice(0, 12); + return safe || "image"; +} + +function decodeCanonicalBase64(value: string): Buffer { + const bytes = Buffer.from(value, "base64"); + if (bytes.byteLength < 1 || bytes.toString("base64") !== value) { + throw new CreateImagesNodeBananaServiceError("invalid", "An embedded image is malformed."); + } + return bytes; +} + +function updateImageEntry( + report: CreateImagesNodeBananaImportReport, + sourceNodeIndex: number, + suffix: string, +): void { + const entry = report.entries.find((candidate) => candidate.sourceNodeIndex === sourceNodeIndex); + if (entry) entry.message = `${entry.message} ${suffix}`; +} + +export class CreateImagesNodeBananaImportService { + private readonly now: () => number; + private readonly randomId: () => string; + + constructor(private readonly dependencies: CreateImagesNodeBananaImportDependencies) { + this.now = dependencies.now ?? Date.now; + this.randomId = dependencies.randomId ?? randomUUID; + } + + private async operationDirectory(): Promise { + const root = path.join(this.dependencies.rootDirectory, COMPATIBILITY_QUARANTINE_DIRECTORY); + await ensurePrivateDirectory(root); + return fs.mkdtemp(path.join(root, "operation-")); + } + + async importFromFile( + source: string, + isCurrent: () => boolean = () => true, + ): Promise { + if (!path.isAbsolute(source) || path.extname(source).toLowerCase() !== ".json") { + throw new CreateImagesNodeBananaServiceError("invalid", "Choose a Node Banana JSON file."); + } + const operationDirectory = await this.operationDirectory(); + try { + let value: unknown; + try { + const bytes = await readRegularFile(source, CREATE_IMAGES_MAX_WORKFLOW_BYTES); + value = JSON.parse(decodeUtf8(bytes)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EFBIG") { + throw new CreateImagesNodeBananaServiceError( + "invalid", + "The Node Banana workflow exceeds Aiden's import limit.", + ); + } + throw new CreateImagesNodeBananaServiceError("invalid", "The Node Banana JSON is invalid."); + } + + const now = new Date(this.now()).toISOString(); + const converted = convertNodeBananaWorkflow(value, { + workflowId: this.randomId(), + now, + nextId: this.randomId, + }); + const assetIdsByNode = new Map(); + for (const [index, image] of converted.inlineImages.entries()) { + const extension = extensionFor(image.mediaType); + const filePath = path.join(operationDirectory, `image-${index}.${extension}`); + try { + const bytes = decodeCanonicalBase64(image.base64); + const handle = await fs.open(filePath, "wx", 0o600); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + const imported = await ingestCreateImagesImageFile(this.dependencies.assets, filePath); + assetIdsByNode.set(image.targetNodeId, imported.asset.assetId); + converted.report.importedEmbeddedImageCount += 1; + updateImageEntry( + converted.report, + image.sourceNodeIndex, + imported.deduplicated + ? "The validated image matched an existing device-local asset." + : "The validated image was stored as a device-local asset.", + ); + } catch { + converted.report.skippedEmbeddedImageCount += 1; + updateImageEntry( + converted.report, + image.sourceNodeIndex, + "Its embedded image failed safe decoding and was left empty.", + ); + } + } + + const assetRefs: string[] = []; + const seenAssetIds = new Set(); + const nodes = converted.workflow.nodes.map((node) => { + if (node.type !== "image-input") return node; + const assetId = assetIdsByNode.get(node.id); + if (!assetId) return node; + if (!seenAssetIds.has(assetId)) { + seenAssetIds.add(assetId); + assetRefs.push(assetId); + } + return { ...node, data: { ...node.data, assetId } }; + }); + const finalized = parseWorkflowDocument({ + ...converted.workflow, + nodes, + assetRefs, + }); + if (!finalized.success) { + throw new CreateImagesNodeBananaServiceError( + "invalid", + "The converted workflow failed Aiden's schema.", + ); + } + const workflow = await this.dependencies.publishImportedWorkflow(finalized.value, isCurrent); + return { + workflow, + sourceFileName: path.basename(source), + importedAssetCount: assetRefs.length, + report: converted.report, + }; + } catch (error) { + if ( + error instanceof CreateImagesNodeBananaServiceError || + error instanceof CreateImagesNodeBananaImportError + ) { + throw error; + } + throw new CreateImagesNodeBananaServiceError( + "invalid", + "The Node Banana workflow could not be imported safely.", + ); + } finally { + await fs.rm(operationDirectory, { recursive: true, force: true }).catch(() => undefined); + } + } +} diff --git a/main/services/create-images/packaged-canvas-acceptance-core.test.ts b/main/services/create-images/packaged-canvas-acceptance-core.test.ts new file mode 100644 index 00000000..c5c3757c --- /dev/null +++ b/main/services/create-images/packaged-canvas-acceptance-core.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { + CREATE_IMAGES_PACKAGED_ACCEPTANCE_CONTROL_FILENAME, + CREATE_IMAGES_PACKAGED_ACCEPTANCE_ENV, + CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROOT_PREFIX, + CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH, + countCreateImagesProductFileMutations, + createImagesPhaseTwoProductFileEvidence, + isCreateImagesDurableWorkflowPublication, + loadCreateImagesPackagedAcceptanceSession, + snapshotCreateImagesProductFiles, +} from "./packaged-canvas-acceptance-core.js"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; + +async function fixture(): Promise<{ root: string; controlPath: string; nonce: string }> { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROOT_PREFIX), + ); + await fs.chmod(root, 0o700); + const controlPath = path.join(root, CREATE_IMAGES_PACKAGED_ACCEPTANCE_CONTROL_FILENAME); + const nonce = randomBytes(32).toString("base64url"); + await fs.writeFile(controlPath, JSON.stringify({ version: 1, nonce }), { + mode: 0o600, + flag: "wx", + }); + await fs.chmod(controlPath, 0o600); + return { root, controlPath, nonce }; +} + +test("durable publication accepts serialized autosaves without hard-coding one revision", () => { + const workflow = createStarterWorkflow({ + workflowId: "workflow-1", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "prompt-edge", + outputEdgeId: "output-edge", + now: "2026-08-11T12:00:00.000Z", + }); + const prompt = workflow.nodes.find((node) => node.type === "prompt"); + assert.ok(prompt?.type === "prompt"); + prompt.data.text = "Durable edit"; + workflow.revision = 3; + + assert.equal(isCreateImagesDurableWorkflowPublication(workflow, 1, "Durable edit"), true); + assert.equal(isCreateImagesDurableWorkflowPublication(workflow, 3, "Durable edit"), false); + assert.equal(isCreateImagesDurableWorkflowPublication(workflow, 1, "Other edit"), false); +}); + +test("packaged canvas acceptance is one-shot, private, and opt-in", async (context) => { + const value = await fixture(); + context.after(() => fs.rm(value.root, { recursive: true, force: true })); + assert.equal( + await loadCreateImagesPackagedAcceptanceSession({ + isPackaged: true, + argv: [`${CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH}=${value.controlPath}`], + environment: {}, + }), + undefined, + ); + const session = await loadCreateImagesPackagedAcceptanceSession({ + isPackaged: true, + argv: [`${CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH}=${value.controlPath}`], + environment: { [CREATE_IMAGES_PACKAGED_ACCEPTANCE_ENV]: "1" }, + }); + assert.equal(session?.control.nonce, value.nonce); + assert.equal(session?.root, await fs.realpath(value.root)); +}); + +test("Phase 2 product evidence is exact, content-addressed, and rejects recovery debris", () => { + const assetId = "a".repeat(64); + const workflowId = "packaged-phase-two"; + const workflowDigest = "b".repeat(64); + const base = "user-data/create-images"; + const firstPredecessorDigest = "1".repeat(64); + const secondPredecessorDigest = "2".repeat(64); + const thirdPredecessorDigest = "3".repeat(64); + const files = [ + { + path: `${base}/.asset-index.json.${firstPredecessorDigest}.11111111-1111-4111-8111-111111111111.previous`, + bytes: 440, + digest: firstPredecessorDigest, + }, + { + path: `${base}/.asset-index.json.${secondPredecessorDigest}.22222222-2222-4222-8222-222222222222.previous`, + bytes: 520, + digest: secondPredecessorDigest, + }, + { + path: `${base}/.asset-index.json.${thirdPredecessorDigest}.33333333-3333-4333-8333-333333333333.previous`, + bytes: 560, + digest: thirdPredecessorDigest, + }, + { path: `${base}/asset-index.json`, bytes: 600, digest: "c".repeat(64) }, + { + path: `${base}/assets/sha256/aa/${assetId}.png`, + bytes: 4096, + digest: assetId, + }, + { path: `${base}/index.json`, bytes: 240, digest: "d".repeat(64) }, + { path: `${base}/run-index.json`, bytes: 72, digest: "9".repeat(64) }, + { path: `${base}/thumbnails/${assetId}/512.png`, bytes: 1200, digest: "e".repeat(64) }, + { + path: `${base}/workflows/${workflowId}/workflow.json`, + bytes: 900, + digest: workflowDigest, + }, + { + path: `${base}/workflows/${workflowId}/workflow.last-known-good.json`, + bytes: 900, + digest: workflowDigest, + }, + ]; + assert.deepEqual( + createImagesPhaseTwoProductFileEvidence([], files, { + workflowId, + assetId, + assetExtension: "png", + }), + [...files].sort((left, right) => left.path.localeCompare(right.path)), + ); + assert.throws( + () => + createImagesPhaseTwoProductFileEvidence( + [], + [ + ...files, + { + path: `${base}/workflows/${workflowId}/workflow.autosave.json`, + bytes: 20, + digest: "f".repeat(64), + }, + ], + { workflowId, assetId, assetExtension: "png" }, + ), + /unexpected (?:durable files|file mutations)/u, + ); + assert.throws( + () => + createImagesPhaseTwoProductFileEvidence( + [], + files.map((entry) => + entry.path.endsWith("workflow.last-known-good.json") + ? { ...entry, digest: "f".repeat(64) } + : entry, + ), + { workflowId, assetId, assetExtension: "png" }, + ), + /content-addressed relationships/u, + ); +}); + +test("product-file snapshots detect durable writes but ignore Chromium-only files", async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-image-snapshot-")); + context.after(() => fs.rm(root, { recursive: true, force: true })); + const configDir = path.join(root, "portable"); + const userDataDir = path.join(root, "user-data"); + await fs.mkdir(path.join(userDataDir, "Cache"), { recursive: true }); + await fs.mkdir(path.join(userDataDir, "logs"), { recursive: true }); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(path.join(configDir, "config.json"), "{}", "utf8"); + await fs.writeFile(path.join(userDataDir, "config.json"), '{"user":true}', "utf8"); + await fs.writeFile(path.join(userDataDir, "usage.json"), '{"usage":[]}', "utf8"); + const before = await snapshotCreateImagesProductFiles({ configDir, userDataDir }); + assert.deepEqual( + before.map((entry) => entry.path), + ["config/config.json", "user-data/config.json", "user-data/usage.json"], + ); + await fs.writeFile(path.join(userDataDir, "Cache", "entry"), "ignored", "utf8"); + await fs.writeFile(path.join(userDataDir, "logs", "aiden.log"), "ignored", "utf8"); + assert.equal( + countCreateImagesProductFileMutations( + before, + await snapshotCreateImagesProductFiles({ configDir, userDataDir }), + ), + 0, + ); + await fs.writeFile(path.join(configDir, "config.json"), '{"changed":true}', "utf8"); + assert.equal( + countCreateImagesProductFileMutations( + before, + await snapshotCreateImagesProductFiles({ configDir, userDataDir }), + ), + 1, + ); + await fs.writeFile(path.join(userDataDir, "provider-keys.json"), '{"secret":"changed"}', "utf8"); + assert.equal( + countCreateImagesProductFileMutations( + before, + await snapshotCreateImagesProductFiles({ configDir, userDataDir }), + ), + 2, + ); +}); diff --git a/main/services/create-images/packaged-canvas-acceptance-core.ts b/main/services/create-images/packaged-canvas-acceptance-core.ts new file mode 100644 index 00000000..8e3cd2dd --- /dev/null +++ b/main/services/create-images/packaged-canvas-acceptance-core.ts @@ -0,0 +1,493 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { WorkflowDocumentV1 } from "../../../renderer/shared/create-images/schema.js"; + +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_ENV = "AIDEN_CREATE_IMAGES_PACKAGED_ACCEPTANCE"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH = "--aiden-create-images-acceptance-control"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROOT_PREFIX = "aiden-create-images-acceptance-"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_CONTROL_FILENAME = "control.json"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_RECEIPT_FILENAME = "receipt.json"; +export const CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION = 1 as const; + +const NONCE_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const PRIVATE_DIRECTORY_MODE = 0o700; +const PRIVATE_FILE_MODE = 0o600; +const MAX_CONTROL_BYTES = 4_096; +const VOLATILE_RUNTIME_USER_DATA_PATHS = new Set([ + "Cache", + "Code Cache", + "Cookies", + "Cookies-journal", + "Crashpad", + "DIPS", + "DIPS-wal", + "DawnGraphiteCache", + "DawnWebGPUCache", + "GPUCache", + "Local State", + "Local Storage", + "Network", + "Network Persistent State", + "Preferences", + "QuotaManager", + "QuotaManager-journal", + "Service Worker", + "Session Storage", + "Shared Dictionary", + "SharedStorage", + "SharedStorage-wal", + "SingletonCookie", + "SingletonLock", + "SingletonSocket", + "TransportSecurity", + "Trust Tokens", + "Trust Tokens-journal", + "WebStorage", + "blob_storage", + "logs", +]); + +export interface CreateImagesPackagedAcceptanceControl { + version: typeof CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION; + nonce: string; +} + +export interface CreateImagesPackagedAcceptanceReceipt { + version: typeof CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION; + nonce: string; + route: "/create-images/stress-100"; + initialNodeCount: 100; + addedNodeCount: 101; + duplicatedNodeCount: 102; + undoNodeCount: 101; + redoNodeCount: 102; + deletedNodeCount: 101; + nativeDeleteUndoNodeCount: 102; + nativeDeleteRedoNodeCount: 101; + spatialConnectionPassed: boolean; + spatialInvalidDropPassed: boolean; + nativeEdgeDeletePassed: boolean; + keyboardConnectionPassed: boolean; + keyboardMoveUndoPassed: boolean; + repeatedAnnouncementPassed: boolean; + uniqueAccessibleNodeLabels: boolean; + narrowValidationPassed: boolean; + narrowAddPlacementPassed: boolean; + focusRestoredAfterPalette: boolean; + focusRestoredAfterNativeDelete: boolean; + nativeNodeDeleteGraphPassed: boolean; + reducedMotionPassed: boolean; + liveRegionMutations: number; + keyboardActions: number; + rendererErrors: number; + networkRequests: number; + rendererEgressProbePassed: boolean; + rendererEgressProbeRequests: number; + rendererEgressProbeBlocked: number; + productFileMutations: number; + durableWorkflowPassed: boolean; + assetProtocolPreviewPassed: boolean; + assetProtocolGrantCount: number; + assetProtocolRequests: number; + assetProtocolAuthorizations: number; + assetProtocolLastRequest: CreateImagesPackagedAssetRequestEvidence; + rendererReloadPersistencePassed: boolean; + noGraphBase64Passed: boolean; + phaseTwoProductFileMutations: number; + phaseTwoProductFiles: ProductFileSnapshotEntry[]; + phaseTwoStorageRelationshipsPassed: boolean; + phaseTwoWorkflowRevision: number; + phaseTwoAssetBytes: number; + phaseTwoAssetWidth: number; + phaseTwoAssetHeight: number; + responsiveWidthsPassed: boolean; + sandboxed: boolean; + contextIsolation: boolean; + nodeIntegration: boolean; + durationMs: number; +} + +export interface CreateImagesPackagedAcceptanceSession { + control: CreateImagesPackagedAcceptanceControl; + root: string; + controlPath: string; + receiptPath: string; +} + +export interface LoadCreateImagesPackagedAcceptanceInput { + isPackaged: boolean; + argv?: readonly string[]; + environment?: Readonly>; + temporaryDirectory?: string; + userId?: number; +} + +export function isCreateImagesDurableWorkflowPublication( + workflow: WorkflowDocumentV1 | undefined, + initialRevision: number, + expectedPrompt: string, +): workflow is WorkflowDocumentV1 { + return ( + workflow !== undefined && + Number.isSafeInteger(workflow.revision) && + workflow.revision > initialRevision && + workflow.nodes.some((node) => node.type === "prompt" && node.data.text === expectedPrompt) + ); +} + +export interface ProductFileSnapshotEntry { + path: string; + bytes: number; + digest: string; +} + +export interface CreateImagesPackagedAssetRequestEvidence { + method: "GET"; + resourceType: "image"; + webContentsIdPresent: true; + framePresent: true; + frameIsMain: true; + frameDetached: false; +} + +const PRODUCT_DIGEST_PATTERN = /^[a-f0-9]{64}$/u; +const CREATE_IMAGES_PRODUCT_PREFIX = "user-data/create-images/"; +const ASSET_INDEX_PREDECESSOR_PATTERN = + /^user-data\/create-images\/\.asset-index\.json\.([a-f0-9]{64})\.[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.previous$/u; + +/** + * Produce strict, sanitized Create Images storage evidence for a fresh acceptance profile. + * Every Create Images durable file must be an expected publication or one of + * the three protected asset-index predecessors retained until restart; journals, + * quarantine records, unexpected thumbnails, orphan assets, and non-empty run + * indexes fail closed. The empty derived run index is expected after Phase 3. + */ +export function createImagesPhaseTwoProductFileEvidence( + before: readonly ProductFileSnapshotEntry[], + after: readonly ProductFileSnapshotEntry[], + identity: { workflowId: string; assetId: string; assetExtension: "jpg" | "png" }, +): ProductFileSnapshotEntry[] { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(identity.workflowId)) { + throw new Error("Packaged Create Images storage evidence has an invalid workflow ID."); + } + if (!PRODUCT_DIGEST_PATTERN.test(identity.assetId)) { + throw new Error("Packaged Create Images storage evidence has an invalid asset ID."); + } + if (before.some((entry) => entry.path.startsWith(CREATE_IMAGES_PRODUCT_PREFIX))) { + throw new Error("Packaged Create Images storage evidence did not start from a fresh profile."); + } + const workflowRoot = `${CREATE_IMAGES_PRODUCT_PREFIX}workflows/${identity.workflowId}`; + const fixedExpectedPaths = [ + `${CREATE_IMAGES_PRODUCT_PREFIX}asset-index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}assets/sha256/${identity.assetId.slice(0, 2)}/${identity.assetId}.${identity.assetExtension}`, + `${CREATE_IMAGES_PRODUCT_PREFIX}index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}run-index.json`, + `${CREATE_IMAGES_PRODUCT_PREFIX}thumbnails/${identity.assetId}/512.png`, + `${workflowRoot}/workflow.json`, + `${workflowRoot}/workflow.last-known-good.json`, + ]; + const predecessorPaths = after + .filter((entry) => ASSET_INDEX_PREDECESSOR_PATTERN.test(entry.path)) + .map((entry) => entry.path); + if (predecessorPaths.length !== 3) { + throw new Error( + "Packaged Create Images storage evidence did not retain its three protected index predecessors.", + ); + } + for (const filePath of predecessorPaths) { + const encodedDigest = ASSET_INDEX_PREDECESSOR_PATTERN.exec(filePath)?.[1]; + if (after.find((entry) => entry.path === filePath)?.digest !== encodedDigest) { + throw new Error("Packaged Create Images storage evidence has an invalid index predecessor."); + } + } + const expectedPaths = [...fixedExpectedPaths, ...predecessorPaths].sort((left, right) => + left.localeCompare(right), + ); + const previous = new Map(before.map((entry) => [entry.path, entry])); + const currentSnapshot = new Map(after.map((entry) => [entry.path, entry])); + const mutatedPaths = [...new Set([...previous.keys(), ...currentSnapshot.keys()])] + .filter((filePath) => { + const left = previous.get(filePath); + const right = currentSnapshot.get(filePath); + return !left || !right || left.bytes !== right.bytes || left.digest !== right.digest; + }) + .sort((left, right) => left.localeCompare(right)); + if ( + mutatedPaths.length !== expectedPaths.length || + mutatedPaths.some((filePath, index) => filePath !== expectedPaths[index]) + ) { + throw new Error("Packaged Create Images storage evidence found unexpected file mutations."); + } + const productFiles = after + .filter((entry) => entry.path.startsWith(CREATE_IMAGES_PRODUCT_PREFIX)) + .map((entry) => ({ ...entry })) + .sort((left, right) => left.path.localeCompare(right.path)); + if ( + productFiles.length !== expectedPaths.length || + productFiles.some((entry, index) => entry.path !== expectedPaths[index]) + ) { + throw new Error("Packaged Create Images storage evidence found unexpected durable files."); + } + if ( + productFiles.some( + (entry) => + !Number.isSafeInteger(entry.bytes) || + entry.bytes < 1 || + !PRODUCT_DIGEST_PATTERN.test(entry.digest), + ) + ) { + throw new Error("Packaged Create Images storage evidence contains invalid file metadata."); + } + const byPath = new Map(productFiles.map((entry) => [entry.path, entry])); + const asset = byPath.get( + `${CREATE_IMAGES_PRODUCT_PREFIX}assets/sha256/${identity.assetId.slice(0, 2)}/${identity.assetId}.${identity.assetExtension}`, + ); + const current = byPath.get(`${workflowRoot}/workflow.json`); + const lastKnownGood = byPath.get(`${workflowRoot}/workflow.last-known-good.json`); + if ( + asset?.digest !== identity.assetId || + !current || + !lastKnownGood || + current.bytes !== lastKnownGood.bytes || + current.digest !== lastKnownGood.digest + ) { + throw new Error( + "Packaged Create Images durable files do not satisfy content-addressed relationships.", + ); + } + return productFiles; +} + +function invalidControl(): Error { + return new Error("Invalid packaged Create Images acceptance control."); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function privateMode(actual: number, expected: number): boolean { + return (actual & 0o777) === expected; +} + +function ownedBy(stat: { uid: number }, userId: number | undefined): boolean { + return userId === undefined || stat.uid === userId; +} + +function sameFileIdentity( + left: { dev: number; ino: number; uid: number; nlink: number; size: number; mode: number }, + right: { dev: number; ino: number; uid: number; nlink: number; size: number; mode: number }, +): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.uid === right.uid && + left.nlink === right.nlink && + left.size === right.size && + left.mode === right.mode + ); +} + +function controlArgument(argv: readonly string[]): string | undefined { + const prefix = `${CREATE_IMAGES_PACKAGED_ACCEPTANCE_SWITCH}=`; + const values = argv + .filter((argument) => argument.startsWith(prefix)) + .map((argument) => argument.slice(prefix.length)); + return values.length === 1 && values[0] ? values[0] : undefined; +} + +export function parseCreateImagesPackagedAcceptanceControl( + value: unknown, +): CreateImagesPackagedAcceptanceControl { + if (!isRecord(value) || Object.keys(value).length !== 2) throw invalidControl(); + if ( + value.version !== CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION || + typeof value.nonce !== "string" || + !NONCE_PATTERN.test(value.nonce) + ) { + throw invalidControl(); + } + return { version: CREATE_IMAGES_PACKAGED_ACCEPTANCE_VERSION, nonce: value.nonce }; +} + +async function readPrivateControl( + controlPath: string, + userId: number | undefined, +): Promise { + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(controlPath, constants.O_RDONLY | constants.O_NOFOLLOW); + const before = await handle.stat(); + if ( + !before.isFile() || + before.isSymbolicLink() || + before.nlink !== 1 || + !ownedBy(before, userId) || + !privateMode(before.mode, PRIVATE_FILE_MODE) || + before.size < 1 || + before.size > MAX_CONTROL_BYTES + ) { + throw invalidControl(); + } + const bytes = Buffer.alloc(before.size); + const read = await handle.read(bytes, 0, bytes.length, 0); + const after = await handle.stat(); + if (read.bytesRead !== bytes.length || !sameFileIdentity(before, after)) { + throw invalidControl(); + } + return parseCreateImagesPackagedAcceptanceControl(JSON.parse(bytes.toString("utf8"))); + } catch (error) { + if (error instanceof SyntaxError) throw invalidControl(); + throw error; + } finally { + await handle?.close(); + } +} + +export async function loadCreateImagesPackagedAcceptanceSession( + input: LoadCreateImagesPackagedAcceptanceInput, +): Promise { + const environment = input.environment ?? process.env; + const argv = input.argv ?? process.argv; + if (!input.isPackaged || environment[CREATE_IMAGES_PACKAGED_ACCEPTANCE_ENV] !== "1") { + return undefined; + } + const suppliedControlPath = controlArgument(argv); + if (!suppliedControlPath || !path.isAbsolute(suppliedControlPath)) throw invalidControl(); + const temporaryDirectory = await fs.realpath(input.temporaryDirectory ?? os.tmpdir()); + const controlPath = await fs.realpath(suppliedControlPath); + const root = await fs.realpath(path.dirname(controlPath)); + if ( + path.dirname(root) !== temporaryDirectory || + !path.basename(root).startsWith(CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROOT_PREFIX) || + path.basename(controlPath) !== CREATE_IMAGES_PACKAGED_ACCEPTANCE_CONTROL_FILENAME + ) { + throw invalidControl(); + } + const rootStat = await fs.lstat(root); + const userId = + input.userId ?? (typeof process.getuid === "function" ? process.getuid() : undefined); + if ( + !rootStat.isDirectory() || + rootStat.isSymbolicLink() || + !ownedBy(rootStat, userId) || + !privateMode(rootStat.mode, PRIVATE_DIRECTORY_MODE) + ) { + throw invalidControl(); + } + const receiptPath = path.join(root, CREATE_IMAGES_PACKAGED_ACCEPTANCE_RECEIPT_FILENAME); + try { + await fs.lstat(receiptPath); + throw invalidControl(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + return { + control: await readPrivateControl(controlPath, userId), + root, + controlPath, + receiptPath, + }; +} + +async function snapshotTarget( + target: string, + displayRoot: string, + displayPrefix: "config" | "user-data", + output: ProductFileSnapshotEntry[], +): Promise { + let stat; + try { + stat = await fs.lstat(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (stat.isSymbolicLink()) throw new Error("Product persistence snapshot rejected a symlink."); + if (stat.isDirectory()) { + const entries = await fs.readdir(target); + entries.sort((left, right) => left.localeCompare(right)); + for (const entry of entries) { + await snapshotTarget(path.join(target, entry), displayRoot, displayPrefix, output); + } + return; + } + if (!stat.isFile()) throw new Error("Product persistence snapshot rejected a special file."); + const bytes = await fs.readFile(target); + output.push({ + path: path.posix.join( + displayPrefix, + path.relative(displayRoot, target).split(path.sep).join("/"), + ), + bytes: bytes.length, + digest: createHash("sha256").update(bytes).digest("hex"), + }); +} + +/** Snapshot every Aiden-owned durable record, excluding explicit Chromium/runtime-only paths. */ +export async function snapshotCreateImagesProductFiles(input: { + configDir: string; + userDataDir: string; +}): Promise { + const output: ProductFileSnapshotEntry[] = []; + await snapshotTarget(input.configDir, input.configDir, "config", output); + let userDataEntries: string[] = []; + try { + userDataEntries = await fs.readdir(input.userDataDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + userDataEntries.sort((left, right) => left.localeCompare(right)); + for (const name of userDataEntries) { + if (VOLATILE_RUNTIME_USER_DATA_PATHS.has(name)) continue; + await snapshotTarget( + path.join(input.userDataDir, name), + input.userDataDir, + "user-data", + output, + ); + } + return output.sort((left, right) => left.path.localeCompare(right.path)); +} + +export function countCreateImagesProductFileMutations( + before: readonly ProductFileSnapshotEntry[], + after: readonly ProductFileSnapshotEntry[], +): number { + const previous = new Map(before.map((entry) => [entry.path, entry])); + const current = new Map(after.map((entry) => [entry.path, entry])); + const paths = new Set([...previous.keys(), ...current.keys()]); + let mutations = 0; + for (const filePath of paths) { + const left = previous.get(filePath); + const right = current.get(filePath); + if (!left || !right || left.bytes !== right.bytes || left.digest !== right.digest) { + mutations += 1; + } + } + return mutations; +} + +export async function writeCreateImagesPackagedAcceptanceReceipt( + session: CreateImagesPackagedAcceptanceSession, + receipt: CreateImagesPackagedAcceptanceReceipt, +): Promise { + if (receipt.nonce !== session.control.nonce) throw invalidControl(); + const handle = await fs.open(session.receiptPath, "wx", PRIVATE_FILE_MODE); + try { + await handle.writeFile(`${JSON.stringify(receipt, null, 2)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.chmod(session.receiptPath, PRIVATE_FILE_MODE); + const directory = await fs.open(session.root, "r"); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} diff --git a/main/services/create-images/packaged-canvas-acceptance-runner.ts b/main/services/create-images/packaged-canvas-acceptance-runner.ts new file mode 100644 index 00000000..f8290876 --- /dev/null +++ b/main/services/create-images/packaged-canvas-acceptance-runner.ts @@ -0,0 +1,1467 @@ +import { app } from "../../platform.js"; +import type { BrowserWindow } from "electron"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { deflateSync } from "node:zlib"; +import { ONBOARDING_COMPLETE_STORAGE_KEY } from "../../../renderer/shared/onboarding.js"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { + countCreateImagesProductFileMutations, + createImagesPhaseTwoProductFileEvidence, + isCreateImagesDurableWorkflowPublication, + snapshotCreateImagesProductFiles, + writeCreateImagesPackagedAcceptanceReceipt, + type CreateImagesPackagedAssetRequestEvidence, + type CreateImagesPackagedAcceptanceSession, +} from "./packaged-canvas-acceptance-core.js"; +import { createImagesService } from "./create-images-service.js"; +import { observeCreateImagesRequestPolicy } from "./asset-protocol.js"; + +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROUTE = "/create-images/stress-100" as const; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_WAIT_MS = 30_000; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_POLL_MS = 25; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_BASELINE_STABLE_SAMPLES = 80; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH = 4_000; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT = 4_000; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_METADATA_BYTES = 20 * 1024 * 1024; +const CREATE_IMAGES_PACKAGED_ACCEPTANCE_EGRESS_PROBE = + "https://create-images-acceptance.invalid/blocked"; + +function createImagesAcceptanceCrc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function createImagesAcceptanceU32(value: number): Buffer { + const bytes = Buffer.alloc(4); + bytes.writeUInt32BE(value); + return bytes; +} + +function createImagesAcceptancePngChunk(type: string, payload: Buffer): Buffer { + const typeBytes = Buffer.from(type, "ascii"); + const checksum = Buffer.concat([typeBytes, payload]); + return Buffer.concat([ + createImagesAcceptanceU32(payload.byteLength), + checksum, + createImagesAcceptanceU32(createImagesAcceptanceCrc32(checksum)), + ]); +} + +/** Same deterministic near-limit static PNG exercised by the decoder memory canary. */ +function createImagesAcceptanceLargePng(): Uint8Array { + const header = Buffer.alloc(13); + header.writeUInt32BE(CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH, 0); + header.writeUInt32BE(CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT, 4); + header[8] = 8; + header[9] = 6; + const rowBytes = CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH * 4 + 1; + const pixels = Buffer.alloc(rowBytes * CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + createImagesAcceptancePngChunk("IHDR", header), + createImagesAcceptancePngChunk( + "tEXt", + Buffer.alloc(CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_METADATA_BYTES), + ), + createImagesAcceptancePngChunk("IDAT", deflateSync(pixels, { level: 9 })), + createImagesAcceptancePngChunk("IEND", Buffer.alloc(0)), + ]); +} + +// Packaged acceptance uses only fixed, build-time scripts and native key events. +// The control file cannot provide routes, selectors, JavaScript, or workflow data. +const CREATE_IMAGES_ACCEPTANCE_COMPLETE_ONBOARDING_SCRIPT = `(() => { + localStorage.setItem("${ONBOARDING_COMPLETE_STORAGE_KEY}", "true"); + localStorage.setItem("aiden-agent.sidebar-collapsed", "0"); + localStorage.setItem("aiden-agent.sidebar-width", "340"); + return true; +})()`; +const CREATE_IMAGES_ACCEPTANCE_READY_SCRIPT = `(() => { + const workbench = document.querySelector(".create-images-workbench"); + return workbench instanceof HTMLElement && workbench.dataset.nodeCount === "100"; +})()`; +const CREATE_IMAGES_ACCEPTANCE_INSTALL_ERROR_COUNTER_SCRIPT = `(() => { + globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ = 0; + window.addEventListener("error", () => { globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ += 1; }); + window.addEventListener("unhandledrejection", () => { globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ += 1; }); + window.addEventListener("securitypolicyviolation", () => { globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ += 1; }); + return true; +})()`; +const CREATE_IMAGES_ACCEPTANCE_NODE_COUNT_SCRIPT = `(() => { + const value = document.querySelector(".create-images-workbench")?.getAttribute("data-node-count"); + return value && /^\\d+$/u.test(value) ? Number(value) : -1; +})()`; +const CREATE_IMAGES_PHASE_TWO_READY_SCRIPT = `(() => { + const workbench = document.querySelector(".create-images-workbench"); + const preview = document.querySelector('.create-images-node img[src^="aiden-asset://asset/"]'); + const prompt = document.querySelector('textarea[aria-label^="Prompt text · "]'); + return { + workbenchPresent: workbench instanceof HTMLElement, + nodeCount: workbench instanceof HTMLElement ? workbench.dataset.nodeCount ?? null : null, + previewPresent: preview instanceof HTMLImageElement, + previewComplete: preview instanceof HTMLImageElement ? preview.complete : false, + previewWidth: preview instanceof HTMLImageElement ? preview.naturalWidth : 0, + promptPresent: prompt instanceof HTMLTextAreaElement, + }; +})()`; +const CREATE_IMAGES_PHASE_TWO_EDIT_SCRIPT = `(() => { + const prompt = document.querySelector('textarea[aria-label^="Prompt text · "]'); + if (!(prompt instanceof HTMLTextAreaElement)) return false; + const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set; + if (!setter) return false; + setter.call(prompt, "Packaged durable prompt edit"); + prompt.dispatchEvent(new Event("input", { bubbles: true })); + prompt.focus(); + prompt.blur(); + return true; +})()`; +const CREATE_IMAGES_PHASE_TWO_REOPENED_SCRIPT = `(() => { + const preview = document.querySelector('.create-images-node img[src^="aiden-asset://asset/"]'); + const prompt = document.querySelector('textarea[aria-label^="Prompt text · "]'); + return preview instanceof HTMLImageElement && preview.complete && preview.naturalWidth > 0 && + prompt instanceof HTMLTextAreaElement && prompt.value === "Packaged durable prompt edit"; +})()`; +const CREATE_IMAGES_ACCEPTANCE_EDGE_COUNT_SCRIPT = `(() => { + const value = document.querySelector(".create-images-workbench")?.getAttribute("data-edge-count"); + return value && /^\\d+$/u.test(value) ? Number(value) : -1; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_INSPECTOR_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Toggle node inspector"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_INSPECTOR_OPEN_SCRIPT = + 'document.querySelector(".create-images-inspector") instanceof HTMLElement'; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_ZOOM_IN_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Zoom In"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_FIT_WORKFLOW_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Fit workflow"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_NODE_LABELS_SCRIPT = `(() => { + const buttons = Array.from(document.querySelectorAll('ul[aria-label="Workflow nodes"] button')); + return buttons.map((button) => button.textContent?.replace(/\\s+/gu, " ").trim() ?? ""); +})()`; +const CREATE_IMAGES_ACCEPTANCE_NO_REDUNDANT_NODE_SEMANTICS_SCRIPT = `(() => { + const promptLabels = Array.from( + document.querySelectorAll('textarea[aria-label^="Prompt text · "]'), + (element) => element.getAttribute("aria-label") ?? "", + ); + return document.querySelectorAll("article.create-images-node, .create-images-node[aria-label]").length === 0 && + promptLabels.length > 0 && new Set(promptLabels).size === promptLabels.length; +})()`; +const CREATE_IMAGES_ACCEPTANCE_PREPARE_SPATIAL_EDGE_SCRIPT = `(() => { + const workbench = document.querySelector(".create-images-workbench"); + const inspector = document.querySelector(".create-images-inspector"); + if (!(workbench instanceof HTMLElement)) return false; + const workbenchBounds = workbench.getBoundingClientRect(); + const inspectorBounds = inspector instanceof HTMLElement ? inspector.getBoundingClientRect() : null; + const sources = Array.from(document.querySelectorAll('.react-flow__handle.source[data-handleid="text"]')); + for (const source of sources) { + if (!(source instanceof HTMLElement)) continue; + const sourceNodeId = source.dataset.nodeid ?? ""; + const match = /^stress-prompt-(\\d+)$/u.exec(sourceNodeId); + if (!match) continue; + const targetNodeId = "stress-generate-" + match[1]; + const target = Array.from(document.querySelectorAll('.react-flow__handle.target[data-handleid="prompt"]')).find( + (candidate) => candidate instanceof HTMLElement && candidate.dataset.nodeid === targetNodeId, + ); + if (!(target instanceof HTMLElement)) continue; + const sourceBounds = source.getBoundingClientRect(); + const targetBounds = target.getBoundingClientRect(); + const inside = [sourceBounds, targetBounds].every((bounds) => + bounds.width > 0 && bounds.height > 0 && bounds.left >= workbenchBounds.left && + bounds.right <= workbenchBounds.right && bounds.top >= workbenchBounds.top && + bounds.bottom <= workbenchBounds.bottom && (!inspectorBounds || bounds.right < inspectorBounds.left) + ); + if (!inside) continue; + globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__ = { + sourceNodeId, + targetNodeId, + edgeId: "stress-edge-prompt-" + match[1], + }; + return true; + } + return false; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_MANAGE_CONNECTIONS_SCRIPT = `(() => { + const button = Array.from(document.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes("Manage connections"), + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_SPATIAL_DISCONNECT_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + if (!edge || typeof edge.edgeId !== "string") return false; + const button = Array.from(document.querySelectorAll("button[data-disconnect-edge]")).find( + (candidate) => candidate instanceof HTMLButtonElement && candidate.dataset.disconnectEdge === edge.edgeId, + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_KEYBOARD_DISCONNECT_SCRIPT = `(() => { + const button = Array.from(document.querySelectorAll("button[data-disconnect-edge]")).find( + (candidate) => { + const text = candidate.closest("li")?.textContent ?? ""; + return text.includes("stress-prompt-0") && text.includes("stress-generate-0"); + }, + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECT_NODES_SCRIPT = `(() => { + const button = Array.from(document.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === "Connect nodes", + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_SPATIAL_POINTS_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + if (!edge) return null; + const source = Array.from(document.querySelectorAll('.react-flow__handle.source[data-handleid="text"]')).find( + (candidate) => candidate instanceof HTMLElement && candidate.dataset.nodeid === edge.sourceNodeId, + ); + const target = Array.from(document.querySelectorAll('.react-flow__handle.target[data-handleid="prompt"]')).find( + (candidate) => candidate instanceof HTMLElement && candidate.dataset.nodeid === edge.targetNodeId, + ); + if (!(source instanceof HTMLElement) || !(target instanceof HTMLElement)) return null; + const from = source.getBoundingClientRect(); + const to = target.getBoundingClientRect(); + return { + fromX: Math.round(from.left + from.width / 2), + fromY: Math.round(from.top + from.height / 2), + toX: Math.round(to.left + to.width / 2), + toY: Math.round(to.top + to.height / 2), + }; +})()`; +const CREATE_IMAGES_ACCEPTANCE_INSTALL_LIVE_MUTATION_COUNTER_SCRIPT = `(() => { + const liveRegion = document.querySelector("[data-create-images-action-status]"); + if (!(liveRegion instanceof HTMLElement)) return false; + globalThis.__AIDEN_CREATE_IMAGES_LIVE_MUTATIONS__ = 0; + const previous = globalThis.__AIDEN_CREATE_IMAGES_LIVE_OBSERVER__; + if (previous instanceof MutationObserver) previous.disconnect(); + const observer = new MutationObserver((records) => { + globalThis.__AIDEN_CREATE_IMAGES_LIVE_MUTATIONS__ += records.length; + }); + observer.observe(liveRegion, { childList: true, characterData: true, subtree: true }); + globalThis.__AIDEN_CREATE_IMAGES_LIVE_OBSERVER__ = observer; + return true; +})()`; +const CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT = + "Number(globalThis.__AIDEN_CREATE_IMAGES_LIVE_MUTATIONS__ ?? 0)"; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_ADD_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Add node"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_OUTPUT_GALLERY_SCRIPT = `(() => { + const dialog = document.querySelector("[data-create-images-node-palette]"); + if (!(dialog instanceof HTMLElement)) return false; + const button = Array.from(dialog.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("Output Gallery") + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_INSIDE_SCRIPT = `(() => { + const dialog = document.querySelector("[data-create-images-node-palette]"); + const search = dialog?.querySelector("input"); + return dialog instanceof HTMLElement && dialog.contains(document.activeElement) && document.activeElement !== search; +})()`; +const CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_SEARCH_SCRIPT = `(() => { + const dialog = document.querySelector("[data-create-images-node-palette]"); + const search = dialog?.querySelector("input"); + return search instanceof HTMLInputElement && document.activeElement === search; +})()`; +const CREATE_IMAGES_ACCEPTANCE_PALETTE_CLOSED_SCRIPT = `(() => { + const add = document.querySelector('button[aria-label="Add node"]'); + return !document.querySelector("[data-create-images-node-palette]") && document.activeElement === add; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECTED_CANVAS_NODE_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + if (!edge || typeof edge.sourceNodeId !== "string") return false; + const node = Array.from(document.querySelectorAll(".react-flow__node[data-id]")).find( + (candidate) => candidate instanceof HTMLElement && candidate.dataset.id === edge.sourceNodeId, + ); + if (!(node instanceof HTMLElement)) return false; + node.focus(); + return document.activeElement === node; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECTED_INSPECTOR_NODE_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + if (!edge || typeof edge.sourceNodeId !== "string") return false; + const button = Array.from(document.querySelectorAll("button[data-workflow-node-id]")).find( + (candidate) => candidate instanceof HTMLButtonElement && + candidate.dataset.workflowNodeId === edge.sourceNodeId, + ); + if (!(button instanceof HTMLButtonElement)) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_BACK_TO_NODES_SCRIPT = `(() => { + const button = Array.from(document.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === "Back to nodes", + ); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_CONNECTION_TOOLS_OPEN_SCRIPT = `(() => + Array.from(document.querySelectorAll("button")).some( + (candidate) => candidate.textContent?.trim() === "Back to nodes", + ) +)()`; +const CREATE_IMAGES_ACCEPTANCE_CONNECTED_CANVAS_NODE_SELECTED_SCRIPT = `(() => { + const edge = globalThis.__AIDEN_CREATE_IMAGES_SPATIAL_EDGE__; + const node = document.querySelector(".react-flow__node.selected"); + return node instanceof HTMLElement && node.dataset.id === edge?.sourceNodeId; +})()`; +const CREATE_IMAGES_ACCEPTANCE_SELECTED_CANVAS_NODE_FOCUSED_SCRIPT = `(() => { + const node = document.querySelector(".react-flow__node.selected"); + return node instanceof HTMLElement && document.activeElement === node; +})()`; +const CREATE_IMAGES_ACCEPTANCE_CAPTURE_SELECTED_NODE_X_SCRIPT = `(() => { + const node = document.querySelector(".react-flow__node.selected"); + if (!(node instanceof HTMLElement) || document.activeElement !== node) return null; + const transform = node.style.transform; + globalThis.__AIDEN_CREATE_IMAGES_SELECTED_NODE_TRANSFORM__ = transform; + return transform; +})()`; +const CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_MOVED_SCRIPT = `(() => { + const node = document.querySelector(".react-flow__node.selected"); + const before = globalThis.__AIDEN_CREATE_IMAGES_SELECTED_NODE_TRANSFORM__; + return node instanceof HTMLElement && typeof before === "string" && + node.style.transform !== before; +})()`; +const CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_POSITION_RESTORED_SCRIPT = `(() => { + const node = document.querySelector(".react-flow__node.selected"); + const before = globalThis.__AIDEN_CREATE_IMAGES_SELECTED_NODE_TRANSFORM__; + return node instanceof HTMLElement && typeof before === "string" && + node.style.transform === before; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_EDGE_SCRIPT = `(() => { + const edge = Array.from(document.querySelectorAll(".react-flow__edge")).find( + (candidate) => candidate instanceof SVGElement && candidate.getAttribute("tabindex") === "0", + ); + if (!(edge instanceof SVGElement)) return false; + edge.focus(); + return document.activeElement === edge; +})()`; +const CREATE_IMAGES_ACCEPTANCE_EDGE_SELECTED_SCRIPT = + "Boolean(document.querySelector('.react-flow__edge.selected'))"; +const CREATE_IMAGES_ACCEPTANCE_INSPECTOR_FOCUSED_SCRIPT = `(() => { + const button = document.querySelector('button[aria-label="Toggle node inspector"]'); + return button instanceof HTMLButtonElement && document.activeElement === button; +})()`; +const createImagesAcceptanceFocusButtonScript = ( + label: "Undo" | "Redo" | "Delete selected nodes", +) => `(() => { + const button = document.querySelector('button[aria-label="${label}"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_ANNOUNCEMENT_SCRIPT = `(() => + document.querySelector("[data-create-images-action-status]")?.textContent?.trim() ?? "" +)()`; +const CREATE_IMAGES_ACCEPTANCE_RENDERER_ERRORS_SCRIPT = + "Number(globalThis.__AIDEN_CREATE_IMAGES_ACCEPTANCE_ERRORS__ ?? 0)"; +const CREATE_IMAGES_ACCEPTANCE_ASSISTANT_HIDDEN_SCRIPT = `(() => { + const dock = document.querySelector('[data-environment-modal-background="assistant"]'); + return dock instanceof HTMLElement && dock.inert && dock.getAttribute("aria-hidden") === "true" && getComputedStyle(dock).visibility === "hidden"; +})()`; +const CREATE_IMAGES_ACCEPTANCE_ENABLE_REDUCED_MOTION_SCRIPT = `(() => { + document.documentElement.dataset.reduceMotion = "true"; + const button = document.querySelector('button[aria-label="Fit workflow"]'); + if (!(button instanceof HTMLButtonElement) || button.disabled) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_REDUCED_MOTION_SCRIPT = `(() => { + const node = document.querySelector(".create-images-node"); + const handle = document.querySelector(".create-images-handle"); + if (!(node instanceof HTMLElement) || !(handle instanceof HTMLElement)) return false; + const nodeStyle = getComputedStyle(node); + const handleStyle = getComputedStyle(handle); + const durations = [ + nodeStyle.animationDuration, + nodeStyle.transitionDuration, + handleStyle.animationDuration, + handleStyle.transitionDuration, + ].flatMap((value) => value.split(",")).map((value) => Number.parseFloat(value)); + return document.documentElement.dataset.reduceMotion === "true" && + durations.length > 0 && durations.every((value) => Number.isFinite(value) && value <= 0.001); +})()`; +const CREATE_IMAGES_ACCEPTANCE_RESPONSIVE_SCRIPT = `(() => ({ + width: window.innerWidth, + workbenchWidth: document.querySelector(".create-images-workbench")?.getBoundingClientRect().width ?? -1, + sidebarWidth: Number(document.querySelector('[role="separator"][aria-label="Resize sidebar"]')?.getAttribute("aria-valuenow") ?? -1), + overflowFree: document.documentElement.scrollWidth <= window.innerWidth + 1 && document.body.scrollWidth <= window.innerWidth + 1, + minimapVisible: Boolean(document.querySelector(".react-flow__minimap")), + minimapToggleVisible: Boolean(document.querySelector('button[aria-label="Toggle minimap"]')), + validationIssueTriggerVisible: (() => { + const trigger = document.querySelector('button[aria-controls="create-images-validation-issues"]'); + return trigger instanceof HTMLButtonElement && getComputedStyle(trigger).display !== "none"; + })(), +}))()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_VALIDATION_TRIGGER_SCRIPT = `(() => { + const trigger = document.querySelector('button[aria-controls="create-images-validation-issues"]'); + if (!(trigger instanceof HTMLButtonElement) || getComputedStyle(trigger).display === "none") return false; + trigger.focus(); + return document.activeElement === trigger; +})()`; +const CREATE_IMAGES_ACCEPTANCE_FOCUS_FIRST_VALIDATION_ISSUE_SCRIPT = `(() => { + const button = document.querySelector('#create-images-validation-issues li button:not(:disabled)'); + if (!(button instanceof HTMLButtonElement)) return false; + button.focus(); + return document.activeElement === button; +})()`; +const CREATE_IMAGES_ACCEPTANCE_VALIDATION_ISSUE_FOCUSED_SCRIPT = `(() => { + const panel = document.querySelector("#create-images-validation-issues"); + return panel instanceof HTMLElement && panel.contains(document.activeElement) && + document.activeElement?.matches("li button:not(:disabled)") === true; +})()`; +const CREATE_IMAGES_ACCEPTANCE_VALIDATION_TARGET_FOCUSED_SCRIPT = `(() => { + const active = document.activeElement; + return active instanceof Element && ( + active.matches(".react-flow__node, .react-flow__edge, button[data-workflow-node-id]") || + active.matches('.create-images-inspector[aria-label="Workflow node inspector"]') + ); +})()`; +const CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_VISIBLE_SCRIPT = `(() => { + const workbench = document.querySelector(".create-images-workbench"); + const node = document.querySelector(".react-flow__node.selected"); + if (!(workbench instanceof HTMLElement) || !(node instanceof HTMLElement)) return false; + const outer = workbench.getBoundingClientRect(); + const inner = node.getBoundingClientRect(); + return inner.width > 0 && inner.height > 0 && + inner.left >= outer.left - 1 && inner.right <= outer.right + 1 && + inner.top >= outer.top + 51 && inner.bottom <= outer.bottom + 1 && + !document.querySelector(".create-images-inspector"); +})()`; + +export interface RunPackagedCreateImagesAcceptanceOptions { + window: BrowserWindow; + reloadRenderer(): Promise; + navigate(path: string): Promise; + runtimeProfile: { configDir: string; userDataPath: string }; +} + +interface PhaseTwoReadyObservation { + workbenchPresent: boolean; + nodeCount: string | null; + previewPresent: boolean; + previewComplete: boolean; + previewWidth: number; + promptPresent: boolean; + grantCount: number; + assetProtocolRequests: number; + assetProtocolAuthorizations: number; + lastAssetRequest: { + method: string; + resourceType: string; + webContentsIdPresent: boolean; + framePresent: boolean; + frameIsMain: boolean; + frameDetached: boolean; + } | null; +} + +function isAcceptedAssetRequestEvidence( + value: PhaseTwoReadyObservation["lastAssetRequest"], +): value is CreateImagesPackagedAssetRequestEvidence { + return ( + value?.method === "GET" && + value.resourceType === "image" && + value.webContentsIdPresent && + value.framePresent && + value.frameIsMain && + !value.frameDetached + ); +} + +let mainWindow: BrowserWindow | null = null; +let createImagesAcceptanceKeyboardActions = 0; + +function pauseForCreateImagesPackagedAcceptance(): Promise { + return new Promise((resolve) => setTimeout(resolve, CREATE_IMAGES_PACKAGED_ACCEPTANCE_POLL_MS)); +} + +async function waitForCreateImagesPackagedAcceptance( + step: string, + read: () => Promise, + accept: (value: T) => boolean, +): Promise { + const deadline = Date.now() + CREATE_IMAGES_PACKAGED_ACCEPTANCE_WAIT_MS; + let lastValue: T | undefined; + while (Date.now() < deadline) { + const value = await read(); + lastValue = value; + if (accept(value)) return value; + await pauseForCreateImagesPackagedAcceptance(); + } + let observation = "unavailable"; + try { + observation = JSON.stringify(lastValue); + } catch { + observation = "unserializable"; + } + throw new Error( + `Packaged Create Images acceptance did not reach ${step}. Last observation: ${observation}.`, + ); +} + +async function readCreateImagesAcceptanceScript(script: string): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its main window."); + } + return (await window.webContents.executeJavaScript(script, true)) as T; +} + +async function focusCreateImagesAcceptanceWindow(window: BrowserWindow): Promise { + app.focus({ steal: true }); + window.show(); + window.focus(); + window.webContents.focus(); + await pauseForCreateImagesPackagedAcceptance(); +} + +async function activateCreateImagesAcceptanceControl(focusScript: string): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its main window."); + } + await focusCreateImagesAcceptanceWindow(window); + const focused = await readCreateImagesAcceptanceScript(focusScript); + if (!focused) throw new Error("Packaged Create Images acceptance could not focus a control."); + createImagesAcceptanceKeyboardActions += 1; + window.webContents.sendInputEvent({ type: "keyDown", keyCode: "Space" }); + await pauseForCreateImagesPackagedAcceptance(); + window.webContents.sendInputEvent({ type: "keyUp", keyCode: "Space" }); + await pauseForCreateImagesPackagedAcceptance(); +} + +async function sendCreateImagesAcceptanceDuplicateShortcut(): Promise { + await sendCreateImagesAcceptanceKey("D", ["meta"]); +} + +async function sendCreateImagesAcceptanceKey( + keyCode: string, + modifiers: Electron.KeyboardInputEvent["modifiers"] = [], +): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its main window."); + } + await focusCreateImagesAcceptanceWindow(window); + createImagesAcceptanceKeyboardActions += 1; + window.webContents.sendInputEvent({ type: "keyDown", keyCode, modifiers }); + await pauseForCreateImagesPackagedAcceptance(); + window.webContents.sendInputEvent({ type: "keyUp", keyCode, modifiers }); + await pauseForCreateImagesPackagedAcceptance(); +} + +async function sendCreateImagesAcceptanceTab(shift = false): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its main window."); + } + await focusCreateImagesAcceptanceWindow(window); + createImagesAcceptanceKeyboardActions += 1; + const modifiers: Electron.KeyboardInputEvent["modifiers"] = shift ? ["shift"] : []; + window.webContents.sendInputEvent({ type: "keyDown", keyCode: "Tab", modifiers }); + await pauseForCreateImagesPackagedAcceptance(); + window.webContents.sendInputEvent({ type: "keyUp", keyCode: "Tab", modifiers }); + await pauseForCreateImagesPackagedAcceptance(); +} + +async function waitForCreateImagesNodeCount(expected: number): Promise { + return waitForCreateImagesPackagedAcceptance( + `${expected} nodes`, + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_NODE_COUNT_SCRIPT), + (count) => count === expected, + ); +} + +async function waitForCreateImagesEdgeCount(expected: number): Promise { + return waitForCreateImagesPackagedAcceptance( + `${expected} edges`, + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_EDGE_COUNT_SCRIPT), + (count) => count === expected, + ); +} + +async function waitForCreateImagesProductFilesToSettle(input: { + configDir: string; + userDataDir: string; +}): Promise>> { + const deadline = Date.now() + CREATE_IMAGES_PACKAGED_ACCEPTANCE_WAIT_MS; + let previous = await snapshotCreateImagesProductFiles(input); + let stableSamples = 0; + while (Date.now() < deadline) { + await pauseForCreateImagesPackagedAcceptance(); + const current = await snapshotCreateImagesProductFiles(input); + if (countCreateImagesProductFileMutations(previous, current) === 0) { + stableSamples += 1; + if (stableSamples >= CREATE_IMAGES_PACKAGED_ACCEPTANCE_BASELINE_STABLE_SAMPLES) + return current; + } else { + stableSamples = 0; + } + previous = current; + } + throw new Error("Packaged Create Images product files did not reach a stable baseline."); +} + +async function dragCreateImagesAcceptanceConnection(): Promise { + const points = await readCreateImagesAcceptanceScript<{ + fromX: number; + fromY: number; + toX: number; + toY: number; + } | null>(CREATE_IMAGES_ACCEPTANCE_SPATIAL_POINTS_SCRIPT); + const window = mainWindow; + if (!points || !window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance lost its spatial connection handles."); + } + await focusCreateImagesAcceptanceWindow(window); + window.webContents.sendInputEvent({ + type: "mouseMove", + x: points.fromX, + y: points.fromY, + }); + window.webContents.sendInputEvent({ + type: "mouseDown", + x: points.fromX, + y: points.fromY, + button: "left", + clickCount: 1, + }); + for (let step = 1; step <= 6; step += 1) { + const progress = step / 6; + window.webContents.sendInputEvent({ + type: "mouseMove", + x: Math.round(points.fromX + (points.toX - points.fromX) * progress), + y: Math.round(points.fromY + (points.toY - points.fromY) * progress), + button: "left", + }); + await pauseForCreateImagesPackagedAcceptance(); + } + window.webContents.sendInputEvent({ + type: "mouseUp", + x: points.toX, + y: points.toY, + button: "left", + clickCount: 1, + }); +} + +async function observeCreateImagesAnnouncement(expected: RegExp): Promise { + await waitForCreateImagesPackagedAcceptance( + `announcement ${expected.source}`, + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_ANNOUNCEMENT_SCRIPT), + (announcement) => expected.test(announcement), + ); +} + +export async function runPackagedCreateImagesAcceptance( + acceptance: CreateImagesPackagedAcceptanceSession, + options: RunPackagedCreateImagesAcceptanceOptions, +): Promise { + mainWindow = options.window; + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("Packaged Create Images acceptance requires a live main window."); + } + const startedAt = performance.now(); + createImagesAcceptanceKeyboardActions = 0; + await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_COMPLETE_ONBOARDING_SCRIPT, + ); + await options.reloadRenderer(); + + const runtimeProfile = options.runtimeProfile; + const persistenceInput = { + configDir: runtimeProfile.configDir, + userDataDir: runtimeProfile.userDataPath, + }; + // A fresh acceptance profile creates ordinary app bootstrap records asynchronously. Establish + // the baseline only after those writes settle, before navigating to the side-effect-free canvas. + const filesBefore = await waitForCreateImagesProductFilesToSettle(persistenceInput); + let networkRequests = 0; + let rendererEgressProbeRequests = 0; + let rendererEgressProbeBlocked = 0; + let rendererEgressProbeWebContentsPresent = false; + let assetProtocolRequests = 0; + let assetProtocolAuthorizations = 0; + let lastAssetRequest: PhaseTwoReadyObservation["lastAssetRequest"] = null; + let mainObservedRendererErrors = 0; + const onConsoleMessage = ( + event: Electron.Event, + legacyLevel: number, + legacyMessage: string, + ) => { + const level = event.level ?? legacyLevel; + const message = event.message ?? legacyMessage ?? ""; + if ( + level === "error" || + (typeof level === "number" && level >= 3) || + /content security policy|securitypolicyviolation/iu.test(message) + ) { + mainObservedRendererErrors += 1; + } + }; + const onRenderProcessGone = () => { + mainObservedRendererErrors += 1; + }; + const onDidFailLoad = ( + _event: Electron.Event, + _errorCode: number, + _errorDescription: string, + _validatedURL: string, + isMainFrame: boolean, + ) => { + if (isMainFrame) mainObservedRendererErrors += 1; + }; + window.webContents.on("console-message", onConsoleMessage); + window.webContents.on("render-process-gone", onRenderProcessGone); + window.webContents.on("did-fail-load", onDidFailLoad); + const stopRequestPolicyObservation = observeCreateImagesRequestPolicy((observation) => { + if (observation.kind === "renderer-egress") { + if (observation.url === CREATE_IMAGES_PACKAGED_ACCEPTANCE_EGRESS_PROBE) { + rendererEgressProbeRequests += 1; + if (!observation.allowed) rendererEgressProbeBlocked += 1; + rendererEgressProbeWebContentsPresent ||= observation.webContentsIdPresent; + return; + } + networkRequests += 1; + return; + } + assetProtocolRequests += 1; + if (observation.allowed) assetProtocolAuthorizations += 1; + lastAssetRequest = { + method: observation.method, + resourceType: observation.resourceType, + webContentsIdPresent: observation.webContentsIdPresent, + framePresent: observation.framePresent, + frameIsMain: observation.frameIsMain, + frameDetached: observation.frameDetached, + }; + }); + try { + // Use Electron's download entry point so the production webRequest policy + // sees a request owned by the real main WebContents. A renderer fetch/image + // is correctly stopped by CSP before webRequest and cannot prove this layer. + window.webContents.downloadURL(CREATE_IMAGES_PACKAGED_ACCEPTANCE_EGRESS_PROBE); + const rendererEgressProbePassed = await waitForCreateImagesPackagedAcceptance( + "the production renderer-egress denial", + async () => ({ + requests: rendererEgressProbeRequests, + blocked: rendererEgressProbeBlocked, + webContentsPresent: rendererEgressProbeWebContentsPresent, + }), + (value) => + value.requests >= 1 && value.blocked === value.requests && value.webContentsPresent, + ).then(() => true); + await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSTALL_ERROR_COUNTER_SCRIPT, + ); + await options.navigate(CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROUTE); + await waitForCreateImagesPackagedAcceptance( + "the production canvas route", + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_READY_SCRIPT), + Boolean, + ); + const initialNodeCount = await waitForCreateImagesNodeCount(100); + const assistantHidden = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_ASSISTANT_HIDDEN_SCRIPT, + ); + if (!assistantHidden) { + throw new Error("Packaged Create Images acceptance found the assistant dock interactive."); + } + + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_INSPECTOR_SCRIPT); + const labels = await waitForCreateImagesPackagedAcceptance( + "the non-spatial node list", + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_NODE_LABELS_SCRIPT), + (value) => value.length === 100, + ); + const uniqueAccessibleNodeLabels = new Set(labels).size === labels.length; + const noRedundantNodeSemantics = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_NO_REDUNDANT_NODE_SEMANTICS_SCRIPT, + ); + if (!uniqueAccessibleNodeLabels || !noRedundantNodeSemantics) { + throw new Error("Packaged Create Images acceptance found duplicate accessible node labels."); + } + + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_FIT_WORKFLOW_SCRIPT); + for (let index = 0; index < 16; index += 1) { + await pauseForCreateImagesPackagedAcceptance(); + } + for (let index = 0; index < 6; index += 1) { + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_ZOOM_IN_SCRIPT); + } + const spatialEdgePrepared = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_PREPARE_SPATIAL_EDGE_SCRIPT, + ); + if (!spatialEdgePrepared) { + throw new Error("Packaged Create Images acceptance found no visible spatial edge pair."); + } + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_MANAGE_CONNECTIONS_SCRIPT, + ); + await waitForCreateImagesPackagedAcceptance( + "the spatial edge disconnect control", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_FOCUS_SPATIAL_DISCONNECT_SCRIPT, + ), + Boolean, + ); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_SPATIAL_DISCONNECT_SCRIPT, + ); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/Nodes disconnected\./u); + await dragCreateImagesAcceptanceConnection(); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Nodes connected\./u); + const spatialConnectionPassed = true; + const liveMutationCounterInstalled = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSTALL_LIVE_MUTATION_COUNTER_SCRIPT, + ); + if (!liveMutationCounterInstalled) { + throw new Error("Packaged Create Images acceptance could not observe its live region."); + } + await dragCreateImagesAcceptanceConnection(); + const firstInvalidMutationCount = await waitForCreateImagesPackagedAcceptance( + "the first invalid spatial-drop announcement", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT, + ), + (count) => count > 0, + ); + await observeCreateImagesAnnouncement(/This connection already exists\./u); + await dragCreateImagesAcceptanceConnection(); + await waitForCreateImagesPackagedAcceptance( + "the repeated invalid spatial-drop announcement", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT, + ), + (count) => count > firstInvalidMutationCount, + ); + const repeatedAnnouncementPassed = true; + const spatialInvalidDropPassed = (await waitForCreateImagesEdgeCount(75)) === 75; + + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_KEYBOARD_DISCONNECT_SCRIPT, + ); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/Nodes disconnected\./u); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECT_NODES_SCRIPT, + ); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Nodes connected\./u); + const keyboardConnectionPassed = true; + + const focusedEdge = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_FOCUS_EDGE_SCRIPT, + ); + if (!focusedEdge) { + throw new Error("Packaged Create Images acceptance could not focus a spatial edge."); + } + await sendCreateImagesAcceptanceKey("Enter"); + await waitForCreateImagesPackagedAcceptance( + "keyboard selection of the spatial edge", + () => + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_EDGE_SELECTED_SCRIPT), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Delete"); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/1 connection deleted\./u); + await waitForCreateImagesPackagedAcceptance( + "focus restoration after native edge deletion", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSPECTOR_FOCUSED_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + await sendCreateImagesAcceptanceKey("Z", ["meta", "shift"]); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/Redid the graph edit\./u); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + const nativeEdgeDeletePassed = true; + + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_ADD_SCRIPT); + await waitForCreateImagesPackagedAcceptance( + "the node palette", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_SEARCH_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceTab(true); + await waitForCreateImagesPackagedAcceptance( + "focus to remain trapped in the node palette", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_INSIDE_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceTab(); + await waitForCreateImagesPackagedAcceptance( + "focus to wrap to the node search", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_PALETTE_FOCUS_SEARCH_SCRIPT, + ), + Boolean, + ); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_OUTPUT_GALLERY_SCRIPT, + ); + const addedNodeCount = await waitForCreateImagesNodeCount(101); + await observeCreateImagesAnnouncement(/Output Gallery added\./u); + const focusRestoredAfterPalette = await waitForCreateImagesPackagedAcceptance( + "palette focus restoration", + () => + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_PALETTE_CLOSED_SCRIPT), + Boolean, + ); + + await sendCreateImagesAcceptanceDuplicateShortcut(); + const duplicatedNodeCount = await waitForCreateImagesNodeCount(102); + await observeCreateImagesAnnouncement(/duplicated\./u); + await waitForCreateImagesPackagedAcceptance( + "focus on the duplicated canvas node", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_SELECTED_CANVAS_NODE_FOCUSED_SCRIPT, + ), + Boolean, + ); + const selectedNodeX = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_CAPTURE_SELECTED_NODE_X_SCRIPT, + ); + if (selectedNodeX === null) { + throw new Error("Packaged Create Images acceptance could not capture duplicate position."); + } + await sendCreateImagesAcceptanceKey("Right"); + await waitForCreateImagesPackagedAcceptance( + "the duplicated node keyboard move", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_MOVED_SCRIPT, + ), + Boolean, + ); + await observeCreateImagesAnnouncement(/Node moved\./u); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesPackagedAcceptance( + "the keyboard-move undo", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_POSITION_RESTORED_SCRIPT, + ), + Boolean, + ); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + const keyboardMoveUndoPassed = true; + await activateCreateImagesAcceptanceControl(createImagesAcceptanceFocusButtonScript("Undo")); + const undoNodeCount = await waitForCreateImagesNodeCount(101); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + await activateCreateImagesAcceptanceControl(createImagesAcceptanceFocusButtonScript("Redo")); + const redoNodeCount = await waitForCreateImagesNodeCount(102); + await observeCreateImagesAnnouncement(/Redid the graph edit\./u); + const inspectorOpen = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSPECTOR_OPEN_SCRIPT, + ); + if (!inspectorOpen) { + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_INSPECTOR_SCRIPT); + } + const connectionToolsOpen = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_CONNECTION_TOOLS_OPEN_SCRIPT, + ); + if (connectionToolsOpen) { + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_BACK_TO_NODES_SCRIPT, + ); + } + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECTED_INSPECTOR_NODE_SCRIPT, + ); + await waitForCreateImagesPackagedAcceptance( + "the selected connected canvas node", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_FOCUS_CONNECTED_CANVAS_NODE_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Enter"); + await waitForCreateImagesPackagedAcceptance( + "keyboard selection of a connected canvas node", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_CONNECTED_CANVAS_NODE_SELECTED_SCRIPT, + ), + Boolean, + ); + await waitForCreateImagesEdgeCount(75); + await sendCreateImagesAcceptanceKey("Delete"); + const deletedNodeCount = await waitForCreateImagesNodeCount(101); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/deleted\./u); + const focusRestoredAfterNativeDelete = await waitForCreateImagesPackagedAcceptance( + "focus restoration after native node deletion", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSPECTOR_FOCUSED_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + const nativeDeleteUndoNodeCount = await waitForCreateImagesNodeCount(102); + await waitForCreateImagesEdgeCount(75); + await observeCreateImagesAnnouncement(/Undid the last graph edit\./u); + await sendCreateImagesAcceptanceKey("Z", ["meta", "shift"]); + const nativeDeleteRedoNodeCount = await waitForCreateImagesNodeCount(101); + await waitForCreateImagesEdgeCount(74); + await observeCreateImagesAnnouncement(/Redid the graph edit\./u); + const nativeNodeDeleteGraphPassed = true; + + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_ENABLE_REDUCED_MOTION_SCRIPT, + ); + const reducedMotionPassed = await waitForCreateImagesPackagedAcceptance( + "the reduced-motion canvas state", + () => + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_REDUCED_MOTION_SCRIPT), + Boolean, + ); + + let responsiveWidthsPassed = true; + let narrowValidationPassed = false; + let narrowAddPlacementPassed = false; + for (const width of [1280, 1000, 700, 390]) { + window.setContentSize(width, 650, false); + const narrowExpected = width !== 1280; + const responsive = await waitForCreateImagesPackagedAcceptance( + `the ${width}px canvas layout`, + () => + readCreateImagesAcceptanceScript<{ + width: number; + workbenchWidth: number; + sidebarWidth: number; + overflowFree: boolean; + minimapVisible: boolean; + minimapToggleVisible: boolean; + validationIssueTriggerVisible: boolean; + }>(CREATE_IMAGES_ACCEPTANCE_RESPONSIVE_SCRIPT), + (value) => + Math.abs(value.width - width) <= 1 && + value.workbenchWidth > 0 && + (narrowExpected ? value.workbenchWidth <= 760 : value.workbenchWidth > 760) && + value.sidebarWidth === 340 && + value.overflowFree && + value.minimapVisible === !narrowExpected && + value.minimapToggleVisible === !narrowExpected && + value.validationIssueTriggerVisible, + ); + responsiveWidthsPassed &&= + responsive.overflowFree && + responsive.minimapVisible === !narrowExpected && + responsive.minimapToggleVisible === !narrowExpected && + responsive.validationIssueTriggerVisible; + + if (width === 390) { + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_VALIDATION_TRIGGER_SCRIPT, + ); + const validationIssueFocused = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_FOCUS_FIRST_VALIDATION_ISSUE_SCRIPT, + ); + if (!validationIssueFocused) { + throw new Error("Packaged Create Images could not focus a narrow validation issue."); + } + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesNodeCount(101); + await waitForCreateImagesPackagedAcceptance( + "validation-panel shortcut isolation", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_VALIDATION_ISSUE_FOCUSED_SCRIPT, + ), + Boolean, + ); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_FIRST_VALIDATION_ISSUE_SCRIPT, + ); + narrowValidationPassed = await waitForCreateImagesPackagedAcceptance( + "validation issue focus at 390px", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_VALIDATION_TARGET_FOCUSED_SCRIPT, + ), + Boolean, + ); + + await activateCreateImagesAcceptanceControl(CREATE_IMAGES_ACCEPTANCE_FOCUS_ADD_SCRIPT); + await activateCreateImagesAcceptanceControl( + CREATE_IMAGES_ACCEPTANCE_FOCUS_OUTPUT_GALLERY_SCRIPT, + ); + await waitForCreateImagesNodeCount(102); + narrowAddPlacementPassed = await waitForCreateImagesPackagedAcceptance( + "a fully visible newly added node at 390px", + () => + readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_SELECTED_NODE_VISIBLE_SCRIPT, + ), + Boolean, + ); + await sendCreateImagesAcceptanceKey("Z", ["meta"]); + await waitForCreateImagesNodeCount(101); + } + } + window.setContentSize(1000, 700, false); + + const [rendererEventErrors, liveRegionMutations, filesAfter] = await Promise.all([ + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_RENDERER_ERRORS_SCRIPT), + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT), + snapshotCreateImagesProductFiles(persistenceInput), + ]); + const runtimePreferences = ( + window.webContents as unknown as { + getLastWebPreferences(): { + sandbox?: boolean; + contextIsolation?: boolean; + nodeIntegration?: boolean; + }; + } + ).getLastWebPreferences(); + const productFileMutations = countCreateImagesProductFileMutations(filesBefore, filesAfter); + + const service = createImagesService(); + const sourceBytes = createImagesAcceptanceLargePng(); + async function* sourceChunks(): AsyncGenerator { + const chunkSize = 64 * 1024; + for (let offset = 0; offset < sourceBytes.byteLength; offset += chunkSize) { + yield sourceBytes.subarray(offset, Math.min(offset + chunkSize, sourceBytes.byteLength)); + } + } + const imported = await service.assets.ingest(sourceChunks(), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "packaged-large-reference.png", + }); + if ( + imported.asset.byteLength <= CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_METADATA_BYTES || + imported.asset.width !== CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH || + imported.asset.height !== CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT + ) { + throw new Error("Packaged Create Images did not import the bounded large-image fixture."); + } + const now = new Date().toISOString(); + const workflow = createStarterWorkflow({ + workflowId: "packaged-phase-two", + promptNodeId: "packaged-prompt", + generationNodeId: "packaged-generate", + outputNodeId: "packaged-output", + promptEdgeId: "packaged-edge-prompt", + outputEdgeId: "packaged-edge-output", + now, + }); + workflow.nodes.push({ + id: "packaged-image", + type: "image-input", + position: { x: 40, y: 360 }, + data: { assetId: imported.asset.assetId, label: "Packaged reference" }, + }); + workflow.assetRefs = [imported.asset.assetId]; + await service.mutateWorkflow(workflow.id, workflow.assetRefs, () => + service.workflows.create(workflow), + ); + await options.navigate(`/create-images/${workflow.id}`); + const phaseTwoReady = await waitForCreateImagesPackagedAcceptance( + "the durable workflow and protocol image preview", + async () => ({ + ...(await readCreateImagesAcceptanceScript< + Omit< + PhaseTwoReadyObservation, + | "grantCount" + | "assetProtocolRequests" + | "assetProtocolAuthorizations" + | "lastAssetRequest" + > + >(CREATE_IMAGES_PHASE_TWO_READY_SCRIPT)), + grantCount: service.grants.size(), + assetProtocolRequests, + assetProtocolAuthorizations, + lastAssetRequest, + }), + (value) => + value.workbenchPresent && + value.nodeCount === "4" && + value.previewPresent && + value.previewComplete && + value.previewWidth > 0 && + value.promptPresent && + value.grantCount >= 1 && + value.assetProtocolRequests >= 1 && + value.assetProtocolAuthorizations >= 1 && + value.assetProtocolAuthorizations === value.assetProtocolRequests && + isAcceptedAssetRequestEvidence(value.lastAssetRequest), + ); + const assetProtocolPreviewPassed = + phaseTwoReady.previewWidth > 0 && + phaseTwoReady.grantCount >= 1 && + phaseTwoReady.assetProtocolRequests >= 1 && + phaseTwoReady.assetProtocolAuthorizations >= 1 && + phaseTwoReady.assetProtocolAuthorizations === phaseTwoReady.assetProtocolRequests && + isAcceptedAssetRequestEvidence(phaseTwoReady.lastAssetRequest); + const preEditWorkflow = await service.workflows.get(workflow.id); + if (!preEditWorkflow) { + throw new Error("Packaged Create Images lost its durable workflow before editing."); + } + const editDispatched = await readCreateImagesAcceptanceScript( + CREATE_IMAGES_PHASE_TWO_EDIT_SCRIPT, + ); + if (!editDispatched) + throw new Error("Packaged Create Images could not edit the durable prompt."); + const savedWorkflow = await waitForCreateImagesPackagedAcceptance( + "the durable autosave publication", + () => service.workflows.get(workflow.id), + (value) => + isCreateImagesDurableWorkflowPublication( + value, + preEditWorkflow.revision, + "Packaged durable prompt edit", + ), + ); + if (!savedWorkflow) { + throw new Error("Packaged Create Images lost its workflow after the durable edit."); + } + const durableWorkflowPassed = isCreateImagesDurableWorkflowPublication( + savedWorkflow, + preEditWorkflow.revision, + "Packaged durable prompt edit", + ); + await options.reloadRenderer(); + await readCreateImagesAcceptanceScript( + CREATE_IMAGES_ACCEPTANCE_INSTALL_ERROR_COUNTER_SCRIPT, + ); + await options.navigate(`/create-images/${workflow.id}`); + const rendererReloadPersistencePassed = await waitForCreateImagesPackagedAcceptance( + "the durable workflow after a renderer restart", + () => readCreateImagesAcceptanceScript(CREATE_IMAGES_PHASE_TWO_REOPENED_SCRIPT), + Boolean, + ); + const graphText = await fs.readFile( + path.join( + app.getPath("userData"), + "create-images", + "workflows", + workflow.id, + "workflow.json", + ), + "utf8", + ); + const noGraphBase64Passed = + Buffer.byteLength(graphText) < 4 * 1024 * 1024 && + !/data:image|;base64,/u.test(graphText) && + graphText.includes(imported.asset.assetId); + const [phaseTwoRendererEventErrors, filesAfterPhaseTwo] = await Promise.all([ + readCreateImagesAcceptanceScript(CREATE_IMAGES_ACCEPTANCE_RENDERER_ERRORS_SCRIPT), + snapshotCreateImagesProductFiles(persistenceInput), + ]); + const phaseTwoProductFileMutations = countCreateImagesProductFileMutations( + filesAfter, + filesAfterPhaseTwo, + ); + const phaseTwoProductFiles = createImagesPhaseTwoProductFileEvidence( + filesAfter, + filesAfterPhaseTwo, + { + workflowId: workflow.id, + assetId: imported.asset.assetId, + assetExtension: imported.asset.mediaType === "image/png" ? "png" : "jpg", + }, + ); + const createImagesRoot = path.join(app.getPath("userData"), "create-images"); + const workflowRoot = path.join(createImagesRoot, "workflows", workflow.id); + const [lastKnownGoodText, workflowIndexText, assetIndexText, runIndexText] = await Promise.all([ + fs.readFile(path.join(workflowRoot, "workflow.last-known-good.json"), "utf8"), + fs.readFile(path.join(createImagesRoot, "index.json"), "utf8"), + fs.readFile(path.join(createImagesRoot, "asset-index.json"), "utf8"), + fs.readFile(path.join(createImagesRoot, "run-index.json"), "utf8"), + ]); + const workflowRecord = JSON.parse(graphText) as { + id?: unknown; + revision?: unknown; + assetRefs?: unknown; + }; + const workflowIndexRecord = JSON.parse(workflowIndexText) as { + workflows?: Array<{ id?: unknown; revision?: unknown; assetCount?: unknown }>; + }; + const assetIndexRecord = JSON.parse(assetIndexText) as { + assets?: Record< + string, + { + assetId?: unknown; + byteLength?: unknown; + width?: unknown; + height?: unknown; + referenceOwners?: unknown; + thumbnails?: Record; + } + >; + }; + const runIndexRecord = JSON.parse(runIndexText) as { + version?: unknown; + revision?: unknown; + entries?: unknown; + degraded?: unknown; + }; + const storedAsset = assetIndexRecord.assets?.[imported.asset.assetId]; + const assetFile = phaseTwoProductFiles.find((entry) => + entry.path.includes(`/assets/sha256/${imported.asset.assetId.slice(0, 2)}/`), + ); + const thumbnailFile = phaseTwoProductFiles.find((entry) => + entry.path.includes(`/thumbnails/${imported.asset.assetId}/512.png`), + ); + const phaseTwoStorageRelationshipsPassed = + graphText === lastKnownGoodText && + workflowRecord.id === workflow.id && + workflowRecord.revision === savedWorkflow.revision && + Array.isArray(workflowRecord.assetRefs) && + workflowRecord.assetRefs.length === 1 && + workflowRecord.assetRefs[0] === imported.asset.assetId && + Array.isArray(workflowIndexRecord.workflows) && + workflowIndexRecord.workflows.length === 1 && + workflowIndexRecord.workflows[0]?.id === workflow.id && + workflowIndexRecord.workflows[0]?.revision === savedWorkflow.revision && + workflowIndexRecord.workflows[0]?.assetCount === 1 && + Object.keys(assetIndexRecord.assets ?? {}).length === 1 && + storedAsset?.assetId === imported.asset.assetId && + storedAsset.byteLength === assetFile?.bytes && + storedAsset.width === CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_WIDTH && + storedAsset.height === CREATE_IMAGES_PACKAGED_ACCEPTANCE_IMAGE_HEIGHT && + runIndexRecord.version === 1 && + Number.isSafeInteger(runIndexRecord.revision) && + (runIndexRecord.revision as number) >= 1 && + Array.isArray(runIndexRecord.entries) && + runIndexRecord.entries.length === 0 && + Array.isArray(runIndexRecord.degraded) && + runIndexRecord.degraded.length === 0 && + Array.isArray(storedAsset.referenceOwners) && + storedAsset.referenceOwners.length === 1 && + storedAsset.referenceOwners[0] === `workflow:${workflow.id}` && + storedAsset.thumbnails?.["512"]?.byteLength === thumbnailFile?.bytes; + if (!phaseTwoStorageRelationshipsPassed) { + throw new Error( + "Packaged Create Images durable workflow, index, and asset relationships are inconsistent.", + ); + } + if (!isAcceptedAssetRequestEvidence(lastAssetRequest)) { + throw new Error( + "Packaged Create Images did not observe an accepted production asset request.", + ); + } + const rendererErrors = + rendererEventErrors + phaseTwoRendererEventErrors + mainObservedRendererErrors; + await writeCreateImagesPackagedAcceptanceReceipt(acceptance, { + version: 1, + nonce: acceptance.control.nonce, + route: CREATE_IMAGES_PACKAGED_ACCEPTANCE_ROUTE, + initialNodeCount: initialNodeCount as 100, + addedNodeCount: addedNodeCount as 101, + duplicatedNodeCount: duplicatedNodeCount as 102, + undoNodeCount: undoNodeCount as 101, + redoNodeCount: redoNodeCount as 102, + deletedNodeCount: deletedNodeCount as 101, + nativeDeleteUndoNodeCount: nativeDeleteUndoNodeCount as 102, + nativeDeleteRedoNodeCount: nativeDeleteRedoNodeCount as 101, + spatialConnectionPassed, + spatialInvalidDropPassed, + nativeEdgeDeletePassed, + keyboardConnectionPassed, + keyboardMoveUndoPassed, + repeatedAnnouncementPassed, + uniqueAccessibleNodeLabels, + narrowValidationPassed, + narrowAddPlacementPassed, + focusRestoredAfterPalette, + focusRestoredAfterNativeDelete, + nativeNodeDeleteGraphPassed, + reducedMotionPassed, + liveRegionMutations, + keyboardActions: createImagesAcceptanceKeyboardActions, + rendererErrors, + networkRequests, + rendererEgressProbePassed, + rendererEgressProbeRequests, + rendererEgressProbeBlocked, + productFileMutations, + durableWorkflowPassed, + assetProtocolPreviewPassed, + assetProtocolGrantCount: phaseTwoReady.grantCount, + assetProtocolRequests, + assetProtocolAuthorizations, + assetProtocolLastRequest: lastAssetRequest, + rendererReloadPersistencePassed, + noGraphBase64Passed, + phaseTwoProductFileMutations, + phaseTwoProductFiles, + phaseTwoStorageRelationshipsPassed, + phaseTwoWorkflowRevision: savedWorkflow.revision, + phaseTwoAssetBytes: imported.asset.byteLength, + phaseTwoAssetWidth: imported.asset.width, + phaseTwoAssetHeight: imported.asset.height, + responsiveWidthsPassed, + sandboxed: runtimePreferences.sandbox === true, + contextIsolation: runtimePreferences.contextIsolation === true, + nodeIntegration: runtimePreferences.nodeIntegration === true, + durationMs: performance.now() - startedAt, + }); + } finally { + stopRequestPolicyObservation(); + window.webContents.off("console-message", onConsoleMessage); + window.webContents.off("render-process-gone", onRenderProcessGone); + window.webContents.off("did-fail-load", onDidFailLoad); + } +} diff --git a/main/services/create-images/phase-three-integration.test.ts b/main/services/create-images/phase-three-integration.test.ts new file mode 100644 index 00000000..79bb0564 --- /dev/null +++ b/main/services/create-images/phase-three-integration.test.ts @@ -0,0 +1,775 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test, { type TestContext } from "node:test"; +import { projectCreateImagesRun } from "../../../renderer/shared/create-images/run-contract.js"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { CreateImagesService } from "./create-images-service.js"; +import { shouldReleaseCreateImagesRunOwner } from "./run-publication-binding-core.js"; + +const NOW = "2026-08-11T12:00:00.000Z"; +const RUN_TIMEOUT_MS = 10_000; + +interface StoredAssetIndex { + schemaVersion: 1; + revision: number; + assets: Record; +} + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concatenate(...parts: readonly Uint8Array[]): Uint8Array { + const bytes = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + return bytes; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const checksum = concatenate(typeBytes, data); + return concatenate(u32(data.byteLength), checksum, u32(crc32(checksum))); +} + +function staticPng(): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(1)); + header.set(u32(1), 4); + header[8] = 8; + header[9] = 6; + return concatenate( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, 0, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +async function temporaryRoot(t: TestContext): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-phase-three-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + return root; +} + +function serviceOptions(now: () => number) { + const thumbnail = staticPng(); + return { + assetStore: { + now, + deepValidator: { + async validate({ descriptor }: { descriptor: { width: number; height: number } }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: thumbnail, + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }; +} + +async function waitUntil( + description: string, + inspect: () => Promise, +): Promise { + const deadline = Date.now() + RUN_TIMEOUT_MS; + while (Date.now() < deadline) { + const result = await inspect(); + if (result !== undefined) return result; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for ${description}.`); +} + +test("a future run index leaves the workflow library readable while run admission stays closed", async (t) => { + const root = await temporaryRoot(t); + let clock = Date.parse(NOW); + const now = () => clock++; + const first = new CreateImagesService(root, serviceOptions(now)); + await first.initialize(); + const workflow = createStarterWorkflow({ + workflowId: "workflow-future-run-index", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + await first.mutateWorkflow(workflow.id, [], () => first.workflows.create(workflow)); + + const futureIndex = '{"version":2,"revision":1,"entries":[]}\n'; + const indexPath = path.join(root, "run-index.json"); + await fs.writeFile(indexPath, futureIndex, "utf8"); + const restarted = new CreateImagesService(root, serviceOptions(now)); + + await assert.rejects(restarted.initialize()); + await restarted.initializeReadOnlyLibrary(); + assert.deepEqual( + (await restarted.workflows.list()).map(({ id, title }) => ({ id, title })), + [{ id: workflow.id, title: workflow.title }], + ); + assert.deepEqual(await restarted.runs.journals.indexHealth(), { status: "unsafe" }); + assert.equal(await fs.readFile(indexPath, "utf8"), futureIndex); + await assert.rejects( + restarted.runs.start( + { + workflowId: workflow.id, + expectedRevision: workflow.revision, + scope: { kind: "all" }, + }, + () => true, + ), + ); + assert.equal(await fs.readFile(indexPath, "utf8"), futureIndex); +}); + +test("an authoritative run-free workflow can still be deleted through the lifecycle fence", async (t) => { + const root = await temporaryRoot(t); + const service = new CreateImagesService( + root, + serviceOptions(() => Date.parse(NOW)), + ); + const workflow = createStarterWorkflow({ + workflowId: "workflow-without-runs", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + await service.mutateWorkflow(workflow.id, [], () => service.workflows.create(workflow)); + + assert.deepEqual(await service.deleteWorkflow(workflow.id, workflow.revision, () => true), { + status: "deleted", + }); + assert.equal(await service.workflows.get(workflow.id), undefined); + assert.deepEqual(await service.runs.list(workflow.id), { status: "not-found" }); +}); + +test("same-process unassociated corruption is discovered authoritatively before deletion", async (t) => { + const root = await temporaryRoot(t); + const service = new CreateImagesService( + root, + serviceOptions(() => Date.parse(NOW)), + ); + const workflow = createStarterWorkflow({ + workflowId: "workflow-post-init-unassociated-run", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + await service.mutateWorkflow(workflow.id, [], () => service.workflows.create(workflow)); + assert.equal(await service.runs.journals.hasUnassociatedDegradedRuns(), false); + + const injectedRunRoot = path.join(root, "runs", "post-init-unassociated-run"); + await fs.mkdir(injectedRunRoot); + await Promise.all([ + fs.writeFile(path.join(injectedRunRoot, "run.json"), "{broken-current", "utf8"), + fs.writeFile( + path.join(injectedRunRoot, "run.last-known-good.json"), + "{broken-recovery", + "utf8", + ), + ]); + assert.equal(await service.runs.journals.hasUnassociatedDegradedRuns(), false); + + const deletion = await service.deleteWorkflow(workflow.id, workflow.revision, () => true); + assert.equal(deletion.status, "unavailable"); + if (deletion.status === "unavailable") { + assert.match(deletion.message, /unassociated run recovery authority/u); + } + assert.deepEqual(await service.workflows.get(workflow.id), workflow); + assert.equal(await service.runs.journals.hasUnassociatedDegradedRuns(), true); +}); + +test("unassociated degraded run authority blocks workflow deletion after restart", async (t) => { + const root = await temporaryRoot(t); + let clock = Date.parse(NOW); + const now = () => clock++; + const first = new CreateImagesService(root, serviceOptions(now)); + const workflow = createStarterWorkflow({ + workflowId: "workflow-unassociated-degraded-run", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + const prompt = workflow.nodes.find((node) => node.id === "prompt-1"); + const generation = workflow.nodes.find((node) => node.id === "generate-1"); + assert.equal(prompt?.type, "prompt"); + assert.equal(generation?.type, "generate-image"); + if (!prompt || prompt.type !== "prompt" || !generation || generation.type !== "generate-image") { + return; + } + prompt.data.text = "Preserve this unassociated degraded run"; + generation.data.providerId = "gemini"; + generation.data.modelId = "gemini-3.1-flash-image"; + await first.mutateWorkflow(workflow.id, [], () => first.workflows.create(workflow)); + const started = await first.runs.start( + { + workflowId: workflow.id, + expectedRevision: workflow.revision, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + await waitUntil("the run to become terminal", async () => { + const journal = await first.runs.journals.get(started.run.runId); + return journal && projectCreateImagesRun(journal).terminal ? journal : undefined; + }); + + const runRoot = path.join(root, "runs", started.run.runId); + await Promise.all([ + fs.writeFile(path.join(runRoot, "run.json"), "{broken-current", "utf8"), + fs.writeFile(path.join(runRoot, "run.last-known-good.json"), "{broken-recovery", "utf8"), + fs.rm(path.join(root, "run-index.json")), + ]); + const restarted = new CreateImagesService(root, serviceOptions(now)); + await restarted.initialize(); + assert.equal(await restarted.runs.journals.hasUnassociatedDegradedRuns(), true); + + const deletion = await restarted.deleteWorkflow(workflow.id, workflow.revision, () => true); + assert.equal(deletion.status, "unavailable"); + if (deletion.status === "unavailable") { + assert.match(deletion.message, /unassociated run recovery authority/u); + } + assert.deepEqual(await restarted.workflows.get(workflow.id), workflow); + assert.equal(await restarted.runs.journals.hasUnassociatedDegradedRuns(), true); +}); + +test("production services preserve a multi-output local run, ownership, and GC protection across restart", async (t) => { + const root = await temporaryRoot(t); + let clock = Date.parse(NOW); + const now = () => clock++; + const first = new CreateImagesService(root, serviceOptions(now)); + await first.initialize(); + + const workflow = createStarterWorkflow({ + workflowId: "phase-three-production-join", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-prompt", + outputEdgeId: "edge-output", + now: NOW, + }); + const generation = workflow.nodes.find((node) => node.id === "generate-1"); + const prompt = workflow.nodes.find((node) => node.id === "prompt-1"); + assert.equal(generation?.type, "generate-image"); + assert.equal(prompt?.type, "prompt"); + if (!generation || generation.type !== "generate-image" || !prompt || prompt.type !== "prompt") { + return; + } + prompt.data.text = "A deterministic three-image production join"; + generation.data.providerId = "gemini"; + generation.data.modelId = "gemini-3.1-flash-image"; + generation.data.count = 3; + await first.mutateWorkflow(workflow.id, [], () => first.workflows.create(workflow)); + assert.deepEqual(await first.workflows.get(workflow.id), workflow); + + const started = await first.runs.start( + { + workflowId: workflow.id, + expectedRevision: workflow.revision, + scope: { kind: "all" }, + }, + () => true, + ); + assert.equal(started.status, "started"); + if (started.status !== "started") return; + const runId = started.run.runId; + + const terminalJournal = await waitUntil("the durable local run to finish", async () => { + const journal = await first.runs.journals.get(runId); + return journal && projectCreateImagesRun(journal).terminal ? journal : undefined; + }); + const terminalList = await waitUntil("the completed run to leave active state", async () => { + const snapshot = await first.runs.list(workflow.id); + return snapshot.status === "ready" && + snapshot.activeRun === undefined && + snapshot.history.some((entry) => entry.runId === runId) + ? snapshot + : undefined; + }); + const projection = projectCreateImagesRun(terminalJournal); + assert.equal(projection.status, "succeeded"); + const generatedAssetIds = projection.nodes["generate-1"]?.outputAssetIds ?? []; + assert.equal(generatedAssetIds.length, 3); + assert.equal(new Set(generatedAssetIds).size, 3); + assert.equal( + terminalList.history.find((entry) => entry.runId === runId)?.outputCount, + generatedAssetIds.length, + ); + assert.equal(terminalList.history.find((entry) => entry.runId === runId)?.requestCount, 1); + assert.deepEqual( + terminalList.latestTerminalRun?.nodes.find((node) => node.nodeId === "generate-1") + ?.outputAssetIds, + generatedAssetIds, + ); + const acceptedIndex = terminalJournal.events.findIndex( + (event) => event.type === "node-submission-accepted" && event.nodeId === "generate-1", + ); + const succeededIndex = terminalJournal.events.findIndex( + (event) => event.type === "node-succeeded" && event.nodeId === "generate-1", + ); + assert.ok(acceptedIndex >= 0 && succeededIndex > acceptedIndex); + + for (const assetId of generatedAssetIds) { + const asset = await first.assets.getAvailable(assetId); + assert.ok(asset); + assert.deepEqual(asset.origin, { + kind: "provider", + providerId: "local-mock", + modelId: "deterministic-v1", + runId, + }); + assert.equal(first.references.isRunAssetReferenced(runId, assetId), true); + assert.equal(await first.runs.isRunAssetReferenced(workflow.id, runId, assetId), true); + } + const firstGc = await first.assets.planGarbageCollection(0); + assert.deepEqual( + firstGc.candidateAssetIds.filter((assetId) => generatedAssetIds.includes(assetId)), + [], + ); + + const firstIndex = JSON.parse( + await fs.readFile(path.join(root, "asset-index.json"), "utf8"), + ) as StoredAssetIndex; + assert.equal(firstIndex.schemaVersion, 1); + for (const assetId of generatedAssetIds) { + assert.deepEqual(firstIndex.assets[assetId]?.referenceOwners, [`run:${runId}`]); + } + + clock += 1_000; + const restarted = new CreateImagesService(root, serviceOptions(now)); + await restarted.initialize(); + assert.deepEqual(await restarted.workflows.get(workflow.id), workflow); + const restartedJournal = await restarted.runs.journals.get(runId); + assert.ok(restartedJournal); + assert.equal(restartedJournal && projectCreateImagesRun(restartedJournal).status, "succeeded"); + assert.deepEqual( + restartedJournal && + projectCreateImagesRun(restartedJournal).nodes["generate-1"]?.outputAssetIds, + generatedAssetIds, + ); + const restartedList = await restarted.runs.list(workflow.id); + assert.equal(restartedList.status, "ready"); + if (restartedList.status !== "ready") return; + assert.equal(restartedList.activeRun, undefined); + assert.equal(restartedList.history.find((entry) => entry.runId === runId)?.status, "succeeded"); + assert.equal( + restartedList.history.find((entry) => entry.runId === runId)?.outputCount, + generatedAssetIds.length, + ); + assert.deepEqual( + restartedList.latestTerminalRun?.nodes.find((node) => node.nodeId === "generate-1") + ?.outputAssetIds, + generatedAssetIds, + ); + const deletion = await restarted.deleteWorkflow(workflow.id, workflow.revision, () => true); + assert.equal(deletion.status, "unavailable"); + if (deletion.status === "unavailable") assert.match(deletion.message, /retained run history/u); + assert.deepEqual(await restarted.workflows.get(workflow.id), workflow); + const retainedList = await restarted.runs.list(workflow.id); + assert.equal(retainedList.status, "ready"); + assert.equal( + retainedList.status === "ready" + ? retainedList.history.some((entry) => entry.runId === runId) + : false, + true, + ); + const retainedDetail = await restarted.runs.get(workflow.id, runId); + assert.equal(retainedDetail.status, "ready"); + assert.equal(retainedDetail.status === "ready" ? retainedDetail.run.runId : undefined, runId); + for (const assetId of generatedAssetIds) { + assert.ok(await restarted.assets.getAvailable(assetId)); + assert.equal(restarted.references.isRunAssetReferenced(runId, assetId), true); + assert.equal(await restarted.runs.isRunAssetReferenced(workflow.id, runId, assetId), true); + } + const restartedGc = await restarted.assets.planGarbageCollection(0); + assert.deepEqual( + restartedGc.candidateAssetIds.filter((assetId) => generatedAssetIds.includes(assetId)), + [], + ); + const restartedIndex = JSON.parse( + await fs.readFile(path.join(root, "asset-index.json"), "utf8"), + ) as StoredAssetIndex; + for (const assetId of generatedAssetIds) { + assert.deepEqual(restartedIndex.assets[assetId]?.referenceOwners, [`run:${runId}`]); + } +}); + +test("transient publication contention retains every renderer-disconnect run owner", () => { + const runIds = ["run-1", "run-2", "run-3", "run-4"]; + for (const status of ["unavailable", "busy", "not-found"]) { + assert.deepEqual( + runIds.map((runId) => shouldReleaseCreateImagesRunOwner(runId, { status })), + [false, false, false, false], + ); + } + assert.deepEqual( + runIds.map((runId) => + shouldReleaseCreateImagesRunOwner(runId, { + status: "ready", + activeRun: { runId: "run-1" }, + }), + ), + [false, true, true, true], + ); + assert.deepEqual( + runIds.map((runId) => shouldReleaseCreateImagesRunOwner(runId, { status: "ready" })), + [true, true, true, true], + ); +}); + +test("durable workflow recovery and asset-picking handlers initialize fully before side effects", async () => { + const handlers = (await fs.readFile(path.resolve("main/handlers/create-images.ts"), "utf8")) + .replace(/\s+/gu, " ") + .replace(/\( /gu, "("); + const handlerSlice = (channel: string, nextChannel: string): string => { + const start = handlers.indexOf(`ipcMain.handle("${channel}"`); + const end = handlers.indexOf(`ipcMain.handle("${nextChannel}"`, start + 1); + assert.ok(start >= 0, `${channel} must be registered`); + assert.ok(end > start, `${channel} must precede ${nextChannel}`); + return handlers.slice(start, end); + }; + const assertInitializedBefore = ( + channel: string, + nextChannel: string, + sideEffects: readonly string[], + ): void => { + const source = handlerSlice(channel, nextChannel); + const initializedAt = source.indexOf("await service.initialize()"); + assert.ok(initializedAt >= 0, `${channel} must perform full service initialization`); + for (const sideEffect of sideEffects) { + const sideEffectAt = source.indexOf(sideEffect); + assert.ok(sideEffectAt >= 0, `${channel} must retain ${sideEffect}`); + assert.ok(initializedAt < sideEffectAt, `${channel} must initialize before ${sideEffect}`); + } + }; + + assertInitializedBefore("imageWorkflows:recover", "imageWorkflows:repairRecoveryMetadata", [ + "service.workflows.recover(", + ]); + assertInitializedBefore( + "imageWorkflows:repairRecoveryMetadata", + "imageWorkflows:discardAutosave", + ["service.workflows.repairRecoveryMetadata("], + ); + assertInitializedBefore("imageWorkflows:discardAutosave", "imageWorkflows:pickAsset", [ + "service.workflows.discardAutosave(", + ]); + assertInitializedBefore("imageWorkflows:pickAsset", "imageWorkflows:grantAsset", [ + "dialog.showOpenDialog(", + "ingestSelectedImage(service", + ]); +}); + +test("workflow deletion uses the admission-fenced run lifecycle guard and honest UI copy", async () => { + const [handlers, service, runService, view] = await Promise.all([ + fs.readFile(path.resolve("main/handlers/create-images.ts"), "utf8"), + fs.readFile(path.resolve("main/services/create-images/create-images-service.ts"), "utf8"), + fs.readFile(path.resolve("main/services/create-images/run-service.ts"), "utf8"), + fs.readFile(path.resolve("renderer/create-images/create-images-view.tsx"), "utf8"), + ]); + const deleteHandler = handlers.slice( + handlers.indexOf('ipcMain.handle("imageWorkflows:delete"'), + handlers.indexOf('ipcMain.handle("imageWorkflows:recover"'), + ); + assert.match(deleteHandler, /service\.deleteWorkflow\(/u); + assert.doesNotMatch(deleteHandler, /service\.runs\.list|service\.workflows\.delete/u); + assert.match(service, /this\.runs\.deleteWorkflowIfRunLifecycleEmpty\(/u); + const deletionGuard = runService.slice( + runService.indexOf("async deleteWorkflowIfRunLifecycleEmpty"), + runService.indexOf( + "async stop(", + runService.indexOf("async deleteWorkflowIfRunLifecycleEmpty"), + ), + ); + const fenceAt = deletionGuard.indexOf("const previous = this.startAdmissionTail"); + const auditAt = deletionGuard.indexOf("await this.journals.auditWorkflowAdmission(workflowId)"); + const listAt = deletionGuard.indexOf("await this.list(workflowId)"); + const deleteAt = deletionGuard.indexOf("value: await deleteWorkflow()"); + assert.ok(fenceAt >= 0 && fenceAt < auditAt); + assert.ok(auditAt < listAt && listAt < deleteAt); + assert.match(deletionGuard, /evaluateCreateImagesWorkflowDeletion\(snapshot\)/u); + assert.match(runService, /snapshot\.latestTerminalRun \|\| snapshot\.history\.length > 0/u); + assert.match(runService, /snapshot\.recoveries\.length > 0/u); + assert.match(view, /can be deleted only when it has no active run, retained run history/u); + assert.match(view, /mutationMessage\(result, "Aiden could not delete the workflow\."\)/u); +}); + +test("main, preload, and renderer sources keep the exact run lifecycle and authorization contract", async () => { + const [handlers, preloadChannels, preload, rendererIpc, main] = await Promise.all([ + fs.readFile(path.resolve("main/handlers/create-images.ts"), "utf8"), + fs.readFile(path.resolve("renderer/preload-channels.ts"), "utf8"), + fs.readFile(path.resolve("renderer/preload.ts"), "utf8"), + fs.readFile(path.resolve("renderer/lib/ipc.ts"), "utf8"), + fs.readFile(path.resolve("main/index.ts"), "utf8"), + ]); + + const runHandlerChannels = [ + ...handlers.matchAll(/ipcMain\.handle\(\s*"(imageWorkflows:[^"]+)"/gu), + ] + .map((match) => match[1]!) + .filter((channel) => /Run|Runs/u.test(channel)) + .sort(); + assert.deepEqual(runHandlerChannels, [ + "imageWorkflows:discardDegradedRun", + "imageWorkflows:downloadRunAsset", + "imageWorkflows:getRun", + "imageWorkflows:grantRunAsset", + "imageWorkflows:listRuns", + "imageWorkflows:planDegradedRunDiscard", + "imageWorkflows:planRunHistoryPrune", + "imageWorkflows:prepareRun", + "imageWorkflows:pruneRunHistory", + "imageWorkflows:recoverRun", + "imageWorkflows:resolveRunAmbiguity", + "imageWorkflows:startRun", + "imageWorkflows:stopRun", + "imageWorkflows:subscribeRuns", + "imageWorkflows:unsubscribeRuns", + ]); + + const ownerBinding = handlers.slice( + handlers.indexOf("const bindRunToOwner"), + handlers.indexOf('ipcMain.handle("imageWorkflows:list"'), + ); + assert.match(ownerBinding, /owner\.onInvalidated\(invalidate\)/u); + assert.match( + ownerBinding, + /runs\.stop\(\s*workflowId,\s*runId,\s*"renderer-disconnected",?\s*\)/u, + ); + const normalizedHandlers = handlers.replace(/\s+/gu, " ").replace(/\( /gu, "("); + assert.match( + normalizedHandlers, + /runs\.start\(\{ workflowId: input\.workflowId, expectedRevision: input\.expectedRevision, scope: input\.scope, executionMode: input\.consent\.executionMode,/u, + ); + assert.match( + normalizedHandlers, + /if \(result\.status === "started"\) \{ bindRunToOwner\(owner, input\.workflowId, result\.run\.runId\); \}/u, + ); + + const grantRunAsset = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:grantRunAsset"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:storageHealth"'), + ); + assert.match( + grantRunAsset, + /service\.runs\.isRunAssetReferenced\(input\.workflowId, input\.runId, input\.assetId, \)/u, + ); + assert.match( + grantRunAsset, + /service\.references\.isRunAssetReferenced\(input\.runId, input\.assetId\)/u, + ); + assert.match( + grantRunAsset, + /service\.grantAsset\(owner, input\.assetId, \(assetId\) => service\.references\.isRunAssetReferenced\(input\.runId, assetId\), \)/u, + ); + + assert.match(preloadChannels, /"imageWorkflows:run-changed"/u); + assert.match(preload, /NOTIFICATION_CHANNELS\.has\(channel\)/u); + const expectedRendererChannels = [ + "imageWorkflows:prepareRun", + "imageWorkflows:startRun", + "imageWorkflows:stopRun", + "imageWorkflows:listRuns", + "imageWorkflows:planRunHistoryPrune", + "imageWorkflows:pruneRunHistory", + "imageWorkflows:getRun", + "imageWorkflows:recoverRun", + "imageWorkflows:subscribeRuns", + "imageWorkflows:unsubscribeRuns", + "imageWorkflows:grantRunAsset", + "imageWorkflows:downloadRunAsset", + "imageWorkflows:run-changed", + ]; + for (const channel of expectedRendererChannels) { + assert.ok(rendererIpc.includes(`"${channel}"`), `${channel} must be wired by renderer IPC`); + } + + assert.match(handlers, /The subscription is live before this initial read begins/u); + const workflowListHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:list"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:get"'), + ); + assert.match(workflowListHandler, /service\.initializeReadOnlyLibrary\(\)/u); + const startRunHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:startRun"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:stopRun"'), + ); + assert.match( + startRunHandler, + /await runBounded\(owner\.id, \(\) => createImagesService\(\)\.runs\.start/u, + ); + assert.match(startRunHandler, /if \(bounded\.status === "busy"\) return runRateFailure\(\)/u); + const subscriptionHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:subscribeRuns"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:getRun"'), + ); + assert.match(subscriptionHandler, /const subscriptionId = randomUUID\(\)/u); + assert.doesNotMatch(subscriptionHandler, /runSubscriptions\.entries\(\)|const existing/u); + const recoverRunHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:recoverRun"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:planRunHistoryPrune"'), + ); + assert.match(recoverRunHandler, /await runBounded\(owner\.id, async \(\) =>/u); + assert.match( + recoverRunHandler, + /return bounded\.status === "completed" \? bounded\.value : runRateFailure\(\)/u, + ); + assert.match(handlers, /streamSequence: subscription\.streamSequence/u); + assert.match(handlers, /readRateLimiter/u); + assert.match(handlers, /readOwnerKey/u); + assert.doesNotMatch(handlers, /document:\$\{owner\.documentId\}:run-read/u); + assert.match(handlers, /runBounded/u); + assert.match(handlers, /readAllowed\(owner, 12\)/u); + const storageHealthHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:storageHealth"'), + ); + assert.match(storageHealthHandler, /if \(!readAllowed\(owner, 12\)\)/u); + assert.match(storageHealthHandler, /await runBounded\(owner\.id, async \(\) =>/u); + assert.match(storageHealthHandler, /service\.initializeReadOnlyLibrary\(\)/u); + assert.match(storageHealthHandler, /if \(bounded\.status === "busy"\)/u); + assert.match(handlers, /activeRunOperations >= 8 \|\| ownerOperations >= 2/u); + assert.match(handlers, /runSubscriptions\.size >= 128/u); + assert.match(handlers, /runPublicationStates\.size >= 256/u); + assert.match(handlers, /attempt < 3 && !snapshot/u); + assert.match(handlers, /shouldReleaseCreateImagesRunOwner\(runId, snapshot\)/u); + assert.doesNotMatch(handlers, /snapshot\.status !== "ready" \|\|\s*snapshot\.activeRun/u); + assert.match(handlers, /service\.runs\.journals\.indexHealth\(\)/u); + assert.match(handlers, /runIndex\.diagnostic === "rebuilt-corrupt-index"/u); + assert.match(handlers, /service\.workflows\.get\(workflowId\)/u); + assert.match(handlers, /parseCreateImagesResolveRunAmbiguityRequest/u); + assert.match(handlers, /runs\.resolveRunAmbiguity\(input\)/u); + assert.match(main, /activeImageRunsWithinQuitDeadline/u); + assert.match(main, /stopped\.status === "blocked"/u); + assert.match(main, /confirmActiveImageRunsBeforeQuit/u); + assert.match(main, /"Keep Aiden Open", stopLabel/u); + assert.match(main, /showQuitMessageBox\(window/u); + assert.match(main, /dialog\.showMessageBoxSync\(options\)/u); + assert.match(main, /confirmActiveImageRunsBeforeQuit\(\)/u); + assert.match( + main, + /function resumeCreateImagesAfterCancelledShutdown\(\): void \{[\s\S]*?resumeRunAdmissionsAfterCancelledShutdown\(\);[\s\S]*?\}/u, + ); + const applicationQuit = main.slice( + main.indexOf("async function requestApplicationQuit"), + main.indexOf("async function clearRendererOnboardingCompletion"), + ); + assert.match( + applicationQuit, + /finally \{[\s\S]*?if \(!shutdownStarted\) resumeCreateImagesAfterCancelledShutdown\(\);/u, + ); + const shutdownAndQuit = main.slice( + main.indexOf("async function shutdownAndQuit"), + main.indexOf("async function refreshCloseGuardFromRenderer"), + ); + assert.match( + shutdownAndQuit, + /computerUseSettings\.resumeAfterCancelledShutdown\(\);\s*resumeCreateImagesAfterCancelledShutdown\(\);/u, + ); + const beforeQuit = main.slice( + main.indexOf('app.on("before-quit"'), + main.indexOf('app.on("will-quit"'), + ); + assert.ok( + beforeQuit.indexOf("confirmActiveImageRunsBeforeQuit()") < + beforeQuit.indexOf("shutdownAndQuit()"), + "windowless quit must confirm active image runs before shutdown can stop them", + ); + + assert.equal(main.match(/createImagesService\(\)\.runs\.stopAll\("app-quit"\)/gu)?.length, 2); +}); + +test("Phase 4 provider status is a bounded main-owned API-key capability read", async () => { + const [handlers, statusCore, providerRegistry, rendererIpc, queries, preloadChannels] = + await Promise.all([ + fs.readFile(path.resolve("main/handlers/create-images.ts"), "utf8"), + fs.readFile( + path.resolve("main/services/create-images/gemini-provider-status-core.ts"), + "utf8", + ), + fs.readFile(path.resolve("main/services/provider-registry.ts"), "utf8"), + fs.readFile(path.resolve("renderer/lib/ipc.ts"), "utf8"), + fs.readFile(path.resolve("renderer/lib/queries.ts"), "utf8"), + fs.readFile(path.resolve("renderer/preload-channels.ts"), "utf8"), + ]); + + const normalizedHandlers = handlers.replace(/\s+/gu, " ").replace(/\( /gu, "("); + const providerStatusHandler = normalizedHandlers.slice( + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:providerStatus"'), + normalizedHandlers.indexOf('ipcMain.handle("imageWorkflows:list"'), + ); + assert.match(providerStatusHandler, /rendererDocumentOwner\(event/u); + assert.match(providerStatusHandler, /if \(!readAllowed\(owner, 2\)\)/u); + assert.match(providerStatusHandler, /await runBounded\(owner\.id/u); + assert.match( + providerStatusHandler, + /providerRegistry\.getBuiltinCredentialKind\(CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID/u, + ); + assert.match( + providerStatusHandler, + /providerRegistry\.getBuiltinRequestAuth\(CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID\)/u, + ); + assert.match(providerStatusHandler, /bounded\.status === "busy" \|\| owner\.isDestroyed\(\)/u); + assert.doesNotMatch(providerStatusHandler, /console\.|onNotification|apiKey/u); + + assert.match(statusCore, /CREATE_IMAGES_GEMINI_CREDENTIAL_PROVIDER_ID = "google"/u); + assert.match(statusCore, /kind !== "api_key"/u); + assert.match(statusCore, /usableApiKey\(auth\)/u); + assert.match(providerRegistry, /async getBuiltinCredentialKind\(/u); + assert.match(providerRegistry, /await this\.credentials\.list\(\)/u); + assert.match(providerRegistry, /this\.models\.getAuth\(providerId\)/u); + + assert.match( + rendererIpc, + /providerStatus:\s*\(\) =>\s*invoke\("imageWorkflows:providerStatus"\)/u, + ); + assert.match(queries, /createImagesProviderStatus: \["createImagesProviderStatus", "gemini"\]/u); + assert.match(queries, /export function useCreateImagesProviderStatus\(enabled = true\)/u); + assert.match(queries, /queryFn: createImagesApi\.providerStatus/u); + assert.match(queries, /retry: false/u); + assert.match(queries, /refetchOnWindowFocus: true/u); + assert.match(preloadChannels, /"imageWorkflows:"/u); + assert.doesNotMatch(preloadChannels, /imageWorkflows:provider-status-changed/u); +}); diff --git a/main/services/create-images/phase-two-integration.test.ts b/main/services/create-images/phase-two-integration.test.ts new file mode 100644 index 00000000..8e095e5c --- /dev/null +++ b/main/services/create-images/phase-two-integration.test.ts @@ -0,0 +1,456 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import { createStarterWorkflow } from "../../../renderer/shared/create-images/schema.js"; +import { + ContentAddressedAssetStore, + type AssetReferenceAuthority, + type AssetReferenceSnapshot, +} from "./asset-store-core.js"; +import { CreateImagesService } from "./create-images-service.js"; +import { WorkflowManifestStore } from "./workflow-manifest-store.js"; + +function crc32(bytes: Uint8Array): number { + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb8_8320 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +function u32(value: number): Uint8Array { + return Uint8Array.from([ + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ]); +} + +function concat(...parts: readonly Uint8Array[]): Uint8Array { + const bytes = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)); + let offset = 0; + for (const part of parts) { + bytes.set(part, offset); + offset += part.byteLength; + } + return bytes; +} + +function pngChunk(type: string, data: Uint8Array): Uint8Array { + const typeBytes = new TextEncoder().encode(type); + const checksum = concat(typeBytes, data); + return concat(u32(data.byteLength), checksum, u32(crc32(checksum))); +} + +function largeStaticPng(payloadBytes = 20 * 1024 * 1024): Uint8Array { + const header = new Uint8Array(13); + header.set(u32(1)); + header.set(u32(1), 4); + header[8] = 8; + header[9] = 6; + return concat( + Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("tEXt", new Uint8Array(payloadBytes)), + pngChunk("IDAT", Uint8Array.from([0x78, 0x9c, 0, 0, 0, 0, 0, 1])), + pngChunk("IEND", new Uint8Array()), + ); +} + +async function* imageChunks(bytes: Uint8Array): AsyncGenerator { + const chunkSize = 256 * 1024; + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + yield bytes.subarray(offset, Math.min(offset + chunkSize, bytes.byteLength)); + } +} + +class IntegrationReferenceAuthority implements AssetReferenceAuthority { + snapshot: AssetReferenceSnapshot = { + epoch: "0", + completeKinds: ["workflow", "run", "export"], + records: [], + }; + + async withSnapshot( + callback: (snapshot: AssetReferenceSnapshot) => Promise, + ): Promise { + return callback(structuredClone(this.snapshot)); + } +} + +test("large content-addressed images and workflows survive restart and recovery without graph bytes", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-phase-two-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const authority = new IntegrationReferenceAuthority(); + const thumbnail = largeStaticPng(0); + const options = { + deepValidator: { + async validate({ descriptor }: { descriptor: { width: number; height: number } }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: thumbnail, + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }; + + const largeImage = largeStaticPng(); + const firstAssets = new ContentAddressedAssetStore(root, authority, options); + const imported = await firstAssets.ingest(imageChunks(largeImage), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "twenty-megabyte-reference.png", + }); + assert.ok(imported.asset.byteLength > 20 * 1024 * 1024); + assert.equal(Object.prototype.hasOwnProperty.call(imported.asset, "filePath"), false); + + const now = "2026-08-11T12:00:00.000Z"; + const workflow = createStarterWorkflow({ + workflowId: "durable-large-image", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now, + }); + workflow.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 20, y: 340 }, + data: { assetId: imported.asset.assetId, label: "Large local reference" }, + }); + workflow.assetRefs = [imported.asset.assetId]; + const firstWorkflows = new WorkflowManifestStore(() => root); + await firstWorkflows.create(workflow); + authority.snapshot = { + epoch: "1", + completeKinds: ["workflow", "run", "export"], + records: [{ kind: "workflow", id: workflow.id, assetIds: [imported.asset.assetId] }], + }; + await firstAssets.rebuildReferenceAccounting(); + + const workflowPath = path.join(root, "workflows", workflow.id, "workflow.json"); + const graphText = await fs.readFile(workflowPath, "utf8"); + assert.ok(Buffer.byteLength(graphText) < 64 * 1024); + assert.doesNotMatch(graphText, /data:image|;base64,/u); + assert.notEqual(path.join(root, "index.json"), path.join(root, "asset-index.json")); + + const restartedWorkflows = new WorkflowManifestStore(() => root); + const restartedAssets = new ContentAddressedAssetStore(root, authority, options); + const reopenedWorkflow = await restartedWorkflows.get(workflow.id); + const reopenedAsset = await restartedAssets.get(imported.asset.assetId); + assert.equal(reopenedWorkflow?.assetRefs[0], imported.asset.assetId); + assert.equal(reopenedAsset?.byteLength, largeImage.byteLength); + const generatedThumbnail = await restartedAssets.getThumbnail(imported.asset.assetId, 512); + assert.ok(generatedThumbnail.byteLength < 4 * 1024 * 1024); + assert.ok(restartedAssets.thumbnailCacheStatus().byteLength < 64 * 1024 * 1024); + assert.deepEqual((await restartedAssets.planGarbageCollection(0)).candidateAssetIds, []); + + await fs.writeFile(workflowPath, "{corrupt", "utf8"); + const recoveryStore = new WorkflowManifestStore(() => root); + const recovery = await recoveryStore.inspect(workflow.id); + assert.equal(recovery.status, "recovery-required"); + const restored = await recoveryStore.recover( + workflow.id, + "last-known-good", + workflow.revision, + "2026-08-11T12:01:00.000Z", + ); + assert.deepEqual(restored.assetRefs, [imported.asset.assetId]); + assert.equal( + (await restartedAssets.get(imported.asset.assetId))?.assetId, + imported.asset.assetId, + ); +}); + +test("asset protocol falls back to the validated source when thumbnail generation is unavailable", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-preview-fallback-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const source = largeStaticPng(0); + const service = new CreateImagesService(root, { + assetStore: { + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: Uint8Array.from([0]), + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }); + const imported = await service.assets.ingest(imageChunks(source), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "fallback-reference.png", + }); + + const response = await service.assetResponse(imported.asset.assetId); + assert.equal(response?.status, 200); + assert.equal(response?.headers.get("content-type"), "image/png"); + assert.deepEqual(new Uint8Array(await response!.arrayBuffer()), source); +}); + +test("durable journal references survive renderer loss, lease expiry, GC, and recovery", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-journal-gc-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + let now = Date.parse("2026-08-11T12:00:00.000Z"); + let rendererAlive = true; + let failAfterJournal = false; + const thumbnail = largeStaticPng(0); + const service = new CreateImagesService(root, { + workflowDurability: { + async afterJournalPublished() { + if (failAfterJournal) rendererAlive = false; + }, + }, + assetStore: { + now: () => now, + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: thumbnail, + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }); + await service.initialize(); + const imported = await service.assets.ingest(imageChunks(thumbnail), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + displayName: "pending-journal-reference.png", + }); + const workflow = createStarterWorkflow({ + workflowId: "journal-reference-recovery", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); + await service.mutateWorkflow(workflow.id, [], () => service.workflows.create(workflow)); + const lease = await service.assets.acquirePreviewLease( + imported.asset.assetId, + "journal-regression", + 1_000, + ); + const pending = structuredClone(workflow); + pending.revision = 2; + pending.updatedAt = "2026-08-11T12:01:00.000Z"; + pending.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 20, y: 340 }, + data: { assetId: imported.asset.assetId, label: "Pending durable reference" }, + }); + pending.assetRefs = [imported.asset.assetId]; + failAfterJournal = true; + + await assert.rejects( + () => + service.mutateWorkflow(workflow.id, pending.assetRefs, () => + service.workflows.save(pending, 1, () => rendererAlive), + ), + /renderer document is no longer active/u, + ); + assert.equal((await service.workflows.autosaveStatus(workflow.id)).state, "pending"); + + now = lease.expiresAt + 1; + const pendingPlan = await service.assets.planGarbageCollection(0); + assert.deepEqual(pendingPlan.candidateAssetIds, []); + assert.deepEqual( + (await service.assets.applyGarbageCollection(pendingPlan.planId)).deletedAssetIds, + [], + ); + assert.equal((await service.assets.get(imported.asset.assetId))?.assetId, imported.asset.assetId); + + const recovered = await service.workflows.recover( + workflow.id, + "autosave", + pending.revision, + "2026-08-11T12:02:00.000Z", + ); + await service.refreshReferenceAuthority(); + assert.deepEqual(recovered.assetRefs, [imported.asset.assetId]); + const recoveredPlan = await service.assets.planGarbageCollection(0); + assert.deepEqual(recoveredPlan.candidateAssetIds, []); + assert.deepEqual( + (await service.assets.applyGarbageCollection(recoveredPlan.planId)).deletedAssetIds, + [], + ); + assert.equal((await service.assets.get(imported.asset.assetId))?.assetId, imported.asset.assetId); +}); + +test("missing workflow assets remain editable but surface deterministic integrity diagnostics", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-missing-asset-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const missingAssetId = "a".repeat(64); + const workflow = createStarterWorkflow({ + workflowId: "missing-asset-diagnostic", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); + workflow.nodes.push({ + id: "missing-image-1", + type: "image-input", + position: { x: 20, y: 340 }, + data: { assetId: missingAssetId, label: "Missing local reference" }, + }); + workflow.assetRefs = [missingAssetId]; + + const service = new CreateImagesService(root, { + assetStore: { + deepValidator: { + async validate({ descriptor }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: largeStaticPng(0), + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }); + await service.workflows.create(workflow); + await service.initialize(); + + assert.equal((await service.workflows.list())[0]?.health, "healthy"); + assert.equal((await service.assets.status()).healthy, true); + assert.deepEqual(service.missingAssetIdsForWorkflow(workflow.id), [missingAssetId]); + assert.equal(service.missingAssetCount(), 1); + + const retained = structuredClone(workflow); + retained.revision = 2; + retained.updatedAt = "2026-08-11T12:01:00.000Z"; + retained.title = "Editable despite a diagnosed missing image"; + await service.mutateWorkflow(workflow.id, retained.assetRefs, () => + service.workflows.save(retained, 1), + ); + assert.deepEqual(service.missingAssetIdsForWorkflow(workflow.id), [missingAssetId]); + assert.equal(service.missingAssetCount(), 1); + + let introducedMissingAssetPublished = false; + await assert.rejects( + () => + service.mutateWorkflow(workflow.id, [missingAssetId, "b".repeat(64)], async () => { + introducedMissingAssetPublished = true; + }), + /does not exist/u, + ); + assert.equal(introducedMissingAssetPublished, false); + + const repaired = structuredClone(retained); + repaired.revision = 3; + repaired.updatedAt = "2026-08-11T12:02:00.000Z"; + repaired.nodes = repaired.nodes.filter((node) => node.id !== "missing-image-1"); + repaired.assetRefs = []; + await service.mutateWorkflow(workflow.id, repaired.assetRefs, () => + service.workflows.save(repaired, 2), + ); + assert.deepEqual(service.missingAssetIdsForWorkflow(workflow.id), []); + assert.equal(service.missingAssetCount(), 0); +}); + +test("an indexed asset whose source disappears is diagnosed after restart", async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "aiden-create-images-missing-source-")); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const options = { + assetStore: { + deepValidator: { + async validate({ descriptor }: { descriptor: { width: number; height: number } }) { + return { width: descriptor.width, height: descriptor.height }; + }, + }, + thumbnailGenerator: { + async generate() { + return { + bytes: largeStaticPng(0), + width: 1, + height: 1, + mediaType: "image/png" as const, + }; + }, + }, + }, + }; + const service = new CreateImagesService(root, options); + const imported = await service.assets.ingest(imageChunks(largeStaticPng(0)), { + origin: { kind: "import" }, + declaredMimeType: "image/png", + }); + const workflow = createStarterWorkflow({ + workflowId: "missing-published-source", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); + workflow.nodes.push({ + id: "image-1", + type: "image-input", + position: { x: 0, y: 340 }, + data: { assetId: imported.asset.assetId }, + }); + workflow.assetRefs = [imported.asset.assetId]; + await service.mutateWorkflow(workflow.id, workflow.assetRefs, () => + service.workflows.create(workflow), + ); + await fs.rm( + path.join( + root, + "assets", + "sha256", + imported.asset.assetId.slice(0, 2), + `${imported.asset.assetId}.png`, + ), + ); + + const restarted = new CreateImagesService(root, options); + await restarted.initialize(); + assert.deepEqual(restarted.missingAssetIdsForWorkflow(workflow.id), [imported.asset.assetId]); + assert.equal(restarted.missingAssetCount(), 1); + assert.equal(await restarted.assets.getAvailable(imported.asset.assetId), undefined); +}); diff --git a/main/services/create-images/phase-zero-contracts.test.ts b/main/services/create-images/phase-zero-contracts.test.ts new file mode 100644 index 00000000..ad73f662 --- /dev/null +++ b/main/services/create-images/phase-zero-contracts.test.ts @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { AssetDeliveryGrantRegistry } from "./asset-delivery-core.js"; +import { + CREATE_IMAGES_FEATURE_FLAG, + createImagesEnabled, + createWhenImagesEnabled, +} from "./feature-flag.js"; +import { + buildGeminiInteractionsRequest, + GEMINI_INTERACTIONS_ENDPOINT, + GEMINI_IMAGE_MODELS, + validateGeminiImageRequest, +} from "./providers/gemini-interactions-core.js"; +import type { RendererDocumentOwner } from "../renderer-document-owner.js"; + +function fakeOwner( + documentId: string, + id = 1, +): { + owner: RendererDocumentOwner; + invalidate(): void; +} { + let destroyed = false; + const listeners = new Set<() => void>(); + return { + owner: { + id, + documentId, + isDestroyed: () => destroyed, + send: () => undefined, + onInvalidated: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + invalidate: () => { + destroyed = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +function fakeLease(expiresAt = Number.MAX_SAFE_INTEGER, released?: () => void) { + return { expiresAt, release: released ?? (() => undefined) }; +} + +test("Create Images feature flag is fail-closed and does not construct services while disabled", () => { + assert.equal(createImagesEnabled({}), false); + assert.equal(createImagesEnabled({ [CREATE_IMAGES_FEATURE_FLAG]: "0" }), false); + assert.equal(createImagesEnabled({ [CREATE_IMAGES_FEATURE_FLAG]: "true" }), false); + assert.equal(createImagesEnabled({ [CREATE_IMAGES_FEATURE_FLAG]: " 1 " }), true); + let constructed = 0; + assert.equal( + createWhenImagesEnabled(() => { + constructed += 1; + return "service"; + }, {}), + undefined, + ); + assert.equal(constructed, 0); +}); + +test("asset delivery grants are opaque, document-bound, expiring, and revocable", () => { + let now = 1_000; + const registry = new AssetDeliveryGrantRegistry(() => now, 1_000, 2); + const firstOwner = fakeOwner("123:45:frame-token", 123); + const otherOwner = fakeOwner("124:46:other-frame", 124); + const authorized = new Set(["asset-1", "asset-2"]); + const first = registry.mint( + firstOwner.owner, + "asset-1", + (assetId) => authorized.has(assetId), + fakeLease(), + ); + assert.doesNotMatch(first.token, /asset-1|frame-token/u); + assert.equal(registry.resolve(first.token, otherOwner.owner), undefined); + assert.equal(registry.resolve(first.token, firstOwner.owner), "asset-1"); + assert.equal(registry.revoke(first.token, otherOwner.owner), false); + assert.equal(registry.revoke(first.token, firstOwner.owner), true); + + const second = registry.mint( + firstOwner.owner, + "asset-2", + (assetId) => authorized.has(assetId), + fakeLease(), + ); + now += 1_000; + assert.equal(registry.resolve(second.token, firstOwner.owner), undefined); + assert.equal(registry.size(), 0); +}); + +test("asset delivery grants enforce authorization and revoke on document invalidation", () => { + const registry = new AssetDeliveryGrantRegistry(); + const current = fakeOwner("123:45:frame-token", 123); + const allowed = new Set(["asset-1"]); + assert.throws( + () => + registry.mint(current.owner, "asset-denied", (assetId) => allowed.has(assetId), fakeLease()), + /not authorized/u, + ); + const grant = registry.mint( + current.owner, + "asset-1", + (assetId) => allowed.has(assetId), + fakeLease(), + ); + allowed.clear(); + assert.equal(registry.resolve(grant.token, current.owner), undefined); + const next = registry.mint(current.owner, "asset-1", () => true, fakeLease()); + current.invalidate(); + assert.equal(registry.resolve(next.token, current.owner), undefined); + assert.equal(registry.size(), 0); +}); + +test("asset delivery grants enforce a bounded registry", () => { + let now = 1_000; + const registry = new AssetDeliveryGrantRegistry(() => now, 60_000, 2); + const current = fakeOwner("123:45:frame-token", 123); + const first = registry.mint(current.owner, "asset-1", () => true, fakeLease()); + now += 1; + const second = registry.mint(current.owner, "asset-2", () => true, fakeLease()); + now += 1; + registry.mint(current.owner, "asset-3", () => true, fakeLease()); + assert.equal(registry.resolve(first.token, current.owner), undefined); + assert.equal(registry.resolve(second.token, current.owner), "asset-2"); + assert.equal(registry.revokeDocument(current.owner), 2); +}); + +test("every grant deletion path releases its resource exactly once", () => { + let now = 1_000; + const released: string[] = []; + const registry = new AssetDeliveryGrantRegistry(() => now, 1_000, 1); + const firstOwner = fakeOwner("123:45:first", 123); + const secondOwner = fakeOwner("124:46:second", 124); + const first = registry.mint( + firstOwner.owner, + "asset-1", + () => true, + fakeLease(2_000, () => released.push("first")), + ); + registry.mint( + firstOwner.owner, + "asset-2", + () => true, + fakeLease(2_000, () => released.push("evicted")), + ); + assert.equal(registry.resolve(first.token, firstOwner.owner), undefined); + assert.deepEqual(released, ["first"]); + firstOwner.invalidate(); + assert.deepEqual(released, ["first", "evicted"]); + + const expiring = registry.mint( + secondOwner.owner, + "asset-3", + () => true, + fakeLease(2_000, () => released.push("expired")), + ); + now = 2_000; + assert.equal(registry.resolve(expiring.token, secondOwner.owner), undefined); + assert.deepEqual(released, ["first", "evicted", "expired"]); +}); + +test("asset protocol delivery requires an exact-frame one-time authorization ticket", () => { + const registry = new AssetDeliveryGrantRegistry(); + const current = fakeOwner("123:45:frame-token", 123); + const grant = registry.mint(current.owner, "asset-1", () => true, fakeLease()); + assert.equal(registry.consumeProtocolRequest(grant.token), undefined); + assert.equal( + registry.authorizeProtocolRequest(grant.token, 123, "123:45:different-frame"), + false, + ); + assert.equal(registry.authorizeProtocolRequest(grant.token, 123, "123:45:frame-token"), true); + assert.equal(registry.consumeProtocolRequest(grant.token), "asset-1"); + assert.equal(registry.consumeProtocolRequest(grant.token), undefined); +}); + +test("Gemini contract uses the fixed Interactions origin and contains no credential fields", () => { + assert.equal( + GEMINI_INTERACTIONS_ENDPOINT, + "https://generativelanguage.googleapis.com/v1beta/interactions", + ); + assert.deepEqual( + GEMINI_IMAGE_MODELS.map((model) => model.id), + ["gemini-3.1-flash-lite-image", "gemini-3.1-flash-image", "gemini-3-pro-image"], + ); + const request = buildGeminiInteractionsRequest({ + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + prompt: " Draw a quiet harbor at dawn. ", + aspectRatio: "16:9", + imageSize: "2K", + outputMime: "image/png", + count: 1, + references: [ + { + assetId: "asset-1", + bytes: new Uint8Array([0, 1, 2, 3]), + mimeType: "image/png", + }, + ], + }); + assert.deepEqual(request, { + model: "gemini-3.1-flash-image", + input: [ + { type: "text", text: "Draw a quiet harbor at dawn." }, + { type: "image", mime_type: "image/png", data: "AAECAw==" }, + ], + response_format: { + type: "image", + mime_type: "image/png", + aspect_ratio: "16:9", + image_size: "2K", + }, + store: false, + background: false, + }); + assert.doesNotMatch(JSON.stringify(request), /api.?key|authorization|credential/iu); +}); + +test("Gemini contract rejects arbitrary models, excess output count, and empty media", () => { + const base = { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + prompt: "prompt", + aspectRatio: "1:1" as const, + imageSize: "1K" as const, + outputMime: "image/png" as const, + count: 1, + references: [], + }; + assert.throws( + () => validateGeminiImageRequest({ ...base, modelId: "attacker/model" }), + /not supported/u, + ); + assert.throws(() => validateGeminiImageRequest({ ...base, count: 2 }), /one output/u); + assert.throws( + () => + validateGeminiImageRequest({ + ...base, + references: [{ assetId: "asset-1", bytes: new Uint8Array(), mimeType: "image/png" }], + }), + /between 1 byte/u, + ); +}); From 6c75bce00c3b8b4c8a9d338b310f861a321ba466 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Thu, 20 Aug 2026 00:42:11 -0400 Subject: [PATCH 005/110] feat(create-images): build the workflow canvas experience Add the lazy React Flow editor, compact image-first reference nodes, drag/drop and clipboard placement, autosave, previews, run controls/history/recovery, provider configuration, accessible dialogs, and polished semantic light/dark styling. Aggregate lifecycle guards so unsaved image work cannot be lost. --- .../asset-preview-lifecycle-core.test.ts | 395 ++ .../asset-preview-lifecycle-core.ts | 431 +++ renderer/create-images/canvas-context.tsx | 62 + renderer/create-images/create-images-view.tsx | 3213 +++++++++++++++++ renderer/create-images/create-images.css | 854 +++++ renderer/create-images/editor-core.test.ts | 305 ++ renderer/create-images/editor-core.ts | 160 + .../create-images/feature-surface.test.ts | 489 +++ renderer/create-images/fixture-summaries.ts | 31 + renderer/create-images/fixtures.test.ts | 49 + renderer/create-images/fixtures.ts | 227 ++ renderer/create-images/image-drop-core.ts | 305 ++ .../create-images/navigation-guard.test.ts | 21 + renderer/create-images/navigation-guard.ts | 39 + .../create-images/provider-connection-core.ts | 158 + .../provider-connection.test.tsx | 210 ++ .../create-images/provider-connection.tsx | 247 ++ .../run-ambiguity-confirmation.tsx | 55 + .../run-degraded-discard-confirmation.tsx | 80 + renderer/create-images/run-path-chooser.tsx | 103 + renderer/create-images/run-path-core.ts | 27 + renderer/create-images/run-ui-adapter.ts | 746 ++++ renderer/create-images/run-ui-core.test.ts | 2489 +++++++++++++ renderer/create-images/run-ui-core.ts | 785 ++++ renderer/create-images/run-ui.css | 1255 +++++++ renderer/create-images/run-ui.test.tsx | 454 +++ renderer/create-images/run-ui.tsx | 1341 +++++++ .../workflow-autosave-core.test.ts | 184 + .../create-images/workflow-autosave-core.ts | 224 ++ renderer/create-images/workflow-canvas.tsx | 2296 ++++++++++++ renderer/create-images/workflow-node.tsx | 603 ++++ renderer/lib/lifecycle-guard.test.ts | 27 + renderer/lib/lifecycle-guard.ts | 48 +- .../preload-create-images-image-decoder.ts | 103 + 34 files changed, 18014 insertions(+), 2 deletions(-) create mode 100644 renderer/create-images/asset-preview-lifecycle-core.test.ts create mode 100644 renderer/create-images/asset-preview-lifecycle-core.ts create mode 100644 renderer/create-images/canvas-context.tsx create mode 100644 renderer/create-images/create-images-view.tsx create mode 100644 renderer/create-images/create-images.css create mode 100644 renderer/create-images/editor-core.test.ts create mode 100644 renderer/create-images/editor-core.ts create mode 100644 renderer/create-images/feature-surface.test.ts create mode 100644 renderer/create-images/fixture-summaries.ts create mode 100644 renderer/create-images/fixtures.test.ts create mode 100644 renderer/create-images/fixtures.ts create mode 100644 renderer/create-images/image-drop-core.ts create mode 100644 renderer/create-images/navigation-guard.test.ts create mode 100644 renderer/create-images/navigation-guard.ts create mode 100644 renderer/create-images/provider-connection-core.ts create mode 100644 renderer/create-images/provider-connection.test.tsx create mode 100644 renderer/create-images/provider-connection.tsx create mode 100644 renderer/create-images/run-ambiguity-confirmation.tsx create mode 100644 renderer/create-images/run-degraded-discard-confirmation.tsx create mode 100644 renderer/create-images/run-path-chooser.tsx create mode 100644 renderer/create-images/run-path-core.ts create mode 100644 renderer/create-images/run-ui-adapter.ts create mode 100644 renderer/create-images/run-ui-core.test.ts create mode 100644 renderer/create-images/run-ui-core.ts create mode 100644 renderer/create-images/run-ui.css create mode 100644 renderer/create-images/run-ui.test.tsx create mode 100644 renderer/create-images/run-ui.tsx create mode 100644 renderer/create-images/workflow-autosave-core.test.ts create mode 100644 renderer/create-images/workflow-autosave-core.ts create mode 100644 renderer/create-images/workflow-canvas.tsx create mode 100644 renderer/create-images/workflow-node.tsx create mode 100644 renderer/lib/lifecycle-guard.test.ts create mode 100644 renderer/preload-create-images-image-decoder.ts diff --git a/renderer/create-images/asset-preview-lifecycle-core.test.ts b/renderer/create-images/asset-preview-lifecycle-core.test.ts new file mode 100644 index 00000000..1d52d122 --- /dev/null +++ b/renderer/create-images/asset-preview-lifecycle-core.test.ts @@ -0,0 +1,395 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { CreateImagesAssetGrantView } from "../shared/create-images/ipc.js"; +import { + AssetPreviewLifecycleManager, + AssetPreviewLoadError, + deferAssetPreviewLifecycleDisposal, +} from "./asset-preview-lifecycle-core.js"; + +const NO_TIMERS = { + set: () => 0, + clear: () => undefined, +}; + +function grant(assetId: string, token: string, expiresAt: number): CreateImagesAssetGrantView { + return { + token, + expiresAt, + url: `aiden-asset://asset/${token}`, + asset: { + assetId, + mediaType: "image/png", + byteLength: 128, + width: 1, + height: 1, + importedAt: "2026-08-11T12:00:00.000Z", + }, + }; +} + +async function settle(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +test("preview requests are per-asset single-flight and concurrency bounded", async () => { + const releases: Array<(value: CreateImagesAssetGrantView) => void> = []; + const calls: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + maxConcurrent: 2, + timers: NO_TIMERS, + load: (assetId) => { + calls.push(assetId); + return new Promise((resolve) => releases.push(resolve)); + }, + revoke: async () => undefined, + }); + + manager.setAssets(["asset-1", "asset-2", "asset-3"]); + manager.retain("asset-1"); + manager.retain("asset-2"); + manager.retain("asset-3"); + manager.setAssets(["asset-1", "asset-2", "asset-3"]); + assert.deepEqual(calls, ["asset-1", "asset-2"]); + assert.equal(manager.status("asset-1"), "loading"); + assert.equal(manager.status("asset-3"), "loading"); + releases[0]?.(grant("asset-1", "token-1", Date.now() + 60_000)); + await settle(); + assert.deepEqual(calls, ["asset-1", "asset-2", "asset-3"]); + releases[1]?.(grant("asset-2", "token-2", Date.now() + 60_000)); + releases[2]?.(grant("asset-3", "token-3", Date.now() + 60_000)); + await settle(); + await manager.dispose(); +}); + +test("renewal keeps the old preview until an atomic swap and revokes every superseded token", async () => { + let now = 1_000; + const loads: Array<(value: CreateImagesAssetGrantView) => void> = []; + const revoked: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + now: () => now, + timers: NO_TIMERS, + load: () => new Promise((resolve) => loads.push(resolve)), + revoke: async (token) => { + revoked.push(token); + }, + }); + + manager.setAssets(["asset-1"]); + manager.retain("asset-1"); + loads[0]?.(grant("asset-1", "old-token", 61_000)); + await settle(); + assert.equal(manager.snapshot()["asset-1"]?.token, "old-token"); + + now = 50_000; + manager.refresh(); + assert.equal(loads.length, 2); + assert.equal(manager.snapshot()["asset-1"]?.token, "old-token"); + loads[1]?.(grant("asset-1", "new-token", 110_000)); + await settle(); + assert.equal(manager.snapshot()["asset-1"]?.token, "new-token"); + assert.deepEqual(revoked, ["old-token"]); + + manager.reportLoadError("asset-1", "new-token"); + assert.equal(manager.snapshot()["asset-1"], undefined); + assert.equal(loads.length, 2); + assert.deepEqual(revoked, ["old-token", "new-token"]); + now = 51_000; + manager.refresh(); + assert.equal(loads.length, 3); + loads[2]?.(grant("asset-1", "recovered-token", 120_000)); + await settle(); + await manager.dispose(); + assert.deepEqual(revoked, ["old-token", "new-token", "recovered-token"]); +}); + +test("pruning and disposal revoke late in-flight grants without resurrecting previews", async () => { + let resolveLoad: ((value: CreateImagesAssetGrantView) => void) | undefined; + const revoked: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + timers: NO_TIMERS, + load: () => new Promise((resolve) => (resolveLoad = resolve)), + revoke: async (token) => { + revoked.push(token); + }, + }); + + manager.setAssets(["asset-1"]); + manager.retain("asset-1"); + manager.setAssets([]); + resolveLoad?.(grant("asset-1", "late-token", Date.now() + 60_000)); + await settle(); + assert.deepEqual(manager.snapshot(), {}); + assert.deepEqual(revoked, ["late-token"]); + await manager.dispose(); +}); + +test("retry backoff is bounded and a wake after expiry renews a sleeping canvas", async () => { + let now = 1_000; + let calls = 0; + const revoked: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + now: () => now, + retryBaseMs: 1_000, + retryMaxMs: 4_000, + timers: NO_TIMERS, + load: async (assetId) => { + calls += 1; + if (calls === 1) throw new AssetPreviewLoadError("temporary", true); + return grant(assetId, `token-${calls}`, now + 60_000); + }, + revoke: async (token) => { + revoked.push(token); + }, + }); + + manager.setAssets(["asset-1"]); + manager.retain("asset-1"); + await settle(); + assert.equal(manager.status("asset-1"), "retrying"); + manager.refresh(); + assert.equal(calls, 1); + now = 2_000; + manager.refresh(); + await settle(); + assert.equal(calls, 2); + assert.equal(manager.snapshot()["asset-1"]?.token, "token-2"); + assert.equal(manager.status("asset-1"), "ready"); + + now = 70_000; + manager.refresh(); + await settle(); + assert.equal(calls, 3); + assert.equal(manager.snapshot()["asset-1"]?.token, "token-3"); + assert.deepEqual(revoked, ["token-2"]); + await manager.dispose(); +}); + +test("preview status distinguishes terminal failure from loading and retrying", async () => { + const manager = new AssetPreviewLifecycleManager({ + timers: NO_TIMERS, + load: async () => { + throw new AssetPreviewLoadError("forbidden", false); + }, + revoke: async () => undefined, + }); + manager.setAssets(["asset-1"]); + manager.retain("asset-1"); + assert.equal(manager.status("asset-1"), "loading"); + await settle(); + assert.equal(manager.status("asset-1"), "unavailable"); + await manager.dispose(); +}); + +test("a wedged grant request times out, retries, and revokes a late token", async () => { + let now = 1_000; + const timers: Array<() => void> = []; + const lateLoads: Array<(value: CreateImagesAssetGrantView) => void> = []; + const revoked: string[] = []; + let calls = 0; + const manager = new AssetPreviewLifecycleManager({ + now: () => now, + loadTimeoutMs: 1_000, + retryBaseMs: 1_000, + retryMaxMs: 1_000, + timers: { + set: (callback) => { + timers.push(callback); + return callback; + }, + clear: () => undefined, + }, + load: async (assetId) => { + calls += 1; + if (calls === 1) return new Promise((resolve) => lateLoads.push(resolve)); + return grant(assetId, "retry-token", now + 60_000); + }, + revoke: async (token) => { + revoked.push(token); + }, + }); + + manager.setAssets(["asset-1"]); + manager.retain("asset-1"); + assert.equal(manager.status("asset-1"), "loading"); + timers.shift()?.(); + await settle(); + assert.equal(manager.status("asset-1"), "retrying"); + + now = 2_000; + manager.refresh(); + await settle(); + assert.equal(calls, 2); + assert.equal(manager.snapshot()["asset-1"]?.token, "retry-token"); + + lateLoads[0]?.(grant("asset-1", "late-token", now + 60_000)); + await settle(); + assert.deepEqual(revoked, ["late-token"]); + await manager.dispose(); +}); + +test("development effect replay cancels deferred preview disposal", async () => { + const revoked: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + timers: NO_TIMERS, + load: async (assetId) => grant(assetId, "live-token", Date.now() + 60_000), + revoke: async (token) => { + revoked.push(token); + }, + }); + + const cancelReplayCleanup = deferAssetPreviewLifecycleDisposal(manager); + cancelReplayCleanup(); + await new Promise((resolve) => setTimeout(resolve, 0)); + manager.setAssets(["asset-1"]); + manager.retain("asset-1"); + await settle(); + + assert.equal(manager.snapshot()["asset-1"]?.token, "live-token"); + assert.deepEqual(revoked, []); + await manager.dispose(); + assert.deepEqual(revoked, ["live-token"]); +}); + +test("a terminal renewal failure keeps the usable URL but still prunes it at expiry", async () => { + let now = 1_000; + let calls = 0; + const revoked: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + now: () => now, + timers: NO_TIMERS, + load: async (assetId) => { + calls += 1; + if (calls > 1) throw new AssetPreviewLoadError("forbidden", false); + return grant(assetId, "usable-token", 61_000); + }, + revoke: async (token) => { + revoked.push(token); + }, + }); + + manager.setAssets(["asset-1"]); + manager.retain("asset-1"); + await settle(); + now = 50_000; + manager.refresh(); + await settle(); + assert.equal(manager.snapshot()["asset-1"]?.token, "usable-token"); + now = 61_000; + manager.refresh(); + assert.equal(manager.snapshot()["asset-1"], undefined); + assert.equal(calls, 2); + assert.deepEqual(revoked, ["usable-token"]); + await manager.dispose(); +}); + +test("an adopted import grant is pruned and revoked when its asset leaves the draft", async () => { + const revoked: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + timers: NO_TIMERS, + load: async () => { + throw new Error("not expected"); + }, + revoke: async (token) => { + revoked.push(token); + }, + }); + manager.adopt("asset-1", grant("asset-1", "import-token", Date.now() + 60_000)); + assert.equal(manager.snapshot()["asset-1"]?.token, "import-token"); + manager.setAssets([]); + assert.deepEqual(manager.snapshot(), {}); + assert.deepEqual(revoked, ["import-token"]); + await manager.dispose(); +}); + +test("same-digest adoption hands ownership to an existing mounted node", async () => { + const revoked: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + timers: NO_TIMERS, + load: async (assetId) => grant(assetId, "loaded-token", Date.now() + 60_000), + revoke: async (token) => { + revoked.push(token); + }, + }); + manager.setAssets(["asset-1"]); + const release = manager.retain("asset-1"); + await settle(); + + manager.adopt("asset-1", grant("asset-1", "same-digest-token", Date.now() + 60_000)); + assert.equal(manager.snapshot()["asset-1"]?.token, "same-digest-token"); + assert.deepEqual(revoked, ["loaded-token"]); + + release(); + assert.deepEqual(manager.snapshot(), {}); + assert.deepEqual(revoked, ["loaded-token", "same-digest-token"]); + await manager.dispose(); +}); + +test("repeated image delivery failures back off until a confirmed image load", async () => { + let now = 1_000; + let calls = 0; + const manager = new AssetPreviewLifecycleManager({ + now: () => now, + timers: NO_TIMERS, + retryBaseMs: 1_000, + retryMaxMs: 4_000, + load: async (assetId) => grant(assetId, `token-${++calls}`, now + 60_000), + revoke: async () => undefined, + }); + manager.setAssets(["asset-1"]); + manager.retain("asset-1"); + await settle(); + + manager.reportLoadError("asset-1", "token-1"); + manager.refresh(); + assert.equal(calls, 1); + now = 2_000; + manager.refresh(); + await settle(); + assert.equal(calls, 2); + + manager.reportLoadError("asset-1", "token-2"); + now = 3_999; + manager.refresh(); + assert.equal(calls, 2); + now = 4_000; + manager.refresh(); + await settle(); + assert.equal(calls, 3); + + manager.reportLoadSuccess("asset-1", "token-3"); + manager.reportLoadError("asset-1", "token-3"); + now = 4_999; + manager.refresh(); + assert.equal(calls, 3); + now = 5_000; + manager.refresh(); + await settle(); + assert.equal(calls, 4); + await manager.dispose(); +}); + +test("virtualized node release revokes its grant and remount requests one replacement", async () => { + let calls = 0; + const revoked: string[] = []; + const manager = new AssetPreviewLifecycleManager({ + timers: NO_TIMERS, + load: async (assetId) => grant(assetId, `token-${++calls}`, Date.now() + 60_000), + revoke: async (token) => { + revoked.push(token); + }, + }); + manager.setAssets(["asset-1"]); + const release = manager.retain("asset-1"); + await settle(); + assert.equal(calls, 1); + release(); + assert.deepEqual(revoked, ["token-1"]); + assert.deepEqual(manager.snapshot(), {}); + + manager.retain("asset-1"); + await settle(); + assert.equal(calls, 2); + assert.equal(manager.snapshot()["asset-1"]?.token, "token-2"); + await manager.dispose(); +}); diff --git a/renderer/create-images/asset-preview-lifecycle-core.ts b/renderer/create-images/asset-preview-lifecycle-core.ts new file mode 100644 index 00000000..8648415a --- /dev/null +++ b/renderer/create-images/asset-preview-lifecycle-core.ts @@ -0,0 +1,431 @@ +import type { CreateImagesAssetGrantView } from "../shared/create-images/ipc"; + +export class AssetPreviewLoadError extends Error { + constructor( + message: string, + readonly retryable: boolean, + ) { + super(message); + this.name = "AssetPreviewLoadError"; + } +} + +interface AssetPreviewEntry { + failures: number; + grant?: CreateImagesAssetGrantView; + inFlight: boolean; + retryAt: number; + terminal: boolean; +} + +export type AssetPreviewLifecycleStatus = "loading" | "retrying" | "ready" | "unavailable"; + +interface AssetPreviewTimerHost { + set(callback: () => void, delayMs: number): unknown; + clear(handle: unknown): void; +} + +export interface AssetPreviewLifecycleOptions { + load(assetId: string): Promise; + revoke(token: string): Promise; + now?: () => number; + maxConcurrent?: number; + renewBeforeMs?: number; + retryBaseMs?: number; + retryMaxMs?: number; + loadTimeoutMs?: number; + timers?: AssetPreviewTimerHost; +} + +const DEFAULT_TIMERS: AssetPreviewTimerHost = { + set: (callback, delayMs) => window.setTimeout(callback, delayMs), + clear: (handle) => window.clearTimeout(handle as number), +}; + +function snapshotOf( + entries: ReadonlyMap, +): Readonly> { + const snapshot: Record = {}; + for (const [assetId, entry] of entries) { + if (entry.grant) snapshot[assetId] = entry.grant; + } + return snapshot; +} + +/** + * Owns renderer preview grants independently of React renders. It limits grant + * requests, renews before expiry, retains the previous URL until its + * replacement is ready, and makes every token leave through one revoke path. + */ +export class AssetPreviewLifecycleManager { + private readonly entries = new Map(); + private readonly desired = new Set(); + private readonly retained = new Map(); + private readonly adopted = new Set(); + private readonly listeners = new Set< + (snapshot: Readonly>) => void + >(); + private readonly queued = new Set(); + private readonly queue: string[] = []; + private readonly now: () => number; + private readonly maxConcurrent: number; + private readonly renewBeforeMs: number; + private readonly retryBaseMs: number; + private readonly retryMaxMs: number; + private readonly loadTimeoutMs: number; + private readonly timers: AssetPreviewTimerHost; + private active = 0; + private disposed = false; + private wakeTimer: unknown; + private snapshotValue: Readonly> = Object.freeze({}); + + constructor(private readonly options: AssetPreviewLifecycleOptions) { + this.now = options.now ?? Date.now; + this.maxConcurrent = options.maxConcurrent ?? 4; + this.renewBeforeMs = options.renewBeforeMs ?? 15_000; + this.retryBaseMs = options.retryBaseMs ?? 1_000; + this.retryMaxMs = options.retryMaxMs ?? 30_000; + this.loadTimeoutMs = options.loadTimeoutMs ?? 10_000; + this.timers = options.timers ?? DEFAULT_TIMERS; + if ( + !Number.isSafeInteger(this.maxConcurrent) || + this.maxConcurrent < 1 || + this.maxConcurrent > 16 + ) { + throw new Error("Asset previews require 1–16 concurrent grant requests."); + } + if (!Number.isFinite(this.renewBeforeMs) || this.renewBeforeMs < 1_000) { + throw new Error("Asset preview renewal must begin at least one second before expiry."); + } + if ( + !Number.isFinite(this.retryBaseMs) || + !Number.isFinite(this.retryMaxMs) || + this.retryBaseMs < 100 || + this.retryMaxMs < this.retryBaseMs + ) { + throw new Error("Asset preview retry bounds are invalid."); + } + if (!Number.isFinite(this.loadTimeoutMs) || this.loadTimeoutMs < 1_000) { + throw new Error("Asset preview loading must have a timeout of at least one second."); + } + } + + snapshot(): Readonly> { + return this.snapshotValue; + } + + status(assetId: string): AssetPreviewLifecycleStatus | undefined { + const entry = this.entries.get(assetId); + if (!entry || !this.desired.has(assetId)) return undefined; + if (entry.grant) return "ready"; + if (entry.terminal) return "unavailable"; + return entry.failures > 0 ? "retrying" : "loading"; + } + + subscribe( + listener: (snapshot: Readonly>) => void, + ): () => void { + this.listeners.add(listener); + listener(this.snapshotValue); + return () => this.listeners.delete(listener); + } + + private publish(): void { + this.snapshotValue = Object.freeze(snapshotOf(this.entries)); + for (const listener of this.listeners) listener(this.snapshotValue); + } + + private revoke(token: string): Promise { + return this.options.revoke(token).then( + () => undefined, + () => undefined, + ); + } + + private clearWakeTimer(): void { + if (this.wakeTimer === undefined) return; + this.timers.clear(this.wakeTimer); + this.wakeTimer = undefined; + } + + private scheduleWake(): void { + this.clearWakeTimer(); + if (this.disposed) return; + const now = this.now(); + let nextAt = Number.POSITIVE_INFINITY; + for (const [assetId, entry] of this.entries) { + if (!this.desired.has(assetId) || entry.inFlight || this.queued.has(assetId)) { + continue; + } + if (entry.terminal) { + if (entry.grant) nextAt = Math.min(nextAt, entry.grant.expiresAt); + continue; + } + const renewalAt = entry.grant ? entry.grant.expiresAt - this.renewBeforeMs : entry.retryAt; + nextAt = Math.min(nextAt, Math.max(entry.retryAt, renewalAt)); + } + if (!Number.isFinite(nextAt)) return; + const delay = Math.min(2_147_483_647, Math.max(0, nextAt - now)); + this.wakeTimer = this.timers.set(() => { + this.wakeTimer = undefined; + this.refresh(); + }, delay); + } + + private enqueue(assetId: string): void { + const entry = this.entries.get(assetId); + if ( + this.disposed || + !entry || + !this.desired.has(assetId) || + entry.inFlight || + entry.terminal || + this.queued.has(assetId) + ) { + return; + } + this.queued.add(assetId); + this.queue.push(assetId); + } + + private pump(): void { + while (!this.disposed && this.active < this.maxConcurrent && this.queue.length > 0) { + const assetId = this.queue.shift(); + if (!assetId) continue; + this.queued.delete(assetId); + const entry = this.entries.get(assetId); + if (!entry || !this.desired.has(assetId) || entry.inFlight || entry.terminal) continue; + entry.inFlight = true; + this.active += 1; + this.publish(); + void this.load(assetId, entry); + } + this.scheduleWake(); + } + + private async load(assetId: string, entry: AssetPreviewEntry): Promise { + try { + const grant = await this.loadWithTimeout(assetId); + if (this.disposed || !this.desired.has(assetId) || this.entries.get(assetId) !== entry) { + await this.revoke(grant.token); + return; + } + const previous = entry.grant; + entry.grant = grant; + entry.retryAt = this.now(); + entry.terminal = false; + this.publish(); + if (previous && previous.token !== grant.token) await this.revoke(previous.token); + } catch (error) { + if (!this.disposed && this.desired.has(assetId) && this.entries.get(assetId) === entry) { + entry.failures += 1; + entry.terminal = error instanceof AssetPreviewLoadError && !error.retryable; + const delay = Math.min( + this.retryMaxMs, + this.retryBaseMs * 2 ** Math.min(16, entry.failures - 1), + ); + entry.retryAt = this.now() + delay; + this.publish(); + } + } finally { + entry.inFlight = false; + this.active -= 1; + this.refresh(); + } + } + + private loadWithTimeout(assetId: string): Promise { + let source: Promise; + try { + source = Promise.resolve(this.options.load(assetId)); + } catch (error) { + source = Promise.reject(error); + } + return new Promise((resolve, reject) => { + let settled = false; + const timeout = this.timers.set(() => { + if (settled) return; + settled = true; + reject(new AssetPreviewLoadError("The preview request timed out.", true)); + }, this.loadTimeoutMs); + void source.then( + (grant) => { + if (settled) { + void this.revoke(grant.token); + return; + } + settled = true; + this.timers.clear(timeout); + resolve(grant); + }, + (error: unknown) => { + if (settled) return; + settled = true; + this.timers.clear(timeout); + reject(error); + }, + ); + }); + } + + setAssets(assetIds: readonly string[]): void { + if (this.disposed) return; + const next = new Set(assetIds); + for (const assetId of [...this.desired]) { + if (next.has(assetId)) continue; + this.desired.delete(assetId); + this.retained.delete(assetId); + this.adopted.delete(assetId); + this.drop(assetId); + } + this.refresh(); + } + + /** Keep one preview live for each mounted virtualized node that consumes it. */ + retain(assetId: string): () => void { + if (this.disposed) return () => undefined; + this.adopted.delete(assetId); + this.retained.set(assetId, (this.retained.get(assetId) ?? 0) + 1); + this.desired.add(assetId); + if (!this.entries.has(assetId)) { + this.entries.set(assetId, { + failures: 0, + inFlight: false, + retryAt: this.now(), + terminal: false, + }); + } + this.refresh(); + let active = true; + return () => { + if (!active) return; + active = false; + const remaining = (this.retained.get(assetId) ?? 1) - 1; + if (remaining > 0) { + this.retained.set(assetId, remaining); + return; + } + this.retained.delete(assetId); + if (this.adopted.has(assetId)) return; + this.desired.delete(assetId); + this.drop(assetId); + this.scheduleWake(); + }; + } + + adopt(assetId: string, grant: CreateImagesAssetGrantView): void { + if (this.disposed) { + void this.revoke(grant.token); + return; + } + if ((this.retained.get(assetId) ?? 0) === 0) this.adopted.add(assetId); + else this.adopted.delete(assetId); + this.desired.add(assetId); + const previous = this.entries.get(assetId)?.grant; + this.entries.set(assetId, { + failures: 0, + grant, + inFlight: false, + retryAt: this.now(), + terminal: false, + }); + this.publish(); + if (previous && previous.token !== grant.token) void this.revoke(previous.token); + this.scheduleWake(); + } + + reportLoadError(assetId: string, token: string): void { + const entry = this.entries.get(assetId); + if (!entry?.grant || entry.grant.token !== token || !this.desired.has(assetId)) return; + const failed = entry.grant; + delete entry.grant; + entry.failures += 1; + entry.retryAt = + this.now() + + Math.min(this.retryMaxMs, this.retryBaseMs * 2 ** Math.min(16, entry.failures - 1)); + entry.terminal = false; + this.publish(); + void this.revoke(failed.token); + this.refresh(); + } + + reportLoadSuccess(assetId: string, token: string): void { + const entry = this.entries.get(assetId); + if (!entry?.grant || entry.grant.token !== token || !this.desired.has(assetId)) return; + entry.failures = 0; + entry.retryAt = this.now(); + entry.terminal = false; + this.scheduleWake(); + } + + refresh(): void { + if (this.disposed) return; + const now = this.now(); + let changed = false; + for (const [assetId, entry] of this.entries) { + if (!this.desired.has(assetId)) continue; + if (entry.grant && entry.grant.expiresAt <= now) { + const expired = entry.grant; + delete entry.grant; + changed = true; + void this.revoke(expired.token); + } + if ( + !entry.inFlight && + !entry.terminal && + entry.retryAt <= now && + (!entry.grant || entry.grant.expiresAt - now <= this.renewBeforeMs) + ) { + this.enqueue(assetId); + } + } + if (changed) this.publish(); + this.pump(); + } + + private drop(assetId: string): void { + const entry = this.entries.get(assetId); + if (!entry) return; + this.entries.delete(assetId); + this.queued.delete(assetId); + if (entry.grant) void this.revoke(entry.grant.token); + this.publish(); + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.clearWakeTimer(); + this.desired.clear(); + this.retained.clear(); + this.adopted.clear(); + this.queued.clear(); + this.queue.length = 0; + const tokens = [...this.entries.values()].flatMap((entry) => + entry.grant ? [entry.grant.token] : [], + ); + this.entries.clear(); + this.listeners.clear(); + this.snapshotValue = Object.freeze({}); + await Promise.all(tokens.map((token) => this.revoke(token))); + } +} + +/** + * Defers permanent disposal until the next task so React development Strict + * Mode can replay an effect cleanup/setup pair without poisoning the manager + * that remains mounted. A real unmount still revokes every grant promptly. + */ +export function deferAssetPreviewLifecycleDisposal( + manager: AssetPreviewLifecycleManager, +): () => void { + let cancelled = false; + const timer = setTimeout(() => { + if (cancelled) return; + void manager.dispose(); + }, 0); + return () => { + cancelled = true; + clearTimeout(timer); + }; +} diff --git a/renderer/create-images/canvas-context.tsx b/renderer/create-images/canvas-context.tsx new file mode 100644 index 00000000..ca03b77b --- /dev/null +++ b/renderer/create-images/canvas-context.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import type { CreateImagesAssetGrantView } from "../shared/create-images/ipc"; +import type { + CreateImagesExecutionMode, + CreateImagesProviderStatus, +} from "../shared/create-images/providers"; +import { disconnectedCreateImagesProviderStatus } from "../shared/create-images/providers"; +import type { WorkflowNodeV1 } from "../shared/create-images/schema"; +import type { CreateImagesNodeRunUiState } from "./run-ui-core"; +import type { AssetPreviewLifecycleStatus } from "./asset-preview-lifecycle-core"; + +export interface CreateImagesCanvasActions { + providerStatus: CreateImagesProviderStatus; + executionMode: CreateImagesExecutionMode; + updateNode(nodeId: string, update: (node: WorkflowNodeV1) => WorkflowNodeV1): void; + beginNodeEdit(nodeId: string): void; + updateNodeDraft(nodeId: string, update: (node: WorkflowNodeV1) => WorkflowNodeV1): void; + commitNodeEdit(nodeId: string): void; + selectNode(nodeId: string): void; + chooseImage(nodeId: string): void; + removeImage(nodeId: string): void; + imageChoicePending(nodeId: string): boolean; + retainAssetPreview(assetId: string): () => void; + assetPreview(assetId: string): CreateImagesAssetGrantView | undefined; + assetPreviewStatus(assetId: string): AssetPreviewLifecycleStatus | undefined; + assetPreviewMissing(assetId: string): boolean; + assetPreviewLoaded(assetId: string, token: string): void; + assetPreviewFailed(assetId: string, token: string): void; + nodeRunState(nodeId: string): CreateImagesNodeRunUiState | undefined; + retainRunAssetPreview(assetId: string): () => void; + runAssetPreview(assetId: string): CreateImagesAssetGrantView | undefined; + runAssetPreviewLoaded(assetId: string, token: string): void; + runAssetPreviewFailed(assetId: string, token: string): void; +} + +export const CreateImagesCanvasActionsContext = React.createContext({ + providerStatus: disconnectedCreateImagesProviderStatus(), + executionMode: "local-mock", + updateNode: () => undefined, + beginNodeEdit: () => undefined, + updateNodeDraft: () => undefined, + commitNodeEdit: () => undefined, + selectNode: () => undefined, + chooseImage: () => undefined, + removeImage: () => undefined, + imageChoicePending: () => false, + retainAssetPreview: () => () => undefined, + assetPreview: () => undefined, + assetPreviewStatus: () => undefined, + assetPreviewMissing: () => false, + assetPreviewLoaded: () => undefined, + assetPreviewFailed: () => undefined, + nodeRunState: () => undefined, + retainRunAssetPreview: () => () => undefined, + runAssetPreview: () => undefined, + runAssetPreviewLoaded: () => undefined, + runAssetPreviewFailed: () => undefined, +}); + +export function useCreateImagesCanvasActions(): CreateImagesCanvasActions { + return React.useContext(CreateImagesCanvasActionsContext); +} diff --git a/renderer/create-images/create-images-view.tsx b/renderer/create-images/create-images-view.tsx new file mode 100644 index 00000000..463611fe --- /dev/null +++ b/renderer/create-images/create-images-view.tsx @@ -0,0 +1,3213 @@ +import * as React from "react"; +import { useBlocker, useNavigate } from "@tanstack/react-router"; +import { useQueryClient } from "@tanstack/react-query"; +import { + AlertTriangle, + ArrowLeft, + Boxes, + Copy, + Download, + FolderOpen, + HardDrive, + ImagePlus, + Loader2, + MoreHorizontal, + Pencil, + Plus, + RefreshCw, + ShieldAlert, + Sparkles, + Trash2, + Upload, + Check, + ChevronDown, + Cloud, +} from "lucide-react"; +import { + AlertDialog, + Button, + Dialog, + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + EmptyState, + Input, + Text, + toast, + useSplitViewState, +} from "../components/ui"; +import { appApi, createImagesApi } from "../lib/ipc"; +import { clearRendererLifecycleGuard, setRendererLifecycleGuard } from "../lib/lifecycle-guard"; +import { + queryKeys, + useCreateImagesProviderStatus, + useCreateImagesWorkspace, + useCreateImagesWorkflow, + useCreateImagesWorkflows, +} from "../lib/queries"; +import type { + CreateImagesAssetGrantView, + CreateImagesDegradedRunDiscardPlanResult, + CreateImagesDegradedRunDiscardResult, + CreateImagesRunHistoryPrunePlanResult, + CreateImagesProviderConsentPlanView, + CreateImagesRunRecoveryView, + CreateImagesRunListResult, + CreateImagesRunView, + CreateImagesStorageHealthView, + CreateImagesWorkflowLoadResult, + CreateImagesWorkflowListResult, + CreateImagesWorkflowMutationResult, + CreateImagesWorkflowRecoveryView, + CreateImagesWorkflowSummary, + CreateImagesWorkspaceStatus, +} from "../shared/create-images/ipc"; +import { + enumerateWorkflowDownstreamPaths, + isWorkflowDownstreamPathExplicit, + planWorkflowExecution, + WorkflowPlanError, + type WorkflowRunScope, +} from "../shared/create-images/execution"; +import { CREATE_IMAGES_NODE_DEFINITIONS } from "../shared/create-images/ports"; +import type { WorkflowDocumentV1 } from "../shared/create-images/schema"; +import { + CREATE_IMAGES_WORKFLOW_TEMPLATES, + type CreateImagesWorkflowTemplateId, +} from "../shared/create-images/templates"; +import { + CREATE_IMAGES_PROVIDER_STATUS_VERSION, + type CreateImagesExecutionMode, + type CreateImagesProviderStatus, +} from "../shared/create-images/providers"; +import { createImagesFixture } from "./fixtures"; +import { + registerCreateImagesNavigationGuard, + requestCreateImagesNavigation, +} from "./navigation-guard"; +import { + AssetPreviewLifecycleManager, + AssetPreviewLoadError, + deferAssetPreviewLifecycleDisposal, +} from "./asset-preview-lifecycle-core"; +import { + deferWorkflowAutosaveControllerDisposal, + WorkflowAutosaveController, + type CreateImagesAutosaveStatus, +} from "./workflow-autosave-core"; +import { WorkflowCanvas } from "./workflow-canvas"; +import { + createImagesRunOutputAssetIds, + createImagesRunAssetOwners, + createImagesSelectedRunSnapshotTransition, + createImagesRunSubscriptionController, + isCreateImagesRunAmbiguityRequestCurrent, + isCreateImagesRunHistoryRequestCurrent, + isCreateImagesRunRecoveryRequestCurrent, + reconcileCreateImagesRunMutation, + reconcileCreateImagesRunState, + removeCreateImagesRunRecord, + type CreateImagesRendererRunState, +} from "./run-ui-adapter"; +import { + createImagesRunConfirmationViewModel, + createImagesDegradedRunDiscardRequest, + type CreateImagesRunConfirmationViewModel, + type CreateImagesRunErrorAction, +} from "./run-ui-core"; +import { + CreateImagesResolveRunAmbiguityDialog, + CreateImagesDiscardDegradedRunDialog, + CreateImagesRunConfirmationDialog, + CreateImagesStopRunDialog, + type CreateImagesRunHistoryDetailState, +} from "./run-ui"; +import { + createImagesRunScopeForPathChoice, + type CreateImagesDownstreamPathChoiceView, +} from "./run-path-core"; +import "./create-images.css"; + +const WORKFLOW_UPDATED_FORMATTER = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", +}); + +function updatedLabel(value: string): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return "Recovery needed"; + return WORKFLOW_UPDATED_FORMATTER.format(timestamp); +} + +function storageBytesLabel(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function mutationMessage(result: CreateImagesWorkflowMutationResult, fallback: string): string { + if (result.status === "unavailable") return result.message; + if (result.status === "conflict") return "The workflow changed in another Aiden window."; + if (result.status === "not-found") return "The workflow no longer exists."; + return fallback; +} + +type ReadyCreateImagesWorkspace = Extract; + +function workspaceLastSyncedLabel(value?: string): string { + return value ? `Last synced ${updatedLabel(value)}` : "Not synced yet"; +} + +function CreateImagesWorkspaceSetup({ + loading, + status, + actionError, + busy, + onChoose, + onRetry, + onBack, +}: { + loading?: boolean; + status?: CreateImagesWorkspaceStatus; + actionError?: string; + busy?: string; + onChoose: () => void; + onRetry: () => void; + onBack: () => void; +}) { + const headingRef = React.useRef(null); + const chooseRef = React.useRef(null); + const unavailable = + !loading && (!status || status.status === "unavailable" || Boolean(actionError)); + const statusToken = loading ? "loading" : unavailable ? "unavailable" : "unconfigured"; + + React.useEffect(() => { + headingRef.current?.focus(); + }, [statusToken]); + + React.useEffect(() => { + if (actionError) chooseRef.current?.focus(); + }, [actionError]); + + const unavailableMessage = + status?.status === "unavailable" + ? status.message + : "Aiden could not read this image workspace."; + const rememberedName = status?.status === "unavailable" ? status.displayName : undefined; + + return ( +
+
+ +

+

+

+ {loading + ? "Checking your image workspace…" + : unavailable + ? "Your image workspace needs attention" + : "Set up your image workspace"} +

+

+ {loading + ? "Aiden is checking the local folder that keeps your imported and generated images easy to find." + : unavailable + ? unavailableMessage + : "Choose or create a local folder for imported and generated images. Aiden keeps recoverable workflow data in its protected local store, and you can change this Finder folder later."} +

+ {rememberedName ? ( +

+ Last known workspace: {rememberedName} +

+ ) : null} + {actionError ? ( +

+ {actionError} +

+ ) : null} + {!loading ? ( +
+ + {unavailable ? ( + + ) : null} + +
+ ) : ( +
+
+ )} +
+
+
+
+ ); +} + +function CreateImagesWorkspaceMenu({ + workspace, + busy, + triggerRef, + onOpen, + onSync, + onChange, +}: { + workspace: ReadyCreateImagesWorkspace; + busy?: string; + triggerRef: React.RefObject; + onOpen: () => void; + onSync: () => void; + onChange: () => void; +}) { + const blocked = Boolean(busy); + return ( + + + + + + Image workspace +
+ + {workspace.displayName} + + + {workspace.importedAssetCount} imported · {workspace.generatedAssetCount} generated + + {workspaceLastSyncedLabel(workspace.lastSyncedAt)} + {workspace.conflictCount > 0 ? ( + + {workspace.conflictCount} conflict{workspace.conflictCount === 1 ? "" : "s"} to review + + ) : null} +
+ + + + + {busy === "workspace-sync" ? ( + + + + +
+
+ ); +} + +type ReadyDegradedRunDiscardPlan = Extract< + CreateImagesDegradedRunDiscardPlanResult, + { status: "ready" } +>; + +function useDegradedRunDiscard({ + onDiscarded, +}: { + onDiscarded( + result: Extract, + plan: ReadyDegradedRunDiscardPlan, + ): void | Promise; +}) { + const [plan, setPlan] = React.useState(); + const [reviewed, setReviewed] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const returnFocusRef = React.useRef(null); + const mountedRef = React.useRef(false); + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const request = React.useCallback( + async (runId: string, trigger: HTMLButtonElement) => { + if (busy) return; + returnFocusRef.current = trigger; + setReviewed(false); + setBusy(true); + try { + const result = await createImagesApi.planDegradedRunDiscard({ runId }); + if (!mountedRef.current) return; + if (result.status === "ready") { + setPlan(result); + } else if (result.status === "recoverable") { + toast.info( + "A verified recovery source exists. Recover this record instead of discarding it.", + ); + } else if (result.status === "not-degraded") { + toast.info("This run record is healthy and cannot be discarded from recovery tools."); + } else if (result.status === "not-found") { + toast.error("The degraded run record no longer exists."); + } else { + toast.error(result.message); + } + } catch { + if (mountedRef.current) toast.error("Aiden could not prepare a safe discard summary."); + } finally { + if (mountedRef.current) setBusy(false); + } + }, + [busy], + ); + + const close = React.useCallback(() => { + if (busy) return; + setPlan(undefined); + setReviewed(false); + }, [busy]); + + const confirm = React.useCallback(async () => { + if (!plan || !reviewed || busy) return; + const request = createImagesDegradedRunDiscardRequest(plan, reviewed); + if (!request) return; + setBusy(true); + try { + const result = await createImagesApi.discardDegradedRun(request); + if (!mountedRef.current) return; + if (result.status === "discarded") { + await onDiscarded(result, plan); + if (!mountedRef.current) return; + setPlan(undefined); + setReviewed(false); + toast.success( + `Permanently discarded the irrecoverable run record and released ${result.releasedAssetCount} retained image or asset reference${result.releasedAssetCount === 1 ? "" : "s"}, which may include imported inputs and generated outputs.`, + ); + } else if (result.status === "conflict") { + setPlan(undefined); + setReviewed(false); + toast.error("The run record changed. Review a fresh discard summary before confirming."); + } else if (result.status === "recoverable") { + setPlan(undefined); + setReviewed(false); + toast.info("A verified recovery source is now available, so discard was refused."); + } else if (result.status === "not-degraded") { + setPlan(undefined); + setReviewed(false); + toast.info("This run record is no longer degraded, so discard was refused."); + } else if (result.status === "not-found") { + setPlan(undefined); + setReviewed(false); + toast.error("The degraded run record no longer exists."); + } else { + toast.error(result.message); + } + } catch { + if (mountedRef.current) toast.error("Aiden could not discard the run record safely."); + } finally { + if (mountedRef.current) setBusy(false); + } + }, [busy, onDiscarded, plan, reviewed]); + + return { plan, reviewed, busy, returnFocusRef, request, close, confirm, setReviewed }; +} + +interface PreparedRun { + scope?: WorkflowRunScope; + workflowId: string; + workflowRevision: number; + workflowSnapshot: WorkflowDocumentV1; + executionMode: CreateImagesExecutionMode; + providerConsent?: CreateImagesProviderConsentPlanView; + model: CreateImagesRunConfirmationViewModel; + downstreamPathSelection?: { + startNodeId: string; + startNodeLabel: string; + choices: readonly CreateImagesDownstreamPathChoiceView[]; + selectedChoiceId?: string; + truncated: boolean; + overflowReason?: "choice-limit" | "search-budget"; + unavailablePathCount: number; + }; +} + +function runNodeLabel(document: WorkflowDocumentV1, nodeId: string): string { + const node = document.nodes.find((candidate) => candidate.id === nodeId); + return node ? `${CREATE_IMAGES_NODE_DEFINITIONS[node.type].title} · ${node.id}` : nodeId; +} + +function downstreamPathDetail(document: WorkflowDocumentV1, nodeIds: readonly string[]): string { + const labels = nodeIds.map((nodeId) => runNodeLabel(document, nodeId)); + if (labels.length <= 4) return labels.join(" → "); + return `${labels[0]} → ${labels[1]} → … ${labels.length - 3} more → ${labels[labels.length - 1]}`; +} + +function downstreamPathChoiceViews( + document: WorkflowDocumentV1, + startNodeId: string, +): NonNullable { + const result = enumerateWorkflowDownstreamPaths(document, startNodeId); + const executableChoices = result.choices.filter((choice) => + isWorkflowDownstreamPathExplicit(document, startNodeId, choice.downstreamPath), + ); + return { + startNodeId, + startNodeLabel: runNodeLabel(document, startNodeId), + choices: executableChoices.map((choice, index) => ({ + id: choice.id, + downstreamPath: [...choice.downstreamPath], + title: `Path ${index + 1} · to ${runNodeLabel(document, choice.terminalNodeId)}`, + detail: `${choice.downstreamPath.length} downstream node${choice.downstreamPath.length === 1 ? "" : "s"} · ${downstreamPathDetail(document, choice.downstreamPath)}`, + })), + truncated: result.truncated, + unavailablePathCount: result.choices.length - executableChoices.length, + ...(result.overflowReason ? { overflowReason: result.overflowReason } : {}), + }; +} + +function runScopeView(workflow: WorkflowDocumentV1, scope: WorkflowRunScope) { + const plan = planWorkflowExecution(workflow, scope); + return { + plan, + scopeView: + scope.kind === "all" + ? ({ kind: "all", includedNodeCount: plan.orderedNodeIds.length } as const) + : ({ + kind: "from-node", + startNodeId: scope.nodeId, + startNodeLabel: runNodeLabel(plan.snapshot, scope.nodeId), + includedNodeCount: plan.orderedNodeIds.length, + downstreamPathLabels: (scope.downstreamPath ?? []).map((nodeId) => + runNodeLabel(plan.snapshot, nodeId), + ), + } as const), + }; +} + +function prepareLocalMockRun(workflow: WorkflowDocumentV1, scope: WorkflowRunScope): PreparedRun { + const { plan, scopeView } = runScopeView(workflow, scope); + const included = new Set(plan.orderedNodeIds); + const nodes = plan.snapshot.nodes.filter((node) => included.has(node.id)); + const generationNodes = nodes.filter((node) => node.type === "generate-image"); + const sizes = [...new Set(generationNodes.map((node) => node.data.imageSize))]; + const outputCount = generationNodes.reduce((total, node) => total + node.data.count, 0); + return { + scope, + workflowId: workflow.id, + workflowRevision: workflow.revision, + workflowSnapshot: plan.snapshot, + executionMode: "local-mock", + model: createImagesRunConfirmationViewModel({ + workflowId: workflow.id, + workflowTitle: workflow.title, + workflowRevision: workflow.revision, + scope: scopeView, + executionMode: "local-mock", + providerLabel: "Aiden local mock", + modelLabel: "Deterministic Phase 3", + remoteRequestCount: generationNodes.length, + outputCount, + imageSizeLabel: + sizes.length === 1 ? sizes[0]! : sizes.length > 1 ? "Mixed sizes" : "No image output", + qualityLabel: "Deterministic preview", + referenceImageCount: nodes.filter( + (node) => node.type === "image-input" && Boolean(node.data.assetId), + ).length, + sendsPrompt: nodes.some((node) => node.type === "prompt"), + estimate: { + kind: "mock", + amount: 0, + currency: "USD", + estimatedAt: new Date().toISOString(), + sourceLabel: "Deterministic Phase 3 mock", + }, + }), + }; +} + +function prepareGeminiRun( + workflow: WorkflowDocumentV1, + scope: WorkflowRunScope, + consent: CreateImagesProviderConsentPlanView, +): PreparedRun { + const { plan, scopeView } = runScopeView(workflow, scope); + const included = new Set(plan.orderedNodeIds); + const generationNodes = plan.snapshot.nodes.filter( + (node): node is Extract<(typeof plan.snapshot.nodes)[number], { type: "generate-image" }> => + included.has(node.id) && node.type === "generate-image", + ); + const sizes = [...new Set(generationNodes.map((node) => node.data.imageSize))]; + return { + scope, + workflowId: workflow.id, + workflowRevision: workflow.revision, + workflowSnapshot: plan.snapshot, + executionMode: "gemini", + providerConsent: consent, + model: createImagesRunConfirmationViewModel({ + workflowId: workflow.id, + workflowTitle: workflow.title, + workflowRevision: workflow.revision, + scope: scopeView, + executionMode: "cloud", + providerLabel: consent.providerLabel, + modelLabel: consent.modelLabel, + remoteRequestCount: consent.accounting.initialRequestCount, + outputCount: consent.accounting.expectedOutputCount, + imageSizeLabel: + sizes.length === 1 ? sizes[0]! : sizes.length > 1 ? "Mixed sizes" : "No image output", + qualityLabel: "Provider-validated output", + referenceImageCount: consent.accounting.referenceImageCount, + sendsPrompt: consent.accounting.promptBytes > 0, + estimate: + consent.estimate.kind === "best-effort" && + consent.estimate.amountMicros !== undefined && + consent.estimate.currency + ? { + kind: "best-effort", + amount: consent.estimate.amountMicros / 1_000_000, + currency: consent.estimate.currency, + estimatedAt: consent.estimate.estimatedAt, + sourceLabel: "Main-owned Gemini estimate snapshot", + } + : { + kind: "unavailable", + estimatedAt: consent.estimate.estimatedAt, + sourceLabel: "Google Gemini pricing was not verified for this request", + }, + }), + }; +} + +function prepareGeminiPathChoice( + workflow: WorkflowDocumentV1, + scope: Extract, +): PreparedRun { + const local = prepareLocalMockRun(workflow, scope); + const plan = planWorkflowExecution(workflow, scope); + const generationNodes = plan.snapshot.nodes.filter( + (node): node is Extract<(typeof plan.snapshot.nodes)[number], { type: "generate-image" }> => + plan.orderedNodeIds.includes(node.id) && node.type === "generate-image", + ); + return { + ...local, + executionMode: "gemini", + model: createImagesRunConfirmationViewModel({ + workflowId: workflow.id, + workflowTitle: workflow.title, + workflowRevision: workflow.revision, + scope: { + kind: "from-node", + startNodeId: scope.nodeId, + startNodeLabel: runNodeLabel(plan.snapshot, scope.nodeId), + includedNodeCount: plan.orderedNodeIds.length, + downstreamPathLabels: [], + }, + executionMode: "cloud", + providerLabel: "Google Gemini", + modelLabel: "Choose an exact downstream path", + remoteRequestCount: generationNodes.length, + outputCount: generationNodes.reduce((total, node) => total + node.data.count, 0), + imageSizeLabel: "Calculated after path selection", + qualityLabel: "Provider-validated output", + referenceImageCount: plan.snapshot.assetRefs.length, + sendsPrompt: true, + estimate: { + kind: "unavailable", + estimatedAt: new Date().toISOString(), + sourceLabel: "Select a path to create a main-owned consent plan", + }, + }), + }; +} + +export function CreateImagesIndexView() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const split = useSplitViewState(); + const workspace = useCreateImagesWorkspace(); + const workspaceReady = workspace.data?.status === "ready"; + const workflows = useCreateImagesWorkflows(workspaceReady); + const [storageHealth, setStorageHealth] = React.useState(); + const [busy, setBusy] = React.useState(); + const [workspaceActionError, setWorkspaceActionError] = React.useState(); + const workspaceMenuRef = React.useRef(null); + const previousWorkspaceReadyRef = React.useRef(workspaceReady); + const [renameTarget, setRenameTarget] = React.useState(); + const [renameValue, setRenameValue] = React.useState(""); + const [deleteTarget, setDeleteTarget] = React.useState(); + const [assetCleanupPlan, setAssetCleanupPlan] = React.useState< + Extract>, { status: "ready" }> + >(); + const [nodeBananaImport, setNodeBananaImport] = React.useState< + Extract>, { status: "imported" }> + >(); + + React.useEffect(() => { + if (workspaceReady && !previousWorkspaceReadyRef.current) { + requestAnimationFrame(() => workspaceMenuRef.current?.focus()); + } + previousWorkspaceReadyRef.current = workspaceReady; + }, [workspaceReady]); + + const chooseWorkspace = React.useCallback(async () => { + if (busy) return; + setWorkspaceActionError(undefined); + setBusy("workspace-choose"); + try { + const result = await createImagesApi.chooseWorkspace(); + if (result.status === "canceled") return; + if (result.status !== "ready") { + setWorkspaceActionError(result.message); + await workspace.refetch(); + return; + } + queryClient.setQueryData(queryKeys.createImagesWorkspace, result.workspace); + await queryClient.invalidateQueries({ queryKey: queryKeys.createImagesWorkflows }); + } catch { + setWorkspaceActionError( + "Aiden could not choose that folder. Try again or choose another folder.", + ); + } finally { + setBusy(undefined); + } + }, [busy, queryClient, workspace]); + + const retryWorkspace = React.useCallback(async () => { + if (busy) return; + setWorkspaceActionError(undefined); + setBusy("workspace-retry"); + try { + await workspace.refetch(); + } catch { + setWorkspaceActionError("Aiden could not check the image workspace. Try again."); + } finally { + setBusy(undefined); + } + }, [busy, workspace]); + + const openWorkspace = React.useCallback(async () => { + if (busy) return; + setBusy("workspace-open"); + try { + const result = await createImagesApi.openWorkspace(); + if (result.status === "opened") toast.success("Opened the image workspace in Finder."); + else if (result.status === "unavailable") toast.error(result.message); + else toast.info("Choose an image workspace folder first."); + } catch { + toast.error("Aiden could not open the image workspace in Finder."); + } finally { + setBusy(undefined); + } + }, [busy]); + + const syncWorkspace = React.useCallback(async () => { + if (busy) return; + setBusy("workspace-sync"); + try { + const result = await createImagesApi.syncWorkspace(); + if (result.status === "synced") { + queryClient.setQueryData(queryKeys.createImagesWorkspace, result.workspace); + await queryClient.invalidateQueries({ queryKey: queryKeys.createImagesWorkflows }); + toast.success("Image workspace synced."); + } else if (result.status === "unavailable") { + toast.error(result.message); + await workspace.refetch(); + } else { + toast.info("Choose an image workspace folder first."); + } + } catch { + toast.error("Aiden could not sync the image workspace."); + } finally { + setBusy(undefined); + } + }, [busy, queryClient, workspace]); + + const backToAiden = React.useCallback(() => { + void navigate({ to: "/" }); + }, [navigate]); + + const refreshStorageHealth = React.useCallback(async () => { + const health = await createImagesApi.storageHealth(); + setStorageHealth(health); + }, []); + + const degradedDiscard = useDegradedRunDiscard({ + onDiscarded: async (_result, plan) => { + if (plan.workflowId) { + queryClient.removeQueries({ + queryKey: queryKeys.createImagesRuns(plan.workflowId), + exact: true, + }); + } + await refreshStorageHealth(); + }, + }); + + React.useEffect(() => { + if (!workspaceReady) return; + let disposed = false; + void createImagesApi + .storageHealth() + .then((health) => { + if (!disposed) setStorageHealth(health); + }) + .catch(() => undefined); + return () => { + disposed = true; + }; + }, [workspaceReady]); + + const refresh = React.useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.createImagesWorkflows }), + [queryClient], + ); + + const createWorkflow = React.useCallback( + async (template: CreateImagesWorkflowTemplateId) => { + if (busy || !workspaceReady) return; + setBusy(`create-${template}`); + try { + const result = await createImagesApi.create({ template }); + if (result.status !== "saved") { + toast.error(mutationMessage(result, "Aiden could not create the workflow.")); + return; + } + await refresh(); + await navigate({ + to: "/create-images/$workflowId", + params: { workflowId: result.workflow.id }, + }); + } catch { + toast.error("Aiden could not create the workflow."); + } finally { + setBusy(undefined); + } + }, + [busy, navigate, refresh, workspaceReady], + ); + + const duplicateWorkflow = React.useCallback( + async (workflow: CreateImagesWorkflowSummary) => { + if (busy) return; + setBusy(`duplicate-${workflow.id}`); + try { + const result = await createImagesApi.duplicate({ + workflowId: workflow.id, + expectedRevision: workflow.revision, + }); + if (result.status !== "saved") { + toast.error(mutationMessage(result, "Aiden could not duplicate the workflow.")); + return; + } + await refresh(); + await navigate({ + to: "/create-images/$workflowId", + params: { workflowId: result.workflow.id }, + }); + } catch { + toast.error("Aiden could not duplicate the workflow."); + } finally { + setBusy(undefined); + } + }, + [busy, navigate, refresh], + ); + + const importArchive = React.useCallback(async () => { + if (busy) return; + setBusy("import-archive"); + try { + const result = await createImagesApi.importArchive(); + if (result.status === "canceled") return; + if (result.status !== "imported") { + toast.error(result.message); + return; + } + await refresh(); + toast.success( + `Imported ${result.sourceFileName} with ${result.importedAssetCount} image${result.importedAssetCount === 1 ? "" : "s"}.`, + ); + await navigate({ + to: "/create-images/$workflowId", + params: { workflowId: result.workflow.id }, + }); + } catch { + toast.error("Aiden could not import the workflow archive."); + } finally { + setBusy(undefined); + } + }, [busy, navigate, refresh]); + + const importNodeBanana = React.useCallback(async () => { + if (busy) return; + setBusy("import-node-banana"); + try { + const result = await createImagesApi.importNodeBanana(); + if (result.status === "canceled") return; + if (result.status !== "imported") { + toast.error(result.message); + return; + } + await refresh(); + setNodeBananaImport(result); + toast.success( + `Imported ${result.sourceFileName} with ${result.report.importedNodeCount} mapped node${result.report.importedNodeCount === 1 ? "" : "s"}.`, + ); + } catch { + toast.error("Aiden could not import the Node Banana workflow safely."); + } finally { + setBusy(undefined); + } + }, [busy, refresh]); + + const exportArchive = React.useCallback( + async (workflow: CreateImagesWorkflowSummary) => { + if (busy) return; + setBusy(`export-${workflow.id}`); + try { + const result = await createImagesApi.exportArchive({ + workflowId: workflow.id, + expectedRevision: workflow.revision, + }); + if (result.status === "canceled") return; + if (result.status === "exported") { + toast.success( + `Exported ${result.fileName} with ${result.assetCount} image${result.assetCount === 1 ? "" : "s"}.`, + ); + return; + } + if (result.status === "conflict") { + toast.error("The workflow changed before export. Refresh and try again."); + await refresh(); + return; + } + toast.error( + result.status === "not-found" ? "The workflow no longer exists." : result.message, + ); + } catch { + toast.error("Aiden could not export the workflow archive."); + } finally { + setBusy(undefined); + } + }, + [busy, refresh], + ); + + const ready: Extract | undefined = + workflows.data?.status === "ready" ? workflows.data : undefined; + return ( +
+
+
+

+ Create Images +

+
+ {workspaceReady && workspace.data?.status === "ready" ? ( +
+ void openWorkspace()} + onSync={() => void syncWorkspace()} + onChange={() => void chooseWorkspace()} + /> + +
+ ) : null} +
+ + {workspace.isLoading ? ( + undefined} + onRetry={() => undefined} + onBack={backToAiden} + /> + ) : !workspaceReady ? ( + void chooseWorkspace()} + onRetry={() => void retryWorkspace()} + onBack={backToAiden} + /> + ) : ( +
+
+
+
+

+ Build images as workflows. +

+ + Connect prompts, reference images, generation, and outputs on a durable visual canvas. + Aiden protects workflow data locally while keeping image copies easy to browse in + Finder. + +
+ + + + +
+
+ + {storageHealth && storageHealth.runIndex.status !== "healthy" ? ( +
+ {storageHealth.runIndex.status === "recovered" ? ( +
+ ) : null} + + {storageHealth && storageHealth.runIndex.degradedRecords.length > 0 ? ( +
+
+
+
    + {storageHealth.runIndex.degradedRecords.map((record) => ( +
  • +
    + + {record.association === "unassociated" + ? "Unassociated run record" + : "Workflow run record"} + + + {record.status === "unsafe" ? "Unsafe journal" : "Recovery required"} · run + ID {record.runId} + +
    + {record.discardEligible ? ( + + ) : ( + Verified recovery available + )} +
  • + ))} +
+ {storageHealth.runIndex.degradedRecordsTruncated ? ( +

+ Showing 100 of {storageHealth.runIndex.degradedRecordCount} degraded records. + Clear or recover visible records, then refresh to review the next bounded set. +

+ ) : null} +
+ ) : null} + +
+
+

Your workflows

+

+ Autosaved locally and ordered by recent activity. +

+
+
+ + + {storageHealth && storageHealth.orphanAssetCount > 0 ? ( + + ) : null} + +
+
+ + {workflows.isLoading ? ( +
+ Loading workflows… +
+ ) : workflows.isError || workflows.data?.status === "unavailable" ? ( +
+

Workflow storage is unavailable

+

+ {workflows.data?.status === "unavailable" + ? workflows.data.message + : "Aiden could not read the device-local workflow library."} +

+ +
+ ) : ready && ready.workflows.length > 0 ? ( +
+ {ready.workflows.map((workflow) => ( +
+ + + + { + setRenameTarget(workflow); + setRenameValue(workflow.title); + }} + > + Rename + + void duplicateWorkflow(workflow)}> + Duplicate + + void exportArchive(workflow)}> + Export .aiden-images + + + setDeleteTarget(workflow)}> + Delete + + + +
+
+ + {workflow.health === "healthy" + ? workflow.missingAssetCount > 0 + ? `${workflow.missingAssetCount} missing image${workflow.missingAssetCount === 1 ? "" : "s"}` + : `${workflow.assetCount} assets` + : workflow.health === "unsafe" + ? "Newer format" + : "Recovery needed"} + + {updatedLabel(workflow.updatedAt)} +
+ + ))} +
+ ) : ( +
+ +
+ {CREATE_IMAGES_WORKFLOW_TEMPLATES.map((template) => ( + + ))} +
+
+ + + +
+
+ )} + + )} + + !open && setNodeBananaImport(undefined)} + title="Node Banana import report" + description={ + nodeBananaImport + ? `${nodeBananaImport.sourceFileName} was converted into a new Aiden workflow. Review every rewritten or skipped source node before opening it.` + : undefined + } + size="large" + confirmLabel="Open workflow" + onConfirm={async () => { + if (!nodeBananaImport) return; + const workflowId = nodeBananaImport.workflow.id; + setNodeBananaImport(undefined); + await navigate({ + to: "/create-images/$workflowId", + params: { workflowId }, + }); + }} + > + {nodeBananaImport ? ( +
+
+ {nodeBananaImport.report.securityNote} +
+
+
+
Nodes
+
+ {nodeBananaImport.report.importedNodeCount} mapped ·{" "} + {nodeBananaImport.report.skippedNodeCount} skipped +
+
+
+
Connections
+
+ {nodeBananaImport.report.importedEdgeCount} mapped ·{" "} + {nodeBananaImport.report.skippedEdgeCount} skipped +
+
+
+
Embedded images
+
+ {nodeBananaImport.report.importedEmbeddedImageCount} validated ·{" "} + {nodeBananaImport.report.skippedEmbeddedImageCount} rejected +
+
+
+
Source
+
Node Banana workflow version 1
+
+
+
    + {nodeBananaImport.report.entries.map((entry) => ( +
  1. +
    + + Node {entry.sourceNodeIndex + 1} · {entry.sourceType} + + + {entry.action === "rewritten" ? "Rewritten" : "Skipped"} + +
    +

    {entry.message}

    +
  2. + ))} +
+
+ ) : null} +
+ + !open && setRenameTarget(undefined)} + title="Rename workflow" + description="Choose a concise name for the workflow library and canvas." + confirmLabel="Rename" + confirmDisabled={!renameValue.trim() || renameValue.trim() === renameTarget?.title} + busy={busy === `rename-${renameTarget?.id}`} + onConfirm={async () => { + if (!renameTarget) return; + setBusy(`rename-${renameTarget.id}`); + try { + const result = await createImagesApi.rename({ + workflowId: renameTarget.id, + expectedRevision: renameTarget.revision, + title: renameValue, + }); + if (result.status !== "saved") { + toast.error(mutationMessage(result, "Aiden could not rename the workflow.")); + return; + } + setRenameTarget(undefined); + await refresh(); + } catch { + toast.error("Aiden could not rename the workflow."); + } finally { + setBusy(undefined); + } + }} + > + setRenameValue(event.target.value)} + /> + + + !open && setDeleteTarget(undefined)} + title="Delete workflow?" + description={ + deleteTarget + ? `“${deleteTarget.title}” can be deleted only when it has no active run, retained run history, or recovery records. Shared assets remain protected until device-local cleanup confirms they are unused.` + : undefined + } + confirmLabel="Delete" + confirmVariant="destructive" + busy={busy === `delete-${deleteTarget?.id}`} + keepOpenOnConfirm + onConfirm={async () => { + if (!deleteTarget) return; + setBusy(`delete-${deleteTarget.id}`); + try { + const result = await createImagesApi.delete({ + workflowId: deleteTarget.id, + expectedRevision: deleteTarget.revision, + }); + if (result.status !== "deleted") { + toast.error(mutationMessage(result, "Aiden could not delete the workflow.")); + return; + } + setDeleteTarget(undefined); + await refresh(); + } catch { + toast.error("Aiden could not delete the workflow."); + } finally { + setBusy(undefined); + } + }} + /> + !open && setAssetCleanupPlan(undefined)} + title="Delete unused images?" + description={ + assetCleanupPlan ? ( + <> + Aiden verified that {assetCleanupPlan.candidateCount} device-local image + {assetCleanupPlan.candidateCount === 1 ? " is" : "s are"} unreferenced by every + workflow, retained run, export operation, and open preview. This permanently deletes + {` ${storageBytesLabel(assetCleanupPlan.reclaimableBytes)}`} of images unused for at + least seven days. + + ) : undefined + } + confirmLabel="Delete unused images" + confirmVariant="destructive" + busy={busy === "apply-asset-cleanup"} + keepOpenOnConfirm + onConfirm={async () => { + if (!assetCleanupPlan) return; + setBusy("apply-asset-cleanup"); + try { + const result = await createImagesApi.applyAssetCleanup({ + planId: assetCleanupPlan.planId, + confirmed: true, + }); + if (result.status === "cleaned") { + toast.success( + `Deleted ${result.deletedCount} unused image${result.deletedCount === 1 ? "" : "s"} and reclaimed ${storageBytesLabel(result.reclaimedBytes)}.`, + ); + setAssetCleanupPlan(undefined); + await refreshStorageHealth(); + } else if (result.status === "stale") { + toast.error("Image references changed. Review a fresh cleanup plan before deleting."); + setAssetCleanupPlan(undefined); + await refreshStorageHealth(); + } else toast.error(result.message); + } catch { + toast.error("Aiden did not delete images because cleanup could not be verified."); + } finally { + setBusy(undefined); + } + }} + /> + {degradedDiscard.plan ? ( + { + if (!open) degradedDiscard.close(); + }} + onConfirm={() => void degradedDiscard.confirm()} + /> + ) : null} +
+ ); +} + +function LoadingWorkflow() { + return ( +
+ Opening workflow… +
+ ); +} + +function WorkflowFailure({ + title, + message, + retry, + secondaryAction, +}: { + title: string; + message: string; + retry?(): void; + secondaryAction?: { label: string; run(): void }; +}) { + const navigate = useNavigate(); + return ( +
+
+
+
+ ); +} + +function RecoveryWorkflow({ + workflowId, + recovery, + reload, +}: { + workflowId: string; + recovery: CreateImagesWorkflowRecoveryView; + reload(): Promise; +}) { + const [busy, setBusy] = React.useState(); + const copyDiagnostics = React.useCallback(() => { + const diagnostic = { + format: "aiden-create-images-recovery-diagnostics", + version: 1, + workflowId, + recovery, + }; + void navigator.clipboard.writeText(`${JSON.stringify(diagnostic, null, 2)}\n`).then( + () => toast.success("Recovery diagnostics copied."), + () => toast.error("Recovery diagnostics could not be copied."), + ); + }, [recovery, workflowId]); + const act = async ( + action: string, + request: () => Promise, + ) => { + setBusy(action); + try { + const result = await request(); + if (result.status !== "saved") { + toast.error(mutationMessage(result, "Workflow recovery did not complete.")); + return; + } + await reload(); + } catch { + toast.error("Workflow recovery did not complete."); + } finally { + setBusy(undefined); + } + }; + if (recovery.status === "unsafe") { + return ( + + ); + } + if (recovery.status !== "recovery-required") { + return ( + + ); + } + const repairable = + recovery.currentRevision !== undefined && + (recovery.reason === "journal-corrupt" || + (recovery.reason === "last-known-good-corrupt" && recovery.autosave === "none")); + const discardableAutosave = + recovery.currentRevision !== undefined && + recovery.autosave === "pending" && + recovery.autosaveTargetRevision !== undefined; + const recoverableAutosave = + recovery.autosave === "pending" && recovery.autosaveTargetRevision !== undefined; + return ( +
+
+
+
+ ); +} + +function SaveStatusBanner({ + status, + onReload, + onRetry, + onSaveCopy, +}: { + status: CreateImagesAutosaveStatus; + onReload(): void; + onRetry(): void; + onSaveCopy(): void; +}) { + if (status.state !== "conflict" && status.state !== "error") return null; + return ( +
+
+ ); +} + +function PersistentWorkflowCanvas({ + initial, + initialMissingAssetIds, +}: { + initial: WorkflowDocumentV1; + initialMissingAssetIds: readonly string[]; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const providerStatusQuery = useCreateImagesProviderStatus(); + const [executionMode, setExecutionMode] = React.useState("local-mock"); + const providerStatus: CreateImagesProviderStatus = providerStatusQuery.data ?? { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: providerStatusQuery.isLoading ? "connecting" : "unavailable", + ...(providerStatusQuery.isLoading ? {} : { safeErrorCode: "capability-check-failed" as const }), + }; + const geminiReady = + providerStatus.connectionState === "connected" && + providerStatus.capabilitySnapshot?.state === "current" && + providerStatus.capabilitySnapshot.models.length > 0; + React.useEffect(() => { + if (!geminiReady) setExecutionMode("local-mock"); + }, [geminiReady]); + const [document, setDocument] = React.useState(initial); + const documentRef = React.useRef(initial); + const [status, setStatus] = React.useState({ + state: "saved", + workflow: initial, + }); + const [canvasEpoch, setCanvasEpoch] = React.useState(0); + const [missingAssetIds, setMissingAssetIds] = + React.useState(initialMissingAssetIds); + const [initialAssetRefs] = React.useState(() => initial.assetRefs); + const [controller] = React.useState( + () => + new WorkflowAutosaveController(initial, { + save: createImagesApi.save, + }), + ); + const cancelPendingControllerDisposalRef = React.useRef<() => void>(() => undefined); + const cancelPendingPreviewDisposalRef = React.useRef<() => void>(() => undefined); + const cancelPendingRunPreviewDisposalRef = React.useRef<() => void>(() => undefined); + const previewManager = React.useMemo( + () => + new AssetPreviewLifecycleManager({ + load: async (assetId) => { + const result = await createImagesApi.grantAsset({ workflowId: initial.id, assetId }); + if (result.status === "ready") return result.grant; + if (result.status === "not-found") { + setMissingAssetIds((current) => + current.includes(assetId) ? current : [...current, assetId], + ); + throw new AssetPreviewLoadError("The image file is missing.", false); + } + throw new AssetPreviewLoadError( + result.status === "unavailable" + ? result.message + : "This workflow is not authorized to preview that image.", + result.status === "unavailable", + ); + }, + revoke: (token) => createImagesApi.revokeAssetGrant({ token }), + }), + [initial.id], + ); + const [previews, setPreviews] = React.useState< + Readonly> + >(() => previewManager.snapshot()); + const initialRunState = React.useMemo( + () => + queryClient.getQueryData( + queryKeys.createImagesRuns(initial.id), + ), + [initial.id, queryClient], + ); + const [runState, setRunState] = React.useState( + initialRunState, + ); + const [selectedHistoryRunId, setSelectedHistoryRunId] = React.useState(); + const selectedHistoryRunIdRef = React.useRef(undefined); + const [runHistoryDetail, setRunHistoryDetail] = React.useState( + { status: "idle" }, + ); + const runHistoryDetailRef = React.useRef({ status: "idle" }); + const runHistoryRequestSequence = React.useRef(0); + const runHistoryLifecycleRef = React.useRef({ mounted: false, generation: 0 }); + React.useEffect(() => { + const generation = runHistoryLifecycleRef.current.generation + 1; + runHistoryLifecycleRef.current = { mounted: true, generation }; + return () => { + if (runHistoryLifecycleRef.current.generation !== generation) return; + runHistoryLifecycleRef.current = { mounted: false, generation: generation + 1 }; + runHistoryRequestSequence.current += 1; + selectedHistoryRunIdRef.current = undefined; + }; + }, []); + const [recoveringRunId, setRecoveringRunId] = React.useState(); + const [ambiguityAcknowledgementRun, setAmbiguityAcknowledgementRun] = + React.useState(); + const [ambiguityAcknowledgementReviewed, setAmbiguityAcknowledgementReviewed] = + React.useState(false); + const [ambiguityAcknowledgementSubmitting, setAmbiguityAcknowledgementSubmitting] = + React.useState(false); + const ambiguityAcknowledgementReturnFocusRef = React.useRef(null); + const [runHistoryPrunePlan, setRunHistoryPrunePlan] = React.useState< + Extract | undefined + >(); + const [runHistoryPruneBusy, setRunHistoryPruneBusy] = React.useState(false); + const runHistoryPruneReturnFocusRef = React.useRef(null); + const runStateRef = React.useRef(initialRunState); + const runAssetOwnersRef = React.useRef>>( + initialRunState?.runAssetOwners ?? {}, + ); + const runPreviewManager = React.useMemo( + () => + new AssetPreviewLifecycleManager({ + load: async (assetId) => { + const runId = runAssetOwnersRef.current[assetId]; + if (!runId) { + throw new AssetPreviewLoadError( + "This output is not authorized by the current run record.", + false, + ); + } + const result = await createImagesApi.grantRunAsset({ + workflowId: initial.id, + runId, + assetId, + }); + if (result.status === "ready") return result.grant; + throw new AssetPreviewLoadError( + result.status === "unavailable" + ? result.message + : "This run output is no longer available.", + result.status === "unavailable", + ); + }, + revoke: (token) => createImagesApi.revokeAssetGrant({ token }), + }), + [initial.id], + ); + const [runAssetPreviews, setRunAssetPreviews] = React.useState< + Readonly> + >(() => runPreviewManager.snapshot()); + const [preparedRun, setPreparedRun] = React.useState(); + const [reviewedRun, setReviewedRun] = React.useState(false); + const [runPreparing, setRunPreparing] = React.useState(false); + const [runSubmitting, setRunSubmitting] = React.useState(false); + const [stopDialogOpen, setStopDialogOpen] = React.useState(false); + const [stopSubmitting, setStopSubmitting] = React.useState(false); + const runRequestActiveRef = React.useRef(false); + const runPreparationGenerationRef = React.useRef(0); + const runReturnFocusRef = React.useRef(null); + const stopReturnFocusRef = React.useRef(null); + const handleDocumentChange = React.useCallback( + (next: WorkflowDocumentV1) => { + documentRef.current = next; + previewManager.setAssets(next.assetRefs); + setMissingAssetIds((current) => + current.filter((assetId) => next.assetRefs.includes(assetId)), + ); + controller.update(next); + }, + [controller, previewManager], + ); + const retainAssetPreview = React.useCallback( + (assetId: string) => previewManager.retain(assetId), + [previewManager], + ); + const assetPreviewStatus = React.useCallback( + (assetId: string) => previewManager.status(assetId), + [previewManager], + ); + const retainRunAssetPreview = React.useCallback( + (assetId: string) => runPreviewManager.retain(assetId), + [runPreviewManager], + ); + const syncRunPreviewAuthority = React.useCallback( + ( + next: CreateImagesRendererRunState | undefined, + detail: CreateImagesRunHistoryDetailState = runHistoryDetailRef.current, + ) => { + const detailOwners = detail.status === "ready" ? createImagesRunAssetOwners(detail.run) : {}; + const owners = { ...(next?.runAssetOwners ?? {}), ...detailOwners }; + runAssetOwnersRef.current = owners; + runPreviewManager.setAssets(Object.keys(owners)); + }, + [runPreviewManager], + ); + const commitRunState = React.useCallback( + (next: CreateImagesRendererRunState) => { + runStateRef.current = next; + syncRunPreviewAuthority(next); + queryClient.setQueryData(queryKeys.createImagesRuns(initial.id), next); + setRunState(next); + }, + [initial.id, queryClient, syncRunPreviewAuthority], + ); + const applyRunList = React.useCallback( + (result: CreateImagesRunListResult) => { + const labels = Object.fromEntries( + documentRef.current.nodes.map((node) => [ + node.id, + CREATE_IMAGES_NODE_DEFINITIONS[node.type].title, + ]), + ); + try { + if (result.status === "ready" && result.authoritative) { + const selectedRunId = selectedHistoryRunIdRef.current; + if (selectedRunId) { + const priorDetail = runHistoryDetailRef.current; + const priorRecovery = + (priorDetail.status === "recovery-required" || priorDetail.status === "unsafe") && + priorDetail.recovery.runId === selectedRunId + ? priorDetail.recovery + : runStateRef.current?.recoveries.find((item) => item.runId === selectedRunId); + const transition = createImagesSelectedRunSnapshotTransition( + result, + selectedRunId, + priorRecovery, + ); + if (transition.kind === "recovery-changed") { + runHistoryRequestSequence.current += 1; + const { recovery } = transition; + const nextDetail: CreateImagesRunHistoryDetailState = + recovery.status === "unsafe" + ? { + status: "unsafe", + recovery, + message: + "Aiden preserved this run without exposing an unsafe journal format.", + } + : { status: "recovery-required", recovery }; + runHistoryDetailRef.current = nextDetail; + setRunHistoryDetail(nextDetail); + syncRunPreviewAuthority(runStateRef.current, nextDetail); + } else if (transition.kind === "removed" || transition.kind === "became-healthy") { + runHistoryRequestSequence.current += 1; + selectedHistoryRunIdRef.current = undefined; + setSelectedHistoryRunId(undefined); + const idle = { status: "idle" as const }; + runHistoryDetailRef.current = idle; + setRunHistoryDetail(idle); + syncRunPreviewAuthority(runStateRef.current, idle); + } + } + } + commitRunState( + reconcileCreateImagesRunState(runStateRef.current, result, initial.id, labels), + ); + } catch { + const previous = runStateRef.current; + commitRunState({ + ...(previous ?? { history: [], recoveries: [], runAssetOwners: {} }), + errorMessage: "Aiden rejected an invalid run snapshot.", + }); + } + }, + [commitRunState, initial.id, syncRunPreviewAuthority], + ); + const handleDegradedRunDiscarded = React.useCallback( + (result: Extract) => { + if (selectedHistoryRunIdRef.current === result.runId) { + runHistoryRequestSequence.current += 1; + selectedHistoryRunIdRef.current = undefined; + setSelectedHistoryRunId(undefined); + const idle = { status: "idle" as const }; + runHistoryDetailRef.current = idle; + setRunHistoryDetail(idle); + } + commitRunState(removeCreateImagesRunRecord(runStateRef.current, result.runId)); + }, + [commitRunState], + ); + const degradedDiscard = useDegradedRunDiscard({ onDiscarded: handleDegradedRunDiscarded }); + const applyRunMutation = React.useCallback( + (run: CreateImagesRunView) => { + if (run.workflowId !== initial.id) return false; + const previous = runStateRef.current; + const next = reconcileCreateImagesRunMutation(previous, run, initial.id); + if (next === previous || (!previous && !next.projection)) return false; + commitRunState(next); + return true; + }, + [commitRunState, initial.id], + ); + const requestRun = React.useCallback( + async (scope: WorkflowRunScope, draft: WorkflowDocumentV1, trigger: HTMLButtonElement) => { + if (runRequestActiveRef.current) return; + runRequestActiveRef.current = true; + const generation = ++runPreparationGenerationRef.current; + runReturnFocusRef.current = trigger; + setRunPreparing(true); + try { + controller.update(draft); + const flushed = await controller.flush(); + if (flushed.state !== "saved") { + toast.error( + flushed.state === "conflict" + ? "Resolve the workflow save conflict before starting a run." + : "Autosave must finish before Aiden can prepare this run.", + ); + runRequestActiveRef.current = false; + return; + } + setReviewedRun(false); + if (executionMode === "gemini" && scope.kind === "all") { + const result = await createImagesApi.prepareRun({ + workflowId: flushed.workflow.id, + expectedRevision: flushed.workflow.revision, + scope, + executionMode: "gemini", + }); + if (generation !== runPreparationGenerationRef.current) return; + if (result.status !== "ready") { + toast.error( + result.status === "conflict" + ? "The workflow changed before cloud review. Prepare the run again." + : result.message, + ); + runRequestActiveRef.current = false; + return; + } + setPreparedRun(prepareGeminiRun(flushed.workflow, scope, result.plan)); + return; + } + const prepared = + executionMode === "gemini" && scope.kind === "from-node" + ? prepareGeminiPathChoice(flushed.workflow, scope) + : prepareLocalMockRun(flushed.workflow, scope); + setPreparedRun( + scope.kind === "from-node" + ? { + ...prepared, + scope: undefined, + downstreamPathSelection: downstreamPathChoiceViews( + prepared.workflowSnapshot, + scope.nodeId, + ), + } + : prepared, + ); + } catch (error) { + toast.error( + error instanceof WorkflowPlanError + ? (error.issues[0]?.message ?? "This scope cannot run.") + : executionMode === "gemini" + ? "Aiden could not prepare this Gemini run." + : "Aiden could not prepare this local mock run.", + ); + runRequestActiveRef.current = false; + } finally { + setRunPreparing(false); + } + }, + [controller, executionMode], + ); + const closeRunConfirmation = React.useCallback(() => { + if (runSubmitting) return; + setPreparedRun(undefined); + setReviewedRun(false); + runPreparationGenerationRef.current += 1; + runRequestActiveRef.current = false; + }, [runSubmitting]); + const selectPreparedRunDownstreamPath = React.useCallback( + async (choiceId: string) => { + const current = preparedRun; + const selection = current?.downstreamPathSelection; + if (!current || !selection || runPreparing) return; + const scope = createImagesRunScopeForPathChoice( + selection.startNodeId, + choiceId, + selection.choices, + ); + if (!scope) return; + setReviewedRun(false); + const generation = ++runPreparationGenerationRef.current; + setRunPreparing(true); + try { + let recomputed: PreparedRun; + if (current.executionMode === "gemini") { + const result = await createImagesApi.prepareRun({ + workflowId: current.workflowId, + expectedRevision: current.workflowRevision, + scope, + executionMode: "gemini", + }); + if (generation !== runPreparationGenerationRef.current) return; + if (result.status !== "ready") { + toast.error( + result.status === "conflict" + ? "The workflow changed before cloud review. Prepare the run again." + : result.message, + ); + return; + } + recomputed = prepareGeminiRun(current.workflowSnapshot, scope, result.plan); + } else { + recomputed = prepareLocalMockRun(current.workflowSnapshot, scope); + } + if (generation !== runPreparationGenerationRef.current) return; + setPreparedRun({ + ...recomputed, + workflowSnapshot: current.workflowSnapshot, + downstreamPathSelection: { ...selection, selectedChoiceId: choiceId }, + }); + } catch { + if (generation === runPreparationGenerationRef.current) { + toast.error("Aiden could not prepare the selected run path."); + } + } finally { + if (generation === runPreparationGenerationRef.current) setRunPreparing(false); + } + }, + [preparedRun, runPreparing], + ); + const startPreparedRun = React.useCallback(async () => { + if (!preparedRun?.scope || !reviewedRun || runSubmitting) return; + if (preparedRun.executionMode === "gemini" && !preparedRun.providerConsent) { + toast.error("A current main-owned Gemini consent plan is required before launch."); + return; + } + setRunSubmitting(true); + try { + const result = await createImagesApi.startRun({ + workflowId: preparedRun.workflowId, + expectedRevision: preparedRun.workflowRevision, + scope: preparedRun.scope, + consent: + preparedRun.executionMode === "gemini" && preparedRun.providerConsent + ? { + executionMode: "gemini", + version: 1, + authorizationId: preparedRun.providerConsent.authorizationId, + consentFingerprint: preparedRun.providerConsent.consentFingerprint, + token: preparedRun.providerConsent.token, + reviewed: true, + } + : { executionMode: "local-mock", reviewed: true }, + }); + if (result.status === "started" || result.status === "already-running") { + applyRunMutation(result.run); + setPreparedRun(undefined); + setReviewedRun(false); + runRequestActiveRef.current = false; + toast.success( + result.status === "started" + ? preparedRun.executionMode === "gemini" + ? "Gemini run started. Submitted requests may incur provider charges." + : "Local mock run started." + : "The active run is open.", + ); + return; + } + toast.error( + result.status === "conflict" + ? "The workflow changed after review. Prepare the run again." + : "message" in result + ? result.message + : "Aiden returned an unexpected run state.", + ); + } catch { + toast.error("Aiden could not start the reviewed image run."); + } finally { + setRunSubmitting(false); + } + }, [applyRunMutation, preparedRun, reviewedRun, runSubmitting]); + const requestStop = React.useCallback((trigger: HTMLButtonElement) => { + stopReturnFocusRef.current = trigger; + setStopDialogOpen(true); + }, []); + const stopRun = React.useCallback(async () => { + const active = runStateRef.current?.projection; + if (!active || stopSubmitting) return; + setStopSubmitting(true); + try { + const result = await createImagesApi.stopRun({ + workflowId: active.workflowId, + runId: active.runId, + }); + if (result.status === "stopping" || result.status === "already-running") { + applyRunMutation(result.run); + setStopDialogOpen(false); + } else { + toast.error( + result.status === "conflict" + ? "The saved workflow revision changed." + : "message" in result + ? result.message + : "Aiden returned an unexpected stop state.", + ); + } + } catch { + toast.error("Aiden could not request a durable stop."); + } finally { + setStopSubmitting(false); + } + }, [applyRunMutation, stopSubmitting]); + const handleRunErrorAction = React.useCallback( + (action: CreateImagesRunErrorAction) => { + if (action === "check-connection") { + toast.info("This Phase 3 mock uses no network connection."); + } else if (action === "open-provider-settings") { + void (async () => { + if ((await controller.flush()).state !== "saved") { + toast.error("Save the workflow before opening provider settings."); + return; + } + await navigate({ to: "/settings", search: { section: "providers" } }); + })(); + } else if (action === "manage-storage") { + toast.info("Free device space before starting another image run."); + } + }, + [controller, navigate], + ); + const selectHistoryRun = React.useCallback( + async (runId: string, _trigger: HTMLButtonElement) => { + const lifecycle = runHistoryLifecycleRef.current; + if (!lifecycle.mounted) return; + const requestSequence = runHistoryRequestSequence.current + 1; + runHistoryRequestSequence.current = requestSequence; + const requestIdentity = { + runId, + lifecycleGeneration: lifecycle.generation, + requestSequence, + }; + const responseIsCurrent = () => + isCreateImagesRunHistoryRequestCurrent( + runStateRef.current, + { + mounted: runHistoryLifecycleRef.current.mounted, + lifecycleGeneration: runHistoryLifecycleRef.current.generation, + selectedRunId: selectedHistoryRunIdRef.current, + requestSequence: runHistoryRequestSequence.current, + }, + requestIdentity, + ); + selectedHistoryRunIdRef.current = runId; + setSelectedHistoryRunId(runId); + const loading = { status: "loading" as const, runId }; + runHistoryDetailRef.current = loading; + setRunHistoryDetail(loading); + syncRunPreviewAuthority(runStateRef.current, loading); + try { + const result = await createImagesApi.getRun({ workflowId: initial.id, runId }); + if (!responseIsCurrent()) return; + runHistoryDetailRef.current = result; + setRunHistoryDetail(result); + syncRunPreviewAuthority(runStateRef.current, result); + } catch { + if (!responseIsCurrent()) return; + const unavailable = { + status: "unavailable" as const, + message: "The durable run record is temporarily unavailable.", + }; + runHistoryDetailRef.current = unavailable; + setRunHistoryDetail(unavailable); + syncRunPreviewAuthority(runStateRef.current, unavailable); + } + }, + [initial.id, syncRunPreviewAuthority], + ); + const recoverHistoryRun = React.useCallback( + async (recovery: CreateImagesRunRecoveryView, trigger: HTMLButtonElement) => { + if ( + recovery.status !== "recovery-required" || + recovery.recoverySource === undefined || + recovery.expectedCandidateJournalRevision === undefined || + recoveringRunId + ) { + return; + } + const lifecycle = runHistoryLifecycleRef.current; + if (!lifecycle.mounted) return; + const requestSequence = runHistoryRequestSequence.current + 1; + runHistoryRequestSequence.current = requestSequence; + const requestIdentity = { + runId: recovery.runId, + lifecycleGeneration: lifecycle.generation, + requestSequence, + source: recovery.recoverySource, + expectedCandidateJournalRevision: recovery.expectedCandidateJournalRevision, + } as const; + const responseIsCurrent = () => + isCreateImagesRunRecoveryRequestCurrent( + runStateRef.current, + { + mounted: runHistoryLifecycleRef.current.mounted, + lifecycleGeneration: runHistoryLifecycleRef.current.generation, + selectedRunId: selectedHistoryRunIdRef.current, + requestSequence: runHistoryRequestSequence.current, + }, + requestIdentity, + ); + let responseOwned = false; + setRecoveringRunId(recovery.runId); + try { + const result = await createImagesApi.recoverRun({ + workflowId: initial.id, + runId: requestIdentity.runId, + source: requestIdentity.source, + expectedCandidateJournalRevision: requestIdentity.expectedCandidateJournalRevision, + }); + if (!responseIsCurrent()) return; + responseOwned = true; + if (result.status === "recovered") { + const detail = { status: "ready" as const, run: result.run }; + runHistoryDetailRef.current = detail; + setRunHistoryDetail(detail); + const current = runStateRef.current; + if (current) { + commitRunState({ + ...current, + recoveries: (current.recoveries ?? []).filter( + (item) => item.runId !== recovery.runId, + ), + }); + } else { + syncRunPreviewAuthority(undefined, detail); + } + toast.success( + recovery.recoverySource === "last-known-good" + ? "The verified last-known-good run record was restored." + : "The recovery copy was repaired from the verified current run record.", + ); + } else if (result.status === "recovery-required" || result.status === "unsafe") { + const detail = + result.status === "unsafe" + ? result + : { status: "recovery-required" as const, recovery: result.recovery }; + runHistoryDetailRef.current = detail; + setRunHistoryDetail(detail); + toast.error( + result.status === "unsafe" + ? result.message + : "The recovery candidate changed. Review the updated run health.", + ); + } else { + toast.error( + result.status === "conflict" + ? result.source === "last-known-good" + ? "The last-known-good candidate changed. Open the run record again." + : "The current repair candidate changed. Open the run record again." + : result.status === "not-found" + ? "The run record no longer exists." + : result.message, + ); + } + } catch { + if (!responseIsCurrent()) return; + responseOwned = true; + toast.error("Aiden could not recover the run record safely."); + } finally { + if ( + runHistoryLifecycleRef.current.mounted && + runHistoryLifecycleRef.current.generation === requestIdentity.lifecycleGeneration + ) { + setRecoveringRunId(undefined); + } + if (responseOwned) { + requestAnimationFrame(() => { + if ( + isCreateImagesRunHistoryRequestCurrent( + runStateRef.current, + { + mounted: runHistoryLifecycleRef.current.mounted, + lifecycleGeneration: runHistoryLifecycleRef.current.generation, + selectedRunId: selectedHistoryRunIdRef.current, + requestSequence: runHistoryRequestSequence.current, + }, + requestIdentity, + ) && + trigger.isConnected + ) { + trigger.focus(); + } + }); + } + } + }, + [commitRunState, initial.id, recoveringRunId, syncRunPreviewAuthority], + ); + const requestAmbiguityAcknowledgement = React.useCallback( + (run: CreateImagesRunView, trigger: HTMLButtonElement) => { + if ( + ambiguityAcknowledgementSubmitting || + run.status !== "needs_attention" || + run.ambiguityResolution || + !run.nodes.some((node) => node.status === "ambiguous") + ) { + return; + } + ambiguityAcknowledgementReturnFocusRef.current = trigger; + setAmbiguityAcknowledgementReviewed(false); + setAmbiguityAcknowledgementRun(run); + }, + [ambiguityAcknowledgementSubmitting], + ); + const closeAmbiguityAcknowledgement = React.useCallback(() => { + if (ambiguityAcknowledgementSubmitting) return; + setAmbiguityAcknowledgementRun(undefined); + setAmbiguityAcknowledgementReviewed(false); + }, [ambiguityAcknowledgementSubmitting]); + const confirmAmbiguityAcknowledgement = React.useCallback(async () => { + const run = ambiguityAcknowledgementRun; + if (!run || !ambiguityAcknowledgementReviewed || ambiguityAcknowledgementSubmitting) return; + const lifecycle = runHistoryLifecycleRef.current; + if (!lifecycle.mounted || selectedHistoryRunIdRef.current !== run.runId) return; + const requestSequence = runHistoryRequestSequence.current + 1; + runHistoryRequestSequence.current = requestSequence; + const requestIdentity = { + runId: run.runId, + lifecycleGeneration: lifecycle.generation, + requestSequence, + expectedLastSequence: run.lastSequence, + }; + const responseIsCurrent = () => + isCreateImagesRunAmbiguityRequestCurrent( + runStateRef.current, + { + mounted: runHistoryLifecycleRef.current.mounted, + lifecycleGeneration: runHistoryLifecycleRef.current.generation, + selectedRunId: selectedHistoryRunIdRef.current, + requestSequence: runHistoryRequestSequence.current, + }, + requestIdentity, + ); + setAmbiguityAcknowledgementSubmitting(true); + let closed = false; + try { + const result = await createImagesApi.resolveRunAmbiguity({ + workflowId: initial.id, + runId: run.runId, + expectedJournalRevision: run.journalRevision, + resolution: "acknowledge-unresolved-submission", + }); + if (!responseIsCurrent()) return; + if (result.status === "resolved" || result.status === "already-resolved") { + if (runStateRef.current?.projection?.runId === run.runId && !applyRunMutation(result.run)) { + return; + } + const detail = { status: "ready" as const, run: result.run }; + runHistoryDetailRef.current = detail; + setRunHistoryDetail(detail); + syncRunPreviewAuthority(runStateRef.current, detail); + setAmbiguityAcknowledgementRun(undefined); + setAmbiguityAcknowledgementReviewed(false); + closed = true; + toast.success( + result.status === "resolved" + ? "The unresolved submission was acknowledged. New runs still require confirmation." + : "This unresolved submission was already acknowledged.", + ); + } else if (result.status === "conflict") { + setAmbiguityAcknowledgementRun(undefined); + setAmbiguityAcknowledgementReviewed(false); + closed = true; + toast.error( + "The run record changed. Open the latest durable record before acknowledging it.", + ); + } else if (result.status === "not-ambiguous") { + setAmbiguityAcknowledgementRun(undefined); + setAmbiguityAcknowledgementReviewed(false); + closed = true; + toast.info("This run no longer has an unresolved submission."); + } else if (result.status === "not-found") { + setAmbiguityAcknowledgementRun(undefined); + setAmbiguityAcknowledgementReviewed(false); + closed = true; + toast.error("The run record no longer exists."); + } else { + toast.error( + "message" in result + ? result.message + : "Aiden returned an unexpected ambiguity acknowledgement state.", + ); + } + } catch { + if (!responseIsCurrent()) return; + toast.error("Aiden could not acknowledge the unresolved submission safely."); + } finally { + if ( + runHistoryLifecycleRef.current.mounted && + runHistoryLifecycleRef.current.generation === requestIdentity.lifecycleGeneration + ) { + setAmbiguityAcknowledgementSubmitting(false); + } + if (closed) { + requestAnimationFrame(() => { + const trigger = ambiguityAcknowledgementReturnFocusRef.current; + if ( + isCreateImagesRunHistoryRequestCurrent( + runStateRef.current, + { + mounted: runHistoryLifecycleRef.current.mounted, + lifecycleGeneration: runHistoryLifecycleRef.current.generation, + selectedRunId: selectedHistoryRunIdRef.current, + requestSequence: runHistoryRequestSequence.current, + }, + requestIdentity, + ) && + trigger?.isConnected + ) { + trigger.focus(); + } + }); + } + } + }, [ + ambiguityAcknowledgementReviewed, + ambiguityAcknowledgementRun, + ambiguityAcknowledgementSubmitting, + applyRunMutation, + initial.id, + syncRunPreviewAuthority, + ]); + const requestRunHistoryPrune = React.useCallback( + async (trigger: HTMLButtonElement) => { + if (runHistoryPruneBusy) return; + const lifecycleGeneration = runHistoryLifecycleRef.current.generation; + if (!runHistoryLifecycleRef.current.mounted) return; + const lifecycleIsCurrent = () => + runHistoryLifecycleRef.current.mounted && + runHistoryLifecycleRef.current.generation === lifecycleGeneration; + runHistoryPruneReturnFocusRef.current = trigger; + setRunHistoryPruneBusy(true); + try { + const result = await createImagesApi.planRunHistoryPrune({ keepLatest: 100 }); + if (!lifecycleIsCurrent()) return; + if (result.status === "ready") { + setRunHistoryPrunePlan(result); + } else if (result.status === "nothing-to-prune") { + toast.info("There is no older Create Images run history to clear."); + } else { + toast.error(result.message); + } + } catch { + if (lifecycleIsCurrent()) toast.error("Aiden could not prepare run history cleanup."); + } finally { + if (lifecycleIsCurrent()) setRunHistoryPruneBusy(false); + } + }, + [runHistoryPruneBusy], + ); + const confirmRunHistoryPrune = React.useCallback(async () => { + const plan = runHistoryPrunePlan; + if (!plan || runHistoryPruneBusy) return; + const lifecycleGeneration = runHistoryLifecycleRef.current.generation; + if (!runHistoryLifecycleRef.current.mounted) return; + const lifecycleIsCurrent = () => + runHistoryLifecycleRef.current.mounted && + runHistoryLifecycleRef.current.generation === lifecycleGeneration; + setRunHistoryPruneBusy(true); + try { + const result = await createImagesApi.pruneRunHistory({ + keepLatest: plan.keepLatest, + authorizationToken: plan.authorizationToken, + confirmed: true, + }); + if (!lifecycleIsCurrent()) return; + if (result.status === "pruned") { + setRunHistoryPrunePlan(undefined); + toast.success( + `Cleared ${result.removedRunCount} run record${result.removedRunCount === 1 ? "" : "s"} and released ${result.releasedAssetCount} retained image or asset reference${result.releasedAssetCount === 1 ? "" : "s"}, which may include imported inputs and generated outputs.`, + ); + } else if (result.status === "nothing-to-prune") { + setRunHistoryPrunePlan(undefined); + toast.info("There is no older Create Images run history to clear."); + } else if (result.status === "conflict") { + setRunHistoryPrunePlan(undefined); + toast.error("Run history changed. Review a fresh cleanup summary before confirming."); + } else { + toast.error(result.message); + } + } catch { + if (lifecycleIsCurrent()) { + toast.error("Aiden could not clear the selected run history safely."); + } + } finally { + if (lifecycleIsCurrent()) setRunHistoryPruneBusy(false); + } + }, [runHistoryPruneBusy, runHistoryPrunePlan]); + useBlocker({ + enableBeforeUnload: false, + shouldBlockFn: async () => (await controller.flush()).state !== "saved", + }); + + React.useEffect( + () => + controller.subscribe((next) => { + setStatus(next); + if (next.state === "saved") { + documentRef.current = next.workflow; + setDocument(next.workflow); + } + const dirty = next.state !== "saved"; + const saving = next.state === "saving"; + setRendererLifecycleGuard("create-images", { dirty, saving }); + void appApi.setCloseGuard({ + dirty, + gitBusy: false, + path: next.workflow.title, + saving, + }); + }), + [controller], + ); + + React.useEffect(() => { + cancelPendingControllerDisposalRef.current(); + const unregister = registerCreateImagesNavigationGuard(async () => { + const result = await controller.flush(); + return result.state === "saved" + ? { allowed: true } + : { + allowed: false, + message: + result.state === "conflict" + ? "Resolve the workflow save conflict before leaving." + : "Wait for autosave to finish or retry it before leaving.", + }; + }); + const flushWhenHidden = () => { + if (window.document.visibilityState === "hidden") void controller.flush(); + }; + window.document.addEventListener("visibilitychange", flushWhenHidden); + return () => { + unregister(); + window.document.removeEventListener("visibilitychange", flushWhenHidden); + cancelPendingControllerDisposalRef.current = deferWorkflowAutosaveControllerDisposal( + controller, + () => { + clearRendererLifecycleGuard("create-images"); + void appApi.setCloseGuard({ dirty: false, gitBusy: false, saving: false }); + }, + ); + }; + }, [controller]); + + React.useEffect(() => { + cancelPendingPreviewDisposalRef.current(); + const unsubscribe = previewManager.subscribe(setPreviews); + previewManager.setAssets(initialAssetRefs); + const refreshWhenVisible = () => { + if (window.document.visibilityState === "visible") previewManager.refresh(); + }; + window.document.addEventListener("visibilitychange", refreshWhenVisible); + return () => { + window.document.removeEventListener("visibilitychange", refreshWhenVisible); + unsubscribe(); + cancelPendingPreviewDisposalRef.current = deferAssetPreviewLifecycleDisposal(previewManager); + }; + }, [initialAssetRefs, previewManager]); + + React.useEffect(() => { + cancelPendingRunPreviewDisposalRef.current(); + const unsubscribe = runPreviewManager.subscribe(setRunAssetPreviews); + runPreviewManager.setAssets(createImagesRunOutputAssetIds(initialRunState)); + const refreshWhenVisible = () => { + if (window.document.visibilityState === "visible") runPreviewManager.refresh(); + }; + window.document.addEventListener("visibilitychange", refreshWhenVisible); + return () => { + window.document.removeEventListener("visibilitychange", refreshWhenVisible); + unsubscribe(); + cancelPendingRunPreviewDisposalRef.current = + deferAssetPreviewLifecycleDisposal(runPreviewManager); + }; + }, [initialRunState, runPreviewManager]); + + React.useEffect(() => { + const runSubscription = createImagesRunSubscriptionController({ + workflowId: initial.id, + subscribe: createImagesApi.subscribeRuns, + unsubscribe: createImagesApi.unsubscribeRuns, + onChanged: createImagesApi.onRunsChanged, + apply: applyRunList, + }); + const retryWhenFocused = () => runSubscription.retryNow(); + const retryWhenVisible = () => { + if (window.document.visibilityState === "visible") runSubscription.retryNow(); + }; + window.addEventListener("focus", retryWhenFocused); + window.document.addEventListener("visibilitychange", retryWhenVisible); + runSubscription.start(); + return () => { + window.removeEventListener("focus", retryWhenFocused); + window.document.removeEventListener("visibilitychange", retryWhenVisible); + runSubscription.dispose(); + }; + }, [applyRunList, initial.id]); + + const statusLabel = + status.state === "saved" + ? "Saved on this device" + : status.state === "dirty" + ? "Autosave pending" + : status.state === "saving" + ? "Saving…" + : status.state === "conflict" + ? "Save conflict" + : "Autosave paused"; + + const reloadConflict = () => { + if (status.state !== "conflict") return; + previewManager.setAssets(status.current.assetRefs); + documentRef.current = status.current; + controller.replacePersisted(status.current); + setDocument(status.current); + setCanvasEpoch((current) => current + 1); + }; + + const saveConflictCopy = async () => { + if (status.state !== "conflict") return; + const created = await createImagesApi.create({ + template: "blank", + title: `${status.workflow.title} copy`, + }); + if (created.status !== "saved") { + toast.error(mutationMessage(created, "Aiden could not create a conflict copy.")); + return; + } + const copy: WorkflowDocumentV1 = { + ...structuredClone(status.workflow), + id: created.workflow.id, + title: created.workflow.title, + revision: 2, + createdAt: created.workflow.createdAt, + updatedAt: new Date().toISOString(), + }; + const saved = await createImagesApi.save({ expectedRevision: 1, workflow: copy }); + if (saved.status !== "saved") { + toast.error(mutationMessage(saved, "Aiden could not save the conflict copy.")); + return; + } + controller.replacePersisted(status.current); + await queryClient.invalidateQueries({ queryKey: queryKeys.createImagesWorkflows }); + await navigate({ to: "/create-images/$workflowId", params: { workflowId: saved.workflow.id } }); + }; + + return ( +
+ previewManager.reportLoadSuccess(assetId, token)} + onAssetPreviewError={(assetId, token) => previewManager.reportLoadError(assetId, token)} + onRunAssetPreviewMount={retainRunAssetPreview} + onRunAssetPreviewLoad={(assetId, token) => + runPreviewManager.reportLoadSuccess(assetId, token) + } + onRunAssetPreviewError={(assetId, token) => + runPreviewManager.reportLoadError(assetId, token) + } + onDownloadRunAsset={(runId, assetId) => { + void createImagesApi + .downloadRunAsset({ + workflowId: documentRef.current.id, + runId, + assetId, + }) + .then((result) => { + if (result.status === "canceled") return; + if (result.status === "saved") { + toast.success(`Saved ${result.fileName} and revealed it in Finder.`); + return; + } + if (result.status === "forbidden") + toast.error("This run record no longer authorizes that image."); + else if (result.status === "not-found") + toast.error("The retained image file is missing."); + else if ("message" in result) toast.error(result.message); + }) + .catch(() => toast.error("Aiden could not save this retained image.")); + }} + statusLabel={`${statusLabel}${missingAssetIds.length > 0 ? ` · ${missingAssetIds.length} image file${missingAssetIds.length === 1 ? "" : "s"} missing` : ""}${runState?.errorMessage ? " · Run updates unavailable" : ""}`} + providerStatus={providerStatus} + executionMode={executionMode} + onExecutionModeChange={setExecutionMode} + onOpenProviderSettings={() => + void navigate({ to: "/settings", search: { section: "providers" } }) + } + onDocumentChange={handleDocumentChange} + onRunRequest={(scope, draft, trigger) => void requestRun(scope, draft, trigger)} + onStopRun={requestStop} + onRunErrorAction={handleRunErrorAction} + onSelectHistoryRun={(runId, trigger) => void selectHistoryRun(runId, trigger)} + onRecoverRun={(recovery, trigger) => void recoverHistoryRun(recovery, trigger)} + onDiscardDegradedRun={(runId, trigger) => void degradedDiscard.request(runId, trigger)} + onAcknowledgeRunAmbiguity={requestAmbiguityAcknowledgement} + onManageRunHistory={(trigger) => void requestRunHistoryPrune(trigger)} + onImportDroppedImages={async (files) => { + let result; + try { + result = await window.aidenAPI.createImages.importDroppedFiles( + documentRef.current.id, + files, + ); + } catch { + return { imported: [], failures: ["The dropped images could not be imported."] }; + } + if (result.status !== "completed") { + return { imported: [], failures: [result.message] }; + } + const imported = []; + const failures: string[] = []; + for (const item of result.items) { + if (item.status === "unavailable") { + failures.push(`${item.fileName}: ${item.message}`); + continue; + } + previewManager.adopt(item.grant.asset.assetId, item.grant); + setMissingAssetIds((current) => + current.filter((assetId) => assetId !== item.grant.asset.assetId), + ); + imported.push({ + assetId: item.grant.asset.assetId, + ...(item.grant.asset.originalName ? { label: item.grant.asset.originalName } : {}), + }); + } + return { imported, failures }; + }} + onChooseImage={async () => { + let result; + try { + result = await createImagesApi.pickAsset({ workflowId: document.id }); + } catch { + toast.error("The image picker is unavailable."); + return undefined; + } + if (result.status === "canceled") return undefined; + if (result.status !== "imported") { + toast.error(result.message); + return undefined; + } + previewManager.adopt(result.grant.asset.assetId, result.grant); + setMissingAssetIds((current) => + current.filter((assetId) => assetId !== result.grant.asset.assetId), + ); + return { + assetId: result.grant.asset.assetId, + ...(result.grant.asset.originalName ? { label: result.grant.asset.originalName } : {}), + }; + }} + onBack={() => { + void (async () => { + const decision = await requestCreateImagesNavigation(); + if (!decision.allowed) { + toast.error(decision.message ?? "Resolve the workflow save issue before leaving."); + return; + } + await queryClient.invalidateQueries({ queryKey: queryKeys.createImagesWorkflows }); + await navigate({ to: "/create-images" }); + })(); + }} + /> + void controller.retry()} + onSaveCopy={() => void saveConflictCopy()} + /> + {preparedRun ? ( + { + if (!open) closeRunConfirmation(); + }} + onConfirm={() => void startPreparedRun()} + /> + ) : null} + + node.status === "queued" || + (node.status === "retry" && node.retryMode === "automatic-mock"), + ).length + } + runningNodeCount={ + Object.values(runState?.projection?.nodes ?? {}).filter( + (node) => node.status === "running", + ).length + } + providerMayComplete={runState?.projection?.executionMode === "gemini"} + returnFocusRef={stopReturnFocusRef} + onOpenChange={setStopDialogOpen} + onConfirm={() => void stopRun()} + /> + {ambiguityAcknowledgementRun ? ( + { + if (!open) closeAmbiguityAcknowledgement(); + }} + onConfirm={() => void confirmAmbiguityAcknowledgement()} + /> + ) : null} + {degradedDiscard.plan ? ( + { + if (!open) degradedDiscard.close(); + }} + onConfirm={() => void degradedDiscard.confirm()} + /> + ) : null} + { + if (!open) setRunHistoryPrunePlan(undefined); + }} + title="Clear oldest run history?" + description={ + runHistoryPrunePlan ? ( +
+

+ This clears {runHistoryPrunePlan.candidateRunCount} of the oldest terminal run + records across all Create Images workflows. Aiden will keep at least the newest{" "} + {runHistoryPrunePlan.keepLatest} records. +

+

+ {runHistoryPrunePlan.releasedAssetCount} retained image or asset reference + {runHistoryPrunePlan.releasedAssetCount === 1 ? "" : "s"} will be released. These + may include imported inputs and generated outputs. Any released file with no other + workflow or run reference may later be removed by device-local cleanup. +

+

This does not rerun, stop, or submit provider work.

+
+ ) : undefined + } + confirmLabel={ + runHistoryPrunePlan + ? `Clear ${runHistoryPrunePlan.candidateRunCount} record${runHistoryPrunePlan.candidateRunCount === 1 ? "" : "s"}` + : "Clear records" + } + confirmVariant="destructive" + busy={runHistoryPruneBusy} + keepOpenOnConfirm + returnFocus={() => runHistoryPruneReturnFocusRef.current} + onConfirm={() => void confirmRunHistoryPrune()} + /> +
+ ); +} + +function DurableWorkflowView({ workflowId }: { workflowId: string }) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const workspace = useCreateImagesWorkspace(); + const workspaceReady = workspace.data?.status === "ready"; + const workflow = useCreateImagesWorkflow(workflowId, workspaceReady); + const [workspaceBusy, setWorkspaceBusy] = React.useState(false); + const [workspaceActionError, setWorkspaceActionError] = React.useState(); + + const chooseWorkspace = React.useCallback(async () => { + if (workspaceBusy) return; + setWorkspaceActionError(undefined); + setWorkspaceBusy(true); + try { + const result = await createImagesApi.chooseWorkspace(); + if (result.status === "canceled") return; + if (result.status !== "ready") { + setWorkspaceActionError(result.message); + await workspace.refetch(); + return; + } + queryClient.setQueryData(queryKeys.createImagesWorkspace, result.workspace); + } catch { + setWorkspaceActionError( + "Aiden could not choose that folder. Try again or choose another folder.", + ); + } finally { + setWorkspaceBusy(false); + } + }, [queryClient, workspace, workspaceBusy]); + + const retryWorkspace = React.useCallback(async () => { + if (workspaceBusy) return; + setWorkspaceActionError(undefined); + setWorkspaceBusy(true); + try { + await workspace.refetch(); + } catch { + setWorkspaceActionError("Aiden could not check the image workspace. Try again."); + } finally { + setWorkspaceBusy(false); + } + }, [workspace, workspaceBusy]); + + if (workspace.isLoading || !workspaceReady) { + return ( + void chooseWorkspace()} + onRetry={() => void retryWorkspace()} + onBack={() => void navigate({ to: "/create-images" })} + /> + ); + } + if (workflow.isLoading) return ; + if (workflow.isError || !workflow.data) { + return ( + void workflow.refetch()} + /> + ); + } + const result: CreateImagesWorkflowLoadResult = workflow.data; + if (result.status === "ready") { + return ( + + ); + } + if (result.status === "recovery-required" || result.status === "unsafe") { + return ( + workflow.refetch()} + /> + ); + } + if (result.status === "not-found") { + return ( + + ); + } + return ( + void workflow.refetch()} + /> + ); +} + +export function CreateImagesWorkflowView({ workflowId }: { workflowId: string }) { + const fixture = React.useMemo( + () => + workflowId === "stress-100" || workflowId === "stress-250" + ? createImagesFixture(workflowId) + : undefined, + [workflowId], + ); + if (fixture) { + return ( + { + window.history.back(); + }} + /> + ); + } + return ; +} diff --git a/renderer/create-images/create-images.css b/renderer/create-images/create-images.css new file mode 100644 index 00000000..97f8d6be --- /dev/null +++ b/renderer/create-images/create-images.css @@ -0,0 +1,854 @@ +.create-images-workbench { + --xy-background-color: transparent; + --xy-edge-stroke: var(--text-quaternary); + --xy-edge-stroke-selected: var(--accent); + --xy-connectionline-stroke: var(--accent); + --xy-minimap-background-color: var(--surface-popover); + --xy-controls-button-background-color: var(--surface-popover); + --xy-controls-button-background-color-hover: var(--surface-control-hover); + --xy-controls-button-color: var(--text-secondary); + --xy-controls-button-border-color: var(--border-separator); +} + +.create-images-workbench .react-flow, +.create-images-workbench .react-flow__renderer { + background: transparent; +} + +.create-images-workbench .react-flow__pane { + background: + radial-gradient( + circle at 50% 0%, + color-mix(in srgb, var(--accent) 2.5%, transparent), + transparent 42% + ), + color-mix(in srgb, var(--surface-background) 97%, var(--surface-popover)); +} + +.create-images-canvas-drop-zone { + isolation: isolate; +} + +.create-images-drop-overlay { + position: absolute; + z-index: 10; + inset: 0; + display: grid; + place-items: center; + padding: 1.5rem; + border: 1px dashed color-mix(in srgb, var(--focus-ring) 64%, var(--border-field)); + background: color-mix(in srgb, var(--accent) 4%, transparent); + pointer-events: none; + animation: create-images-drop-overlay-in 150ms cubic-bezier(0.19, 1, 0.22, 1) both; +} + +.create-images-drop-overlay-card { + display: grid; + max-width: min(26rem, 100%); + justify-items: center; + gap: 0.375rem; + padding: 1rem 1.25rem; + border: 1px solid color-mix(in srgb, var(--focus-ring) 34%, var(--border-field)); + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--surface-popover) 94%, transparent); + box-shadow: var(--elevation-popover); + color: var(--text-primary); + text-align: center; + backdrop-filter: blur(14px); +} + +.create-images-drop-overlay-icon { + display: grid; + width: 2.25rem; + height: 2.25rem; + place-items: center; + border-radius: 0.625rem; + background: color-mix(in srgb, var(--accent) 12%, var(--surface-control)); + color: var(--accent); +} + +.create-images-drop-overlay-icon > svg { + width: 1.125rem; + height: 1.125rem; +} + +.create-images-drop-overlay-title { + font-size: var(--text-small-strong); + font-weight: 600; +} + +.create-images-drop-overlay-copy { + color: var(--text-secondary); + font-size: var(--text-small); +} + +@keyframes create-images-drop-overlay-in { + from { + opacity: 0; + transform: translateY(4px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* `output` is also one of React Flow's built-in node class names. Reset that + wrapper so Aiden's custom Output node does not inherit the built-in card. */ +.create-images-workbench .react-flow__node-output { + width: auto; + padding: 0; + color: inherit; + font-size: inherit; + text-align: inherit; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.create-images-workbench .react-flow__node-output.selectable:hover, +.create-images-workbench .react-flow__node-output.selectable.selected, +.create-images-workbench .react-flow__node-output.selectable:focus, +.create-images-workbench .react-flow__node-output.selectable:focus-visible { + box-shadow: none; +} + +.create-images-workspace-setup { + display: grid; + min-height: calc(100% - 3.25rem); + place-items: center; + padding: clamp(1.5rem, 5vw, 4.5rem) 1.5rem; + background: color-mix(in srgb, var(--surface-background) 96%, var(--surface-popover)); +} + +.create-images-workspace-setup-card { + display: grid; + width: min(100%, 35rem); + justify-items: center; + gap: 0.75rem; + padding: clamp(1.5rem, 5vw, 2.75rem); + border: 1px solid var(--border-field); + border-radius: var(--radius-dialog); + background: var(--surface-popover); + box-shadow: var(--elevation-modal); + text-align: center; +} + +.create-images-workspace-mark { + display: grid; + width: 3.25rem; + height: 3.25rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border-field)); + border-radius: 1rem; + background: color-mix(in srgb, var(--accent) 10%, var(--surface-control)); + color: var(--accent); +} + +.create-images-workspace-mark > svg { + width: 1.375rem; + height: 1.375rem; +} + +.create-images-workspace-spinner { + animation: create-images-workspace-spin 900ms linear infinite; +} + +@keyframes create-images-workspace-spin { + to { + transform: rotate(360deg); + } +} + +.create-images-workspace-eyebrow { + display: inline-flex; + align-items: center; + gap: 0.375rem; + margin-top: 0.25rem; + color: var(--text-tertiary); + font-size: var(--text-mini); + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.create-images-workspace-eyebrow > svg { + width: 0.875rem; + height: 0.875rem; +} + +.create-images-workspace-title { + max-width: 28rem; + outline: none; + color: var(--text-primary); + font-size: clamp(1.35rem, 3vw, 1.75rem); + font-weight: 650; + letter-spacing: -0.025em; + line-height: 1.12; +} + +.create-images-workspace-copy { + max-width: 29rem; + color: var(--text-secondary); + font-size: var(--text-regular); + line-height: 1.55; +} + +.create-images-workspace-remembered { + max-width: 100%; + overflow: hidden; + color: var(--text-tertiary); + font-size: var(--text-small); + text-overflow: ellipsis; + white-space: nowrap; +} + +.create-images-workspace-remembered strong { + color: var(--text-secondary); + font-weight: 600; +} + +.create-images-workspace-error { + max-width: 29rem; + padding: 0.625rem 0.75rem; + border: 1px solid color-mix(in srgb, var(--support-red) 26%, var(--border-field)); + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--support-red) 7%, var(--surface-well)); + color: var(--support-red); + font-size: var(--text-small); + line-height: 1.4; +} + +.create-images-workspace-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.create-images-workspace-loading { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + color: var(--text-secondary); + font-size: var(--text-small); +} + +.create-images-workspace-loading > svg { + width: 1rem; + height: 1rem; +} + +.create-images-workspace-note { + display: flex; + max-width: 28rem; + align-items: flex-start; + gap: 0.375rem; + margin-top: 0.75rem; + color: var(--text-tertiary); + font-size: var(--text-mini); + line-height: 1.4; + text-align: left; +} + +.create-images-workspace-note > svg { + width: 0.875rem; + height: 0.875rem; + flex: 0 0 auto; + margin-top: 0.125rem; + color: var(--support-green); +} + +.create-images-workspace-trigger { + min-width: 0; + border-color: transparent; + color: var(--text-secondary); +} + +.create-images-workspace-trigger-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.create-images-workspace-trigger > svg:last-child { + width: 0.875rem; + height: 0.875rem; + flex: 0 0 auto; + color: var(--text-tertiary); +} + +.create-images-workspace-menu-content { + max-height: min(28rem, calc(100vh - 4.5rem)); +} + +.create-images-workspace-menu-summary { + display: grid; + gap: 0.2rem; + padding: 0.25rem 0.5rem 0.5rem; + color: var(--text-tertiary); + font-size: var(--text-mini); + line-height: 1.35; +} + +.create-images-workspace-menu-conflict { + color: var(--support-warning); +} + +.create-images-node { + transition: + border-color 140ms ease-out, + box-shadow 140ms ease-out, + transform 140ms ease-out; +} + +.create-images-node[data-selected="true"] { + border-color: var(--accent); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent), + var(--elevation-control-hover); +} + +.create-images-node[data-run-status="running"] { + border-color: color-mix(in srgb, var(--accent) 48%, var(--border-field)); +} + +.create-images-node[data-run-status="failed"], +.create-images-node[data-run-status="retry"] { + border-color: color-mix(in srgb, var(--support-red) 42%, var(--border-field)); +} + +.create-images-node[data-run-status="succeeded"] { + border-color: color-mix(in srgb, var(--support-green) 42%, var(--border-field)); +} + +.create-images-image-node { + border: 0; + border-radius: var(--radius-card); + background: transparent; + box-shadow: none; + isolation: isolate; +} + +.create-images-image-node-frame { + width: 100%; + min-height: 8.5rem; + max-height: 18rem; + overflow: hidden; + border-radius: var(--radius-card); + background-color: var(--surface-well); +} + +.create-images-image-node-frame > img { + min-height: 8.5rem; + max-height: 18rem; +} + +.create-images-image-node-label, +.create-images-image-node-action { + border: 1px solid color-mix(in srgb, var(--border-field) 72%, transparent); + background: color-mix(in srgb, var(--surface-popover) 88%, transparent); + box-shadow: var(--elevation-control); + color: var(--text-secondary); + backdrop-filter: blur(12px) saturate(1.08); +} + +.create-images-image-node-actions { + pointer-events: none; + opacity: 0; + transform: translateY(-2px); + transition: + opacity 140ms ease-out, + transform 140ms ease-out; +} + +.create-images-image-node:hover .create-images-image-node-actions, +.create-images-image-node:focus-within .create-images-image-node-actions, +.create-images-image-node[data-selected="true"] .create-images-image-node-actions { + pointer-events: auto; + opacity: 1; + transform: translateY(0); +} + +.create-images-image-node-action:hover { + background: color-mix(in srgb, var(--surface-popover) 76%, var(--surface-control-hover)); +} + +.create-images-image-node[data-selected="true"] { + box-shadow: + 0 0 0 2px var(--surface-background), + 0 0 0 4px var(--accent), + var(--elevation-control-hover); +} + +.create-images-image-node[data-selected="false"][data-run-status="running"] { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent) 64%, transparent), + var(--elevation-control-hover); +} + +.create-images-image-node[data-selected="false"][data-run-status="failed"], +.create-images-image-node[data-selected="false"][data-run-status="retry"] { + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--support-red) 64%, transparent), + var(--elevation-control-hover); +} + +.create-images-image-node-handle { + right: -5px; + opacity: 0.82; +} + +.create-images-node-select { + width: 100%; + min-width: 0; + height: 2rem; + padding: 0 1.625rem 0 0.5rem; + border: 1px solid var(--border-field); + border-radius: var(--radius-control); + outline: none; + background: var(--surface-input); + color: var(--text-primary); + font-size: var(--text-small); + text-overflow: ellipsis; +} + +.create-images-node-select:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--text-primary) 26%, var(--border-field)); +} + +.create-images-node-select:focus-visible { + border-color: var(--focus-ring); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-ring) 28%, transparent); +} + +.create-images-node-select:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.create-images-node-provider-state { + display: flex; + align-items: flex-start; + gap: 0.375rem; + font-size: var(--text-mini); + line-height: 1.35; +} + +.create-images-node-provider-state > span:first-child { + margin-top: 0.25rem; +} + +.create-images-run-surface > .create-images-run-panel, +.create-images-run-surface > .create-images-run-history { + max-height: 100%; +} + +.create-images-workbench .react-flow__node:focus-visible { + outline: none; +} + +.create-images-workbench .react-flow__node:focus-visible .create-images-node { + box-shadow: + 0 0 0 2px var(--surface-background), + 0 0 0 4px var(--focus-ring), + var(--elevation-control-hover); +} + +.create-images-workbench .react-flow__node:focus-visible .create-images-image-node { + box-shadow: + 0 0 0 2px var(--surface-background), + 0 0 0 4px var(--focus-ring), + var(--elevation-control-hover); +} + +.create-images-workbench .create-images-handle { + width: 11px; + height: 11px; + border: 2px solid var(--surface-popover); + background: var(--text-quaternary); + box-shadow: 0 0 0 1px var(--border-field); + transition: + background-color 120ms ease-out, + box-shadow 120ms ease-out, + transform 120ms ease-out; +} + +.create-images-workbench .create-images-handle:hover, +.create-images-workbench .create-images-handle.connecting, +.create-images-workbench .create-images-handle.valid { + background: var(--accent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 26%, transparent); + transform: scale(1.18); +} + +.create-images-workbench .react-flow__edge-path { + stroke-width: 1.7; +} + +.create-images-workbench .react-flow__edge.selected .react-flow__edge-path, +.create-images-workbench .react-flow__edge:focus-visible .react-flow__edge-path { + stroke: var(--accent); + stroke-width: 2.4; +} + +.create-images-workbench .react-flow__edge:focus-visible { + outline: none; +} + +.create-images-workbench .react-flow__controls, +.create-images-workbench .react-flow__minimap { + overflow: hidden; + border: 1px solid var(--border-field); + border-radius: 12px; + background: color-mix(in srgb, var(--surface-popover) 94%, transparent); + box-shadow: var(--elevation-control); + backdrop-filter: blur(16px); +} + +.create-images-workbench .react-flow__controls-button { + border-bottom-color: var(--border-separator); +} + +.create-images-workbench .react-flow__controls-button svg { + fill: currentColor; +} + +.create-images-provider-trigger { + max-width: 16rem; +} + +.create-images-provider-trigger-state { + overflow: hidden; + max-width: 7.5rem; + color: var(--text-tertiary); + font-size: var(--text-mini); + text-overflow: ellipsis; + white-space: nowrap; +} + +.create-images-provider-trigger-state[data-state="invalid"] { + color: var(--support-red); +} + +.create-images-provider-trigger-state[data-state="connected"] { + color: var(--support-green); +} + +.create-images-provider-popover { + max-height: min(42rem, calc(100vh - 5rem)); + overflow-y: auto; + overscroll-behavior: contain; +} + +.create-images-provider-content { + min-width: 0; +} + +.create-images-provider-mark { + display: grid; + width: 2.25rem; + height: 2.25rem; + flex: 0 0 auto; + place-items: center; + border: 1px solid var(--border-field); + border-radius: var(--radius-control); + background: var(--surface-well); +} + +.create-images-provider-choice { + display: flex; + width: 100%; + min-height: 3.75rem; + align-items: center; + gap: 0.625rem; + padding: 0.625rem; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: var(--surface-well); + color: var(--text-primary); + text-align: left; + transition: + border-color 150ms ease-out, + background-color 150ms ease-out, + box-shadow 150ms ease-out; +} + +.create-images-provider-choice:hover:not(:disabled) { + background: var(--surface-control-hover); +} + +.create-images-provider-choice:active:not(:disabled) { + background: var(--surface-control-active); + box-shadow: var(--elevation-control-pressed); +} + +.create-images-provider-choice:focus-visible { + outline: none; + box-shadow: 0 0 0 3px var(--focus-ring); +} + +.create-images-provider-choice[aria-checked="true"] { + border-color: color-mix(in srgb, var(--accent) 38%, var(--border-field)); + background: color-mix(in srgb, var(--accent) 7%, var(--surface-well)); +} + +.create-images-provider-choice:disabled { + cursor: not-allowed; + opacity: 0.52; +} + +.create-images-provider-choice-icon, +.create-images-provider-choice-check { + display: grid; + width: 1.75rem; + height: 1.75rem; + flex: 0 0 auto; + place-items: center; + border-radius: 0.5rem; + background: var(--surface-control); + color: var(--text-secondary); +} + +.create-images-provider-choice-check { + width: 1.25rem; + height: 1.25rem; + border: 1px solid var(--border-field); + border-radius: 999px; + background: var(--surface-background); +} + +.create-images-provider-choice[aria-checked="true"] .create-images-provider-choice-check { + border-color: var(--accent); + background: var(--accent); + color: var(--accent-foreground); +} + +.create-images-provider-disclosure { + margin-top: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--border-separator); + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--surface-well) 78%, transparent); +} + +.create-images-provider-disclosure li { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + gap: 0.5rem; +} + +.create-images-provider-disclosure li > svg { + width: 0.875rem; + height: 0.875rem; + margin-top: 0.125rem; + color: var(--text-tertiary); +} + +.create-images-provider-spinner { + animation: create-images-provider-spin 900ms linear infinite; +} + +@keyframes create-images-provider-spin { + to { + transform: rotate(360deg); + } +} + +.create-images-workbench .create-images-minimap { + margin-left: 44px; +} + +.create-images-preview-grid, +.create-images-output-tile, +.create-images-card-preview, +.create-images-run-output-preview { + background-image: + linear-gradient(45deg, var(--surface-control) 25%, transparent 25%), + linear-gradient(-45deg, var(--surface-control) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--surface-control) 75%), + linear-gradient(-45deg, transparent 75%, var(--surface-control) 75%); + background-position: + 0 0, + 0 6px, + 6px -6px, + -6px 0; + background-size: 12px 12px; +} + +@media (max-width: 760px) { + .create-images-toolbar { + gap: 0.25rem; + } + + .create-images-toolbar span.create-images-validity { + display: none; + } + + .create-images-inspector { + top: auto; + right: 0.75rem; + bottom: 4.25rem; + left: 0.75rem; + width: auto; + height: min(15rem, 40vh); + } + + .create-images-run-surface { + top: auto; + right: 0.75rem; + bottom: 4.25rem; + left: 0.75rem; + width: auto; + height: min(25rem, 58vh); + } + + .create-images-workbench .react-flow__minimap { + display: none; + } + + .create-images-provider-trigger-state { + display: none; + } +} + +@media (max-width: 560px) { + .create-images-workspace-setup { + min-height: calc(100% - 3.25rem); + padding: 1rem; + } + + .create-images-workspace-setup-card { + padding: 1.5rem 1rem; + } + + .create-images-workspace-actions { + width: 100%; + } + + .create-images-workspace-actions > button { + width: 100%; + } + + .create-images-workspace-trigger { + max-width: 2.25rem; + padding: 0; + } + + .create-images-workspace-trigger-name { + display: none; + } + + .create-images-toolbar .create-images-run-controls > button span:not(.sr-only) { + display: none; + } + + .create-images-action-bar { + max-width: calc(100vw - 1.5rem); + } + + .create-images-provider-trigger-label { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .create-images-node, + .create-images-workbench .create-images-handle, + .create-images-provider-choice, + .create-images-drop-overlay, + .create-images-image-node-actions { + transition: none; + animation: none; + } + + .create-images-provider-spinner { + animation: none; + } + + .create-images-workspace-spinner { + animation: none; + } +} + +:root[data-reduce-motion="true"] .create-images-provider-choice { + transition: none; +} + +:root[data-reduce-motion="true"] .create-images-provider-spinner { + animation: none; +} + +:root[data-reduce-motion="true"] .create-images-drop-overlay { + animation: none; +} + +:root[data-reduce-motion="true"] .create-images-image-node-actions { + transition: none; +} + +:root[data-reduce-motion="true"] .create-images-workspace-spinner { + animation: none; +} + +@media (forced-colors: active) { + .create-images-provider-mark, + .create-images-provider-choice, + .create-images-provider-disclosure, + .create-images-node-select, + .create-images-image-node-frame, + .create-images-image-node-action { + border: 1px solid CanvasText; + } + + .create-images-provider-choice[aria-checked="true"] { + border-width: 2px; + } + + .create-images-drop-overlay { + border: 2px dashed CanvasText; + background: Canvas; + } + + .create-images-drop-overlay-card { + border-color: CanvasText; + background: Canvas; + box-shadow: none; + color: CanvasText; + } + + .create-images-drop-overlay-icon { + border: 1px solid CanvasText; + background: Canvas; + color: CanvasText; + } + + .create-images-image-node[data-selected="true"] { + box-shadow: 0 0 0 2px Highlight; + } + + .create-images-image-node-label, + .create-images-image-node-action { + background: Canvas; + color: CanvasText; + } + + .create-images-workspace-setup-card, + .create-images-workspace-mark, + .create-images-workspace-error { + border-color: CanvasText; + background: Canvas; + color: CanvasText; + box-shadow: none; + } + + .create-images-workspace-trigger { + border-color: CanvasText; + color: CanvasText; + } +} diff --git a/renderer/create-images/editor-core.test.ts b/renderer/create-images/editor-core.test.ts new file mode 100644 index 00000000..583e462a --- /dev/null +++ b/renderer/create-images/editor-core.test.ts @@ -0,0 +1,305 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createStarterWorkflow } from "../shared/create-images/schema.js"; +import { + boundedPromptText, + boundedCanvasPosition, + commitEditorHistory, + createEditorHistory, + decideCanvasMutationCapacity, + decideCanvasConnection, + redoEditorHistory, + resolveCreateImagesGraphShortcut, + undoEditorHistory, +} from "./editor-core.js"; +import { + CREATE_IMAGES_MAX_EDGES, + CREATE_IMAGES_MAX_NODES, + CREATE_IMAGES_MAX_PROMPT_LENGTH, + CREATE_IMAGES_POSITION_LIMIT, +} from "../shared/create-images/schema.js"; +import { + CREATE_IMAGES_DROP_NODE_HEIGHT, + CREATE_IMAGES_DROP_NODE_WIDTH, + filterSupportedCreateImagesFiles, + hasPotentialCreateImagesFileDrag, + INITIAL_CREATE_IMAGES_DROP_STATE, + planCreateImagesDrop, + reduceCreateImagesDropState, + sanitizeCreateImagesImageLabel, +} from "./image-drop-core.js"; + +const workflow = () => + createStarterWorkflow({ + workflowId: "workflow-1", + promptNodeId: "prompt-1", + generationNodeId: "generate-1", + outputNodeId: "output-1", + promptEdgeId: "edge-1", + outputEdgeId: "edge-2", + now: "2026-08-11T12:00:00.000Z", + }); + +test("canvas connection decisions enforce typed ports, duplicates, and cycles", () => { + const document = workflow(); + const mismatch = decideCanvasConnection( + document, + { + source: "prompt-1", + sourcePort: "text", + target: "output-1", + targetPort: "images", + }, + "candidate-1", + ); + assert.equal(mismatch.allowed, false); + if (!mismatch.allowed) assert.match(mismatch.message, /cannot connect/iu); + const duplicate = decideCanvasConnection( + document, + { + source: "prompt-1", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + "candidate-2", + ); + assert.equal(duplicate.allowed, false); + if (!duplicate.allowed) assert.match(duplicate.message, /already exists/iu); + + const prompt = structuredClone(document.nodes.find((node) => node.type === "prompt")); + assert.ok(prompt?.type === "prompt"); + prompt.id = "prompt-2"; + document.nodes.push(prompt); + const cardinality = decideCanvasConnection( + document, + { + source: "prompt-2", + sourcePort: "text", + target: "generate-1", + targetPort: "prompt", + }, + "candidate-cardinality", + ); + assert.equal(cardinality.allowed, false); + if (!cardinality.allowed) assert.match(cardinality.message, /at most 1 connection/iu); + + const second = structuredClone(document.nodes.find((node) => node.type === "generate-image")); + assert.ok(second?.type === "generate-image"); + second.id = "generate-2"; + document.nodes.push(second); + document.edges.push({ + id: "edge-to-second", + source: "generate-1", + sourcePort: "images", + target: "generate-2", + targetPort: "references", + }); + const cycle = decideCanvasConnection( + document, + { + source: "generate-2", + sourcePort: "images", + target: "generate-1", + targetPort: "references", + }, + "candidate-cycle", + ); + assert.equal(cycle.allowed, false); + if (!cycle.allowed) assert.match(cycle.message, /cycle/iu); + + const selfLoop = decideCanvasConnection( + document, + { + source: "generate-2", + sourcePort: "images", + target: "generate-2", + targetPort: "references", + }, + "candidate-self-loop", + ); + assert.equal(selfLoop.allowed, false); + if (!selfLoop.allowed) assert.match(selfLoop.message, /itself|cycle/iu); +}); + +test("editor history is bounded and clears redo on a new commit", () => { + const empty = createEditorHistory(0); + assert.strictEqual(undoEditorHistory(empty), empty); + assert.strictEqual(redoEditorHistory(empty), empty); + let history = empty; + history = commitEditorHistory(history, 1, 2); + history = commitEditorHistory(history, 2, 2); + history = commitEditorHistory(history, 3, 2); + assert.deepEqual(history.past, [1, 2]); + history = undoEditorHistory(history); + assert.equal(history.present, 2); + history = redoEditorHistory(history); + assert.equal(history.present, 3); + history = undoEditorHistory(history); + history = commitEditorHistory(history, 4, 2); + assert.deepEqual(history.future, []); +}); + +test("editor mutations enforce schema capacity before allocating graph history", () => { + assert.deepEqual(decideCanvasMutationCapacity(CREATE_IMAGES_MAX_NODES - 1, 0, 1, 0), { + allowed: true, + }); + assert.match( + decideCanvasMutationCapacity(CREATE_IMAGES_MAX_NODES, 0, 1, 0).message ?? "", + /500 nodes/u, + ); + assert.match( + decideCanvasMutationCapacity(0, CREATE_IMAGES_MAX_EDGES, 0, 1).message ?? "", + /2,000 connections/u, + ); +}); + +test("prompt edits are bounded to the schema limit", () => { + const value = boundedPromptText("x".repeat(CREATE_IMAGES_MAX_PROMPT_LENGTH + 1)); + assert.equal(value.length, CREATE_IMAGES_MAX_PROMPT_LENGTH); +}); + +test("canvas positions remain finite and inside the document schema", () => { + assert.deepEqual( + boundedCanvasPosition({ + x: CREATE_IMAGES_POSITION_LIMIT + 48, + y: -CREATE_IMAGES_POSITION_LIMIT - 48, + }), + { x: CREATE_IMAGES_POSITION_LIMIT, y: -CREATE_IMAGES_POSITION_LIMIT }, + ); + assert.deepEqual(boundedCanvasPosition({ x: Number.POSITIVE_INFINITY, y: Number.NaN }), { + x: 0, + y: 0, + }); +}); + +test("graph shortcuts reject modifier supersets and the global dictation binding", () => { + const shortcut = ( + key: string, + modifiers: Partial<{ + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + }> = {}, + ) => + resolveCreateImagesGraphShortcut({ + key, + metaKey: true, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...modifiers, + }); + + assert.equal(shortcut("d"), "duplicate"); + assert.equal(shortcut("z"), "undo"); + assert.equal(shortcut("z", { shiftKey: true }), "redo"); + assert.equal(shortcut("d", { shiftKey: true }), null); + assert.equal(shortcut("d", { altKey: true }), null); + assert.equal(shortcut("d", { ctrlKey: true }), null); + assert.equal(shortcut("z", { altKey: true }), null); + assert.equal(shortcut("z", { ctrlKey: true }), null); + assert.equal(shortcut("z", { metaKey: false }), null); +}); + +test("image drops accept image MIME types and extension-only native file drags", () => { + const files = filterSupportedCreateImagesFiles([ + { name: "portrait.webp", type: "image/webp" }, + { name: "reference.heic", type: "" }, + { name: "scanner.tiff", type: "application/octet-stream" }, + { name: "notes.png", type: "application/pdf" }, + { name: "notes.txt", type: "text/plain" }, + ]); + assert.deepEqual( + files.map((file) => file.name), + ["portrait.webp", "reference.heic", "scanner.tiff"], + ); + assert.equal( + hasPotentialCreateImagesFileDrag({ + items: [{ kind: "file", type: "image/avif" }], + types: ["Files"], + }), + true, + ); + assert.equal( + hasPotentialCreateImagesFileDrag({ + items: [{ kind: "file", type: "application/pdf" }], + types: ["Files"], + }), + false, + ); + assert.equal(sanitizeCreateImagesImageLabel("/Users/aiden/reference.webp"), "reference.webp"); +}); + +test("image drop state only activates for file drags and survives nested canvas targets", () => { + let state = INITIAL_CREATE_IMAGES_DROP_STATE; + state = reduceCreateImagesDropState(state, { type: "enter", valid: false }); + assert.deepEqual(state, INITIAL_CREATE_IMAGES_DROP_STATE); + state = reduceCreateImagesDropState(state, { + type: "enter", + valid: true, + targetNodeId: "input-1", + }); + state = reduceCreateImagesDropState(state, { type: "enter", valid: true }); + assert.deepEqual(state, { active: true, depth: 2 }); + state = reduceCreateImagesDropState(state, { + type: "over", + valid: true, + targetNodeId: "input-2", + }); + assert.deepEqual(state, { active: true, depth: 2, targetNodeId: "input-2" }); + state = reduceCreateImagesDropState(state, { type: "leave", inside: true }); + assert.deepEqual(state, { active: true, depth: 1, targetNodeId: "input-2" }); + state = reduceCreateImagesDropState(state, { type: "leave", inside: false }); + assert.deepEqual(state, INITIAL_CREATE_IMAGES_DROP_STATE); +}); + +test("image drop planning is deterministic, collision-aware, and replacement-safe", () => { + const existingNodes = [ + { + id: "existing", + type: "prompt" as const, + position: { x: 0, y: 0 }, + width: CREATE_IMAGES_DROP_NODE_WIDTH, + height: CREATE_IMAGES_DROP_NODE_HEIGHT, + }, + ]; + const input = { + dropPoint: { x: 144, y: 150 }, + existingNodes, + fileCount: 3, + } as const; + const first = planCreateImagesDrop(input); + const second = planCreateImagesDrop(input); + assert.deepEqual(first, second); + assert.equal(first.positions.length, 3); + const overlaps = (left: { x: number; y: number }, right: { x: number; y: number }) => + left.x < right.x + CREATE_IMAGES_DROP_NODE_WIDTH && + left.x + CREATE_IMAGES_DROP_NODE_WIDTH > right.x && + left.y < right.y + CREATE_IMAGES_DROP_NODE_HEIGHT && + left.y + CREATE_IMAGES_DROP_NODE_HEIGHT > right.y; + for (const position of first.positions) { + assert.equal(overlaps(position, existingNodes[0]!.position), false); + } + for (let index = 0; index < first.positions.length; index += 1) { + for (let other = index + 1; other < first.positions.length; other += 1) { + assert.equal(overlaps(first.positions[index]!, first.positions[other]!), false); + } + } + + const replacement = planCreateImagesDrop({ + dropPoint: { x: 144, y: 150 }, + existingNodes: [ + { + id: "input-1", + type: "image-input", + position: { x: 0, y: 0 }, + }, + ], + fileCount: 2, + targetNodeId: "input-1", + }); + assert.equal(replacement.replacementNodeId, "input-1"); + assert.equal(replacement.positions.length, 1); +}); diff --git a/renderer/create-images/editor-core.ts b/renderer/create-images/editor-core.ts new file mode 100644 index 00000000..b50f04c1 --- /dev/null +++ b/renderer/create-images/editor-core.ts @@ -0,0 +1,160 @@ +import { + CREATE_IMAGES_NODE_DEFINITIONS, + isCreateImagesPortCompatible, + validateWorkflowGraph, +} from "../shared/create-images/ports"; +import type { WorkflowDocumentV1, WorkflowEdgeV1 } from "../shared/create-images/schema"; +import { + CREATE_IMAGES_MAX_EDGES, + CREATE_IMAGES_MAX_NODES, + CREATE_IMAGES_MAX_PROMPT_LENGTH, + CREATE_IMAGES_POSITION_LIMIT, +} from "../shared/create-images/schema"; +import type { CreateImagesPosition } from "../shared/create-images/schema"; + +export interface CanvasConnectionIntent { + source: string | null; + sourcePort: string | null; + target: string | null; + targetPort: string | null; +} + +export type CanvasConnectionDecision = + | { allowed: true; edge: WorkflowEdgeV1 } + | { allowed: false; message: string }; + +export interface CanvasMutationCapacity { + allowed: boolean; + message?: string; +} + +export type CreateImagesGraphShortcut = "undo" | "redo" | "duplicate"; + +export function resolveCreateImagesGraphShortcut(input: { + key: string; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; +}): CreateImagesGraphShortcut | null { + if (!input.metaKey || input.ctrlKey || input.altKey) return null; + const key = input.key.toLowerCase(); + if (key === "z") return input.shiftKey ? "redo" : "undo"; + if (key === "d" && !input.shiftKey) return "duplicate"; + return null; +} + +export function decideCanvasMutationCapacity( + nodeCount: number, + edgeCount: number, + addedNodes: number, + addedEdges: number, +): CanvasMutationCapacity { + if (nodeCount + addedNodes > CREATE_IMAGES_MAX_NODES) { + return { + allowed: false, + message: `Workflows are limited to ${CREATE_IMAGES_MAX_NODES.toLocaleString("en-US")} nodes.`, + }; + } + if (edgeCount + addedEdges > CREATE_IMAGES_MAX_EDGES) { + return { + allowed: false, + message: `Workflows are limited to ${CREATE_IMAGES_MAX_EDGES.toLocaleString("en-US")} connections.`, + }; + } + return { allowed: true }; +} + +export function boundedPromptText(value: string): string { + return value.slice(0, CREATE_IMAGES_MAX_PROMPT_LENGTH); +} + +export function boundedCanvasPosition(position: CreateImagesPosition): CreateImagesPosition { + const coordinate = (value: number) => + Number.isFinite(value) + ? Math.max(-CREATE_IMAGES_POSITION_LIMIT, Math.min(CREATE_IMAGES_POSITION_LIMIT, value)) + : 0; + return { x: coordinate(position.x), y: coordinate(position.y) }; +} + +export function decideCanvasConnection( + document: WorkflowDocumentV1, + intent: CanvasConnectionIntent, + edgeId: string, +): CanvasConnectionDecision { + const capacity = decideCanvasMutationCapacity(document.nodes.length, document.edges.length, 0, 1); + if (!capacity.allowed) return { allowed: false, message: capacity.message! }; + if (!intent.source || !intent.sourcePort || !intent.target || !intent.targetPort) { + return { allowed: false, message: "Choose a source and destination port." }; + } + const source = document.nodes.find((node) => node.id === intent.source); + const target = document.nodes.find((node) => node.id === intent.target); + if (!source || !target) return { allowed: false, message: "That node is no longer available." }; + const sourcePort = CREATE_IMAGES_NODE_DEFINITIONS[source.type].outputs.find( + (port) => port.id === intent.sourcePort, + ); + const targetPort = CREATE_IMAGES_NODE_DEFINITIONS[target.type].inputs.find( + (port) => port.id === intent.targetPort, + ); + if (!sourcePort || !targetPort) { + return { allowed: false, message: "Connect an output port to an input port." }; + } + if (!isCreateImagesPortCompatible(sourcePort.kind, targetPort.kind)) { + return { + allowed: false, + message: `${sourcePort.label} cannot connect to ${targetPort.label}.`, + }; + } + const edge: WorkflowEdgeV1 = { + id: edgeId, + source: source.id, + sourcePort: sourcePort.id, + target: target.id, + targetPort: targetPort.id, + }; + const issues = validateWorkflowGraph({ ...document, edges: [...document.edges, edge] }); + const introduced = issues.find((issue) => issue.edgeId === edge.id || issue.code === "cycle"); + return introduced ? { allowed: false, message: introduced.message } : { allowed: true, edge }; +} + +export interface EditorHistory { + past: readonly T[]; + present: T; + future: readonly T[]; +} + +export function createEditorHistory(present: T): EditorHistory { + return { past: [], present, future: [] }; +} + +export function commitEditorHistory( + history: EditorHistory, + next: T, + limit = 50, +): EditorHistory { + return { + past: [...history.past, history.present].slice(-limit), + present: next, + future: [], + }; +} + +export function undoEditorHistory(history: EditorHistory): EditorHistory { + const previous = history.past[history.past.length - 1]; + if (previous === undefined) return history; + return { + past: history.past.slice(0, -1), + present: previous, + future: [history.present, ...history.future], + }; +} + +export function redoEditorHistory(history: EditorHistory): EditorHistory { + const next = history.future[0]; + if (next === undefined) return history; + return { + past: [...history.past, history.present], + present: next, + future: history.future.slice(1), + }; +} diff --git a/renderer/create-images/feature-surface.test.ts b/renderer/create-images/feature-surface.test.ts new file mode 100644 index 00000000..872bd3f2 --- /dev/null +++ b/renderer/create-images/feature-surface.test.ts @@ -0,0 +1,489 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +function source(relativePath: string): string { + return readFileSync(new URL(relativePath, import.meta.url), "utf8"); +} + +test("Create Images is a lazy, fail-closed route inside the shared sidebar shell", () => { + const router = source("../main/router.tsx"); + const sidebar = source("../components/chat-sidebar.tsx"); + const shell = source("../main/chat-layout.tsx"); + const root = source("../main/root-view.tsx"); + + assert.match( + router, + /React\.lazy\(\(\) =>\s*import\("\.\.\/create-images\/create-images-view"\)/u, + ); + assert.match(router, /if \(!createImages\) void navigate\(\{ to: "\/", replace: true \}\)/u); + assert.match(router, /path: "\/create-images"/u); + assert.match(router, /path: "\/create-images\/\$workflowId"/u); + assert.match(sidebar, /appCapabilities\.createImages \? \(/u); + assert.match(sidebar, /title="Create Images"/u); + assert.match(sidebar, /createImagesMode \? "Search workflows…" : "Search chats…"/u); + assert.match(sidebar, //u); + assert.match(sidebar, /useCreateImagesWorkflows/u); + assert.match(sidebar, /createImagesApi\.create/u); + assert.doesNotMatch(sidebar, /from "\.\.\/create-images\/fixtures"/u); + assert.match( + sidebar, + /React\.useEffect\(\(\) => \{\s*if \(createImagesMode\) return;\s*const unregister = shortcutAssignments/u, + ); + assert.match(shell, /createImagesMode \? \(\s*/u); + assert.match( + root, + / { + const queries = source("../lib/queries.ts"); + const view = source("./create-images-view.tsx"); + const styles = source("./create-images.css"); + const handlers = source("../../main/handlers/create-images.ts"); + const service = source("../../main/services/create-images/create-images-service.ts"); + const assets = source("../../main/services/create-images/asset-store-core.ts"); + + assert.match(queries, /useCreateImagesWorkspace\(enabled\)/u); + assert.match(queries, /enabled: enabled && workspace\.data\?\.status === "ready"/u); + assert.match(view, /CreateImagesWorkspaceSetup/u); + assert.match(view, /Choose workspace folder/u); + assert.match(view, /Open in Finder/u); + assert.match(view, /Sync now/u); + assert.match(view, /Change workspace folder/u); + assert.match(view, /createImagesApi\.chooseWorkspace/u); + assert.match(view, /createImagesApi\.openWorkspace/u); + assert.match(view, /createImagesApi\.syncWorkspace/u); + assert.match(view, /displayName/u); + assert.doesNotMatch(view, /absolutePath|folderPath/u); + assert.match(styles, /\.create-images-workspace-setup/u); + assert.match(styles, /\.create-images-workspace-menu-content/u); + assert.match(styles, /prefers-reduced-motion: reduce/u); + assert.match(styles, /forced-colors: active/u); + for (const channel of [ + "imageWorkflows:workspaceStatus", + "imageWorkflows:chooseWorkspace", + "imageWorkflows:openWorkspace", + "imageWorkflows:syncWorkspace", + ]) { + assert.ok(handlers.includes(`"${channel}"`), `${channel} must stay main-owned`); + } + assert.match(handlers, /parseCreateImagesWorkspaceRequest\(value\)/u); + assert.match(handlers, /properties: \["openDirectory", "createDirectory"\]/u); + assert.match(handlers, /shell\.openPath\(target\.filePath\)/u); + assert.doesNotMatch(handlers, /value\.(?:path|folderPath|absolutePath)/u); + assert.match(service, /workspaceRequired: true/u); + assert.match(service, /onAssetPublished:[\s\S]{0,180}syncAsset/u); + assert.match(assets, /onAssetPublished\?\.\(result\.asset\)/u); +}); + +test("the Phase 1 editor exposes five typed node kinds and non-spatial controls", () => { + const canvas = source("./workflow-canvas.tsx"); + const node = source("./workflow-node.tsx"); + const view = source("./create-images-view.tsx"); + const dropCore = source("./image-drop-core.ts"); + const styles = source("./create-images.css"); + + for (const type of ["image-input", "prompt", "generate-image", "output", "output-gallery"]) { + const entry = type.includes("-") ? `"${type}": WorkflowNode` : `${type}: WorkflowNode`; + assert.ok(canvas.includes(entry), `missing ${type} node renderer`); + } + assert.match(canvas, /decideCanvasConnection/u); + assert.match(canvas, /onlyRenderVisibleElements/u); + assert.match(canvas, / void handleCanvasDrop\(event\)\}/u); + assert.match(canvas, /onImportDroppedImages\?: CreateImagesDroppedImageImporter/u); + assert.match(canvas, /CREATE_IMAGES_ASSET_ID_PATTERN/u); + assert.match(canvas, /sanitizeCreateImagesImageLabel/u); + assert.match(dropCore, /reduceCreateImagesDropState/u); + assert.match(dropCore, /planCreateImagesDrop/u); + assert.match(dropCore, /type.startsWith\("image\/"\)/u); + assert.match(styles, /create-images-drop-overlay/u); + assert.match(styles, /prefers-reduced-motion: reduce/u); + assert.match(styles, /forced-colors: active/u); + assert.match(styles, /\.create-images-toolbar span\.create-images-validity/u); + assert.doesNotMatch( + styles, + /\.create-images-toolbar \.create-images-validity\s*\{\s*display: none/u, + ); + assert.match(canvas, /connectOnClick=\{false\}/u); + assert.match(canvas, /defaultViewport=\{document\.viewport\}/u); + assert.match(canvas, /minZoom=\{CREATE_IMAGES_MIN_ZOOM\}/u); + assert.match(canvas, /maxZoom=\{CREATE_IMAGES_MAX_ZOOM\}/u); + assert.doesNotMatch(canvas, /\sfitView\s*\n\s*fitViewOptions=/u); + assert.match(canvas, /Connections must run from an output port to an input port\./u); + assert.match(canvas, /deletedEdges\.length === 1/u); + assert.match(canvas, /onBeforeDelete=/u); + assert.match( + canvas, + /edges: current\.edges\.map\(\(edge\) => \(\{ \.\.\.edge, selected: false \}\)\)/u, + ); + assert.match(canvas, //u); + assert.match(canvas, /data-create-images-action-status/u); + assert.match(canvas, /paletteOpen \|\|/u); + assert.match(canvas, /!workbenchRef\.current\?\.contains\(event\.target\)/u); + assert.match(canvas, /closest\("#create-images-validation-issues"\)/u); + assert.match(canvas, /addEventListener\("keydown", onKeyDown, \{ capture: true \}\)/u); + assert.match(canvas, /miniMapVisible && !narrowCanvas/u); + assert.match(canvas, /narrowCanvas \? null/u); + assert.match(view, / { + const main = source("../../main/index.ts"); + const acceptance = source( + "../../main/services/create-images/packaged-canvas-acceptance-runner.ts", + ); + + assert.match( + main, + /import\(\s*"\.\/services\/create-images\/packaged-canvas-acceptance-runner\.js"/u, + ); + assert.doesNotMatch(main, /CREATE_IMAGES_ACCEPTANCE_FOCUS_/u); + assert.match(acceptance, /observeCreateImagesRequestPolicy\(\(observation\) =>/u); + assert.match( + acceptance, + /observation\.kind === "renderer-egress"\)[\s\S]*networkRequests \+= 1/u, + ); + assert.doesNotMatch(acceptance, /webRequest\.onBeforeRequest/u); + assert.match(acceptance, /assetProtocolAuthorizations === value\.assetProtocolRequests/u); + assert.match(acceptance, /isAcceptedAssetRequestEvidence\(value\.lastAssetRequest\)/u); + assert.match(acceptance, /createImagesAcceptanceKeyboardActions \+= 1/u); + assert.match(acceptance, /CREATE_IMAGES_ACCEPTANCE_FOCUS_FIT_WORKFLOW_SCRIPT/u); + assert.match(acceptance, /phaseTwoWorkflowRevision: savedWorkflow\.revision/u); + assert.doesNotMatch(acceptance, /workflowRecord\.revision === 2/u); + assert.match(acceptance, /securitypolicyviolation/u); + assert.match(acceptance, /webContents\.on\("console-message", onConsoleMessage\)/u); + assert.match(acceptance, /const level = event\.level \?\? legacyLevel/u); + assert.match(acceptance, /const message = event\.message \?\? legacyMessage/u); + assert.match(acceptance, /webContents\.on\("render-process-gone", onRenderProcessGone\)/u); + assert.match(acceptance, /getLastWebPreferences\(\)/u); + assert.match( + acceptance, + /readCreateImagesAcceptanceScript\(\s*CREATE_IMAGES_ACCEPTANCE_LIVE_MUTATION_COUNT_SCRIPT/u, + ); + assert.match( + acceptance, + /liveRegionMutations,\s*keyboardActions: createImagesAcceptanceKeyboardActions/u, + ); + assert.doesNotMatch(acceptance, /liveRegionMutations: 17/u); + assert.doesNotMatch(acceptance, /keyboardActions: 35/u); + const runner = source("../../scripts/create-images-packaged-acceptance.mjs"); + assert.match(runner, /waitForChildExitBefore\(childState, deadline\)/u); + assert.match(runner, /did not exit before the acceptance deadline/u); + assert.doesNotMatch(runner, /const outcome = await childState\.promise/u); + const packageJson = source("../../package.json"); + assert.match(packageJson, /verify-create-images-lazy-boundary\.mjs/u); +}); + +test("Create Images conflicts remain protected across workspace switches and app close", () => { + const sidebar = source("../components/chat-sidebar.tsx"); + const lifecycle = source("../lib/lifecycle-guard.ts"); + const view = source("./create-images-view.tsx"); + const main = source("../../main/index.ts"); + + assert.match( + sidebar, + /if \(createImagesMode\) \{\s*const decision = await requestCreateImagesNavigation\(\)/u, + ); + assert.match(lifecycle, /const owners = new Map/u); + assert.match(view, /setRendererLifecycleGuard\("create-images", \{ dirty, saving \}\)/u); + assert.match(view, /clearRendererLifecycleGuard\("create-images"\)/u); + assert.match( + main, + /if \(createImagesFlushAllowed !== true\) \{\s*throw new Error\(\s*"Create Images autosave did not authorize/u, + ); +}); + +test("Phase 2 storage stays main-owned, CAS-safe, and path-free across IPC", () => { + const channels = source("../preload-channels.ts"); + const rendererIpc = source("../lib/ipc.ts"); + const preload = source("../preload.ts"); + const sharedIpc = source("../shared/create-images/ipc.ts"); + const handlers = source("../../main/handlers/create-images.ts"); + const registrations = source("../../main/handlers/index.ts"); + const service = source("../../main/services/create-images/create-images-service.ts"); + const workflowStore = source("../../main/services/create-images/workflow-manifest-store.ts"); + const assetStore = source("../../main/services/create-images/asset-store-core.ts"); + const protocol = source("../../main/services/create-images/asset-protocol.ts"); + const protocolCore = source("../../main/services/create-images/asset-protocol-core.ts"); + const delivery = source("../../main/services/create-images/asset-delivery-core.ts"); + const html = source("../../main-window.html"); + const view = source("./create-images-view.tsx"); + + assert.match(channels, /"imageWorkflows:"/u); + assert.match(registrations, /registerCreateImagesHandlers\(\)/u); + assert.match(handlers, /if \(!createImagesEnabled\(\)\) return/u); + assert.match(handlers, /rendererDocumentOwner\(\s*event/u); + assert.match(handlers, /parseCreateImagesSaveWorkflowRequest/u); + assert.match(handlers, /randomUUID\(\)/u); + assert.match(handlers, /service\.mutateWorkflow/u); + assert.match(rendererIpc, /imageWorkflows:save/u); + assert.doesNotMatch(sharedIpc, /absolutePath/u); + assert.match(sharedIpc, /CreateImagesDroppedAssetImportRequest[\s\S]*filePaths/u); + assert.doesNotMatch(rendererIpc, /importDroppedFiles/u); + assert.match(preload, /webUtils\.getPathForFile\(file\)/u); + assert.match(preload, /NATIVE_INVOKE_CHANNELS\.createImagesImportDroppedFiles/u); + assert.match(handlers, /"aiden:create-images:import-dropped-files"/u); + assert.match(service, /new WorkflowManifestStore/u); + assert.match(service, /new ContentAddressedAssetStore/u); + assert.match(service, /new Set\(\[\.\.\.previous, \.\.\.next\]\)/u); + assert.match(protocol, /authorizeCreateImagesAssetRequest/u); + assert.match(protocolCore, /details\.resourceType === "image"/u); + assert.match(protocol, /authorizeProtocolRequest/u); + assert.match(delivery, /consumeProtocolRequest/u); + assert.match(html, /img-src 'self' aiden-asset:/u); + assert.match(view, /new WorkflowAutosaveController/u); + assert.match(view, /window\.aidenAPI\.createImages\.importDroppedFiles/u); + assert.match(view, /previewManager\.adopt\(item\.grant\.asset\.assetId, item\.grant\)/u); + assert.match(view, /const \[controller\] = React\.useState/u); + assert.doesNotMatch(view, /new WorkflowAutosaveController\(initial,[\s\S]{0,160}\[initial\]/u); + assert.match(view, /const \[initialAssetRefs\] = React\.useState/u); + assert.match(view, /\}, \[initialAssetRefs, previewManager\]\);/u); + assert.match(view, /registerCreateImagesNavigationGuard/u); + assert.match(view, /useBlocker\(\{[\s\S]*controller\.flush\(\)/u); + assert.match(view, /Save a copy/u); + assert.match(view, /setCanvasEpoch\(\(current\) => current \+ 1\)/u); + assert.match(view, /Workflow recovery needed/u); + assert.match( + view, + /recovery\.reason === "last-known-good-corrupt" && recovery\.autosave === "none"/u, + ); + assert.match(view, /previewManager\.reportLoadError/u); + assert.match(view, /result\.status === "not-found"[\s\S]{0,220}setMissingAssetIds/u); + assert.match(view, /previewManager\.adopt/u); + assert.match( + workflowStore, + /const created = await fs\.mkdir\(target,[\s\S]{0,260}created !== undefined\)[\s\S]{0,80}syncDirectory\(path\.dirname\(target\)\)/u, + ); + assert.match( + assetStore, + /const created = await fs\.mkdir\(directory,[\s\S]{0,320}created !== undefined\)[\s\S]{0,80}syncDirectory\(path\.dirname\(directory\)\)/u, + ); +}); + +test("Phase 5 native archives are main-owned, explicit, and available from the workflow library", () => { + const view = source("./create-images-view.tsx"); + const rendererIpc = source("../lib/ipc.ts"); + const sharedIpc = source("../shared/create-images/ipc.ts"); + const handlers = source("../../main/handlers/create-images.ts"); + const archive = source("../../main/services/create-images/native-archive-service.ts"); + const nodeBanana = source("../../main/services/create-images/node-banana-import-service.ts"); + const notices = source("../../THIRD_PARTY_NOTICES.md"); + + assert.match(view, /Import \.aiden-images/u); + assert.match(view, /Export \.aiden-images/u); + assert.match(view, /Import Node Banana JSON/u); + assert.match(view, /Node Banana import report/u); + assert.match(view, /Review cleanup/u); + assert.match(view, /Delete unused images/u); + assert.match(rendererIpc, /imageWorkflows:importArchive/u); + assert.match(rendererIpc, /imageWorkflows:exportArchive/u); + assert.match(rendererIpc, /imageWorkflows:importNodeBanana/u); + assert.match(sharedIpc, /parseCreateImagesImportArchiveRequest/u); + assert.match(sharedIpc, /parseCreateImagesExportArchiveRequest/u); + assert.match(sharedIpc, /parseCreateImagesImportNodeBananaRequest/u); + assert.doesNotMatch( + sharedIpc, + /CreateImagesExportArchiveRequest[\s\S]{0,180}(?:path|destination)/u, + ); + assert.match(handlers, /dialog\.showOpenDialog\(parent,[\s\S]*aiden-images/u); + assert.match(handlers, /dialog\.showSaveDialog\(parent,[\s\S]*aiden-images/u); + assert.match(handlers, /shell\.showItemInFolder/u); + assert.match(handlers, /imageWorkflows:downloadRunAsset/u); + assert.match(handlers, /service\.assets\.exportAssetToFile/u); + assert.match(view, /downloadRunAsset/u); + assert.match(handlers, /CREATE_IMAGES_ASSET_CLEANUP_GRACE_MS/u); + assert.match(handlers, /planGarbageCollection/u); + assert.match(handlers, /applyGarbageCollection/u); + assert.match(archive, /validateCreateImagesArchiveBootstrap/u); + assert.match(archive, /validateCreateImagesArchiveExtractedEntries/u); + assert.match(archive, /validateCreateImagesArchiveWorkflowAssets/u); + assert.match(archive, /validateQuarantinedAssetFile/u); + assert.match(archive, /origin: \{ kind: "import" \}/u); + assert.match(nodeBanana, /readRegularFile\(source, CREATE_IMAGES_MAX_WORKFLOW_BYTES\)/u); + assert.match(nodeBanana, /ingestCreateImagesImageFile/u); + assert.match(nodeBanana, /parseWorkflowDocument/u); + assert.doesNotMatch(nodeBanana, /directoryPath|apiKey/u); + assert.match(notices, /`yauzl@3\.4\.0`/u); + assert.match(notices, /`yazl@3\.3\.1`/u); +}); + +test("Phase 3 renderer runs are consented, resubscribed, revision-safe, and run-asset scoped", () => { + const view = source("./create-images-view.tsx"); + const canvas = source("./workflow-canvas.tsx"); + const node = source("./workflow-node.tsx"); + const adapter = source("./run-ui-adapter.ts"); + const rendererIpc = source("../lib/ipc.ts"); + const channels = source("../preload-channels.ts"); + + for (const channel of [ + "startRun", + "stopRun", + "listRuns", + "getRun", + "recoverRun", + "resolveRunAmbiguity", + "subscribeRuns", + "unsubscribeRuns", + "grantRunAsset", + "planRunHistoryPrune", + "pruneRunHistory", + "planDegradedRunDiscard", + "discardDegradedRun", + "prepareRun", + ]) { + assert.match(rendererIpc, new RegExp(`imageWorkflows:${channel}`, "u")); + } + assert.match(rendererIpc, /imageWorkflows:run-changed/u); + assert.match(channels, /"imageWorkflows:run-changed"/u); + assert.match( + view, + /controller\.update\(draft\);[\s\S]{0,200}const flushed = await controller\.flush\(\);[\s\S]{0,1200}createImagesApi\.prepareRun/u, + ); + assert.match(view, /enumerateWorkflowDownstreamPaths\(document, startNodeId\)/u); + assert.match(view, /scope: undefined,[\s\S]{0,180}downstreamPathChoiceViews/u); + assert.match(view, /createImagesRunScopeForPathChoice\(/u); + assert.match(view, /setReviewedRun\(false\)/u); + assert.match(view, /scope: preparedRun\.scope/u); + assert.match(view, /executionMode: "gemini"[\s\S]{0,300}consentFingerprint:/u); + assert.match(view, /createImagesApi\s*\.\s*subscribeRuns/u); + assert.match(view, /createImagesApi\s*\.\s*unsubscribeRuns/u); + assert.match(adapter, /notification\.streamSequence > lastStreamSequence/u); + assert.match(view, /createImagesApi\.getRun/u); + assert.match(view, /createImagesApi\.recoverRun/u); + assert.match( + view, + /createImagesApi\.resolveRunAmbiguity\(\{[\s\S]{0,260}expectedJournalRevision: run\.journalRevision[\s\S]{0,120}resolution: "acknowledge-unresolved-submission"/u, + ); + assert.match(view, /result\.status === "resolved"[\s\S]{0,180}applyRunMutation\(result\.run\)/u); + assert.match(view, /storageHealth\.runIndex\.degradedRecords\.map/u); + assert.match(view, /record\.discardEligible/u); + assert.match(view, /record\.association === "unassociated"/u); + assert.match( + view, + /createImagesDegradedRunDiscardRequest\(plan, reviewed\)[\s\S]{0,200}createImagesApi\.discardDegradedRun\(request\)/u, + ); + assert.match( + view, + /result\.status === "conflict"[\s\S]{0,120}setPlan\(undefined\)[\s\S]{0,220}fresh discard summary/u, + ); + assert.match(view, /removeCreateImagesRunRecord\(runStateRef\.current, result\.runId\)/u); + assert.match(view, /runHistoryRequestSequence\.current \+= 1/u); + assert.doesNotMatch(view, /result\.authoritativeList/u); + assert.doesNotMatch(view, /createImagesApi\.listRuns/u); + assert.match(view, /result\.status === "conflict"[\s\S]{0,240}closed = true/u); + assert.match(canvas, /!runProjection\.ambiguityAcknowledged/u); + assert.match(view, /source: recovery\.recoverySource/u); + assert.match(view, /expectedCandidateJournalRevision/u); + assert.match(view, /isCreateImagesRunRecoveryRequestCurrent\(/u); + assert.match(view, /isCreateImagesRunHistoryRequestCurrent\(/u); + assert.match(view, /isCreateImagesRunAmbiguityRequestCurrent\(/u); + assert.match(view, /createImagesSelectedRunSnapshotTransition\(/u); + assert.match( + view, + /transition\.kind === "recovery-changed"[\s\S]{0,120}runHistoryRequestSequence\.current \+= 1/u, + ); + assert.match( + view, + /transition\.kind === "removed" \|\| transition\.kind === "became-healthy"[\s\S]{0,120}runHistoryRequestSequence\.current \+= 1/u, + ); + assert.doesNotMatch(view, /if \(selectedRunId\) \{\s*runHistoryRequestSequence\.current \+= 1/u); + assert.match(view, /if \(!responseIsCurrent\(\)\) return;/u); + assert.match( + view, + /runHistoryLifecycleRef\.current = \{ mounted: false, generation: generation \+ 1 \}[\s\S]{0,120}runHistoryRequestSequence\.current \+= 1[\s\S]{0,100}selectedHistoryRunIdRef\.current = undefined/u, + ); + assert.match( + view, + /isCreateImagesRunHistoryRequestCurrent\([\s\S]{0,700}trigger\.isConnected[\s\S]{0,100}trigger\.focus\(\)/u, + ); + assert.match(view, /!applyRunMutation\(result\.run\)/u); + assert.match(view, /if \(!mountedRef\.current\) return;/u); + assert.match(view, /createImagesRunSubscriptionController/u); + assert.match(view, /window\.addEventListener\("focus", retryWhenFocused\)/u); + assert.match(view, /visibilityState === "visible"/u); + assert.match(adapter, /retryDelaysMs \?\? DEFAULT_SUBSCRIPTION_RETRY_DELAYS_MS/u); + assert.match(adapter, /MAX_PENDING_SUBSCRIPTIONS/u); + assert.match(adapter, /pendingSnapshot\.streamSequence > lastStreamSequence/u); + assert.match(adapter, /removeNotificationListener\?\.\(\)/u); + assert.match(view, /new AssetPreviewLifecycleManager\(\{[\s\S]*?grantRunAsset/u); + assert.match(view, /deferAssetPreviewLifecycleDisposal\(runPreviewManager\)/u); + assert.match(canvas, /planWorkflowExecution\(currentDocument, runFromHereScope\)/u); + assert.match(canvas, /runAllDisabledReason[\s\S]{0,500}graphIssues\[0\]\?\.message/u); + assert.match(canvas, /runFromHereDisabledReason/u); + assert.match(canvas, /CreateImagesRunProgressPanel/u); + assert.match(canvas, /CreateImagesTerminalRunHistory/u); + assert.match(node, /CreateImagesNodeRunStatusBadge/u); + assert.match(node, /retainRunAssetPreview/u); + assert.match(adapter, /nextProjection\.lastSequence < oldProjection\.lastSequence/u); + assert.match(view, /reconcileCreateImagesRunMutation\(previous, run, initial\.id\)/u); + assert.doesNotMatch(view, /applyRunList\(/u); + assert.match(view, /apply: applyRunList/u); + assert.doesNotMatch(view, /activeRun: run,\s*history: \[\],\s*recoveries: \[\]/u); + assert.match(adapter, /result\.recoveries\.filter/u); + assert.match(adapter, /recoveryRunIds\.has\(previous\.projection\.runId\)/u); + assert.match(adapter, /createImagesRunAssetOwners\(latestTerminalRun\)/u); + assert.match(adapter, /previous\.history\.filter\(\(item\) => item\.runId !== runId\)/u); + assert.match(view, /selectedHistoryRunIdRef\.current = undefined/u); + assert.doesNotMatch( + view, + /selectHistoryRun[\s\S]{0,1600}requestAnimationFrame\(\(\) => trigger\.focus\(\)\)/u, + ); + assert.match(view, /across all Create Images workflows/u); + assert.match(view, /may include imported inputs and generated outputs/u); + assert.match( + view, + /released file[\s\S]{0,160}with no other\s+workflow or run reference may later be removed/u, + ); + assert.match(view, /storageHealth\.runIndex\.status !== "healthy"/u); + assert.match(view, /Run history index recovered/u); + assert.match(node, /key=\{`\$\{assetId\}:\$\{index\}`\}/u); +}); diff --git a/renderer/create-images/fixture-summaries.ts b/renderer/create-images/fixture-summaries.ts new file mode 100644 index 00000000..fdfd552d --- /dev/null +++ b/renderer/create-images/fixture-summaries.ts @@ -0,0 +1,31 @@ +export interface CreateImagesFixtureSummary { + id: string; + title: string; + description: string; + nodeCount: number; + updatedLabel: string; +} + +export const CREATE_IMAGES_FIXTURES: readonly CreateImagesFixtureSummary[] = Object.freeze([ + Object.freeze({ + id: "starter", + title: "Editorial portrait study", + description: "Prompt → Generate Image → Output", + nodeCount: 3, + updatedLabel: "Starter", + }), + Object.freeze({ + id: "reference-edit", + title: "Reference-led campaign", + description: "Image + prompt → Generate Image → Gallery", + nodeCount: 4, + updatedLabel: "Fixture", + }), + Object.freeze({ + id: "stress-100", + title: "100-node canvas fixture", + description: "Mixed-node performance and keyboard test", + nodeCount: 100, + updatedLabel: "Performance", + }), +]); diff --git a/renderer/create-images/fixtures.test.ts b/renderer/create-images/fixtures.test.ts new file mode 100644 index 00000000..93538717 --- /dev/null +++ b/renderer/create-images/fixtures.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseWorkflowDocument } from "../shared/create-images/schema.js"; +import { validateWorkflowGraph } from "../shared/create-images/ports.js"; +import { CREATE_IMAGES_FIXTURES, createImagesFixture } from "./fixtures.js"; + +test("every shipped image-workflow fixture satisfies the strict document and graph contracts", () => { + const ids = ["blank", ...CREATE_IMAGES_FIXTURES.map((fixture) => fixture.id), "stress-250"]; + for (const id of ids) { + const document = createImagesFixture(id); + assert.ok(document, `missing fixture ${id}`); + const parsed = parseWorkflowDocument(document); + if (!parsed.success) assert.fail(`${id}: ${JSON.stringify(parsed.issues)}`); + assert.deepEqual(validateWorkflowGraph(document), [], `${id} has an invalid graph`); + } +}); + +test("stress fixtures expose exact bounded canvas sizes without leaking mutable instances", () => { + const hundred = createImagesFixture("stress-100"); + const twoHundredFifty = createImagesFixture("stress-250"); + assert.equal(hundred?.nodes.length, 100); + assert.equal(twoHundredFifty?.nodes.length, 250); + assert.ok((twoHundredFifty?.edges.length ?? 0) > (hundred?.edges.length ?? 0)); + assert.ok( + (hundred?.nodes.find((node) => node.id === "stress-prompt-1")?.position.y ?? 0) - + (hundred?.nodes.find((node) => node.id === "stress-prompt-0")?.position.y ?? 0) >= + 1_000, + "stress rows must remain clear of the full capability-driven Generate Image card", + ); + + const starter = createImagesFixture("starter"); + assert.ok(starter); + starter.title = "mutated"; + assert.notEqual(createImagesFixture("starter")?.title, "mutated"); +}); + +test("fixture lookup rejects inherited, encoded, and oversized route identifiers", () => { + for (const id of [ + "constructor", + "toString", + "__proto__", + "%5F%5Fproto%5F%5F", + "../starter", + "x".repeat(129), + "", + ]) { + assert.equal(createImagesFixture(id), undefined, id); + } +}); diff --git a/renderer/create-images/fixtures.ts b/renderer/create-images/fixtures.ts new file mode 100644 index 00000000..6d1be02b --- /dev/null +++ b/renderer/create-images/fixtures.ts @@ -0,0 +1,227 @@ +import { + createStarterWorkflow, + type WorkflowDocumentV1, + type WorkflowEdgeV1, + type WorkflowNodeV1, +} from "../shared/create-images/schema"; +export { CREATE_IMAGES_FIXTURES } from "./fixture-summaries"; + +const FIXTURE_NOW = "2026-08-11T16:00:00.000Z"; +const REFERENCE_FIXTURE_ASSET_ID = "f".repeat(64); + +function stressAssetId(index: number): string { + return (index + 1).toString(16).padStart(64, "0"); +} + +function starterFixture(): WorkflowDocumentV1 { + const document = createStarterWorkflow({ + workflowId: "starter", + promptNodeId: "starter-prompt", + generationNodeId: "starter-generate", + outputNodeId: "starter-output", + promptEdgeId: "starter-edge-prompt", + outputEdgeId: "starter-edge-output", + now: FIXTURE_NOW, + }); + document.title = "Editorial portrait study"; + const prompt = document.nodes.find((node) => node.type === "prompt"); + if (prompt?.type === "prompt") { + prompt.data.text = "A quiet editorial portrait in soft window light, warm neutral palette"; + } + const generation = document.nodes.find((node) => node.type === "generate-image"); + if (generation?.type === "generate-image") { + generation.data.providerId = "gemini"; + generation.data.modelId = "gemini-3.1-flash-image"; + } + return document; +} + +function referenceFixture(): WorkflowDocumentV1 { + return { + schemaVersion: 1, + id: "reference-edit", + title: "Reference-led campaign", + revision: 1, + createdAt: FIXTURE_NOW, + updatedAt: FIXTURE_NOW, + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [ + { + id: "reference-input", + type: "image-input", + position: { x: 60, y: 120 }, + data: { assetId: REFERENCE_FIXTURE_ASSET_ID, label: "Reference image" }, + }, + { + id: "reference-prompt", + type: "prompt", + position: { x: 60, y: 360 }, + data: { text: "Preserve the composition; shift the scene to a misty blue hour" }, + }, + { + id: "reference-generate", + type: "generate-image", + position: { x: 410, y: 220 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "4:5", + imageSize: "2K", + outputMime: "image/png", + count: 1, + }, + }, + { + id: "reference-gallery", + type: "output-gallery", + position: { x: 780, y: 220 }, + data: { label: "Campaign selects" }, + }, + ], + edges: [ + { + id: "reference-edge-image", + source: "reference-input", + sourcePort: "image", + target: "reference-generate", + targetPort: "references", + }, + { + id: "reference-edge-prompt", + source: "reference-prompt", + sourcePort: "text", + target: "reference-generate", + targetPort: "prompt", + }, + { + id: "reference-edge-output", + source: "reference-generate", + sourcePort: "images", + target: "reference-gallery", + targetPort: "images", + }, + ], + assetRefs: [REFERENCE_FIXTURE_ASSET_ID], + settings: { concurrency: 1, defaultProviderId: "gemini" }, + }; +} + +function stressFixture(nodeCount: 100 | 250): WorkflowDocumentV1 { + const nodes: WorkflowNodeV1[] = []; + const edges: WorkflowEdgeV1[] = []; + const assetRefs: string[] = []; + const groups = Math.ceil(nodeCount / 4); + for (let index = 0; index < groups; index += 1) { + // Keep stress rows clear at the production gate's 0.7 zoom even when the + // capability-driven Generate Image card renders every curated control. + const row = index * 1_120; + const promptId = `stress-prompt-${index}`; + const inputId = `stress-input-${index}`; + const generationId = `stress-generate-${index}`; + const outputId = `stress-output-${index}`; + const assetId = stressAssetId(index); + nodes.push( + { + id: promptId, + type: "prompt", + position: { x: 40, y: row + 20 }, + data: { text: `Concept ${index + 1}: sculptural still life` }, + }, + { + id: inputId, + type: "image-input", + position: { x: 390, y: row + 20 }, + data: { assetId, label: `Reference ${index + 1}` }, + }, + { + id: generationId, + type: "generate-image", + position: { x: 740, y: row + 20 }, + data: { + providerId: "gemini", + modelId: "gemini-3.1-flash-image", + aspectRatio: "1:1", + imageSize: "1K", + outputMime: "image/png", + count: 1, + }, + }, + { + id: outputId, + type: index % 5 === 0 ? "output-gallery" : "output", + position: { x: 1_090, y: row + 20 }, + data: index % 5 === 0 ? { label: `Gallery ${index + 1}` } : {}, + }, + ); + assetRefs.push(assetId); + edges.push( + { + id: `stress-edge-prompt-${index}`, + source: promptId, + sourcePort: "text", + target: generationId, + targetPort: "prompt", + }, + { + id: `stress-edge-image-${index}`, + source: inputId, + sourcePort: "image", + target: generationId, + targetPort: "references", + }, + { + id: `stress-edge-output-${index}`, + source: generationId, + sourcePort: "images", + target: outputId, + targetPort: "images", + }, + ); + } + const keptNodes = nodes.slice(0, nodeCount); + const keptNodeIds = new Set(keptNodes.map((node) => node.id)); + return { + schemaVersion: 1, + id: `stress-${nodeCount}`, + title: `${nodeCount}-node canvas fixture`, + revision: 1, + createdAt: FIXTURE_NOW, + updatedAt: FIXTURE_NOW, + viewport: { x: 0, y: 0, zoom: 0.7 }, + nodes: keptNodes, + edges: edges.filter((edge) => keptNodeIds.has(edge.source) && keptNodeIds.has(edge.target)), + assetRefs: assetRefs.filter((_assetId, index) => index * 4 + 1 < nodeCount), + settings: { concurrency: 1, defaultProviderId: "gemini" }, + }; +} + +const FIXTURES: ReadonlyMap WorkflowDocumentV1> = new Map< + string, + () => WorkflowDocumentV1 +>([ + [ + "blank", + () => ({ + schemaVersion: 1, + id: "blank", + title: "Untitled image workflow", + revision: 1, + createdAt: FIXTURE_NOW, + updatedAt: FIXTURE_NOW, + viewport: { x: 0, y: 0, zoom: 1 }, + nodes: [], + edges: [], + assetRefs: [], + settings: { concurrency: 1 }, + }), + ], + ["starter", starterFixture], + ["reference-edit", referenceFixture], + ["stress-100", () => stressFixture(100)], + ["stress-250", () => stressFixture(250)], +]); + +export function createImagesFixture(workflowId: string): WorkflowDocumentV1 | undefined { + if (workflowId.length === 0 || workflowId.length > 128) return undefined; + return FIXTURES.get(workflowId)?.(); +} diff --git a/renderer/create-images/image-drop-core.ts b/renderer/create-images/image-drop-core.ts new file mode 100644 index 00000000..a12b85d6 --- /dev/null +++ b/renderer/create-images/image-drop-core.ts @@ -0,0 +1,305 @@ +import type { WorkflowNodeV1 } from "../shared/create-images/schema"; +import { + CREATE_IMAGES_MAX_NODES, + CREATE_IMAGES_POSITION_LIMIT, + type CreateImagesPosition, +} from "../shared/create-images/schema"; + +const SUPPORTED_IMAGE_EXTENSIONS = new Set([ + ".png", + ".jpg", + ".jpeg", + ".webp", + ".avif", + ".bmp", + ".ico", + ".tif", + ".tiff", + ".heic", + ".heif", + ".gif", +]); + +export const CREATE_IMAGES_DROP_NODE_WIDTH = 288; +export const CREATE_IMAGES_DROP_NODE_HEIGHT = 300; +export const CREATE_IMAGES_DROP_NODE_GAP = 24; + +export interface CreateImagesDragItemLike { + readonly kind?: string; + readonly type?: string; +} + +export interface CreateImagesDragDataLike { + readonly items?: Iterable | null; + readonly types?: Iterable | null; +} + +export interface CreateImagesFileLike { + readonly name?: string; + readonly type?: string; +} + +export interface CreateImagesDropState { + readonly active: boolean; + readonly depth: number; + readonly targetNodeId?: string; +} + +export type CreateImagesDropAction = + | { type: "enter"; valid: boolean; targetNodeId?: string } + | { type: "over"; valid: boolean; targetNodeId?: string } + | { type: "leave"; inside: boolean } + | { type: "drop" }; + +export interface CreateImagesDropExistingNode { + readonly id: string; + readonly type: WorkflowNodeV1["type"]; + readonly position: CreateImagesPosition; + readonly width?: number; + readonly height?: number; +} + +export interface CreateImagesDropPlanInput { + readonly dropPoint: CreateImagesPosition; + readonly existingNodes: readonly CreateImagesDropExistingNode[]; + readonly fileCount: number; + readonly targetNodeId?: string; + readonly nodeWidth?: number; + readonly nodeHeight?: number; + readonly nodeGap?: number; +} + +export interface CreateImagesDropPlan { + readonly replacementNodeId?: string; + readonly positions: readonly CreateImagesPosition[]; +} + +export const INITIAL_CREATE_IMAGES_DROP_STATE: CreateImagesDropState = Object.freeze({ + active: false, + depth: 0, +}); + +function normalizedMimeType(type: string | undefined): string { + return (type ?? "").trim().toLowerCase(); +} + +function extensionForName(name: string | undefined): string { + const value = (name ?? "").trim().toLowerCase(); + const separator = value.lastIndexOf("."); + return separator === -1 ? "" : value.slice(separator); +} + +export function isSupportedCreateImagesFile(file: CreateImagesFileLike): boolean { + const mimeType = normalizedMimeType(file.type); + if (mimeType.startsWith("image/")) return true; + const extensionSupported = SUPPORTED_IMAGE_EXTENSIONS.has(extensionForName(file.name)); + return ( + extensionSupported && + (mimeType.length === 0 || + mimeType === "application/octet-stream" || + mimeType === "binary/octet-stream") + ); +} + +export function filterSupportedCreateImagesFiles( + files: readonly T[], +): T[] { + return files.filter(isSupportedCreateImagesFile); +} + +export function sanitizeCreateImagesImageLabel(label: string | undefined): string | undefined { + const value = label?.trim(); + if (!value) return undefined; + const basename = value.replace(/\\/gu, "/").split("/").pop()?.trim(); + return basename ? basename.slice(0, 120) : undefined; +} + +/** + * Drag sources can hide file MIME types until drop. An empty file item type is + * therefore treated as a potential image, while an explicitly unsupported + * MIME type is rejected before the canvas ever shows drop affordances. + */ +export function hasPotentialCreateImagesFileDrag(data: CreateImagesDragDataLike): boolean { + const items = Array.from(data.items ?? []); + const fileItems = items.filter((item) => item.kind?.toLowerCase() === "file"); + if (fileItems.length > 0) { + return fileItems.some((item) => { + const type = normalizedMimeType(item.type); + return type.length === 0 || type.startsWith("image/"); + }); + } + return Array.from(data.types ?? []).some((type) => type.trim().toLowerCase() === "files"); +} + +export function reduceCreateImagesDropState( + state: CreateImagesDropState, + action: CreateImagesDropAction, +): CreateImagesDropState { + if (action.type === "drop") return INITIAL_CREATE_IMAGES_DROP_STATE; + if (action.type === "enter") { + if (!action.valid) return state; + return { + active: true, + depth: state.depth + 1, + ...(action.targetNodeId ? { targetNodeId: action.targetNodeId } : {}), + }; + } + if (action.type === "over") { + if (!action.valid && !state.active) return state; + return { + active: state.active || action.valid, + depth: state.active ? Math.max(1, state.depth) : 1, + ...(action.targetNodeId ? { targetNodeId: action.targetNodeId } : {}), + }; + } + if (!action.inside) return INITIAL_CREATE_IMAGES_DROP_STATE; + const depth = Math.max(0, state.depth - 1); + return depth === 0 ? INITIAL_CREATE_IMAGES_DROP_STATE : { ...state, depth }; +} + +function finitePosition(position: CreateImagesPosition): CreateImagesPosition { + const coordinate = (value: number) => + Number.isFinite(value) + ? Math.max(-CREATE_IMAGES_POSITION_LIMIT, Math.min(CREATE_IMAGES_POSITION_LIMIT, value)) + : 0; + return { x: coordinate(position.x), y: coordinate(position.y) }; +} + +interface Rectangle { + x: number; + y: number; + width: number; + height: number; +} + +function rectanglesOverlap(left: Rectangle, right: Rectangle): boolean { + return ( + left.x < right.x + right.width && + left.x + left.width > right.x && + left.y < right.y + right.height && + left.y + left.height > right.y + ); +} + +function candidateOffsets(stepX: number, stepY: number): Array<{ x: number; y: number }> { + const offsets = [{ x: 0, y: 0 }]; + // A deterministic square spiral keeps the first placement at the pointer, + // then moves in a predictable reading order when existing nodes occupy it. + for (let ring = 1; ring <= CREATE_IMAGES_MAX_NODES; ring += 1) { + const x = ring * stepX; + const y = ring * stepY; + offsets.push( + { x, y: 0 }, + { x: -x, y: 0 }, + { x: 0, y }, + { x: 0, y: -y }, + { x, y }, + { x: -x, y }, + { x, y: -y }, + { x: -x, y: -y }, + ); + } + return offsets; +} + +function preferredPosition( + point: CreateImagesPosition, + index: number, + count: number, + width: number, + height: number, + gap: number, +): CreateImagesPosition { + const columns = Math.min(3, Math.max(1, count)); + const row = Math.floor(index / columns); + const column = index % columns; + const horizontalOffset = (column - (columns - 1) / 2) * (width + gap); + return finitePosition({ + x: point.x - width / 2 + horizontalOffset, + y: point.y - height / 2 + row * (height + gap), + }); +} + +function nodeRectangle( + node: Pick, + defaultWidth: number, + defaultHeight: number, +): Rectangle { + return { + x: node.position.x, + y: node.position.y, + width: node.width ?? defaultWidth, + height: node.height ?? defaultHeight, + }; +} + +function findAvailablePosition( + preferred: CreateImagesPosition, + occupied: readonly Rectangle[], + width: number, + height: number, + stepX: number, + stepY: number, +): CreateImagesPosition { + for (const offset of candidateOffsets(stepX, stepY)) { + const position = finitePosition({ + x: preferred.x + offset.x, + y: preferred.y + offset.y, + }); + const rectangle = { x: position.x, y: position.y, width, height }; + if (!occupied.some((other) => rectanglesOverlap(rectangle, other))) return position; + } + return preferred; +} + +export function planCreateImagesDrop({ + dropPoint, + existingNodes, + fileCount, + targetNodeId, + nodeWidth = CREATE_IMAGES_DROP_NODE_WIDTH, + nodeHeight = CREATE_IMAGES_DROP_NODE_HEIGHT, + nodeGap = CREATE_IMAGES_DROP_NODE_GAP, +}: CreateImagesDropPlanInput): CreateImagesDropPlan { + const count = Math.max(0, Math.min(CREATE_IMAGES_MAX_NODES, Math.floor(fileCount))); + const target = targetNodeId + ? existingNodes.find((node) => node.id === targetNodeId && node.type === "image-input") + : undefined; + const replacementNodeId = target && count > 0 ? target.id : undefined; + const createCount = count - (replacementNodeId ? 1 : 0); + const occupied = existingNodes.map((node) => nodeRectangle(node, nodeWidth, nodeHeight)); + const placements: CreateImagesPosition[] = []; + const stepX = nodeWidth + nodeGap; + const stepY = nodeHeight + nodeGap; + for (let index = 0; index < createCount; index += 1) { + const preferred = preferredPosition( + dropPoint, + index, + createCount, + nodeWidth, + nodeHeight, + nodeGap, + ); + const position = findAvailablePosition( + preferred, + [ + ...occupied, + ...placements.map((item) => ({ + x: item.x, + y: item.y, + width: nodeWidth, + height: nodeHeight, + })), + ], + nodeWidth, + nodeHeight, + stepX, + stepY, + ); + placements.push(position); + } + return { + ...(replacementNodeId ? { replacementNodeId } : {}), + positions: placements, + }; +} diff --git a/renderer/create-images/navigation-guard.test.ts b/renderer/create-images/navigation-guard.test.ts new file mode 100644 index 00000000..5508a515 --- /dev/null +++ b/renderer/create-images/navigation-guard.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + registerCreateImagesNavigationGuard, + requestCreateImagesNavigation, +} from "./navigation-guard.js"; + +test("Create Images route navigation waits for the active autosave guard", async () => { + let calls = 0; + const unregister = registerCreateImagesNavigationGuard(async () => { + calls += 1; + return { allowed: false, message: "Resolve the save conflict." }; + }); + assert.deepEqual(await requestCreateImagesNavigation(), { + allowed: false, + message: "Resolve the save conflict.", + }); + assert.equal(calls, 1); + unregister(); + assert.deepEqual(await requestCreateImagesNavigation(), { allowed: true }); +}); diff --git a/renderer/create-images/navigation-guard.ts b/renderer/create-images/navigation-guard.ts new file mode 100644 index 00000000..e09395d4 --- /dev/null +++ b/renderer/create-images/navigation-guard.ts @@ -0,0 +1,39 @@ +export interface CreateImagesNavigationDecision { + allowed: boolean; + message?: string; +} + +let flushBeforeNavigation: (() => Promise) | undefined; + +declare global { + interface Window { + __aidenFlushCreateImagesForLifecycle?: () => Promise; + } +} + +export function registerCreateImagesNavigationGuard( + flush: () => Promise, +): () => void { + flushBeforeNavigation = flush; + if (typeof window !== "undefined") { + window.__aidenFlushCreateImagesForLifecycle = async () => { + const timeout = new Promise((resolve) => { + window.setTimeout( + () => resolve({ allowed: false, message: "Autosave is still running." }), + 2_500, + ); + }); + return (await Promise.race([flush(), timeout])).allowed; + }; + } + return () => { + if (flushBeforeNavigation === flush) { + flushBeforeNavigation = undefined; + if (typeof window !== "undefined") delete window.__aidenFlushCreateImagesForLifecycle; + } + }; +} + +export async function requestCreateImagesNavigation(): Promise { + return flushBeforeNavigation ? flushBeforeNavigation() : { allowed: true }; +} diff --git a/renderer/create-images/provider-connection-core.ts b/renderer/create-images/provider-connection-core.ts new file mode 100644 index 00000000..32b843d1 --- /dev/null +++ b/renderer/create-images/provider-connection-core.ts @@ -0,0 +1,158 @@ +import type { Provider } from "../lib/types"; +import { + CREATE_IMAGES_PROVIDER_STATUS_VERSION, + type CreateImagesProviderBindingIssue, + type CreateImagesProviderStatus, +} from "../shared/create-images/providers"; + +export interface CreateImagesProviderStatusViewModel { + label: "Disconnected" | "Connecting" | "Connected" | "Invalid" | "Unavailable"; + title: string; + detail: string; + tone: "neutral" | "progress" | "success" | "danger" | "warning"; + manageActionLabel: "Set up in Providers" | "Review in Providers" | "Manage in Providers"; + canUseGemini: boolean; +} + +const SAFE_ERROR_DETAIL: Readonly< + Record, string> +> = { + "credential-missing": "Add a Google API key in Aiden's existing Providers settings.", + "credential-invalid": "The configured API-key credential is missing or malformed. Review it before cloud use.", + "credential-scope-unverified": + "A Google credential exists, but Aiden has not verified that its auth kind and image scope are compatible.", + "capability-check-failed": "Aiden could not verify the current curated image-model capabilities.", + "provider-unreachable": "Google Gemini could not be reached for a capability check.", + "rate-limited": "Google temporarily limited the capability check. Local mock remains available.", + "feature-unavailable": "Gemini image generation is unavailable in this Aiden build.", +}; + +export function createImagesProviderStatusViewModel( + status: CreateImagesProviderStatus, +): CreateImagesProviderStatusViewModel { + const safeDetail = status.safeErrorCode ? SAFE_ERROR_DETAIL[status.safeErrorCode] : undefined; + switch (status.connectionState) { + case "disconnected": + return { + label: "Disconnected", + title: "Connect Google Gemini", + detail: safeDetail ?? "Set up Google in Providers before using a remote image model.", + tone: "neutral", + manageActionLabel: "Set up in Providers", + canUseGemini: false, + }; + case "connecting": + return { + label: "Connecting", + title: "Checking Google Gemini", + detail: "Aiden is checking the main-owned credential and current image capabilities.", + tone: "progress", + manageActionLabel: "Manage in Providers", + canUseGemini: false, + }; + case "connected": { + const snapshot = status.capabilitySnapshot; + const capabilitiesReady = snapshot?.state === "current" && snapshot.models.length > 0; + return { + label: "Connected", + title: capabilitiesReady ? "Google Gemini is ready" : "Google Gemini needs a refresh", + detail: capabilitiesReady + ? `${snapshot.models.length} release-curated image model${snapshot.models.length === 1 ? "" : "s"} available. Google validates the API key only when you explicitly submit a reviewed run.` + : "An API-key connection is configured, but Aiden does not have a current image capability snapshot.", + tone: capabilitiesReady ? "success" : "warning", + manageActionLabel: "Manage in Providers", + canUseGemini: capabilitiesReady, + }; + } + case "invalid": + return { + label: "Invalid", + title: "Google credential needs attention", + detail: safeDetail ?? "The configured API-key credential needs review.", + tone: "danger", + manageActionLabel: "Review in Providers", + canUseGemini: false, + }; + case "unavailable": + return { + label: "Unavailable", + title: "Google Gemini is unavailable", + detail: safeDetail ?? "Aiden could not verify this provider right now.", + tone: "warning", + manageActionLabel: "Review in Providers", + canUseGemini: false, + }; + } +} + +export function createImagesBindingIssueLabel(issue: CreateImagesProviderBindingIssue): string { + switch (issue) { + case "connection-not-ready": + return "Configure now; Google runs stay disabled until the connection is ready."; + case "capabilities-unavailable": + return "Current model capabilities are unavailable. Refresh the provider before cloud use."; + case "capabilities-stale": + return "Model capabilities changed or expired. Review the current catalog before cloud use."; + case "model-unselected": + return "Choose a curated Gemini image model."; + case "model-not-curated": + return "This model is not in Aiden's release-pinned Gemini catalog."; + case "model-no-longer-available": + return "This model is no longer in the current provider capability snapshot."; + case "aspect-ratio-no-longer-supported": + return "This aspect ratio is no longer supported by the selected model."; + case "image-size-no-longer-supported": + return "This image size is no longer supported by the selected model."; + case "output-format-no-longer-supported": + return "This output format is no longer supported by the selected model."; + case "output-count-no-longer-supported": + return "This output count is no longer supported by the selected model."; + } +} + +/** + * The chat-provider list cannot prove image auth compatibility. A configured + * Google record therefore stays fail-closed until the image-specific main + * status seam verifies API-key auth and capabilities. + */ +export function createImagesProviderStatusFromExistingProvider( + state: + | { kind: "loading" } + | { kind: "error" } + | { kind: "ready"; providers: readonly Provider[] }, +): CreateImagesProviderStatus { + if (state.kind === "loading") { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "connecting", + }; + } + if (state.kind === "error") { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "capability-check-failed", + }; + } + const google = state.providers.find((provider) => provider.id === "google"); + if (!google?.hasKey) { + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "disconnected", + safeErrorCode: "credential-missing", + }; + } + return { + schemaVersion: CREATE_IMAGES_PROVIDER_STATUS_VERSION, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: "unavailable", + safeErrorCode: "credential-scope-unverified", + }; +} diff --git a/renderer/create-images/provider-connection.test.tsx b/renderer/create-images/provider-connection.test.tsx new file mode 100644 index 00000000..8083b5ae --- /dev/null +++ b/renderer/create-images/provider-connection.test.tsx @@ -0,0 +1,210 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { DOMImplementation } from "@xmldom/xmldom"; +import { CREATE_IMAGES_GEMINI_RELEASE_CATALOG } from "../shared/create-images/providers.js"; +import { CreateImagesProviderConnectionContent } from "./provider-connection.js"; +import { + createImagesProviderStatusFromExistingProvider, + createImagesProviderStatusViewModel, +} from "./provider-connection-core.js"; + +function installMountedDom(): { container: HTMLElement; restore(): void } { + const document = new DOMImplementation().createDocument( + null, + "html", + null, + ) as unknown as Document; + const body = document.createElement("body"); + const container = document.createElement("div"); + body.appendChild(container); + document.documentElement.appendChild(body); + const elementPrototype = Object.getPrototypeOf(document.createElement("div")) as HTMLElement & + Record; + elementPrototype.addEventListener = () => undefined; + elementPrototype.removeEventListener = () => undefined; + elementPrototype.focus = () => undefined; + Object.defineProperty(elementPrototype, "style", { configurable: true, get: () => ({}) }); + const documentPrototype = Object.getPrototypeOf(document) as Document; + documentPrototype.addEventListener = () => undefined; + documentPrototype.removeEventListener = () => undefined; + Object.defineProperty(document, "body", { configurable: true, value: body }); + const window = { + document, + HTMLIFrameElement: class HTMLIFrameElement {}, + addEventListener: () => undefined, + removeEventListener: () => undefined, + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => undefined, + }; + Object.defineProperty(document, "defaultView", { configurable: true, value: window }); + const keys = [ + "window", + "document", + "navigator", + "Node", + "Element", + "HTMLElement", + "requestAnimationFrame", + "cancelAnimationFrame", + ] as const; + const previous = new Map( + keys.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + const elementConstructor = Object.getPrototypeOf(document.documentElement).constructor; + Object.defineProperties(globalThis, { + window: { configurable: true, value: window }, + document: { configurable: true, value: document }, + navigator: { configurable: true, value: { userAgent: "provider-connection-test" } }, + Node: { configurable: true, value: elementConstructor }, + Element: { configurable: true, value: elementConstructor }, + HTMLElement: { configurable: true, value: elementConstructor }, + requestAnimationFrame: { configurable: true, value: window.requestAnimationFrame }, + cancelAnimationFrame: { configurable: true, value: window.cancelAnimationFrame }, + }); + return { + container, + restore: () => { + for (const key of keys) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }, + }; +} + +function reactOnClick(button: HTMLButtonElement): () => void { + const key = Object.getOwnPropertyNames(button).find((candidate) => + candidate.startsWith("__reactProps$"), + ); + assert.ok(key); + const props = (button as unknown as Record)[key] as { onClick?: () => void }; + assert.ok(props.onClick); + return props.onClick; +} + +test("provider status view covers every safe connection state without exposing backend detail", () => { + const expected = { + disconnected: "Disconnected", + connecting: "Connecting", + connected: "Connected", + invalid: "Invalid", + unavailable: "Unavailable", + } as const; + for (const [connectionState, label] of Object.entries(expected)) { + const model = createImagesProviderStatusViewModel({ + schemaVersion: 1, + providerId: "gemini", + displayName: "Google Gemini", + connectionState: connectionState as keyof typeof expected, + ...(connectionState === "connected" + ? { + credentialKind: "google-api-key" as const, + capabilitySnapshot: CREATE_IMAGES_GEMINI_RELEASE_CATALOG, + } + : {}), + }); + assert.equal(model.label, label); + } +}); + +test("legacy chat provider state never claims image auth compatibility", () => { + const status = createImagesProviderStatusFromExistingProvider({ + kind: "ready", + providers: [ + { + id: "google", + kind: "openai", + label: "Google Gemini", + baseUrl: "", + models: [], + needsKey: true, + isBuiltin: true, + hasKey: true, + }, + ], + }); + assert.equal(status.connectionState, "unavailable"); + assert.equal(status.safeErrorCode, "credential-scope-unverified"); + assert.equal(status.credentialKind, undefined); +}); + +test("mounted provider disclosure keeps local mock available and gates Gemini selection", async () => { + const mounted = installMountedDom(); + const { createRoot } = await import("react-dom/client"); + const { flushSync } = await import("react-dom"); + const root = createRoot(mounted.container); + let selected = ""; + let openedSettings = false; + try { + flushSync(() => + root.render( + { + selected = mode; + }} + onOpenProviderSettings={() => { + openedSettings = true; + }} + />, + ), + ); + const text = mounted.container.textContent ?? ""; + assert.match(text, /private, deterministic, \$0, and fully on this Mac/iu); + assert.match(text, /prompts and selected reference images leave this Mac/iu); + assert.match(text, /SynthID/iu); + assert.match(text, /may create a billed request/iu); + assert.match(text, /cancellation is advisory/iu); + assert.match(text, /never automatically retries a paid request/iu); + const buttons = Array.from(mounted.container.getElementsByTagName("button")); + const radios = buttons.filter((button) => button.getAttribute("role") === "radio"); + assert.equal(radios.length, 2); + assert.equal(radios[0]?.getAttribute("aria-checked"), "true"); + assert.equal(radios[1]?.hasAttribute("disabled"), false); + reactOnClick(radios[1]!)(); + assert.equal(selected, "gemini"); + reactOnClick(buttons[buttons.length - 1]!)(); + assert.equal(openedSettings, true); + } finally { + flushSync(() => root.unmount()); + // React 19 schedules host cleanup after unmount. Keep this test process's + // synthetic DOM installed so that deferred cleanup cannot observe a + // missing window after the test callback returns. + await new Promise((resolve) => setTimeout(resolve, 0)); + } +}); + +test("provider source contract includes responsive, reduced-motion, forced-color, and settings-only setup", () => { + const component = readFileSync(new URL("./provider-connection.tsx", import.meta.url), "utf8"); + const core = readFileSync(new URL("./provider-connection-core.ts", import.meta.url), "utf8"); + const styles = readFileSync(new URL("./create-images.css", import.meta.url), "utf8"); + const view = readFileSync(new URL("./create-images-view.tsx", import.meta.url), "utf8"); + const node = readFileSync(new URL("./workflow-node.tsx", import.meta.url), "utf8"); + assert.match(core, /Set up in Providers/u); + assert.doesNotMatch(component, /type="password"|API key.*["tone"], string> +> = { + neutral: "", + progress: "blue", + success: "green", + danger: "red", + warning: "", +}; + +const STATUS_ICON = { + disconnected: AlertTriangle, + connecting: Loader2, + connected: Check, + invalid: AlertTriangle, + unavailable: AlertTriangle, +} satisfies Record< + CreateImagesProviderStatus["connectionState"], + React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }> +>; + +function ExecutionChoice({ + checked, + disabled, + description, + icon: Icon, + label, + onSelect, +}: { + checked: boolean; + disabled?: boolean; + description: string; + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>; + label: string; + onSelect(): void; +}) { + return ( + + ); +} + +export function CreateImagesProviderConnectionContent({ + status, + executionMode, + onExecutionModeChange, + onOpenProviderSettings, +}: { + status: CreateImagesProviderStatus; + executionMode: CreateImagesExecutionMode; + onExecutionModeChange?(mode: CreateImagesExecutionMode): void; + onOpenProviderSettings(): void; +}) { + const view = createImagesProviderStatusViewModel(status); + const StatusIcon = STATUS_ICON[status.connectionState]; + const geminiSelectable = view.canUseGemini && Boolean(onExecutionModeChange); + return ( +
+
+ +
+
+ Google Gemini + + +
+ + {view.title}. {view.detail} + +
+
+ +
+ +
+ Execution provider +
+ onExecutionModeChange?.("local-mock")} + /> + onExecutionModeChange?.("gemini")} + /> +
+
+ + + +
+
+
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
+
+ ); +} + +export function CreateImagesProviderConnectionControl({ + status, + executionMode, + onExecutionModeChange, + onOpenProviderSettings, +}: { + status: CreateImagesProviderStatus; + executionMode: CreateImagesExecutionMode; + onExecutionModeChange?(mode: CreateImagesExecutionMode): void; + onOpenProviderSettings(): void; +}) { + const [open, setOpen] = React.useState(false); + const view = createImagesProviderStatusViewModel(status); + return ( + + + + + + { + setOpen(false); + onOpenProviderSettings(); + }} + /> + + + ); +} diff --git a/renderer/create-images/run-ambiguity-confirmation.tsx b/renderer/create-images/run-ambiguity-confirmation.tsx new file mode 100644 index 00000000..2a91ed8c --- /dev/null +++ b/renderer/create-images/run-ambiguity-confirmation.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { CircleAlert, ShieldCheck } from "lucide-react"; + +export function CreateImagesAmbiguityAcknowledgement({ + reviewed, + disabled = false, + reviewRef, + onReviewedChange, +}: { + reviewed: boolean; + disabled?: boolean; + reviewRef?: React.RefObject; + onReviewedChange(reviewed: boolean): void; +}) { + const consequencesId = React.useId(); + return ( +
+
+
+
+
+ +
+ ); +} diff --git a/renderer/create-images/run-degraded-discard-confirmation.tsx b/renderer/create-images/run-degraded-discard-confirmation.tsx new file mode 100644 index 00000000..5aa1c3d7 --- /dev/null +++ b/renderer/create-images/run-degraded-discard-confirmation.tsx @@ -0,0 +1,80 @@ +import * as React from "react"; +import { CircleAlert, HardDrive, ShieldCheck } from "lucide-react"; +import type { CreateImagesDegradedRunDiscardPlanResult } from "../shared/create-images/ipc"; + +type ReadyDiscardPlan = Extract; + +export function CreateImagesDegradedRunDiscardConfirmation({ + plan, + reviewed, + disabled = false, + reviewRef, + onReviewedChange, +}: { + plan: ReadyDiscardPlan; + reviewed: boolean; + disabled?: boolean; + reviewRef?: React.RefObject; + onReviewedChange(reviewed: boolean): void; +}) { + const warningId = React.useId(); + return ( +
+
+
+
+
+
Record
+
{plan.association === "unassociated" ? "Unassociated run" : "Workflow run"}
+
+
+
Retained images and assets
+
Imported-input and generated-output references may be released
+
+
+
+
+
+
+ +
+ ); +} diff --git a/renderer/create-images/run-path-chooser.tsx b/renderer/create-images/run-path-chooser.tsx new file mode 100644 index 00000000..00eec7ff --- /dev/null +++ b/renderer/create-images/run-path-chooser.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; +import { + CREATE_IMAGES_SELECTED_NODE_ONLY_CHOICE, + type CreateImagesDownstreamPathChoiceView, +} from "./run-path-core"; + +export function CreateImagesDownstreamPathChooser({ + startNodeLabel, + choices, + selectedChoiceId, + truncated, + overflowReason, + unavailablePathCount, + firstChoiceRef, + onSelectionChange, +}: { + startNodeLabel: string; + choices: readonly CreateImagesDownstreamPathChoiceView[]; + selectedChoiceId?: string; + truncated: boolean; + overflowReason?: "choice-limit" | "search-budget"; + unavailablePathCount: number; + firstChoiceRef?: React.RefObject; + onSelectionChange(choiceId: string): void; +}) { + const legendId = React.useId(); + const hintId = React.useId(); + const overflowId = React.useId(); + const unavailableId = React.useId(); + const describedBy = [ + hintId, + ...(truncated ? [overflowId] : []), + ...(unavailablePathCount > 0 ? [unavailableId] : []), + ].join(" "); + const options = [ + { + id: CREATE_IMAGES_SELECTED_NODE_ONLY_CHOICE, + title: "Selected node only", + detail: `Run required inputs and ${startNodeLabel}; do no downstream work.`, + }, + ...choices, + ]; + + return ( +
+ Choose downstream work +

+ Select no downstream work or one connected path. Aiden will not run sibling branches. +

+
+ {options.map((option, index) => { + const inputId = `${legendId}-choice-${index}`; + const checked = selectedChoiceId === option.id; + return ( + + ); + })} +
+ {truncated ? ( +

+ {overflowReason === "search-budget" + ? "This workflow has more branching than Aiden can safely inspect here. Only the first bounded set of connected paths is shown; unshown work will not run." + : `Only the first ${choices.length} connected paths are shown. Unshown paths will not run; narrow the workflow branching to choose another path.`} +

+ ) : null} + {unavailablePathCount > 0 ? ( +

+ {unavailablePathCount} inspected downstream path + {unavailablePathCount === 1 ? " is" : "s are"} unavailable due to additional branch work + or unresolved validation issues. Use Run workflow to include branching. +

+ ) : null} + {!selectedChoiceId ? ( +

+ Choose one option to calculate the immutable request and output summary. +

+ ) : null} +
+ ); +} diff --git a/renderer/create-images/run-path-core.ts b/renderer/create-images/run-path-core.ts new file mode 100644 index 00000000..82859af5 --- /dev/null +++ b/renderer/create-images/run-path-core.ts @@ -0,0 +1,27 @@ +import type { WorkflowRunScope } from "../shared/create-images/execution"; + +export const CREATE_IMAGES_SELECTED_NODE_ONLY_CHOICE = "selected-node-only"; + +export interface CreateImagesDownstreamPathChoiceView { + id: string; + downstreamPath: readonly string[]; + title: string; + detail: string; +} + +export function createImagesRunScopeForPathChoice( + startNodeId: string, + choiceId: string, + choices: readonly CreateImagesDownstreamPathChoiceView[], +): WorkflowRunScope | undefined { + if (choiceId === CREATE_IMAGES_SELECTED_NODE_ONLY_CHOICE) { + return { kind: "from-node", nodeId: startNodeId }; + } + const selected = choices.find((choice) => choice.id === choiceId); + if (!selected || selected.downstreamPath.length === 0) return undefined; + return { + kind: "from-node", + nodeId: startNodeId, + downstreamPath: [...selected.downstreamPath], + }; +} diff --git a/renderer/create-images/run-ui-adapter.ts b/renderer/create-images/run-ui-adapter.ts new file mode 100644 index 00000000..59457385 --- /dev/null +++ b/renderer/create-images/run-ui-adapter.ts @@ -0,0 +1,746 @@ +import type { + CreateImagesRunChangedNotification, + CreateImagesRunListResult, + CreateImagesRunNodeView, + CreateImagesRunRecoveryView, + CreateImagesRunSubscriptionResult, + CreateImagesRunView, + CreateImagesTerminalRunView, +} from "../shared/create-images/ipc"; +import { + createImagesRunUiProjection, + type CreateImagesNodeRunUiState, + type CreateImagesRunUiProjection, + type CreateImagesRunUiStatus, + type CreateImagesSafeRunError, + type CreateImagesSafeRunErrorCode, + type CreateImagesTerminalRunHistoryItem, +} from "./run-ui-core"; + +export interface CreateImagesRendererRunState { + projection?: CreateImagesRunUiProjection; + history: readonly CreateImagesTerminalRunHistoryItem[]; + recoveries: readonly CreateImagesRunRecoveryView[]; + runAssetOwners: Readonly>; + runTombstones?: readonly string[]; + projectionUpdatedAt?: string; + errorMessage?: string; +} + +export type CreateImagesSelectedRunSnapshotTransition = + | { kind: "unchanged" } + | { kind: "recovery-changed"; recovery: CreateImagesRunRecoveryView } + | { kind: "became-healthy" } + | { kind: "removed" }; + +const MAX_RENDERER_RUN_TOMBSTONES = 256; + +const EMPTY_RUN_STATE: CreateImagesRendererRunState = Object.freeze({ + history: Object.freeze([]), + recoveries: Object.freeze([]), + runAssetOwners: Object.freeze({}), + runTombstones: Object.freeze([]), +}); + +const RUN_STATUS: Readonly> = { + queued: "queued", + running: "running", + cancel_requested: "stopping", + needs_attention: "retry", + succeeded: "succeeded", + failed: "failed", + cancelled: "cancelled", + interrupted: "interrupted", +}; + +function sameRunRecovery( + previous: CreateImagesRunRecoveryView | undefined, + next: CreateImagesRunRecoveryView, +): boolean { + if ( + !previous || + previous.status !== next.status || + previous.workflowId !== next.workflowId || + previous.runId !== next.runId || + previous.reason !== next.reason + ) { + return false; + } + if (previous.status === "unsafe" || next.status === "unsafe") return true; + return ( + previous.currentJournalRevision === next.currentJournalRevision && + previous.lastKnownGoodJournalRevision === next.lastKnownGoodJournalRevision && + previous.recoverySource === next.recoverySource && + previous.expectedCandidateJournalRevision === next.expectedCandidateJournalRevision + ); +} + +/** Classifies only authoritative changes to the currently selected durable run record. */ +export function createImagesSelectedRunSnapshotTransition( + result: Extract, + selectedRunId: string, + previousRecovery?: CreateImagesRunRecoveryView, +): CreateImagesSelectedRunSnapshotTransition { + const recovery = result.recoveries.find((item) => item.runId === selectedRunId); + if (recovery) { + return sameRunRecovery(previousRecovery, recovery) + ? { kind: "unchanged" } + : { kind: "recovery-changed", recovery }; + } + const healthy = + result.activeRun?.runId === selectedRunId || + result.latestTerminalRun?.runId === selectedRunId || + result.history.some((item) => item.runId === selectedRunId); + if (!healthy) return { kind: "removed" }; + return previousRecovery ? { kind: "became-healthy" } : { kind: "unchanged" }; +} + +const SAFE_ERROR_CODES: Readonly> = { + offline: "offline", + "rate-limited": "rate_limited", + "provider-refused": "provider_refused", + "provider-unavailable": "provider_unavailable", + "output-invalid": "output_invalid", + "output-publication-failed": "output_invalid", + "quota-full": "quota_full", + interrupted: "interrupted", + "submission-ambiguous": "submission_ambiguous", +}; + +function nodeError(node: CreateImagesRunNodeView): CreateImagesSafeRunError | undefined { + if (node.status === "ambiguous") { + return { code: "submission_ambiguous", retryKind: "none" }; + } + if (!node.errorCode) return undefined; + return { + code: SAFE_ERROR_CODES[node.errorCode] ?? "unknown", + retryKind: node.status === "failed" ? "local" : "none", + }; +} + +function runNode(node: CreateImagesRunNodeView): Omit { + const automaticMockRetry = node.status === "running" && node.retrySafety !== undefined; + const status = automaticMockRetry ? "retry" : node.status === "ambiguous" ? "retry" : node.status; + return { + nodeId: node.nodeId, + label: node.label, + status, + attempt: node.attempt, + outputAssetIds: [...node.outputAssetIds], + ...(automaticMockRetry ? { retryMode: "automatic-mock" as const } : {}), + ...(node.status === "ambiguous" ? { retryMode: "manual-review" as const } : {}), + ...(nodeError(node) ? { error: nodeError(node) } : {}), + }; +} + +export function createImagesRunProjectionFromView( + run: CreateImagesRunView, +): CreateImagesRunUiProjection { + return createImagesRunUiProjection({ + workflowId: run.workflowId, + workflowRevision: run.workflowRevision, + runId: run.runId, + ...(run.executionMode ? { executionMode: run.executionMode } : {}), + status: RUN_STATUS[run.status], + lastSequence: run.lastSequence, + ...(run.ambiguityResolution ? { ambiguityAcknowledged: true as const } : {}), + nodes: run.nodes.map(runNode), + }); +} + +function terminalStatus( + status: CreateImagesTerminalRunView["status"], +): CreateImagesTerminalRunHistoryItem["status"] { + return status === "needs_attention" ? "retry" : status; +} + +function scopeLabel( + run: CreateImagesTerminalRunView, + nodeLabels: Readonly>, +): string { + if (run.scope.kind === "all") return `Entire workflow · revision ${run.workflowRevision}`; + return `From ${nodeLabels[run.scope.nodeId] ?? run.scope.nodeId}`; +} + +export function createImagesTerminalHistoryFromViews( + history: readonly CreateImagesTerminalRunView[], + nodeLabels: Readonly> = {}, +): readonly CreateImagesTerminalRunHistoryItem[] { + return history.map((run) => ({ + runId: run.runId, + workflowRevision: run.workflowRevision, + scopeLabel: scopeLabel(run, nodeLabels), + status: terminalStatus(run.status), + startedAt: run.createdAt, + finishedAt: run.updatedAt, + providerLabel: run.providerLabel ?? "Aiden local mock", + modelLabel: run.modelLabel ?? "Deterministic Phase 3", + requestCount: run.requestCount, + completedNodeCount: run.completedNodeCount, + totalNodeCount: run.totalNodeCount, + outputCount: run.outputCount, + costLabel: run.costLabel ?? "$0.00 mock", + ...(run.ambiguityResolution ? { ambiguityAcknowledged: true as const } : {}), + })); +} + +export function createImagesRunAssetOwners( + run: CreateImagesRunView, +): Readonly> { + return Object.freeze( + Object.fromEntries( + run.nodes.flatMap((node) => node.outputAssetIds.map((assetId) => [assetId, run.runId])), + ), + ); +} + +function activeUiStatus(status: CreateImagesRunUiStatus): boolean { + return status === "queued" || status === "running" || status === "stopping"; +} + +function addRunTombstones( + current: readonly string[] | undefined, + additions: Iterable, +): readonly string[] { + const ordered = new Set(current ?? []); + for (const runId of additions) { + ordered.delete(runId); + ordered.add(runId); + } + return Object.freeze([...ordered].slice(-MAX_RENDERER_RUN_TOMBSTONES)); +} + +function rendererRunIds(state: CreateImagesRendererRunState): ReadonlySet { + return new Set([ + ...(state.projection ? [state.projection.runId] : []), + ...state.history.map((item) => item.runId), + ...state.recoveries.map((item) => item.runId), + ]); +} + +function withAuthoritativeRunTombstones( + previous: CreateImagesRendererRunState, + next: CreateImagesRendererRunState, +): CreateImagesRendererRunState { + const nextRunIds = rendererRunIds(next); + const removedRunIds = [...rendererRunIds(previous)].filter((runId) => !nextRunIds.has(runId)); + return { + ...next, + runTombstones: addRunTombstones(previous.runTombstones, removedRunIds), + }; +} + +function terminalProjection( + projection: CreateImagesRunUiProjection, + terminal: CreateImagesTerminalRunView, +): CreateImagesRunUiProjection { + const status = terminalStatus(terminal.status); + return { + ...projection, + status, + ...(terminal.ambiguityResolution + ? { ambiguityAcknowledged: true as const } + : { ambiguityAcknowledged: undefined }), + announcement: + status === "retry" + ? "Workflow run needs review before any retry." + : `Workflow run ${status}.`, + }; +} + +/** + * Reconciles self-contained main-owned run snapshots. Sequence gaps are valid + * because every notification is complete; older snapshots and cross-workflow + * active runs are ignored. A terminal summary may seal the last full node view + * after main removes it from the active-run slot. + */ +export function reconcileCreateImagesRunState( + current: CreateImagesRendererRunState | undefined, + result: CreateImagesRunListResult, + workflowId: string, + nodeLabels: Readonly> = {}, +): CreateImagesRendererRunState { + const previous = current ?? EMPTY_RUN_STATE; + if (result.status === "unavailable" || result.status === "not-found") { + return { + ...previous, + errorMessage: + result.status === "not-found" ? "This workflow no longer exists." : result.message, + }; + } + const recoveries = Object.freeze( + result.recoveries.filter((recovery) => recovery.workflowId === workflowId), + ); + const recoveryRunIds = new Set(recoveries.map((recovery) => recovery.runId)); + const incomingHistory = createImagesTerminalHistoryFromViews( + result.history.filter((run) => !recoveryRunIds.has(run.runId)), + nodeLabels, + ); + const history = Object.freeze( + incomingHistory + .slice() + .sort( + (left, right) => + Date.parse(right.finishedAt) - Date.parse(left.finishedAt) || + right.runId.localeCompare(left.runId), + ), + ); + const finish = (next: CreateImagesRendererRunState) => + withAuthoritativeRunTombstones(previous, next); + const retainedPrevious: CreateImagesRendererRunState = + previous.projection && recoveryRunIds.has(previous.projection.runId) + ? { + history: previous.history, + recoveries: previous.recoveries, + runAssetOwners: Object.freeze({}), + runTombstones: previous.runTombstones, + errorMessage: previous.errorMessage, + } + : previous; + const retainedProjection = retainedPrevious.projection; + const retainedProjectionSealed = + retainedProjection !== undefined && + history.some((item) => item.runId === retainedProjection.runId); + const retainedTerminalProjectionTombstoned = + retainedProjection !== undefined && + !activeUiStatus(retainedProjection.status) && + !retainedProjectionSealed; + const active = + result.activeRun?.workflowId === workflowId && !recoveryRunIds.has(result.activeRun.runId) + ? result.activeRun + : undefined; + if (active) { + const nextProjection = createImagesRunProjectionFromView(active); + const oldProjection = retainedProjection; + if (history.some((item) => item.runId === active.runId)) { + return finish({ ...retainedPrevious, history, recoveries, errorMessage: undefined }); + } + if ( + oldProjection?.runId === nextProjection.runId && + nextProjection.lastSequence < oldProjection.lastSequence + ) { + return finish({ ...retainedPrevious, history, recoveries, errorMessage: undefined }); + } + if ( + oldProjection && + oldProjection.runId !== nextProjection.runId && + activeUiStatus(oldProjection.status) && + !retainedProjectionSealed + ) { + return finish({ ...retainedPrevious, history, recoveries, errorMessage: undefined }); + } + if ( + oldProjection?.runId !== nextProjection.runId && + !retainedProjectionSealed && + !retainedTerminalProjectionTombstoned && + retainedPrevious.projectionUpdatedAt && + Date.parse(active.updatedAt) < Date.parse(retainedPrevious.projectionUpdatedAt) + ) { + return finish({ ...retainedPrevious, history, recoveries, errorMessage: undefined }); + } + return finish({ + projection: nextProjection, + projectionUpdatedAt: active.updatedAt, + history, + recoveries, + runAssetOwners: createImagesRunAssetOwners(active), + errorMessage: undefined, + }); + } + const latestTerminalRun = + result.latestTerminalRun?.workflowId === workflowId && + !recoveryRunIds.has(result.latestTerminalRun.runId) + ? result.latestTerminalRun + : undefined; + if (latestTerminalRun) { + const nextProjection = createImagesRunProjectionFromView(latestTerminalRun); + const oldProjection = retainedProjection; + if ( + oldProjection?.runId === nextProjection.runId && + nextProjection.lastSequence < oldProjection.lastSequence + ) { + return finish({ ...retainedPrevious, history, recoveries, errorMessage: undefined }); + } + if ( + oldProjection && + oldProjection.runId !== nextProjection.runId && + activeUiStatus(oldProjection.status) && + !retainedProjectionSealed + ) { + return finish({ ...retainedPrevious, history, recoveries, errorMessage: undefined }); + } + if ( + oldProjection?.runId !== nextProjection.runId && + !retainedProjectionSealed && + !retainedTerminalProjectionTombstoned && + retainedPrevious.projectionUpdatedAt && + Date.parse(latestTerminalRun.updatedAt) < Date.parse(retainedPrevious.projectionUpdatedAt) + ) { + return finish({ ...retainedPrevious, history, recoveries, errorMessage: undefined }); + } + return finish({ + projection: nextProjection, + projectionUpdatedAt: latestTerminalRun.updatedAt, + history, + recoveries, + runAssetOwners: createImagesRunAssetOwners(latestTerminalRun), + errorMessage: undefined, + }); + } + const oldProjection = retainedPrevious.projection; + const terminal = oldProjection + ? result.history.find((candidate) => candidate.runId === oldProjection.runId) + : undefined; + if (!oldProjection || !terminal || recoveryRunIds.has(oldProjection.runId)) { + return finish({ + history, + recoveries, + runAssetOwners: Object.freeze({}), + errorMessage: undefined, + }); + } + return finish({ + projection: terminalProjection(oldProjection, terminal), + projectionUpdatedAt: terminal.updatedAt, + history, + recoveries, + runAssetOwners: retainedPrevious.runAssetOwners, + errorMessage: undefined, + }); +} + +/** + * Reconciles a single mutation acknowledgement without inferring anything + * about authoritative history or recovery retention from that response. + */ +export function reconcileCreateImagesRunMutation( + current: CreateImagesRendererRunState | undefined, + run: CreateImagesRunView, + workflowId: string, +): CreateImagesRendererRunState { + const previous = current ?? EMPTY_RUN_STATE; + if (run.workflowId !== workflowId) return previous; + + const nextProjection = createImagesRunProjectionFromView(run); + const oldProjection = previous.projection; + const currentSameRun = oldProjection?.runId === nextProjection.runId; + if (previous.runTombstones?.includes(run.runId)) return previous; + if (previous.recoveries.some((recovery) => recovery.runId === run.runId)) return previous; + if (previous.history.some((item) => item.runId === run.runId) && !currentSameRun) { + return previous; + } + if (currentSameRun && nextProjection.lastSequence < oldProjection.lastSequence) { + return previous; + } + if (!currentSameRun) { + if (oldProjection && activeUiStatus(oldProjection.status)) return previous; + } + + return { + ...previous, + projection: nextProjection, + projectionUpdatedAt: run.updatedAt, + runAssetOwners: createImagesRunAssetOwners(run), + errorMessage: undefined, + }; +} + +export interface CreateImagesRunHistoryRequestIdentity { + runId: string; + lifecycleGeneration: number; + requestSequence: number; +} + +export interface CreateImagesRunHistoryRequestAuthority { + mounted: boolean; + lifecycleGeneration: number; + selectedRunId?: string; + requestSequence: number; +} + +export interface CreateImagesRunRecoveryRequestIdentity extends CreateImagesRunHistoryRequestIdentity { + source: "last-known-good" | "current"; + expectedCandidateJournalRevision: number; +} + +export interface CreateImagesRunAmbiguityRequestIdentity extends CreateImagesRunHistoryRequestIdentity { + expectedLastSequence: number; +} + +/** Validates ownership of a selected-run async continuation. */ +export function isCreateImagesRunHistoryRequestCurrent( + state: CreateImagesRendererRunState | undefined, + authority: CreateImagesRunHistoryRequestAuthority, + request: CreateImagesRunHistoryRequestIdentity, +): boolean { + return ( + authority.mounted && + authority.lifecycleGeneration === request.lifecycleGeneration && + authority.requestSequence === request.requestSequence && + authority.selectedRunId === request.runId && + !state?.runTombstones?.includes(request.runId) + ); +} + +/** Validates that an async recovery response still owns the selected candidate. */ +export function isCreateImagesRunRecoveryRequestCurrent( + state: CreateImagesRendererRunState | undefined, + authority: CreateImagesRunHistoryRequestAuthority, + request: CreateImagesRunRecoveryRequestIdentity, +): boolean { + if (!state || !isCreateImagesRunHistoryRequestCurrent(state, authority, request)) return false; + const recovery = state.recoveries.find((item) => item.runId === request.runId); + return ( + recovery?.status === "recovery-required" && + recovery.recoverySource === request.source && + recovery.expectedCandidateJournalRevision === request.expectedCandidateJournalRevision + ); +} + +/** Validates that an ambiguity acknowledgement still owns the visible run. */ +export function isCreateImagesRunAmbiguityRequestCurrent( + state: CreateImagesRendererRunState | undefined, + authority: CreateImagesRunHistoryRequestAuthority, + request: CreateImagesRunAmbiguityRequestIdentity, +): boolean { + if (!state || !isCreateImagesRunHistoryRequestCurrent(state, authority, request)) return false; + if (state.recoveries.some((recovery) => recovery.runId === request.runId)) return false; + if (state.projection) { + if (state.projection.runId !== request.runId || state.projection.status !== "retry") + return false; + return ( + state.projection.lastSequence === request.expectedLastSequence || + (state.projection.ambiguityAcknowledged === true && + state.projection.lastSequence >= request.expectedLastSequence) + ); + } + return state.history.some((item) => item.runId === request.runId && item.status === "retry"); +} + +/** Removes only the causally confirmed run while preserving unrelated state. */ +export function removeCreateImagesRunRecord( + current: CreateImagesRendererRunState | undefined, + runId: string, +): CreateImagesRendererRunState { + const previous = current ?? EMPTY_RUN_STATE; + const projectionRemoved = previous.projection?.runId === runId; + const history = previous.history.some((item) => item.runId === runId) + ? Object.freeze(previous.history.filter((item) => item.runId !== runId)) + : previous.history; + const recoveries = previous.recoveries.some((item) => item.runId === runId) + ? Object.freeze(previous.recoveries.filter((item) => item.runId !== runId)) + : previous.recoveries; + const hasOwnedAssets = Object.values(previous.runAssetOwners).includes(runId); + const runAssetOwners = hasOwnedAssets + ? Object.freeze( + Object.fromEntries( + Object.entries(previous.runAssetOwners).filter(([, ownerRunId]) => ownerRunId !== runId), + ), + ) + : previous.runAssetOwners; + + return { + ...previous, + ...(projectionRemoved ? { projection: undefined, projectionUpdatedAt: undefined } : {}), + history, + recoveries, + runAssetOwners, + runTombstones: addRunTombstones(previous.runTombstones, [runId]), + }; +} + +export function createImagesRunOutputAssetIds( + state: CreateImagesRendererRunState | undefined, +): readonly string[] { + return state ? Object.keys(state.runAssetOwners) : []; +} + +const DEFAULT_SUBSCRIPTION_RETRY_DELAYS_MS = Object.freeze([500, 1_000, 2_000, 4_000, 8_000]); +const MAX_PENDING_SUBSCRIPTIONS = 8; + +type SubscriptionTimer = unknown; + +export interface CreateImagesRunSubscriptionControllerOptions { + workflowId: string; + subscribe(request: { workflowId: string }): Promise; + unsubscribe(request: { subscriptionId: string }): Promise | unknown; + onChanged(handler: (notification: CreateImagesRunChangedNotification) => void): () => void; + apply(result: CreateImagesRunListResult): void; + retryDelaysMs?: readonly number[]; + schedule?(callback: () => void, delayMs: number): SubscriptionTimer; + cancelSchedule?(timer: SubscriptionTimer): void; +} + +export interface CreateImagesRunSubscriptionController { + start(): void; + retryNow(): void; + dispose(): void; +} + +function boundedRetryAfter(retryAfterMs: number | undefined, fallbackMs: number): number { + if (retryAfterMs === undefined || !Number.isFinite(retryAfterMs)) return fallbackMs; + return Math.max(fallbackMs, Math.min(30_000, Math.max(250, Math.trunc(retryAfterMs)))); +} + +/** + * Owns one main-process subscription at a time. Automatic retries are bounded; + * a later focus/visibility signal can open a fresh bounded retry window. + */ +export function createImagesRunSubscriptionController( + options: CreateImagesRunSubscriptionControllerOptions, +): CreateImagesRunSubscriptionController { + const retryDelays = options.retryDelaysMs ?? DEFAULT_SUBSCRIPTION_RETRY_DELAYS_MS; + const schedule = options.schedule ?? ((callback, delayMs) => setTimeout(callback, delayMs)); + const cancelSchedule = + options.cancelSchedule ?? + ((timer: SubscriptionTimer) => clearTimeout(timer as ReturnType)); + const pending = new Map(); + let disposed = false; + let started = false; + let inFlight = false; + let attemptGeneration = 0; + let failedAttempts = 0; + let retryTimer: SubscriptionTimer | undefined; + let subscriptionId: string | undefined; + let lastStreamSequence = -1; + let removeNotificationListener: (() => void) | undefined; + + const clearRetry = () => { + if (retryTimer === undefined) return; + cancelSchedule(retryTimer); + retryTimer = undefined; + }; + + const release = (id: string) => { + void Promise.resolve(options.unsubscribe({ subscriptionId: id })).catch(() => undefined); + }; + + const rememberPending = (notification: CreateImagesRunChangedNotification) => { + const prior = pending.get(notification.subscriptionId); + if (prior && prior.streamSequence >= notification.streamSequence) return; + if (!prior && pending.size >= MAX_PENDING_SUBSCRIPTIONS) { + const oldestId = pending.keys().next().value as string | undefined; + if (oldestId) pending.delete(oldestId); + } + pending.set(notification.subscriptionId, notification); + }; + + const releaseCurrentForSnapshot = (snapshot: CreateImagesRunListResult) => { + if (snapshot.status === "ready" || !subscriptionId) return; + const releasedSubscriptionId = subscriptionId; + subscriptionId = undefined; + lastStreamSequence = -1; + pending.clear(); + release(releasedSubscriptionId); + if (snapshot.status === "unavailable") { + failedAttempts += 1; + scheduleRetry(snapshot.retryAfterMs); + } + }; + + const onNotification = (notification: CreateImagesRunChangedNotification) => { + if (disposed) return; + if (!subscriptionId) { + rememberPending(notification); + return; + } + if ( + notification.subscriptionId === subscriptionId && + notification.streamSequence > lastStreamSequence + ) { + lastStreamSequence = notification.streamSequence; + options.apply(notification.snapshot); + releaseCurrentForSnapshot(notification.snapshot); + } + }; + + const scheduleRetry = (retryAfterMs?: number) => { + if (disposed || retryTimer !== undefined || failedAttempts > retryDelays.length) return; + const fallbackMs = retryDelays[Math.max(0, failedAttempts - 1)]; + if (fallbackMs === undefined) return; + retryTimer = schedule( + () => { + retryTimer = undefined; + void attempt(); + }, + boundedRetryAfter(retryAfterMs, fallbackMs), + ); + }; + + const attempt = async () => { + if (disposed || inFlight || subscriptionId) return; + inFlight = true; + const generation = ++attemptGeneration; + try { + const result = await options.subscribe({ workflowId: options.workflowId }); + if (disposed || generation !== attemptGeneration) { + if (result.status === "ready") release(result.subscriptionId); + return; + } + if (result.status !== "ready") { + failedAttempts += 1; + options.apply( + result.status === "not-found" + ? { status: "not-found" } + : { + status: "unavailable", + message: result.message, + ...(result.retryAfterMs === undefined ? {} : { retryAfterMs: result.retryAfterMs }), + }, + ); + if (result.status === "unavailable") scheduleRetry(result.retryAfterMs); + return; + } + subscriptionId = result.subscriptionId; + lastStreamSequence = result.streamSequence; + failedAttempts = 0; + clearRetry(); + options.apply(result.snapshot); + releaseCurrentForSnapshot(result.snapshot); + if (!subscriptionId) return; + const pendingSnapshot = pending.get(result.subscriptionId); + if (pendingSnapshot && pendingSnapshot.streamSequence > lastStreamSequence) { + lastStreamSequence = pendingSnapshot.streamSequence; + options.apply(pendingSnapshot.snapshot); + releaseCurrentForSnapshot(pendingSnapshot.snapshot); + } + pending.clear(); + } catch { + if (disposed || generation !== attemptGeneration) return; + failedAttempts += 1; + options.apply({ + status: "unavailable", + message: "Run updates are temporarily unavailable.", + }); + scheduleRetry(); + } finally { + if (generation === attemptGeneration) inFlight = false; + } + }; + + return { + start() { + if (started || disposed) return; + started = true; + removeNotificationListener = options.onChanged(onNotification); + void attempt(); + }, + retryNow() { + if (disposed || subscriptionId || inFlight) return; + clearRetry(); + failedAttempts = 0; + void attempt(); + }, + dispose() { + if (disposed) return; + disposed = true; + attemptGeneration += 1; + clearRetry(); + removeNotificationListener?.(); + removeNotificationListener = undefined; + pending.clear(); + if (subscriptionId) release(subscriptionId); + subscriptionId = undefined; + }, + }; +} diff --git a/renderer/create-images/run-ui-core.test.ts b/renderer/create-images/run-ui-core.test.ts new file mode 100644 index 00000000..926af065 --- /dev/null +++ b/renderer/create-images/run-ui-core.test.ts @@ -0,0 +1,2489 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createImagesRunConfirmationViewModel, + createImagesDegradedRunDiscardRequest, + createImagesRunErrorViewModel, + createImagesRunUiProjection, + createImagesTerminalRunHistoryViews, + formatCreateImagesEstimate, + reduceCreateImagesRunUiEvent, + summarizeCreateImagesRunProgress, + type CreateImagesNodeRunUiState, + type CreateImagesRunConfirmationInput, + type CreateImagesRunUiEvent, + type CreateImagesRunUiSnapshot, +} from "./run-ui-core"; +import { + createImagesRunProjectionFromView, + createImagesSelectedRunSnapshotTransition, + createImagesRunSubscriptionController, + isCreateImagesRunAmbiguityRequestCurrent, + isCreateImagesRunHistoryRequestCurrent, + isCreateImagesRunRecoveryRequestCurrent, + reconcileCreateImagesRunMutation, + reconcileCreateImagesRunState, + removeCreateImagesRunRecord, +} from "./run-ui-adapter"; +import type { + CreateImagesRunChangedNotification, + CreateImagesRunListResult, + CreateImagesRunSubscriptionResult, + CreateImagesTerminalRunView, + CreateImagesRunView, +} from "../shared/create-images/ipc"; +import { + CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY, + createImagesLocalMockAttemptBudget, +} from "../shared/create-images/retry-policy"; + +const identity = { + workflowId: "workflow-1", + workflowRevision: 7, + runId: "run-1", +} as const; + +function confirmation( + overrides: Partial = {}, +): CreateImagesRunConfirmationInput { + return { + workflowId: "workflow-1", + workflowTitle: "Campaign key art", + workflowRevision: 7, + scope: { kind: "all", includedNodeCount: 4 }, + executionMode: "local-mock", + providerLabel: "Aiden local mock", + modelLabel: "Deterministic checkerboard", + remoteRequestCount: 1, + outputCount: 1, + imageSizeLabel: "1024 × 1024", + qualityLabel: "Preview quality", + referenceImageCount: 0, + sendsPrompt: true, + estimate: { + kind: "mock", + amount: 0, + currency: "USD", + estimatedAt: "2026-08-11T12:00:00.000Z", + sourceLabel: "Deterministic Phase 3 estimate", + }, + ...overrides, + }; +} + +function snapshot(overrides: Partial = {}): CreateImagesRunUiSnapshot { + return { + ...identity, + status: "running", + lastSequence: 0, + nodes: [ + { nodeId: "prompt-1", label: "Prompt", status: "queued", attempt: 0 }, + { nodeId: "generate-1", label: "Generate Image", status: "queued", attempt: 0 }, + ], + ...overrides, + }; +} + +type EventWithoutIdentity = T extends CreateImagesRunUiEvent + ? Omit + : never; + +function event(value: EventWithoutIdentity): CreateImagesRunUiEvent { + return { ...identity, ...value } as CreateImagesRunUiEvent; +} + +test("local mock confirmation states $0 and no network or billable work", () => { + const model = createImagesRunConfirmationViewModel(confirmation()); + assert.equal(model.title, "Run workflow?"); + assert.equal(model.confirmLabel, "Run mock workflow"); + assert.match(model.estimateLabel, /0[.,]00.*mock estimate/iu); + assert.equal(model.isMock, true); + assert.match( + model.rows.find((row) => row.id === "privacy")?.value ?? "", + /nothing leaves this Mac/u, + ); + assert.match(model.privacyNotices.join(" "), /no provider request/u); + assert.match(model.privacyNotices.join(" "), /no billable work/u); + assert.match(model.consentStatement, /reviewed this mock run plan/u); +}); + +test("degraded-run discard echoes only the reviewed plan's CAS revisions and token", () => { + const plan = { + status: "ready" as const, + runId: "run-unsafe-1", + reason: "unsafe-storage" as const, + association: "unassociated" as const, + expectedCurrentJournalRevision: 9, + authorizationToken: "c".repeat(64), + mayLoseOutputs: true as const, + mayDuplicateProviderWork: true as const, + }; + assert.equal(createImagesDegradedRunDiscardRequest(plan, false), undefined); + assert.deepEqual(createImagesDegradedRunDiscardRequest(plan, true), { + runId: "run-unsafe-1", + expectedCurrentJournalRevision: 9, + authorizationToken: "c".repeat(64), + confirmed: true, + }); +}); + +function runView(overrides: Partial = {}): CreateImagesRunView { + return { + runId: "run-1", + workflowId: "workflow-1", + workflowRevision: 7, + journalRevision: 1, + status: "running", + lastSequence: 0, + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:00:00.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "queued", + attempt: 0, + outputAssetIds: [], + }, + ], + ...overrides, + }; +} + +function terminalRunView( + overrides: Partial = {}, +): CreateImagesTerminalRunView { + return { + runId: "run-1", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:01:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + ...overrides, + }; +} + +test("shared run snapshots map retries, ambiguity, safe errors, and output IDs", () => { + const assetId = "a".repeat(64); + const projection = createImagesRunProjectionFromView( + runView({ + lastSequence: 9, + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "running", + attempt: 1, + outputAssetIds: [assetId], + retrySafety: "confirmed-not-submitted", + errorCode: "rate-limited", + }, + { + nodeId: "generate-2", + label: "Generate Image 2", + status: "ambiguous", + attempt: 1, + outputAssetIds: [], + }, + ], + }), + ); + assert.equal(projection.nodes["generate-1"]?.status, "retry"); + assert.equal(projection.nodes["generate-1"]?.retryMode, "automatic-mock"); + assert.deepEqual(projection.nodes["generate-1"]?.outputAssetIds, [assetId]); + assert.equal(projection.nodes["generate-2"]?.status, "retry"); + assert.equal(projection.nodes["generate-2"]?.retryMode, "manual-review"); + assert.equal(projection.nodes["generate-2"]?.error?.code, "submission_ambiguous"); + assert.equal(projection.nodes["generate-2"]?.error?.retryKind, "none"); +}); + +test("an acknowledged ambiguity preserves the warning but releases the renderer admission block", () => { + const projection = createImagesRunProjectionFromView( + runView({ + status: "needs_attention", + journalRevision: 8, + lastSequence: 7, + ambiguityResolution: { + kind: "acknowledged-unresolved-submission", + acknowledgedAt: "2026-08-11T12:00:07.000Z", + acknowledgedAtJournalRevision: 8, + }, + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "ambiguous", + attempt: 1, + outputAssetIds: [], + }, + ], + }), + ); + assert.equal(projection.status, "retry"); + assert.equal(projection.nodes["generate-1"]?.retryMode, "manual-review"); + assert.equal(projection.ambiguityAcknowledged, true); +}); + +test("an authoritative acknowledgement snapshot immediately clears the renderer admission block", () => { + const ambiguousRun = runView({ + status: "needs_attention", + lastSequence: 7, + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "ambiguous", + attempt: 1, + outputAssetIds: [], + }, + ], + }); + const terminal = { + runId: "run-1", + workflowRevision: 7, + status: "needs_attention" as const, + scope: { kind: "all" as const }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:00:07.000Z", + requestCount: 1, + outputCount: 0, + completedNodeCount: 0, + totalNodeCount: 1, + }; + const unresolved = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: ambiguousRun, + history: [terminal], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(unresolved.projection?.ambiguityAcknowledged, undefined); + + const ambiguityResolution = { + kind: "acknowledged-unresolved-submission" as const, + acknowledgedAt: "2026-08-11T12:00:08.000Z", + acknowledgedAtJournalRevision: 9, + }; + const acknowledged = reconcileCreateImagesRunState( + unresolved, + { + status: "ready", + authoritative: true, + latestTerminalRun: { + ...ambiguousRun, + journalRevision: 9, + lastSequence: 8, + updatedAt: ambiguityResolution.acknowledgedAt, + ambiguityResolution, + }, + history: [ + { ...terminal, updatedAt: ambiguityResolution.acknowledgedAt, ambiguityResolution }, + ], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(acknowledged.projection?.ambiguityAcknowledged, true); + assert.equal(acknowledged.history[0]?.ambiguityAcknowledged, true); +}); + +test("self-contained snapshots accept gaps, reject stale sequence, and retain terminal outputs", () => { + const assetId = "b".repeat(64); + const initial = reconcileCreateImagesRunState( + undefined, + { status: "ready", authoritative: true, activeRun: runView(), history: [], recoveries: [] }, + "workflow-1", + ); + const jumped = reconcileCreateImagesRunState( + initial, + { + status: "ready", + authoritative: true, + activeRun: runView({ + lastSequence: 7, + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [assetId], + }, + ], + }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(jumped.projection?.lastSequence, 7); + assert.equal(jumped.runAssetOwners[assetId], "run-1"); + + const stale = reconcileCreateImagesRunState( + jumped, + { + status: "ready", + authoritative: true, + activeRun: runView({ lastSequence: 5 }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(stale.projection?.lastSequence, 7); + + const terminal: CreateImagesRunListResult = { + status: "ready", + authoritative: true, + history: [ + { + runId: "run-1", + workflowRevision: 7, + status: "needs_attention", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:00:03.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }; + const sealed = reconcileCreateImagesRunState(stale, terminal, "workflow-1"); + assert.equal(sealed.projection?.status, "retry"); + assert.equal(sealed.history[0]?.status, "retry"); + assert.equal(sealed.runAssetOwners[assetId], "run-1"); +}); + +test("a cold terminal snapshot restores node details and run-authorized output ownership", () => { + const assetId = "c".repeat(64); + const latestTerminalRun = runView({ + status: "succeeded", + lastSequence: 4, + updatedAt: "2026-08-11T12:00:04.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [assetId], + }, + ], + }); + const restored = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun, + history: [ + { + runId: "run-1", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:00:04.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(restored.projection?.status, "succeeded"); + assert.deepEqual(restored.projection?.nodes["generate-1"]?.outputAssetIds, [assetId]); + assert.equal(restored.runAssetOwners[assetId], "run-1"); +}); + +test("a stale same-run terminal snapshot cannot regress sequence or output ownership", () => { + const currentAssetId = "3".repeat(64); + const staleAssetId = "4".repeat(64); + const current = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-b", + status: "succeeded", + lastSequence: 8, + updatedAt: "2026-08-11T12:00:08.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [currentAssetId], + }, + ], + }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + + const stale = reconcileCreateImagesRunState( + current, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-b", + status: "succeeded", + lastSequence: 5, + updatedAt: "2026-08-11T12:00:05.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [staleAssetId], + }, + ], + }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(stale.projection?.runId, "run-b"); + assert.equal(stale.projection?.lastSequence, 8); + assert.deepEqual(stale.projection?.nodes["generate-1"]?.outputAssetIds, [currentAssetId]); + assert.deepEqual(stale.runAssetOwners, { [currentAssetId]: "run-b" }); + assert.equal(stale.runAssetOwners[staleAssetId], undefined); +}); + +test("authoritative retention snapshots replace or clear a tombstoned terminal projection", () => { + const newerAssetId = "5".repeat(64); + const olderAssetId = "6".repeat(64); + const currentB = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-b", + status: "succeeded", + lastSequence: 8, + createdAt: "2026-08-11T13:00:00.000Z", + updatedAt: "2026-08-11T13:01:00.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [newerAssetId], + }, + ], + }), + history: [ + { + runId: "run-b", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T13:00:00.000Z", + updatedAt: "2026-08-11T13:01:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + + const fallbackA = reconcileCreateImagesRunState( + currentB, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-a", + status: "succeeded", + lastSequence: 4, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:01:00.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [olderAssetId], + }, + ], + }), + history: [ + { + runId: "run-a", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:01:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(fallbackA.projection?.runId, "run-a"); + assert.deepEqual(fallbackA.projection?.nodes["generate-1"]?.outputAssetIds, [olderAssetId]); + assert.deepEqual(fallbackA.runAssetOwners, { [olderAssetId]: "run-a" }); + assert.equal(fallbackA.runAssetOwners[newerAssetId], undefined); + + const cleared = reconcileCreateImagesRunState( + currentB, + { status: "ready", authoritative: true, history: [], recoveries: [] }, + "workflow-1", + ); + assert.equal(cleared.projection, undefined); + assert.deepEqual(cleared.runAssetOwners, {}); +}); + +test("mutation acknowledgements reject inverse races without changing history or recoveries", () => { + const terminalAssetId = "7".repeat(64); + const activeAssetId = "8".repeat(64); + const higherAssetId = "9".repeat(64); + const terminalB = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-b", + status: "succeeded", + lastSequence: 8, + createdAt: "2026-08-11T13:00:00.000Z", + updatedAt: "2026-08-11T13:01:00.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [terminalAssetId], + }, + ], + }), + history: [ + { + runId: "run-b", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T13:00:00.000Z", + updatedAt: "2026-08-11T13:01:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + { + runId: "run-a", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:01:00.000Z", + requestCount: 1, + outputCount: 0, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [ + { + status: "recovery-required", + workflowId: "workflow-1", + runId: "run-damaged", + reason: "current-missing", + }, + ], + }, + "workflow-1", + ); + + const delayedA = reconcileCreateImagesRunMutation( + terminalB, + runView({ + runId: "run-a", + lastSequence: 2, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:01:00.000Z", + }), + "workflow-1", + ); + assert.equal(delayedA, terminalB); + assert.deepEqual(delayedA.runAssetOwners, { [terminalAssetId]: "run-b" }); + + const activeC = reconcileCreateImagesRunMutation( + terminalB, + runView({ + runId: "run-c", + lastSequence: 5, + createdAt: "2026-08-10T13:02:00.000Z", + updatedAt: "2026-08-10T13:02:00.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [activeAssetId], + }, + ], + }), + "workflow-1", + ); + assert.equal(activeC.projection?.runId, "run-c"); + assert.equal(activeC.history, terminalB.history); + assert.equal(activeC.recoveries, terminalB.recoveries); + assert.deepEqual(activeC.runAssetOwners, { [activeAssetId]: "run-c" }); + + const delayedRecoveryMutation = reconcileCreateImagesRunMutation( + terminalB, + runView({ runId: "run-damaged", lastSequence: 2 }), + "workflow-1", + ); + assert.equal(delayedRecoveryMutation, terminalB); + + const newerDifferentActive = reconcileCreateImagesRunMutation( + activeC, + runView({ + runId: "run-d", + lastSequence: 1, + createdAt: "2026-08-11T13:03:00.000Z", + updatedAt: "2026-08-11T13:03:00.000Z", + }), + "workflow-1", + ); + assert.equal(newerDifferentActive, activeC); + + const lowerC = reconcileCreateImagesRunMutation( + activeC, + runView({ runId: "run-c", lastSequence: 4, updatedAt: "2026-08-11T13:02:01.000Z" }), + "workflow-1", + ); + assert.equal(lowerC, activeC); + + const higherC = reconcileCreateImagesRunMutation( + activeC, + runView({ + runId: "run-c", + lastSequence: 6, + updatedAt: "2026-08-11T13:02:02.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [higherAssetId], + }, + ], + }), + "workflow-1", + ); + assert.equal(higherC.projection?.lastSequence, 6); + assert.equal(higherC.history, terminalB.history); + assert.equal(higherC.recoveries, terminalB.recoveries); + assert.deepEqual(higherC.runAssetOwners, { [higherAssetId]: "run-c" }); + + const wrongWorkflow = reconcileCreateImagesRunMutation( + higherC, + runView({ workflowId: "workflow-other", runId: "run-d" }), + "workflow-1", + ); + assert.equal(wrongWorkflow, higherC); +}); + +test("a partial ambiguity mutation cannot replace a newer terminal projection", () => { + const currentAssetId = "a".repeat(64); + const currentC = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-c", + status: "succeeded", + lastSequence: 12, + updatedAt: "2026-08-11T14:00:00.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [currentAssetId], + }, + ], + }), + history: [ + { + runId: "run-c", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T13:59:00.000Z", + updatedAt: "2026-08-11T14:00:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + { + runId: "run-b", + workflowRevision: 7, + status: "needs_attention", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:01:00.000Z", + requestCount: 1, + outputCount: 0, + completedNodeCount: 0, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + + const delayedAmbiguityB = reconcileCreateImagesRunMutation( + currentC, + runView({ + runId: "run-b", + status: "needs_attention", + lastSequence: 9, + ambiguityResolution: { + kind: "acknowledged-unresolved-submission", + acknowledgedAt: "2026-08-11T12:02:00.000Z", + acknowledgedAtJournalRevision: 10, + }, + }), + "workflow-1", + ); + assert.equal(delayedAmbiguityB, currentC); + assert.deepEqual(delayedAmbiguityB.runAssetOwners, { [currentAssetId]: "run-c" }); +}); + +test("causal discard, prune, and recovery state reject delayed run mutations", () => { + const discardedAssetId = "b".repeat(64); + const keptAssetId = "c".repeat(64); + const beforeDiscard = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-discarded", + status: "failed", + lastSequence: 7, + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "failed", + attempt: 1, + outputAssetIds: [discardedAssetId], + }, + ], + }), + history: [ + { + runId: "run-discarded", + workflowRevision: 7, + status: "failed", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:01:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 0, + totalNodeCount: 1, + }, + { + runId: "run-kept", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T11:00:00.000Z", + updatedAt: "2026-08-11T11:01:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [ + { + status: "recovery-required", + workflowId: "workflow-1", + runId: "run-recovery", + reason: "current-missing", + }, + ], + }, + "workflow-1", + ); + const withUnrelatedOwner = { + ...beforeDiscard, + runAssetOwners: Object.freeze({ + ...beforeDiscard.runAssetOwners, + [keptAssetId]: "run-kept", + }), + }; + const discarded = removeCreateImagesRunRecord(withUnrelatedOwner, "run-discarded"); + assert.equal(discarded.projection, undefined); + assert.deepEqual( + discarded.history.map((item) => item.runId), + ["run-kept"], + ); + assert.deepEqual( + discarded.recoveries.map((item) => item.runId), + ["run-recovery"], + ); + assert.deepEqual(discarded.runAssetOwners, { [keptAssetId]: "run-kept" }); + assert.equal(discarded.runTombstones?.includes("run-discarded"), true); + + const delayedDiscardedStart = reconcileCreateImagesRunMutation( + discarded, + runView({ runId: "run-discarded", status: "running", lastSequence: 8 }), + "workflow-1", + ); + assert.equal(delayedDiscardedStart, discarded); + const delayedRecoveryStop = reconcileCreateImagesRunMutation( + discarded, + runView({ runId: "run-recovery", status: "cancel_requested", lastSequence: 8 }), + "workflow-1", + ); + assert.equal(delayedRecoveryStop, discarded); + + const beforePrune = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ runId: "run-pruned", status: "succeeded", lastSequence: 5 }), + history: [ + { + runId: "run-pruned", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T10:00:00.000Z", + updatedAt: "2026-08-11T10:01:00.000Z", + requestCount: 1, + outputCount: 0, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + const pruned = reconcileCreateImagesRunState( + beforePrune, + { status: "ready", authoritative: true, history: [], recoveries: [] }, + "workflow-1", + ); + assert.equal(pruned.runTombstones?.includes("run-pruned"), true); + const delayedPrunedStop = reconcileCreateImagesRunMutation( + pruned, + runView({ runId: "run-pruned", status: "cancel_requested", lastSequence: 6 }), + "workflow-1", + ); + assert.equal(delayedPrunedStop, pruned); + + let bounded = discarded; + for (let index = 0; index < 300; index += 1) { + bounded = removeCreateImagesRunRecord(bounded, `run-removed-${index}`); + } + assert.equal(bounded.runTombstones?.length, 256); + assert.equal(bounded.runTombstones?.includes("run-removed-0"), false); + assert.equal(bounded.runTombstones?.includes("run-removed-299"), true); +}); + +test("unrelated authoritative snapshots preserve selected detail and ambiguity continuations", async () => { + const selectedTerminal = terminalRunView({ + runId: "run-a", + status: "needs_attention", + }); + let state = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-a", + status: "needs_attention", + lastSequence: 7, + }), + history: [selectedTerminal], + recoveries: [], + }, + "workflow-1", + ); + const authority = { + mounted: true, + lifecycleGeneration: 4, + selectedRunId: "run-a" as string | undefined, + requestSequence: 12, + }; + const detailRequest = { + runId: "run-a", + lifecycleGeneration: 4, + requestSequence: 12, + }; + let detailCommits = 0; + let resolveDetail!: () => void; + const pendingDetail = new Promise((resolve) => { + resolveDetail = resolve; + }).then(() => { + if (isCreateImagesRunHistoryRequestCurrent(state, authority, detailRequest)) { + detailCommits += 1; + } + }); + + for (const lastSequence of [1, 2]) { + const unrelatedSnapshot = { + status: "ready" as const, + authoritative: true as const, + activeRun: runView({ runId: "run-b", lastSequence }), + latestTerminalRun: runView({ + runId: "run-a", + status: "needs_attention", + lastSequence: 7, + }), + history: [selectedTerminal], + recoveries: [], + }; + assert.deepEqual(createImagesSelectedRunSnapshotTransition(unrelatedSnapshot, "run-a"), { + kind: "unchanged", + }); + state = reconcileCreateImagesRunState(state, unrelatedSnapshot, "workflow-1"); + assert.equal(isCreateImagesRunHistoryRequestCurrent(state, authority, detailRequest), true); + } + resolveDetail(); + await pendingDetail; + assert.equal(detailCommits, 1); + + const ambiguityRequest = { ...detailRequest, expectedLastSequence: 7 }; + let ambiguityState = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-a", + status: "needs_attention", + lastSequence: 7, + }), + history: [selectedTerminal], + recoveries: [], + }, + "workflow-1", + ); + const acknowledgement = { + kind: "acknowledged-unresolved-submission" as const, + acknowledgedAt: "2026-08-11T12:02:00.000Z", + acknowledgedAtJournalRevision: 2, + }; + const ownPublication = { + status: "ready" as const, + authoritative: true as const, + latestTerminalRun: runView({ + runId: "run-a", + status: "needs_attention", + lastSequence: 8, + ambiguityResolution: acknowledgement, + }), + history: [ + terminalRunView({ + ...selectedTerminal, + ambiguityResolution: acknowledgement, + }), + ], + recoveries: [], + }; + assert.deepEqual(createImagesSelectedRunSnapshotTransition(ownPublication, "run-a"), { + kind: "unchanged", + }); + ambiguityState = reconcileCreateImagesRunState(ambiguityState, ownPublication, "workflow-1"); + assert.equal( + isCreateImagesRunAmbiguityRequestCurrent(ambiguityState, authority, ambiguityRequest), + true, + ); + const completionEffects = { dialogClosed: 0, detail: 0, toast: 0, focus: 0 }; + if (isCreateImagesRunAmbiguityRequestCurrent(ambiguityState, authority, ambiguityRequest)) { + completionEffects.dialogClosed += 1; + completionEffects.detail += 1; + completionEffects.toast += 1; + completionEffects.focus += 1; + } + assert.deepEqual(completionEffects, { dialogClosed: 1, detail: 1, toast: 1, focus: 1 }); + + const unacknowledgedHigherSequence = { + ...ambiguityState, + projection: createImagesRunProjectionFromView( + runView({ runId: "run-a", status: "needs_attention", lastSequence: 8 }), + ), + }; + assert.equal( + isCreateImagesRunAmbiguityRequestCurrent( + unacknowledgedHigherSequence, + authority, + ambiguityRequest, + ), + false, + ); + const differentProjection = { + ...ambiguityState, + projection: createImagesRunProjectionFromView( + runView({ + runId: "run-b", + status: "needs_attention", + lastSequence: 8, + ambiguityResolution: acknowledgement, + }), + ), + }; + assert.equal( + isCreateImagesRunAmbiguityRequestCurrent(differentProjection, authority, ambiguityRequest), + false, + ); +}); + +test("selected recovery candidate changes and removal invalidate async ownership", () => { + const recovery = { + status: "recovery-required" as const, + workflowId: "workflow-1", + runId: "run-a", + reason: "current-corrupt" as const, + currentJournalRevision: 9, + recoverySource: "current" as const, + expectedCandidateJournalRevision: 9, + }; + const snapshot = { + status: "ready" as const, + authoritative: true as const, + history: [], + recoveries: [recovery], + }; + assert.deepEqual(createImagesSelectedRunSnapshotTransition(snapshot, "run-a", recovery), { + kind: "unchanged", + }); + const revisionChanged = { + ...snapshot, + recoveries: [{ ...recovery, expectedCandidateJournalRevision: 10 }], + }; + assert.equal( + createImagesSelectedRunSnapshotTransition(revisionChanged, "run-a", recovery).kind, + "recovery-changed", + ); + const sourceChanged = { + ...snapshot, + recoveries: [ + { + ...recovery, + reason: "last-known-good-corrupt" as const, + recoverySource: "last-known-good" as const, + }, + ], + }; + assert.equal( + createImagesSelectedRunSnapshotTransition(sourceChanged, "run-a", recovery).kind, + "recovery-changed", + ); + const statusChanged = { + ...snapshot, + recoveries: [ + { + status: "unsafe" as const, + workflowId: "workflow-1", + runId: "run-a", + reason: "current-future-schema" as const, + }, + ], + }; + assert.equal( + createImagesSelectedRunSnapshotTransition(statusChanged, "run-a", recovery).kind, + "recovery-changed", + ); + + const removedSnapshot = { + status: "ready" as const, + authoritative: true as const, + history: [], + recoveries: [], + }; + assert.deepEqual(createImagesSelectedRunSnapshotTransition(removedSnapshot, "run-a"), { + kind: "removed", + }); + const becameHealthy = { ...removedSnapshot, history: [terminalRunView({ runId: "run-a" })] }; + assert.deepEqual(createImagesSelectedRunSnapshotTransition(becameHealthy, "run-a", recovery), { + kind: "became-healthy", + }); + + let authority = { + mounted: true, + lifecycleGeneration: 5, + selectedRunId: "run-a" as string | undefined, + requestSequence: 20, + }; + const request = { + runId: "run-a", + lifecycleGeneration: 5, + requestSequence: 20, + source: "current" as const, + expectedCandidateJournalRevision: 9, + }; + let state = reconcileCreateImagesRunState(undefined, snapshot, "workflow-1"); + assert.equal(isCreateImagesRunRecoveryRequestCurrent(state, authority, request), true); + authority = { ...authority, requestSequence: 21 }; + state = reconcileCreateImagesRunState(state, revisionChanged, "workflow-1"); + assert.equal(isCreateImagesRunRecoveryRequestCurrent(state, authority, request), false); + state = removeCreateImagesRunRecord(state, "run-a"); + assert.equal(isCreateImagesRunHistoryRequestCurrent(state, authority, request), false); +}); + +test("async recovery authority expires on selection, candidate, or tombstone changes", () => { + const selectedAssetId = "d".repeat(64); + const state = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + activeRun: runView({ + runId: "run-b", + lastSequence: 3, + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [selectedAssetId], + }, + ], + }), + history: [], + recoveries: [ + { + status: "recovery-required", + workflowId: "workflow-1", + runId: "run-a", + reason: "current-corrupt", + recoverySource: "last-known-good", + expectedCandidateJournalRevision: 7, + }, + ], + }, + "workflow-1", + ); + const request = { + runId: "run-a", + lifecycleGeneration: 2, + requestSequence: 4, + source: "last-known-good" as const, + expectedCandidateJournalRevision: 7, + }; + const authority = { + mounted: true, + lifecycleGeneration: 2, + selectedRunId: "run-a", + requestSequence: 4, + }; + assert.equal(isCreateImagesRunRecoveryRequestCurrent(state, authority, request), true); + + const selectedOther = isCreateImagesRunRecoveryRequestCurrent( + state, + { ...authority, selectedRunId: "run-b", requestSequence: 5 }, + request, + ); + assert.equal(selectedOther, false); + assert.equal(state.projection?.runId, "run-b"); + assert.deepEqual(state.runAssetOwners, { [selectedAssetId]: "run-b" }); + + const changedCandidate = { + ...state, + recoveries: state.recoveries.map((recovery) => + recovery.runId === "run-a" && recovery.status === "recovery-required" + ? { ...recovery, expectedCandidateJournalRevision: 8 } + : recovery, + ), + }; + assert.equal( + isCreateImagesRunRecoveryRequestCurrent(changedCandidate, authority, request), + false, + ); + + const tombstoned = removeCreateImagesRunRecord(state, "run-a"); + assert.equal(isCreateImagesRunRecoveryRequestCurrent(tombstoned, authority, request), false); + assert.equal(tombstoned.projection?.runId, "run-b"); + assert.deepEqual(tombstoned.runAssetOwners, { [selectedAssetId]: "run-b" }); +}); + +test("unmount invalidates deferred history detail and recovery continuations", async () => { + const state = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + history: [], + recoveries: [ + { + status: "recovery-required", + workflowId: "workflow-1", + runId: "run-a", + reason: "current-corrupt", + recoverySource: "current", + expectedCandidateJournalRevision: 9, + }, + ], + }, + "workflow-1", + ); + const effects = { commit: 0, cache: 0, detail: 0, preview: 0, toast: 0, focus: 0 }; + let authority = { + mounted: true, + lifecycleGeneration: 3, + selectedRunId: "run-a" as string | undefined, + requestSequence: 6, + }; + const recoveryRequest = { + runId: "run-a", + lifecycleGeneration: 3, + requestSequence: 6, + source: "current" as const, + expectedCandidateJournalRevision: 9, + }; + let resolveRecovery!: () => void; + const recoveryResponse = new Promise((resolve) => { + resolveRecovery = resolve; + }).then(() => { + if (!isCreateImagesRunRecoveryRequestCurrent(state, authority, recoveryRequest)) return; + effects.commit += 1; + effects.cache += 1; + effects.detail += 1; + effects.preview += 1; + effects.toast += 1; + effects.focus += 1; + }); + authority = { + mounted: false, + lifecycleGeneration: 4, + selectedRunId: undefined, + requestSequence: 7, + }; + resolveRecovery(); + await recoveryResponse; + assert.deepEqual(effects, { commit: 0, cache: 0, detail: 0, preview: 0, toast: 0, focus: 0 }); + + authority = { + mounted: true, + lifecycleGeneration: 5, + selectedRunId: "run-b", + requestSequence: 8, + }; + const detailRequest = { + runId: "run-b", + lifecycleGeneration: 5, + requestSequence: 8, + }; + let resolveDetail!: () => void; + const detailResponse = new Promise((resolve) => { + resolveDetail = resolve; + }).then(() => { + if (!isCreateImagesRunHistoryRequestCurrent(state, authority, detailRequest)) return; + effects.cache += 1; + effects.detail += 1; + effects.preview += 1; + }); + authority = { + mounted: false, + lifecycleGeneration: 6, + selectedRunId: undefined, + requestSequence: 9, + }; + resolveDetail(); + await detailResponse; + assert.deepEqual(effects, { commit: 0, cache: 0, detail: 0, preview: 0, toast: 0, focus: 0 }); +}); + +test("ambiguity response authority expires on another projection, recovery, prune, or lifecycle", () => { + const ambiguous = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-a", + status: "needs_attention", + lastSequence: 7, + }), + history: [ + { + runId: "run-a", + workflowRevision: 7, + status: "needs_attention", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:01:00.000Z", + requestCount: 1, + outputCount: 0, + completedNodeCount: 0, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + const request = { + runId: "run-a", + lifecycleGeneration: 7, + requestSequence: 10, + expectedLastSequence: 7, + }; + const authority = { + mounted: true, + lifecycleGeneration: 7, + selectedRunId: "run-a", + requestSequence: 10, + }; + assert.equal(isCreateImagesRunAmbiguityRequestCurrent(ambiguous, authority, request), true); + const terminalB = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ runId: "run-b", status: "succeeded", lastSequence: 4 }), + history: [ + { + runId: "run-b", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:02:00.000Z", + updatedAt: "2026-08-11T12:03:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + const newerProjection = { ...ambiguous, projection: terminalB.projection }; + assert.equal( + isCreateImagesRunAmbiguityRequestCurrent(newerProjection, authority, request), + false, + ); + assert.equal( + isCreateImagesRunAmbiguityRequestCurrent( + ambiguous, + { ...authority, selectedRunId: "run-b", requestSequence: 11 }, + request, + ), + false, + ); + assert.equal( + isCreateImagesRunAmbiguityRequestCurrent( + ambiguous, + { ...authority, mounted: false, lifecycleGeneration: 8, requestSequence: 11 }, + request, + ), + false, + ); + + const recovering = { + ...ambiguous, + projection: undefined, + recoveries: [ + { + status: "recovery-required" as const, + workflowId: "workflow-1", + runId: "run-a", + reason: "current-corrupt" as const, + recoverySource: "current" as const, + expectedCandidateJournalRevision: 8, + }, + ], + }; + assert.equal(isCreateImagesRunAmbiguityRequestCurrent(recovering, authority, request), false); + const pruned = removeCreateImagesRunRecord(ambiguous, "run-a"); + assert.equal(isCreateImagesRunAmbiguityRequestCurrent(pruned, authority, request), false); +}); + +test("a delayed pre-start terminal snapshot cannot replace a newer active run", () => { + const active = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + activeRun: runView({ runId: "run-new", lastSequence: 3 }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + const delayed = reconcileCreateImagesRunState( + active, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-old", + status: "succeeded", + lastSequence: 9, + }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(delayed.projection?.runId, "run-new"); + assert.equal(delayed.projection?.status, "running"); +}); + +test("a coalesced terminal-to-active handoff follows the new run through completion", () => { + const oldAssetId = "e".repeat(64); + const newAssetId = "f".repeat(64); + const activeA = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + activeRun: runView({ + runId: "run-a", + lastSequence: 3, + updatedAt: "2026-08-11T12:00:02.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [oldAssetId], + }, + ], + }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + + const staleDifferentRun = reconcileCreateImagesRunState( + activeA, + { + status: "ready", + authoritative: true, + activeRun: runView({ + runId: "run-b", + updatedAt: "2026-08-11T12:00:01.000Z", + }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(staleDifferentRun.projection?.runId, "run-a"); + assert.equal(staleDifferentRun.runAssetOwners[oldAssetId], "run-a"); + + const activeB = reconcileCreateImagesRunState( + staleDifferentRun, + { + status: "ready", + authoritative: true, + activeRun: runView({ + runId: "run-b", + lastSequence: 1, + createdAt: "2026-08-11T12:00:04.000Z", + updatedAt: "2026-08-11T12:00:04.000Z", + }), + history: [ + { + runId: "run-a", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:00:03.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(activeB.projection?.runId, "run-b"); + assert.equal(activeB.projection?.status, "running"); + assert.equal(activeB.runAssetOwners[oldAssetId], undefined); + + const terminalB = reconcileCreateImagesRunState( + activeB, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-b", + status: "succeeded", + lastSequence: 4, + createdAt: "2026-08-11T12:00:04.000Z", + updatedAt: "2026-08-11T12:00:05.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [newAssetId], + }, + ], + }), + history: [ + { + runId: "run-b", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:04.000Z", + updatedAt: "2026-08-11T12:00:05.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + { + runId: "run-a", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:00:03.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(terminalB.projection?.runId, "run-b"); + assert.equal(terminalB.projection?.status, "succeeded"); + assert.deepEqual(terminalB.projection?.nodes["generate-1"]?.outputAssetIds, [newAssetId]); + assert.deepEqual(terminalB.runAssetOwners, { [newAssetId]: "run-b" }); +}); + +test("a coalesced active-to-new-terminal handoff replaces the sealed prior run", () => { + const oldAssetId = "1".repeat(64); + const newAssetId = "2".repeat(64); + const activeA = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + activeRun: runView({ + runId: "run-a", + lastSequence: 3, + updatedAt: "2026-08-11T12:00:02.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [oldAssetId], + }, + ], + }), + history: [], + recoveries: [], + }, + "workflow-1", + ); + + const terminalB = reconcileCreateImagesRunState( + activeA, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-b", + status: "succeeded", + lastSequence: 4, + createdAt: "2026-08-11T12:00:04.000Z", + updatedAt: "2026-08-11T12:00:05.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [newAssetId], + }, + ], + }), + history: [ + { + runId: "run-b", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:04.000Z", + updatedAt: "2026-08-11T12:00:05.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + { + runId: "run-a", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T12:00:00.000Z", + updatedAt: "2026-08-11T12:00:03.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [], + }, + "workflow-1", + ); + assert.equal(terminalB.projection?.runId, "run-b"); + assert.equal(terminalB.projection?.status, "succeeded"); + assert.deepEqual(terminalB.projection?.nodes["generate-1"]?.outputAssetIds, [newAssetId]); + assert.deepEqual(terminalB.runAssetOwners, { [newAssetId]: "run-b" }); + assert.equal(terminalB.runAssetOwners[oldAssetId], undefined); +}); + +test("authoritative snapshots remove stale history and a recovery tombstones its healthy view", () => { + const assetId = "d".repeat(64); + const newer = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ + runId: "run-new", + status: "succeeded", + lastSequence: 8, + createdAt: "2026-08-11T13:00:00.000Z", + updatedAt: "2026-08-11T13:01:00.000Z", + nodes: [ + { + nodeId: "generate-1", + label: "Generate Image", + status: "succeeded", + attempt: 1, + outputAssetIds: [assetId], + }, + ], + }), + history: [ + { + runId: "run-new", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T13:00:00.000Z", + updatedAt: "2026-08-11T13:01:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [ + { + status: "recovery-required", + workflowId: "workflow-1", + runId: "run-damaged", + reason: "current-corrupt", + lastKnownGoodJournalRevision: 4, + recoverySource: "last-known-good", + expectedCandidateJournalRevision: 4, + }, + ], + }, + "workflow-1", + ); + assert.equal(newer.runAssetOwners[assetId], "run-new"); + + const authoritative = reconcileCreateImagesRunState( + newer, + { + status: "ready", + authoritative: true, + latestTerminalRun: runView({ runId: "run-new", status: "succeeded" }), + history: [ + { + runId: "run-new", + workflowRevision: 7, + status: "succeeded", + scope: { kind: "all" }, + createdAt: "2026-08-11T13:00:00.000Z", + updatedAt: "2026-08-11T13:01:00.000Z", + requestCount: 1, + outputCount: 1, + completedNodeCount: 1, + totalNodeCount: 1, + }, + ], + recoveries: [ + { + status: "recovery-required", + workflowId: "workflow-1", + runId: "run-new", + reason: "last-known-good-missing", + currentJournalRevision: 8, + recoverySource: "current", + expectedCandidateJournalRevision: 8, + }, + ], + }, + "workflow-1", + ); + assert.equal(authoritative.projection, undefined); + assert.deepEqual(authoritative.history, []); + assert.deepEqual(authoritative.runAssetOwners, {}); + assert.deepEqual( + authoritative.recoveries.map((item) => item.runId), + ["run-new"], + ); +}); + +test("authoritative snapshots clear removed projections and recoveries", () => { + const previous = reconcileCreateImagesRunState( + undefined, + { + status: "ready", + authoritative: true, + activeRun: runView(), + history: [], + recoveries: [ + { + status: "recovery-required", + workflowId: "workflow-1", + runId: "run-damaged", + reason: "current-missing", + }, + ], + }, + "workflow-1", + ); + const cleared = reconcileCreateImagesRunState( + previous, + { status: "ready", authoritative: true, history: [], recoveries: [] }, + "workflow-1", + ); + assert.equal(cleared.projection, undefined); + assert.equal(cleared.projectionUpdatedAt, undefined); + assert.deepEqual(cleared.runAssetOwners, {}); + assert.deepEqual(cleared.recoveries, []); +}); + +test("run subscriptions retry with backoff and keep stream sequence monotonic", async () => { + const subscriptions: CreateImagesRunSubscriptionResult[] = [ + { status: "unavailable", message: "busy", retryAfterMs: 750 }, + { + status: "ready", + subscriptionId: "subscription-1", + streamSequence: 4, + snapshot: { status: "ready", authoritative: true, history: [], recoveries: [] }, + }, + ]; + const applied: CreateImagesRunListResult[] = []; + const scheduled: Array<{ callback: () => void; delayMs: number }> = []; + let listener: ((notification: CreateImagesRunChangedNotification) => void) | undefined; + const controller = createImagesRunSubscriptionController({ + workflowId: "workflow-1", + subscribe: async () => subscriptions.shift() ?? { status: "not-found" }, + unsubscribe: () => true, + onChanged: (handler) => { + listener = handler; + return () => { + listener = undefined; + }; + }, + apply: (result) => applied.push(result), + retryDelaysMs: [500], + schedule: (callback, delayMs) => { + scheduled.push({ callback, delayMs }); + return 1; + }, + cancelSchedule: () => undefined, + }); + controller.start(); + await Promise.resolve(); + assert.equal(scheduled[0]?.delayMs, 750); + scheduled.shift()?.callback(); + await Promise.resolve(); + listener?.({ + subscriptionId: "subscription-1", + streamSequence: 3, + snapshot: { status: "not-found" }, + }); + listener?.({ + subscriptionId: "subscription-1", + streamSequence: 5, + snapshot: { status: "not-found" }, + }); + assert.deepEqual( + applied.map((result) => result.status), + ["unavailable", "ready", "not-found"], + ); + controller.dispose(); +}); + +test("run subscription retries are bounded until an explicit retry signal", async () => { + let calls = 0; + const scheduled: Array<() => void> = []; + const controller = createImagesRunSubscriptionController({ + workflowId: "workflow-1", + subscribe: async () => { + calls += 1; + return { status: "unavailable", message: "busy" }; + }, + unsubscribe: () => true, + onChanged: () => () => undefined, + apply: () => undefined, + retryDelaysMs: [1, 2], + schedule: (callback) => { + scheduled.push(callback); + return scheduled.length; + }, + cancelSchedule: () => undefined, + }); + controller.start(); + await Promise.resolve(); + scheduled.shift()?.(); + await Promise.resolve(); + scheduled.shift()?.(); + await Promise.resolve(); + assert.equal(calls, 3); + assert.equal(scheduled.length, 0); + controller.retryNow(); + await Promise.resolve(); + assert.equal(calls, 4); + controller.dispose(); +}); + +test("an unavailable subscribed snapshot releases the stale stream and resubscribes", async () => { + let listener: ((notification: CreateImagesRunChangedNotification) => void) | undefined; + const scheduled: Array<{ callback: () => void; delayMs: number }> = []; + const released: string[] = []; + const applied: CreateImagesRunListResult[] = []; + let subscribeCount = 0; + const controller = createImagesRunSubscriptionController({ + workflowId: "workflow-1", + subscribe: async () => { + subscribeCount += 1; + return { + status: "ready", + subscriptionId: `subscription-${subscribeCount}`, + streamSequence: subscribeCount === 1 ? 4 : 10, + snapshot: { status: "ready", authoritative: true, history: [], recoveries: [] }, + }; + }, + unsubscribe: ({ subscriptionId }) => { + released.push(subscriptionId); + return true; + }, + onChanged: (handler) => { + listener = handler; + return () => { + listener = undefined; + }; + }, + apply: (result) => applied.push(result), + retryDelaysMs: [500], + schedule: (callback, delayMs) => { + scheduled.push({ callback, delayMs }); + return scheduled.length; + }, + cancelSchedule: () => undefined, + }); + controller.start(); + await Promise.resolve(); + listener?.({ + subscriptionId: "subscription-1", + streamSequence: 5, + snapshot: { status: "unavailable", message: "snapshot read failed", retryAfterMs: 900 }, + }); + assert.deepEqual(released, ["subscription-1"]); + assert.equal(scheduled[0]?.delayMs, 900); + scheduled.shift()?.callback(); + await Promise.resolve(); + listener?.({ + subscriptionId: "subscription-1", + streamSequence: 99, + snapshot: { status: "not-found" }, + }); + listener?.({ + subscriptionId: "subscription-2", + streamSequence: 11, + snapshot: { status: "ready", authoritative: true, history: [], recoveries: [] }, + }); + assert.deepEqual( + applied.map((result) => result.status), + ["ready", "unavailable", "ready", "ready"], + ); + controller.dispose(); + assert.deepEqual(released, ["subscription-1", "subscription-2"]); +}); + +test("overlapping subscription controllers retain independent ownership through disposal and retry", async () => { + const listeners = new Set<(notification: CreateImagesRunChangedNotification) => void>(); + const subscriptionIds = ["subscription-old", "subscription-new", "subscription-new-retry"]; + const released: string[] = []; + const oldApplied: CreateImagesRunListResult[] = []; + const newApplied: CreateImagesRunListResult[] = []; + const scheduled: Array<() => void> = []; + const common = { + workflowId: "workflow-1", + subscribe: async (): Promise => ({ + status: "ready", + subscriptionId: subscriptionIds.shift() ?? "unexpected-subscription", + streamSequence: 0, + snapshot: { status: "ready", authoritative: true, history: [], recoveries: [] }, + }), + unsubscribe: ({ subscriptionId }: { subscriptionId: string }) => { + released.push(subscriptionId); + return true; + }, + onChanged: (handler: (notification: CreateImagesRunChangedNotification) => void) => { + listeners.add(handler); + return () => listeners.delete(handler); + }, + retryDelaysMs: [1], + schedule: (callback: () => void) => { + scheduled.push(callback); + return scheduled.length; + }, + cancelSchedule: () => undefined, + }; + const oldController = createImagesRunSubscriptionController({ + ...common, + apply: (result) => oldApplied.push(result), + }); + const newController = createImagesRunSubscriptionController({ + ...common, + apply: (result) => newApplied.push(result), + }); + + oldController.start(); + await Promise.resolve(); + newController.start(); + await Promise.resolve(); + oldController.dispose(); + assert.deepEqual(released, ["subscription-old"]); + + for (const listener of listeners) { + listener({ + subscriptionId: "subscription-new", + streamSequence: 1, + snapshot: { status: "unavailable", message: "retry this stream" }, + }); + } + assert.deepEqual(released, ["subscription-old", "subscription-new"]); + assert.equal(scheduled.length, 1); + scheduled.shift()?.(); + await Promise.resolve(); + for (const listener of listeners) { + listener({ + subscriptionId: "subscription-new-retry", + streamSequence: 1, + snapshot: { status: "not-found" }, + }); + } + + assert.deepEqual( + oldApplied.map((result) => result.status), + ["ready"], + ); + assert.deepEqual( + newApplied.map((result) => result.status), + ["ready", "unavailable", "ready", "not-found"], + ); + newController.dispose(); + assert.deepEqual(released, ["subscription-old", "subscription-new", "subscription-new-retry"]); + assert.equal(listeners.size, 0); +}); + +test("a notification that races the subscribe response applies only above its baseline", async () => { + let listener: ((notification: CreateImagesRunChangedNotification) => void) | undefined; + let resolveSubscription: ((result: CreateImagesRunSubscriptionResult) => void) | undefined; + const applied: CreateImagesRunListResult[] = []; + const controller = createImagesRunSubscriptionController({ + workflowId: "workflow-1", + subscribe: () => + new Promise((resolve) => { + resolveSubscription = resolve; + }), + unsubscribe: () => true, + onChanged: (handler) => { + listener = handler; + return () => undefined; + }, + apply: (result) => applied.push(result), + }); + controller.start(); + listener?.({ + subscriptionId: "subscription-race", + streamSequence: 6, + snapshot: { status: "not-found" }, + }); + resolveSubscription?.({ + status: "ready", + subscriptionId: "subscription-race", + streamSequence: 5, + snapshot: { status: "ready", authoritative: true, history: [], recoveries: [] }, + }); + await Promise.resolve(); + assert.deepEqual( + applied.map((result) => result.status), + ["ready", "not-found"], + ); + controller.dispose(); +}); + +test("run-from-here confirmation names the path and exact bounded retry accounting", () => { + const model = createImagesRunConfirmationViewModel( + confirmation({ + scope: { + kind: "from-node", + startNodeId: "generate-1", + startNodeLabel: "Generate Image", + includedNodeCount: 3, + downstreamPathLabels: ["Generate Image", "Output Gallery"], + }, + remoteRequestCount: 2, + }), + ); + assert.equal(model.title, "Run from here?"); + assert.match(model.rows[0]?.value ?? "", /Generate Image · 3 nodes/u); + assert.match(model.rows[0]?.detail ?? "", /Generate Image → Output Gallery/u); + const budget = createImagesLocalMockAttemptBudget(2); + assert.equal( + model.rows.find((row) => row.id === "requests")?.value, + `${budget.initialGenerationRequests} initial requests · up to ${budget.maximumTotalAttempts} total attempts`, + ); + assert.match( + model.rows.find((row) => row.id === "requests")?.detail ?? "", + new RegExp( + `up to ${budget.maximumAutomaticRetryAttempts} safe automatic retry attempts \\(${CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY.maxRetriesPerNode} per generation node\\)`, + "u", + ), + ); + assert.match(model.rows.find((row) => row.id === "requests")?.detail ?? "", /costs \$0/u); +}); + +test("local mock attempt budget fails closed before integer overflow", () => { + assert.throws( + () => createImagesLocalMockAttemptBudget(Number.MAX_SAFE_INTEGER), + /safe integer range/u, + ); +}); + +test("long selected paths stay readable without hiding their endpoint", () => { + const model = createImagesRunConfirmationViewModel( + confirmation({ + scope: { + kind: "from-node", + startNodeId: "prompt-1", + startNodeLabel: "Prompt · prompt-1", + includedNodeCount: 8, + downstreamPathLabels: [ + "Generate · generation-1", + "Output · output-1", + "Generate · generation-2", + "Output · output-2", + "Generate · generation-3", + "Gallery · gallery-1", + ], + }, + }), + ); + assert.equal( + model.rows[0]?.detail, + "Selected path: Generate · generation-1 → Output · output-1 → … 3 more → Gallery · gallery-1", + ); +}); + +test("cloud confirmation makes transfer, rights, cost, and advisory cancellation explicit", () => { + const model = createImagesRunConfirmationViewModel( + confirmation({ + executionMode: "cloud", + providerLabel: "Example Images", + modelLabel: "Example v1", + referenceImageCount: 2, + firstCloudUse: true, + estimate: { + kind: "best-effort", + amount: 0.08, + currency: "USD", + estimatedAt: "2026-08-11T12:00:00.000Z", + sourceLabel: "Provider price snapshot", + }, + }), + ); + assert.equal(model.isMock, false); + assert.equal(model.confirmLabel, "Confirm & run"); + assert.match( + model.rows.find((row) => row.id === "privacy")?.value ?? "", + /sent to Example Images/u, + ); + assert.match(model.privacyNotices.join(" "), /leave this Mac/u); + assert.match(model.privacyNotices.join(" "), /rights and consent/u); + assert.match(model.privacyNotices.join(" "), /may not prevent provider completion or billing/u); + assert.match(model.privacyNotices.join(" "), /first cloud image run/u); +}); + +test("confirmation rejects unsafe counts and estimate precision", () => { + assert.throws( + () => createImagesRunConfirmationViewModel(confirmation({ remoteRequestCount: -1 })), + /Remote request count/u, + ); + assert.throws( + () => + formatCreateImagesEstimate({ + kind: "best-effort", + amount: Number.NaN, + currency: "USD", + estimatedAt: "2026-08-11T12:00:00.000Z", + sourceLabel: "Snapshot", + }), + /priced estimate/u, + ); + assert.equal( + formatCreateImagesEstimate({ + kind: "unavailable", + estimatedAt: "2026-08-11T12:00:00.000Z", + sourceLabel: "Provider did not publish a price", + }), + "Estimate unavailable", + ); +}); + +test("projection accepts only contiguous events for the exact run identity", () => { + const initial = createImagesRunUiProjection(snapshot()); + assert.equal(initial.nodes["prompt-1"]?.attempt, 0); + const running = reduceCreateImagesRunUiEvent( + initial, + event({ kind: "node-status", sequence: 1, nodeId: "prompt-1", status: "running", attempt: 1 }), + ); + assert.equal(running.lastSequence, 1); + assert.equal(running.nodes["prompt-1"]?.status, "running"); + assert.match(running.announcement, /Prompt: running/u); + + const progressed = reduceCreateImagesRunUiEvent( + running, + event({ + kind: "node-progress", + sequence: 2, + nodeId: "prompt-1", + completed: 1, + total: 4, + label: "Preparing prompt", + }), + ); + assert.equal(progressed.nodes["prompt-1"]?.progress?.completed, 1); + assert.match(progressed.announcement, /25 percent/u); +}); + +test("projection suppresses another revision, run, duplicate, and sequence gap", () => { + const initial = createImagesRunUiProjection(snapshot()); + const wrongRevision = reduceCreateImagesRunUiEvent(initial, { + ...event({ kind: "run-status", sequence: 1, status: "succeeded" }), + workflowRevision: 8, + }); + const wrongRun = reduceCreateImagesRunUiEvent(wrongRevision, { + ...event({ kind: "run-status", sequence: 1, status: "succeeded" }), + runId: "run-2", + }); + const gap = reduceCreateImagesRunUiEvent( + wrongRun, + event({ kind: "run-status", sequence: 2, status: "succeeded" }), + ); + const accepted = reduceCreateImagesRunUiEvent( + gap, + event({ kind: "node-status", sequence: 1, nodeId: "prompt-1", status: "running", attempt: 1 }), + ); + const duplicate = reduceCreateImagesRunUiEvent( + accepted, + event({ kind: "node-status", sequence: 1, nodeId: "prompt-1", status: "running", attempt: 1 }), + ); + assert.equal(duplicate.lastSequence, 1); + assert.equal(duplicate.ignoredEventCount, 4); +}); + +test("automatic local mock retry advances only at the next attempt", () => { + const initial = createImagesRunUiProjection( + snapshot({ + nodes: [{ nodeId: "generate-1", label: "Generate Image", status: "running", attempt: 1 }], + }), + ); + const waiting = reduceCreateImagesRunUiEvent( + initial, + event({ + kind: "node-status", + sequence: 1, + nodeId: "generate-1", + status: "retry", + attempt: 1, + retryMode: "automatic-mock", + error: { code: "rate_limited", retryKind: "local" }, + }), + ); + const wrongAttempt = reduceCreateImagesRunUiEvent( + waiting, + event({ + kind: "node-status", + sequence: 2, + nodeId: "generate-1", + status: "running", + attempt: 3, + }), + ); + assert.equal(wrongAttempt.lastSequence, 1); + const resumed = reduceCreateImagesRunUiEvent( + wrongAttempt, + event({ + kind: "node-status", + sequence: 2, + nodeId: "generate-1", + status: "running", + attempt: 2, + }), + ); + assert.equal(resumed.lastSequence, 2); + assert.equal(resumed.nodes["generate-1"]?.attempt, 2); + assert.equal(resumed.nodes["generate-1"]?.status, "running"); + assert.equal(resumed.nodes["generate-1"]?.error, undefined); +}); + +test("manual or paid retry remains terminal for the current run attempt", () => { + const initial = createImagesRunUiProjection( + snapshot({ + nodes: [{ nodeId: "generate-1", label: "Generate Image", status: "running", attempt: 1 }], + }), + ); + const review = reduceCreateImagesRunUiEvent( + initial, + event({ + kind: "node-status", + sequence: 1, + nodeId: "generate-1", + status: "retry", + attempt: 1, + retryMode: "manual-review", + error: { code: "rate_limited", retryKind: "remote" }, + }), + ); + const forbiddenResume = reduceCreateImagesRunUiEvent( + review, + event({ + kind: "node-status", + sequence: 2, + nodeId: "generate-1", + status: "running", + attempt: 2, + }), + ); + assert.equal(forbiddenResume.lastSequence, 1); + assert.equal(forbiddenResume.nodes["generate-1"]?.status, "retry"); + assert.equal(forbiddenResume.ignoredEventCount, 1); +}); + +test("terminal run state suppresses late completion even with the next sequence", () => { + const initial = createImagesRunUiProjection(snapshot({ status: "stopping" })); + const cancelled = reduceCreateImagesRunUiEvent( + initial, + event({ kind: "run-status", sequence: 1, status: "cancelled" }), + ); + const late = reduceCreateImagesRunUiEvent( + cancelled, + event({ + kind: "node-status", + sequence: 2, + nodeId: "prompt-1", + status: "succeeded", + attempt: 0, + }), + ); + assert.equal(late.status, "cancelled"); + assert.equal(late.lastSequence, 1); + assert.equal(late.nodes["prompt-1"]?.status, "queued"); +}); + +test("safe error presentation never enables ambiguous or automatic retry", () => { + const ambiguous = createImagesRunErrorViewModel({ + code: "submission_ambiguous", + retryKind: "remote", + }); + assert.equal(ambiguous.retry.available, false); + assert.equal(ambiguous.retry.automatic, false); + assert.doesNotMatch(ambiguous.nextStep, /retry now/iu); + assert.match(ambiguous.nextStep, /Do not retry/u); + assert.match(ambiguous.nextStep, /explicitly acknowledge/u); + + const limited = createImagesRunErrorViewModel({ + code: "rate_limited", + retryKind: "remote", + retainedOutputCount: 2, + }); + assert.equal(limited.retry.available, true); + assert.equal(limited.retry.requiresConfirmation, true); + assert.equal(limited.retry.label, "Review & retry"); + assert.match(limited.retainedOutputLabel ?? "", /2 completed outputs retained locally/u); + assert.match(limited.nextStep, /will not submit a paid retry automatically/u); + + const interrupted = createImagesRunErrorViewModel({ + code: "interrupted", + retryKind: "local", + }); + assert.match(interrupted.description, /could not safely continue/u); + assert.doesNotMatch(interrupted.description, /restart/iu); +}); + +test("progress summary counts every visible terminal outcome without using color", () => { + const nodes: CreateImagesNodeRunUiState[] = [ + { nodeId: "1", label: "One", status: "succeeded", sequence: 1, attempt: 1 }, + { nodeId: "2", label: "Two", status: "running", sequence: 1, attempt: 1 }, + { nodeId: "3", label: "Three", status: "queued", sequence: 1, attempt: 1 }, + { nodeId: "4", label: "Four", status: "blocked", sequence: 1, attempt: 1 }, + { + nodeId: "5", + label: "Five", + status: "retry", + sequence: 1, + attempt: 1, + retryMode: "manual-review", + }, + ]; + assert.deepEqual(summarizeCreateImagesRunProgress(nodes), { + completed: 3, + total: 5, + active: 1, + waiting: 1, + failed: 1, + percentage: 60, + label: "3 nodes finished of 5", + }); +}); + +test("automatic mock retry-wait remains nonterminal progress", () => { + const nodes: CreateImagesNodeRunUiState[] = [ + { nodeId: "1", label: "One", status: "succeeded", sequence: 1, attempt: 1 }, + { + nodeId: "2", + label: "Two", + status: "retry", + sequence: 2, + attempt: 1, + retryMode: "automatic-mock", + }, + { nodeId: "3", label: "Three", status: "queued", sequence: 2, attempt: 0 }, + ]; + assert.deepEqual(summarizeCreateImagesRunProgress(nodes), { + completed: 1, + total: 3, + active: 0, + waiting: 2, + failed: 0, + percentage: 33, + label: "1 node finished of 3", + }); +}); + +test("terminal history is newest first, durable, and does not mutate its input", () => { + const input = [ + { + runId: "older", + workflowRevision: 6, + scopeLabel: "Entire workflow", + status: "succeeded" as const, + startedAt: "2026-08-11T10:00:00.000Z", + finishedAt: "2026-08-11T10:01:05.000Z", + providerLabel: "Aiden local mock", + modelLabel: "Checkerboard", + requestCount: 1, + completedNodeCount: 4, + totalNodeCount: 4, + outputCount: 1, + costLabel: "$0.00 mock", + }, + { + runId: "newer", + workflowRevision: 7, + scopeLabel: "From Generate Image", + status: "interrupted" as const, + startedAt: "2026-08-11T11:00:00.000Z", + finishedAt: "2026-08-11T11:00:09.000Z", + providerLabel: "Aiden local mock", + modelLabel: "Checkerboard", + requestCount: 1, + completedNodeCount: 1, + totalNodeCount: 3, + outputCount: 0, + costLabel: "$0.00 mock", + }, + ]; + const views = createImagesTerminalRunHistoryViews(input); + assert.deepEqual( + input.map((item) => item.runId), + ["older", "newer"], + ); + assert.deepEqual( + views.map((item) => item.runId), + ["newer", "older"], + ); + assert.equal(views[0]?.durationLabel, "9s"); + assert.equal(views[0]?.nodeSummary, "1 of 3 nodes succeeded"); + assert.equal(views[1]?.durationLabel, "1m 5s"); +}); diff --git a/renderer/create-images/run-ui-core.ts b/renderer/create-images/run-ui-core.ts new file mode 100644 index 00000000..1522ef2b --- /dev/null +++ b/renderer/create-images/run-ui-core.ts @@ -0,0 +1,785 @@ +import { + CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY, + createImagesLocalMockAttemptBudget, +} from "../shared/create-images/retry-policy"; +import type { + CreateImagesDegradedRunDiscardPlanResult, + CreateImagesDiscardDegradedRunRequest, +} from "../shared/create-images/ipc"; + +export function createImagesDegradedRunDiscardRequest( + plan: Extract, + reviewed: boolean, +): CreateImagesDiscardDegradedRunRequest | undefined { + if (!reviewed) return undefined; + return Object.freeze({ + runId: plan.runId, + ...(plan.expectedCurrentJournalRevision === undefined + ? {} + : { expectedCurrentJournalRevision: plan.expectedCurrentJournalRevision }), + ...(plan.expectedLastKnownGoodJournalRevision === undefined + ? {} + : { expectedLastKnownGoodJournalRevision: plan.expectedLastKnownGoodJournalRevision }), + authorizationToken: plan.authorizationToken, + confirmed: true, + }); +} + +export type CreateImagesNodeRunUiStatus = + | "queued" + | "running" + | "retry" + | "blocked" + | "failed" + | "cancelled" + | "succeeded"; + +export type CreateImagesRunUiStatus = + | "awaiting-consent" + | "queued" + | "running" + | "stopping" + | "retry" + | "failed" + | "cancelled" + | "succeeded" + | "interrupted"; + +export type CreateImagesRunScopeView = + | { kind: "all"; includedNodeCount: number } + | { + kind: "from-node"; + startNodeId: string; + startNodeLabel: string; + includedNodeCount: number; + downstreamPathLabels: readonly string[]; + }; + +export interface CreateImagesMockEstimate { + kind: "mock" | "best-effort" | "unavailable"; + amount?: number; + currency?: string; + estimatedAt: string; + sourceLabel: string; +} + +export interface CreateImagesRunConfirmationInput { + workflowId: string; + workflowTitle: string; + workflowRevision: number; + scope: CreateImagesRunScopeView; + executionMode: "local-mock" | "cloud"; + providerLabel: string; + modelLabel: string; + remoteRequestCount: number; + outputCount: number; + imageSizeLabel: string; + qualityLabel: string; + referenceImageCount: number; + sendsPrompt: boolean; + estimate: CreateImagesMockEstimate; + firstCloudUse?: boolean; +} + +export interface CreateImagesRunConfirmationRow { + id: "scope" | "destination" | "requests" | "outputs" | "estimate" | "privacy"; + label: string; + value: string; + detail?: string; +} + +export interface CreateImagesRunConfirmationViewModel { + title: string; + confirmLabel: string; + workflowId: string; + workflowRevision: number; + scopeKind: CreateImagesRunScopeView["kind"]; + rows: readonly CreateImagesRunConfirmationRow[]; + privacyNotices: readonly string[]; + consentStatement: string; + estimateLabel: string; + isMock: boolean; +} + +const COUNT_FORMATTER = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }); + +function boundedCount(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer.`); + } + return value; +} + +function nonEmptyLabel(value: string, label: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`${label} is required.`); + return normalized; +} + +function plural(count: number, singular: string, multiple = `${singular}s`): string { + return `${COUNT_FORMATTER.format(count)} ${count === 1 ? singular : multiple}`; +} + +export function formatCreateImagesEstimate(estimate: CreateImagesMockEstimate): string { + if (!Number.isFinite(Date.parse(estimate.estimatedAt))) { + throw new Error("The estimate timestamp must be an ISO-compatible date."); + } + nonEmptyLabel(estimate.sourceLabel, "Estimate source"); + if (estimate.kind === "unavailable") return "Estimate unavailable"; + if ( + estimate.amount === undefined || + !Number.isFinite(estimate.amount) || + estimate.amount < 0 || + !estimate.currency + ) { + throw new Error("A priced estimate requires a non-negative amount and currency."); + } + let amount: string; + try { + amount = new Intl.NumberFormat(undefined, { + style: "currency", + currency: estimate.currency, + minimumFractionDigits: 2, + maximumFractionDigits: 4, + }).format(estimate.amount); + } catch { + throw new Error("The estimate currency is invalid."); + } + return estimate.kind === "mock" ? `${amount} mock estimate` : `About ${amount}`; +} + +function scopePresentation(scope: CreateImagesRunScopeView): { + title: string; + value: string; + detail?: string; +} { + const includedNodeCount = boundedCount(scope.includedNodeCount, "Included node count"); + if (scope.kind === "all") { + return { + title: "Run workflow?", + value: `Entire workflow · ${plural(includedNodeCount, "node")}`, + }; + } + const startNodeLabel = nonEmptyLabel(scope.startNodeLabel, "Start node label"); + const downstreamPathLabels = scope.downstreamPathLabels.map((label) => + nonEmptyLabel(label, "Downstream path label"), + ); + const downstreamPathDetail = + downstreamPathLabels.length <= 4 + ? downstreamPathLabels.join(" → ") + : `${downstreamPathLabels[0]} → ${downstreamPathLabels[1]} → … ${downstreamPathLabels.length - 3} more → ${downstreamPathLabels[downstreamPathLabels.length - 1]}`; + return { + title: "Run from here?", + value: `${startNodeLabel} · ${plural(includedNodeCount, "node")}`, + detail: + downstreamPathLabels.length > 0 + ? `Selected path: ${downstreamPathDetail}` + : "Required inputs and the selected node are included.", + }; +} + +export function createImagesRunConfirmationViewModel( + input: CreateImagesRunConfirmationInput, +): CreateImagesRunConfirmationViewModel { + nonEmptyLabel(input.workflowId, "Workflow ID"); + nonEmptyLabel(input.workflowTitle, "Workflow title"); + if (!Number.isSafeInteger(input.workflowRevision) || input.workflowRevision < 0) { + throw new Error("Workflow revision must be a non-negative safe integer."); + } + const provider = nonEmptyLabel(input.providerLabel, "Provider label"); + const model = nonEmptyLabel(input.modelLabel, "Model label"); + const imageSize = nonEmptyLabel(input.imageSizeLabel, "Image size label"); + const quality = nonEmptyLabel(input.qualityLabel, "Quality label"); + const remoteRequests = boundedCount(input.remoteRequestCount, "Remote request count"); + const outputCount = boundedCount(input.outputCount, "Output count"); + const referenceImageCount = boundedCount(input.referenceImageCount, "Reference image count"); + const scope = scopePresentation(input.scope); + const estimateLabel = formatCreateImagesEstimate(input.estimate); + const isMock = input.executionMode === "local-mock"; + const attemptBudget = isMock + ? createImagesLocalMockAttemptBudget(remoteRequests) + : { + initialGenerationRequests: remoteRequests, + maximumAutomaticRetryAttempts: 0, + maximumTotalAttempts: remoteRequests, + }; + const dataKinds = [ + ...(input.sendsPrompt ? ["prompt text"] : []), + ...(referenceImageCount > 0 ? [plural(referenceImageCount, "reference image")] : []), + ]; + const privacyValue = isMock + ? "Local simulation · nothing leaves this Mac" + : dataKinds.length > 0 + ? `${dataKinds.join(" and ")} sent to ${provider}` + : `Run metadata sent to ${provider}`; + const privacyNotices = isMock + ? [ + "This Phase 3 mock run makes no provider request and creates no billable work.", + "The confirmation mirrors the information Aiden will require before a future cloud run.", + "Mock outputs remain in Aiden's device-local image store.", + ] + : [ + "Prompts and reference images listed above leave this Mac and are handled under the provider's terms and retention policy.", + "Cost may be incurred. Stopping a submitted request may not prevent provider completion or billing.", + "Only upload material you have the rights and consent to use. Aiden copies valid outputs to its device-local image store.", + ]; + if (!isMock && input.firstCloudUse) { + privacyNotices.unshift( + "This is the first cloud image run confirmed for this workflow on this device.", + ); + } + return { + title: scope.title, + confirmLabel: isMock ? "Run mock workflow" : "Confirm & run", + workflowId: input.workflowId, + workflowRevision: input.workflowRevision, + scopeKind: input.scope.kind, + estimateLabel, + isMock, + consentStatement: isMock + ? "I reviewed this mock run plan and understand that it stays on this Mac." + : "I reviewed the requests, estimate, and data transfer described above.", + rows: [ + { + id: "scope", + label: "Scope", + value: scope.value, + ...(scope.detail ? { detail: scope.detail } : {}), + }, + { id: "destination", label: "Destination", value: `${provider} · ${model}` }, + { + id: "requests", + label: isMock ? "Simulated requests" : "Remote requests", + value: isMock + ? `${plural(attemptBudget.initialGenerationRequests, "initial request")} · up to ${plural(attemptBudget.maximumTotalAttempts, "total attempt")}` + : plural(attemptBudget.initialGenerationRequests, "request"), + detail: isMock + ? `Includes up to ${plural(attemptBudget.maximumAutomaticRetryAttempts, "safe automatic retry attempt")} (${CREATE_IMAGES_LOCAL_MOCK_RETRY_POLICY.maxRetriesPerNode} per generation node). The Phase 3 mock stays local and costs $0.` + : "This confirmation does not authorize paid automatic retries. A provider retry policy must be reviewed separately before launch.", + }, + { + id: "outputs", + label: "Expected output", + value: `${plural(outputCount, "image")} · ${imageSize} · ${quality}`, + }, + { + id: "estimate", + label: "Cost", + value: estimateLabel, + detail: `${input.estimate.sourceLabel} · ${new Date(input.estimate.estimatedAt).toLocaleString()}`, + }, + { id: "privacy", label: "Data", value: privacyValue }, + ], + privacyNotices, + }; +} + +export type CreateImagesSafeRunErrorCode = + | "offline" + | "rate_limited" + | "provider_refused" + | "provider_unavailable" + | "output_invalid" + | "quota_full" + | "interrupted" + | "submission_ambiguous" + | "unknown"; + +export type CreateImagesRunErrorAction = + | "review-retry" + | "check-connection" + | "open-provider-settings" + | "manage-storage" + | "view-history"; + +export interface CreateImagesSafeRunError { + code: CreateImagesSafeRunErrorCode; + nodeLabel?: string; + retainedOutputCount?: number; + retryKind: "none" | "local" | "remote"; +} + +export interface CreateImagesRunErrorViewModel { + title: string; + description: string; + nextStep: string; + actions: readonly CreateImagesRunErrorAction[]; + retainedOutputLabel?: string; + retry: { + available: boolean; + automatic: false; + requiresConfirmation: boolean; + label: "Retry" | "Review & retry"; + }; +} + +const SAFE_ERROR_COPY: Readonly< + Record< + CreateImagesSafeRunErrorCode, + Pick + > +> = { + offline: { + title: "No network connection", + description: "Aiden could not reach the configured image provider.", + nextStep: "Check the connection, then review the run before retrying.", + actions: ["check-connection", "review-retry"], + }, + rate_limited: { + title: "Provider rate limit reached", + description: "The provider asked Aiden to wait before another request.", + nextStep: "Retry only when you are ready; Aiden will not submit a paid retry automatically.", + actions: ["review-retry"], + }, + provider_refused: { + title: "Provider declined this request", + description: "The provider did not generate an image for this request.", + nextStep: "Review the prompt and provider policy before starting a new run.", + actions: ["view-history"], + }, + provider_unavailable: { + title: "Image provider unavailable", + description: "The configured provider or model cannot accept this run right now.", + nextStep: "Check the provider connection and model configuration before retrying.", + actions: ["open-provider-settings", "review-retry"], + }, + output_invalid: { + title: "Provider output could not be saved", + description: "Aiden rejected the returned file because it did not pass image validation.", + nextStep: "Review the run record. A new provider request requires an explicit retry.", + actions: ["view-history", "review-retry"], + }, + quota_full: { + title: "Image storage is full", + description: "Aiden cannot safely persist another output within the configured storage limit.", + nextStep: "Free space or raise the storage limit before retrying.", + actions: ["manage-storage"], + }, + interrupted: { + title: "Run interrupted", + description: "Aiden could not safely continue this run to a durable terminal result.", + nextStep: "Inspect the terminal run record before deciding whether to start a new run.", + actions: ["view-history", "review-retry"], + }, + submission_ambiguous: { + title: "Provider submission is unresolved", + description: "Aiden cannot confirm whether the provider accepted the request.", + nextStep: + "Do not retry. Open the durable run record and explicitly acknowledge the unresolved outcome before considering a separately confirmed new run.", + actions: ["view-history"], + }, + unknown: { + title: "Run could not continue", + description: "Aiden recorded a safe error code without exposing provider response data.", + nextStep: "Review the run history and configuration before retrying.", + actions: ["view-history"], + }, +}; + +export function createImagesRunErrorViewModel( + error: CreateImagesSafeRunError, +): CreateImagesRunErrorViewModel { + const copy = SAFE_ERROR_COPY[error.code]; + const retainedOutputCount = boundedCount(error.retainedOutputCount ?? 0, "Retained output count"); + const retryAvailable = error.retryKind !== "none" && copy.actions.includes("review-retry"); + return { + ...copy, + retainedOutputLabel: + retainedOutputCount > 0 + ? `${plural(retainedOutputCount, "completed output")} retained locally.` + : undefined, + retry: { + available: retryAvailable, + automatic: false, + requiresConfirmation: error.retryKind === "remote", + label: error.retryKind === "remote" ? "Review & retry" : "Retry", + }, + }; +} + +export interface CreateImagesRunUiIdentity { + workflowId: string; + workflowRevision: number; + runId: string; +} + +export interface CreateImagesNodeRunUiState { + nodeId: string; + label: string; + status: CreateImagesNodeRunUiStatus; + sequence: number; + attempt: number; + outputAssetIds?: readonly string[]; + retryMode?: "automatic-mock" | "manual-review"; + progress?: { completed: number; total: number; label: string }; + error?: CreateImagesSafeRunError; +} + +interface CreateImagesRunUiEventBase extends CreateImagesRunUiIdentity { + sequence: number; +} + +export type CreateImagesRunUiEvent = + | (CreateImagesRunUiEventBase & { + kind: "node-status"; + nodeId: string; + status: CreateImagesNodeRunUiStatus; + attempt: number; + retryMode?: "automatic-mock" | "manual-review"; + error?: CreateImagesSafeRunError; + }) + | (CreateImagesRunUiEventBase & { + kind: "node-progress"; + nodeId: string; + completed: number; + total: number; + label: string; + }) + | (CreateImagesRunUiEventBase & { + kind: "run-status"; + status: CreateImagesRunUiStatus; + }); + +export interface CreateImagesRunUiProjection extends CreateImagesRunUiIdentity { + executionMode?: "local-mock" | "gemini"; + status: CreateImagesRunUiStatus; + lastSequence: number; + nodes: Readonly>; + ignoredEventCount: number; + announcement: string; + ambiguityAcknowledged?: true; +} + +export interface CreateImagesRunUiSnapshot extends CreateImagesRunUiIdentity { + executionMode?: "local-mock" | "gemini"; + status: CreateImagesRunUiStatus; + lastSequence: number; + nodes: readonly Omit[]; + ambiguityAcknowledged?: true; +} + +const NODE_TRANSITIONS: Readonly< + Record +> = { + queued: ["running", "blocked", "cancelled"], + running: ["succeeded", "failed", "cancelled", "retry"], + retry: ["running"], + blocked: [], + failed: ["retry"], + cancelled: [], + succeeded: [], +}; + +const RUN_TRANSITIONS: Readonly< + Record +> = { + "awaiting-consent": ["queued", "cancelled"], + queued: ["running", "failed", "cancelled", "interrupted"], + running: ["stopping", "retry", "failed", "cancelled", "succeeded", "interrupted"], + stopping: ["failed", "cancelled", "succeeded", "interrupted"], + retry: [], + failed: ["retry"], + cancelled: [], + succeeded: [], + interrupted: ["retry"], +}; + +const NODE_STATUS_LABELS: Readonly> = { + queued: "Queued", + running: "Running", + retry: "Retry needed", + blocked: "Blocked", + failed: "Failed", + cancelled: "Cancelled", + succeeded: "Succeeded", +}; + +export const CREATE_IMAGES_RUN_STATUS_LABELS: Readonly> = { + "awaiting-consent": "Waiting for confirmation", + queued: "Queued", + running: "Running", + stopping: "Stopping", + retry: "Retry needs review", + failed: "Failed", + cancelled: "Cancelled", + succeeded: "Succeeded", + interrupted: "Interrupted", +}; + +function validIdentity(identity: CreateImagesRunUiIdentity): void { + nonEmptyLabel(identity.workflowId, "Workflow ID"); + nonEmptyLabel(identity.runId, "Run ID"); + if (!Number.isSafeInteger(identity.workflowRevision) || identity.workflowRevision < 0) { + throw new Error("Workflow revision must be a non-negative safe integer."); + } +} + +function validSequence(sequence: number): void { + if (!Number.isSafeInteger(sequence) || sequence < 0) { + throw new Error("Run event sequence must be a non-negative safe integer."); + } +} + +export function createImagesRunUiProjection( + snapshot: CreateImagesRunUiSnapshot, +): CreateImagesRunUiProjection { + validIdentity(snapshot); + validSequence(snapshot.lastSequence); + const nodes: Record = {}; + for (const node of snapshot.nodes) { + nonEmptyLabel(node.nodeId, "Node ID"); + nonEmptyLabel(node.label, "Node label"); + if (nodes[node.nodeId]) throw new Error(`Duplicate run node "${node.nodeId}".`); + if (!Number.isSafeInteger(node.attempt) || node.attempt < 0) { + throw new Error("Node attempt must be a non-negative safe integer."); + } + if (node.attempt === 0 && !["queued", "blocked", "cancelled"].includes(node.status)) { + throw new Error("Only nodes that have not started may use attempt zero."); + } + nodes[node.nodeId] = { ...node, sequence: snapshot.lastSequence }; + } + return { + workflowId: snapshot.workflowId, + workflowRevision: snapshot.workflowRevision, + runId: snapshot.runId, + ...(snapshot.executionMode ? { executionMode: snapshot.executionMode } : {}), + status: snapshot.status, + lastSequence: snapshot.lastSequence, + nodes, + ignoredEventCount: 0, + announcement: `${CREATE_IMAGES_RUN_STATUS_LABELS[snapshot.status]} run loaded.`, + ...(snapshot.ambiguityAcknowledged ? { ambiguityAcknowledged: true as const } : {}), + }; +} + +function ignoreRunUiEvent(state: CreateImagesRunUiProjection): CreateImagesRunUiProjection { + return { ...state, ignoredEventCount: state.ignoredEventCount + 1 }; +} + +function identitiesEqual( + state: CreateImagesRunUiIdentity, + event: CreateImagesRunUiIdentity, +): boolean { + return ( + state.workflowId === event.workflowId && + state.workflowRevision === event.workflowRevision && + state.runId === event.runId + ); +} + +function isRunTerminal(status: CreateImagesRunUiStatus): boolean { + return ["retry", "failed", "cancelled", "succeeded", "interrupted"].includes(status); +} + +function progressIsValid(completed: number, total: number): boolean { + return ( + Number.isSafeInteger(completed) && + Number.isSafeInteger(total) && + completed >= 0 && + total > 0 && + completed <= total + ); +} + +/** + * Applies only the exact next event for this immutable workflow revision and run. + * Gaps, duplicates, other runs, invalid transitions, and late terminal events are + * ignored. A fresh main-owned snapshot is the only way to reconcile a sequence gap. + */ +export function reduceCreateImagesRunUiEvent( + state: CreateImagesRunUiProjection, + event: CreateImagesRunUiEvent, +): CreateImagesRunUiProjection { + if ( + !identitiesEqual(state, event) || + !Number.isSafeInteger(event.sequence) || + event.sequence !== state.lastSequence + 1 || + isRunTerminal(state.status) + ) { + return ignoreRunUiEvent(state); + } + if (event.kind === "run-status") { + if (!RUN_TRANSITIONS[state.status].includes(event.status)) return ignoreRunUiEvent(state); + return { + ...state, + status: event.status, + lastSequence: event.sequence, + announcement: `Workflow run ${CREATE_IMAGES_RUN_STATUS_LABELS[event.status].toLowerCase()}.`, + }; + } + const node = state.nodes[event.nodeId]; + if (!node) return ignoreRunUiEvent(state); + if (event.kind === "node-progress") { + if ( + node.status !== "running" || + !progressIsValid(event.completed, event.total) || + !event.label.trim() + ) { + return ignoreRunUiEvent(state); + } + const percentage = Math.round((event.completed / event.total) * 100); + return { + ...state, + lastSequence: event.sequence, + nodes: { + ...state.nodes, + [node.nodeId]: { + ...node, + sequence: event.sequence, + progress: { + completed: event.completed, + total: event.total, + label: event.label.trim(), + }, + }, + }, + announcement: `${node.label}: ${event.label.trim()}, ${percentage} percent.`, + }; + } + if (!Number.isSafeInteger(event.attempt) || event.attempt < 0) { + return ignoreRunUiEvent(state); + } + if (!NODE_TRANSITIONS[node.status].includes(event.status)) return ignoreRunUiEvent(state); + if ( + node.status === "retry" && + (node.retryMode !== "automatic-mock" || + event.status !== "running" || + event.attempt !== node.attempt + 1) + ) { + return ignoreRunUiEvent(state); + } + if ( + node.status === "queued" && + event.status === "running" && + event.attempt !== node.attempt + 1 + ) { + return ignoreRunUiEvent(state); + } + if ( + !(node.status === "queued" && event.status === "running") && + node.status !== "retry" && + event.attempt !== node.attempt + ) { + return ignoreRunUiEvent(state); + } + if (event.status === "retry" && !event.retryMode) return ignoreRunUiEvent(state); + return { + ...state, + lastSequence: event.sequence, + nodes: { + ...state.nodes, + [node.nodeId]: { + ...node, + status: event.status, + attempt: event.attempt, + sequence: event.sequence, + ...(event.status === "retry" && event.retryMode + ? { retryMode: event.retryMode } + : { retryMode: undefined }), + ...(event.error ? { error: event.error } : { error: undefined }), + ...(event.status === "running" ? { progress: undefined } : {}), + }, + }, + announcement: `${node.label}: ${NODE_STATUS_LABELS[event.status].toLowerCase()}.`, + }; +} + +export interface CreateImagesRunProgressSummary { + completed: number; + total: number; + active: number; + waiting: number; + failed: number; + percentage: number; + label: string; +} + +export function summarizeCreateImagesRunProgress( + nodes: readonly CreateImagesNodeRunUiState[], +): CreateImagesRunProgressSummary { + const automaticRetryWaiting = (node: CreateImagesNodeRunUiState): boolean => + node.status === "retry" && node.retryMode === "automatic-mock"; + const completed = nodes.filter( + (node) => + ["succeeded", "failed", "cancelled", "blocked", "retry"].includes(node.status) && + !automaticRetryWaiting(node), + ).length; + const active = nodes.filter((node) => node.status === "running").length; + const waiting = nodes.filter( + (node) => node.status === "queued" || automaticRetryWaiting(node), + ).length; + const failed = nodes.filter( + (node) => + node.status === "failed" || (node.status === "retry" && node.retryMode !== "automatic-mock"), + ).length; + const total = nodes.length; + const percentage = total === 0 ? 0 : Math.round((completed / total) * 100); + return { + completed, + total, + active, + waiting, + failed, + percentage, + label: + total === 0 + ? "No nodes scheduled" + : `${plural(completed, "node")} finished of ${COUNT_FORMATTER.format(total)}`, + }; +} + +export interface CreateImagesTerminalRunHistoryItem { + runId: string; + workflowRevision: number; + scopeLabel: string; + status: "retry" | "failed" | "cancelled" | "succeeded" | "interrupted"; + startedAt: string; + finishedAt: string; + providerLabel: string; + modelLabel: string; + requestCount: number; + completedNodeCount: number; + totalNodeCount: number; + outputCount: number; + costLabel: string; + ambiguityAcknowledged?: true; +} + +export interface CreateImagesTerminalRunHistoryView extends CreateImagesTerminalRunHistoryItem { + durationLabel: string; + nodeSummary: string; + outputSummary: string; +} + +function durationLabel(startedAt: string, finishedAt: string): string { + const duration = Date.parse(finishedAt) - Date.parse(startedAt); + if (!Number.isFinite(duration) || duration < 0) return "Duration unavailable"; + const seconds = Math.round(duration / 1_000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`; +} + +export function createImagesTerminalRunHistoryViews( + items: readonly CreateImagesTerminalRunHistoryItem[], +): readonly CreateImagesTerminalRunHistoryView[] { + return [...items] + .map((item) => { + nonEmptyLabel(item.runId, "Run ID"); + boundedCount(item.requestCount, "Request count"); + boundedCount(item.completedNodeCount, "Completed node count"); + boundedCount(item.totalNodeCount, "Total node count"); + boundedCount(item.outputCount, "Output count"); + if (item.completedNodeCount > item.totalNodeCount) { + throw new Error("Completed nodes cannot exceed total nodes."); + } + return { + ...item, + durationLabel: durationLabel(item.startedAt, item.finishedAt), + nodeSummary: `${item.completedNodeCount} of ${item.totalNodeCount} nodes succeeded`, + outputSummary: plural(item.outputCount, "output"), + }; + }) + .sort((left, right) => Date.parse(right.finishedAt) - Date.parse(left.finishedAt)); +} diff --git a/renderer/create-images/run-ui.css b/renderer/create-images/run-ui.css new file mode 100644 index 00000000..eb67dd8e --- /dev/null +++ b/renderer/create-images/run-ui.css @@ -0,0 +1,1255 @@ +.create-images-run-dialog-overlay { + position: fixed; + z-index: 70; + inset: 0; + background: color-mix(in srgb, var(--text-primary) 22%, transparent); + backdrop-filter: blur(2px); + animation: create-images-run-overlay-in 180ms ease-out both; +} + +.create-images-run-dialog { + position: fixed; + z-index: 71; + top: 50%; + left: 50%; + display: flex; + width: min(34rem, calc(100vw - 2rem)); + max-height: min(44rem, calc(100vh - 2rem)); + flex-direction: column; + overflow: hidden auto; + padding: 1.125rem; + border: 1px solid var(--border-field); + border-radius: var(--radius-dialog); + background: var(--surface-popover); + box-shadow: var(--elevation-dialog); + color: var(--text-primary); + transform: translate(-50%, -50%); + animation: create-images-run-dialog-in 180ms cubic-bezier(0.19, 1, 0.22, 1) both; +} + +.create-images-run-dialog-heading { + display: grid; + grid-template-columns: 2.25rem minmax(0, 1fr); + align-items: start; + gap: 0.75rem; +} + +.create-images-run-dialog-heading h2 { + margin: 0; + font-size: var(--text-large-strong); + font-weight: 650; + letter-spacing: -0.012em; +} + +.create-images-run-dialog-heading p { + margin: 0.3rem 0 0; + color: var(--text-secondary); + font-size: var(--text-small); + line-height: 1.45; +} + +.create-images-run-dialog-icon { + display: grid; + width: 2.25rem; + height: 2.25rem; + place-items: center; + border-radius: var(--radius-control); + background: var(--surface-control); + color: var(--accent); +} + +.create-images-run-dialog-icon[data-tone="danger"] { + background: color-mix(in srgb, var(--support-red) 10%, var(--surface-popover)); + color: var(--support-red); +} + +.create-images-run-dialog-icon svg, +.create-images-run-mock-banner svg, +.create-images-run-section-title svg, +.create-images-stop-note svg, +.create-images-run-error > svg, +.create-images-run-history > header > span svg { + width: 1rem; + height: 1rem; + flex: 0 0 auto; +} + +.create-images-run-mock-banner { + display: flex; + align-items: flex-start; + gap: 0.625rem; + margin-top: 1rem; + padding: 0.7rem 0.75rem; + border: 1px solid color-mix(in srgb, var(--support-green) 30%, transparent); + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--support-green) 8%, var(--surface-popover)); + color: var(--support-green); + font-size: var(--text-small); +} + +.create-images-run-mock-banner span, +.create-images-run-mock-banner strong { + display: block; +} + +.create-images-run-path-chooser { + min-width: 0; + margin: 1rem 0 0; + padding: 0; + border: 0; +} + +.create-images-run-path-chooser > legend { + padding: 0; + font-size: var(--text-small); + font-weight: 650; +} + +.create-images-run-path-chooser > p { + margin: 0.25rem 0 0; + color: var(--text-secondary); + font-size: var(--text-mini); + line-height: 1.45; +} + +.create-images-run-path-options { + display: grid; + gap: 0.4rem; + max-height: min(16rem, 34vh); + margin-top: 0.65rem; + padding: 0.15rem; + overflow-y: auto; + overscroll-behavior: contain; +} + +.create-images-run-path-option { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + gap: 0.65rem; + padding: 0.65rem 0.7rem; + border: 1px solid var(--border-field); + border-radius: var(--radius-control); + background: transparent; + cursor: pointer; + transition: + border-color 150ms ease-out, + background-color 150ms ease-out, + box-shadow 150ms ease-out; +} + +.create-images-run-path-option:hover { + background: var(--surface-list-hover); +} + +.create-images-run-path-option:focus-within { + border-color: var(--focus-ring); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-ring) 26%, transparent); +} + +.create-images-run-path-option[data-selected="true"] { + border-color: color-mix(in srgb, var(--accent) 50%, var(--border-field)); + background: color-mix(in srgb, var(--accent) 7%, var(--surface-popover)); +} + +.create-images-run-path-option input { + width: 1rem; + height: 1rem; + margin: 0.1rem 0 0; + accent-color: var(--accent); +} + +.create-images-run-path-option strong, +.create-images-run-path-option span > span { + display: block; + overflow-wrap: anywhere; +} + +.create-images-run-path-option strong { + font-size: var(--text-small); + font-weight: 620; +} + +.create-images-run-path-option span > span { + margin-top: 0.12rem; + color: var(--text-tertiary); + font-size: var(--text-mini); + line-height: 1.4; +} + +.create-images-run-path-chooser p.create-images-run-path-overflow { + color: var(--support-warning); +} + +.create-images-run-path-chooser p.create-images-run-path-required { + color: var(--text-primary); + font-weight: 600; +} + +.create-images-run-mock-banner strong { + margin-bottom: 0.1rem; + font-weight: 650; +} + +.create-images-run-plan { + display: grid; + gap: 1px; + overflow: hidden; + margin: 1rem 0 0; + border: 1px solid var(--border-separator); + border-radius: var(--radius-card); + background: var(--border-separator); +} + +.create-images-run-plan-row { + display: grid; + grid-template-columns: minmax(6.5rem, 0.65fr) minmax(0, 1.35fr); + gap: 0.75rem; + padding: 0.65rem 0.75rem; + background: var(--surface-well); +} + +.create-images-run-plan-row dt { + color: var(--text-tertiary); + font-size: var(--text-mini); + font-weight: 600; +} + +.create-images-run-plan-row dd { + min-width: 0; + margin: 0; +} + +.create-images-run-plan-row dd strong, +.create-images-run-plan-row dd span { + display: block; + overflow-wrap: anywhere; +} + +.create-images-run-plan-row dd strong { + font-size: var(--text-small); + font-weight: 600; +} + +.create-images-run-plan-row dd span { + margin-top: 0.15rem; + color: var(--text-secondary); + font-size: var(--text-mini); + line-height: 1.4; +} + +.create-images-run-privacy { + margin-top: 1rem; + padding: 0.75rem; + border-radius: var(--radius-card); + background: var(--surface-well); +} + +.create-images-run-section-title { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.create-images-run-section-title h3 { + margin: 0; + font-size: var(--text-small); + font-weight: 650; +} + +.create-images-run-privacy ul { + display: grid; + gap: 0.35rem; + margin: 0.55rem 0 0; + padding-left: 1.2rem; + color: var(--text-secondary); + font-size: var(--text-mini); + line-height: 1.45; +} + +.create-images-run-review-check { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); + gap: 0.65rem; + margin-top: 0.75rem; + padding: 0.7rem 0.75rem; + border: 1px solid var(--border-field); + border-radius: var(--radius-control); + background: transparent; + transition: + border-color 150ms ease-out, + background-color 150ms ease-out, + box-shadow 150ms ease-out; +} + +.create-images-run-review-check:hover { + background: var(--surface-list-hover); +} + +.create-images-run-review-check:focus-within { + border-color: var(--focus-ring); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-ring) 26%, transparent); +} + +.create-images-run-review-check input { + width: 1rem; + height: 1rem; + margin: 0.1rem 0 0; + accent-color: var(--accent); +} + +.create-images-run-review-check strong, +.create-images-run-review-check span > span { + display: block; +} + +.create-images-run-review-check strong { + margin-bottom: 0.1rem; + font-size: var(--text-small); + font-weight: 650; +} + +.create-images-run-review-check span { + color: var(--text-secondary); + font-size: var(--text-mini); + line-height: 1.45; +} + +.create-images-run-ambiguity-confirmation { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.create-images-run-ambiguity-warning, +.create-images-run-ambiguity-mock-note, +.create-images-run-ambiguity-record { + display: flex; + width: 100%; + align-items: flex-start; + gap: 0.65rem; + padding: 0.75rem; + border: 1px solid var(--border-field); + border-radius: var(--radius-card); +} + +.create-images-run-ambiguity-warning { + border-color: color-mix(in srgb, var(--support-warning) 38%, var(--border-field)); + background: color-mix(in srgb, var(--support-warning) 7%, var(--surface-popover)); +} + +.create-images-run-ambiguity-warning > svg, +.create-images-run-ambiguity-record > svg { + width: 1rem; + height: 1rem; + flex: 0 0 auto; + color: var(--support-warning); +} + +.create-images-run-ambiguity-warning h3, +.create-images-run-ambiguity-record h4 { + margin: 0; + color: var(--text-primary); + font-size: var(--text-small); + font-weight: 650; +} + +.create-images-run-ambiguity-warning p, +.create-images-run-ambiguity-mock-note p, +.create-images-run-ambiguity-record p { + margin: 0.3rem 0 0; + color: var(--text-secondary); + font-size: var(--text-mini); + line-height: 1.45; +} + +.create-images-run-ambiguity-mock-note { + background: var(--surface-well); +} + +.create-images-run-ambiguity-mock-note > svg { + width: 1rem; + height: 1rem; + flex: 0 0 auto; + color: var(--support-green); +} + +.create-images-run-ambiguity-mock-note strong { + color: var(--text-primary); +} + +.create-images-run-ambiguity-check { + margin-top: 0; +} + +.create-images-run-ambiguity-record { + background: var(--surface-popover); +} + +.create-images-run-ambiguity-record button { + margin-top: 0.55rem; +} + +.create-images-run-discard-confirmation { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.create-images-run-discard-warning, +.create-images-run-discard-local-note, +.create-images-run-discard-storage-note { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.75rem; + border: 1px solid var(--border-field); + border-radius: var(--radius-card); +} + +.create-images-run-discard-warning { + border-color: color-mix(in srgb, var(--support-red) 38%, var(--border-field)); + background: color-mix(in srgb, var(--support-red) 7%, var(--surface-popover)); +} + +.create-images-run-discard-warning > svg, +.create-images-run-discard-local-note > svg, +.create-images-run-discard-storage-note > svg { + width: 1rem; + height: 1rem; + flex: 0 0 auto; +} + +.create-images-run-discard-warning > svg { + color: var(--support-red); +} + +.create-images-run-discard-local-note > svg { + color: var(--support-green); +} + +.create-images-run-discard-warning h3, +.create-images-run-discard-warning p, +.create-images-run-discard-local-note p, +.create-images-run-discard-storage-note p { + margin: 0; +} + +.create-images-run-discard-warning h3 { + color: var(--text-primary); + font-size: var(--text-small); + font-weight: 650; +} + +.create-images-run-discard-warning p, +.create-images-run-discard-local-note p, +.create-images-run-discard-storage-note p { + margin-top: 0.3rem; + color: var(--text-secondary); + font-size: var(--text-mini); + line-height: 1.45; +} + +.create-images-run-discard-local-note, +.create-images-run-discard-storage-note { + background: var(--surface-well); +} + +.create-images-run-discard-local-note strong { + color: var(--text-primary); +} + +.create-images-run-discard-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + margin: 0; + border: 1px solid var(--border-separator); + border-radius: var(--radius-card); + background: var(--border-separator); +} + +.create-images-run-discard-summary div { + min-width: 0; + padding: 0.65rem 0.75rem; + background: var(--surface-well); +} + +.create-images-run-discard-summary dt { + color: var(--text-tertiary); + font-size: var(--text-mini); +} + +.create-images-run-discard-summary dd { + margin: 0.2rem 0 0; + color: var(--text-primary); + font-size: var(--text-small); + font-weight: 600; + overflow-wrap: anywhere; +} + +.create-images-run-discard-check { + margin-top: 0; +} + +.create-images-run-dialog-actions, +.create-images-run-panel-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} + +.create-images-stop-summary { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + margin: 1rem 0 0; +} + +.create-images-stop-summary div { + padding: 0.7rem 0.75rem; + border-radius: var(--radius-control); + background: var(--surface-well); +} + +.create-images-stop-summary dt { + color: var(--text-tertiary); + font-size: var(--text-mini); +} + +.create-images-stop-summary dd { + margin: 0.15rem 0 0; + font-size: var(--text-small); + font-weight: 650; +} + +.create-images-stop-note { + display: flex; + align-items: flex-start; + gap: 0.55rem; + margin-top: 0.75rem; + padding: 0.7rem 0.75rem; + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--support-warning) 9%, var(--surface-popover)); + color: var(--support-warning); + font-size: var(--text-small); + line-height: 1.45; +} + +.create-images-run-status { + display: inline-flex; + min-height: 1.55rem; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.5rem; + border-radius: var(--radius-pill); + background: var(--surface-control); + color: var(--text-secondary); + font-size: var(--text-mini); + font-weight: 650; + white-space: nowrap; +} + +.create-images-run-status svg { + width: 0.8rem; + height: 0.8rem; +} + +.create-images-run-status[data-compact="true"] { + width: 1.55rem; + justify-content: center; + padding: 0; +} + +.create-images-run-status[data-status="running"] { + background: color-mix(in srgb, var(--accent) 10%, var(--surface-popover)); + color: var(--accent); +} + +.create-images-run-status[data-status="retry"], +.create-images-run-status[data-status="stopping"], +.create-images-run-status[data-status="interrupted"] { + background: color-mix(in srgb, var(--support-warning) 10%, var(--surface-popover)); + color: var(--support-warning); +} + +.create-images-run-status[data-status="failed"] { + background: color-mix(in srgb, var(--support-red) 9%, var(--surface-popover)); + color: var(--support-red); +} + +.create-images-run-status[data-status="succeeded"] { + background: color-mix(in srgb, var(--support-green) 9%, var(--surface-popover)); + color: var(--support-green); +} + +.create-images-run-spinner { + animation: create-images-run-spin 900ms linear infinite; +} + +.create-images-run-panel, +.create-images-run-history { + display: flex; + min-width: 0; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-field); + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--surface-popover) 94%, transparent); + box-shadow: var(--elevation-control); + color: var(--text-primary); +} + +.create-images-run-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + padding: 0.8rem 0.875rem; + border-bottom: 1px solid var(--border-separator); +} + +.create-images-run-panel-header h2, +.create-images-run-history h2 { + margin: 0; + font-size: var(--text-small-strong); + font-weight: 650; +} + +.create-images-run-panel-header p, +.create-images-run-history header p { + margin: 0.15rem 0 0; + color: var(--text-tertiary); + font-size: var(--text-mini); +} + +.create-images-run-overall-progress { + display: grid; + gap: 0.4rem; + padding: 0.7rem 0.875rem; + border-bottom: 1px solid var(--border-separator); +} + +.create-images-run-overall-progress progress, +.create-images-run-node-progress progress { + width: 100%; + height: 0.32rem; + overflow: hidden; + border: 0; + border-radius: var(--radius-pill); + appearance: none; + background: var(--surface-control); +} + +.create-images-run-overall-progress progress::-webkit-progress-bar, +.create-images-run-node-progress progress::-webkit-progress-bar { + border-radius: var(--radius-pill); + background: var(--surface-control); +} + +.create-images-run-overall-progress progress::-webkit-progress-value, +.create-images-run-node-progress progress::-webkit-progress-value { + border-radius: var(--radius-pill); + background: var(--accent); + transition: width 180ms ease-out; +} + +.create-images-run-overall-progress progress::-moz-progress-bar, +.create-images-run-node-progress progress::-moz-progress-bar { + border-radius: var(--radius-pill); + background: var(--accent); +} + +.create-images-run-overall-progress > div { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + color: var(--text-tertiary); + font-size: var(--text-mini); + font-variant-numeric: tabular-nums; +} + +.create-images-run-overall-progress > div span:first-child { + color: var(--text-primary); + font-weight: 650; +} + +.create-images-run-node-list, +.create-images-run-history ol { + display: flex; + min-height: 0; + flex-direction: column; + gap: 0; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; +} + +.create-images-run-node { + padding: 0.7rem 0.875rem; + border-bottom: 1px solid var(--border-separator); + border-inline-start: 3px solid transparent; +} + +.create-images-run-node:last-child { + border-bottom: 0; +} + +.create-images-run-node[data-status="running"] { + border-inline-start-color: var(--accent); +} + +.create-images-run-node[data-status="retry"], +.create-images-run-node[data-status="blocked"] { + border-inline-start-color: var(--support-warning); +} + +.create-images-run-node[data-status="failed"] { + border-inline-start-color: var(--support-red); +} + +.create-images-run-node[data-status="succeeded"] { + border-inline-start-color: var(--support-green); +} + +.create-images-run-node-main { + display: flex; + min-width: 0; + align-items: center; + gap: 0.55rem; +} + +.create-images-run-node-glyph { + display: grid; + width: 1.65rem; + height: 1.65rem; + flex: 0 0 auto; + place-items: center; + border-radius: 0.5rem; + background: var(--surface-control); + color: var(--text-secondary); +} + +.create-images-run-node-glyph svg { + width: 0.85rem; + height: 0.85rem; +} + +.create-images-run-node-copy { + min-width: 0; + flex: 1; +} + +.create-images-run-node-copy strong, +.create-images-run-node-copy span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.create-images-run-node-copy strong { + font-size: var(--text-small); + font-weight: 600; +} + +.create-images-run-node-copy span { + margin-top: 0.05rem; + color: var(--text-tertiary); + font-family: var(--font-code-family); + font-size: var(--text-mini); +} + +.create-images-run-node-progress { + display: grid; + gap: 0.3rem; + margin: 0.55rem 0 0 2.2rem; +} + +.create-images-run-node-progress span, +.create-images-run-node-note { + color: var(--text-secondary); + font-size: var(--text-mini); +} + +.create-images-run-node-note { + margin: 0.5rem 0 0 2.2rem; + line-height: 1.45; +} + +.create-images-run-node-error { + display: grid; + gap: 0.2rem; + margin: 0.55rem 0 0 2.2rem; + padding: 0.55rem 0.625rem; + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--support-red) 7%, var(--surface-popover)); +} + +.create-images-run-node-error strong { + color: var(--support-red); + font-size: var(--text-mini); + font-weight: 650; +} + +.create-images-run-node-error > span { + color: var(--text-secondary); + font-size: var(--text-mini); + line-height: 1.4; +} + +.create-images-run-node-error > div, +.create-images-run-error-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.3rem; +} + +.create-images-run-panel-actions { + margin: 0; + padding: 0.65rem 0.75rem; + border-top: 1px solid var(--border-separator); +} + +.create-images-run-empty, +.create-images-run-history-empty { + margin: 0; + padding: 1.5rem 1rem; + color: var(--text-secondary); + font-size: var(--text-small); + text-align: center; +} + +.create-images-run-error { + display: grid; + grid-template-columns: 1.1rem minmax(0, 1fr); + gap: 0.55rem; + padding: 0.75rem; + border: 1px solid color-mix(in srgb, var(--support-red) 28%, transparent); + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--support-red) 7%, var(--surface-popover)); + color: var(--support-red); +} + +.create-images-run-error-copy h3, +.create-images-run-error-copy p { + margin: 0; +} + +.create-images-run-error-copy h3 { + font-size: var(--text-small); + font-weight: 650; +} + +.create-images-run-error-copy p { + margin-top: 0.18rem; + color: var(--text-secondary); + font-size: var(--text-mini); + line-height: 1.45; +} + +.create-images-run-error-copy p.create-images-run-error-next { + color: var(--text-primary); +} + +.create-images-run-history > header { + display: grid; + grid-template-columns: 1.9rem minmax(0, 1fr) auto; + align-items: start; + gap: 0.6rem; + padding: 0.8rem 0.875rem; + border-bottom: 1px solid var(--border-separator); +} + +.create-images-run-history > header > span { + display: grid; + width: 1.9rem; + height: 1.9rem; + place-items: center; + border-radius: 0.55rem; + background: var(--surface-control); + color: var(--text-secondary); +} + +.create-images-run-history-row { + display: grid; + width: 100%; + gap: 0.45rem; + padding: 0.7rem 0.875rem; + border: 0; + border-bottom: 1px solid var(--border-separator); + border-radius: 0; + background: transparent; + color: var(--text-primary); + text-align: left; + transition: background-color 150ms ease-out; +} + +button.create-images-run-history-row:hover { + background: var(--surface-list-hover); +} + +button.create-images-run-history-row:focus-visible, +button.create-images-run-history-row[data-selected="true"] { + background: var(--surface-list-selection); + box-shadow: inset 0 0 0 1px var(--focus-ring); +} + +.create-images-run-history-lead, +.create-images-run-history-meta { + display: flex; + min-width: 0; + align-items: center; + gap: 0.5rem; +} + +.create-images-run-history-lead strong { + min-width: 0; + flex: 1; + overflow: hidden; + font-size: var(--text-small); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.create-images-run-history-lead time, +.create-images-run-history-meta { + color: var(--text-tertiary); + font-size: var(--text-mini); + font-variant-numeric: tabular-nums; +} + +.create-images-run-history-meta { + flex-wrap: wrap; + padding-left: 0.15rem; +} + +.create-images-run-history-meta span:not(:last-child)::after { + margin-left: 0.5rem; + color: var(--text-quaternary); + content: "·"; +} + +.create-images-run-recovery-row { + color: var(--support-warning); +} + +.create-images-run-recovery-row .create-images-run-history-meta { + color: var(--text-secondary); +} + +.create-images-run-history-detail { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: flex-start; + gap: 0.6rem; + max-height: min(23rem, 48vh); + padding: 0.8rem 0.875rem; + overflow-y: auto; + border-top: 1px solid var(--border-separator); + background: var(--surface-well); + color: var(--text-secondary); + font-size: var(--text-mini); +} + +.create-images-run-history-detail > header { + display: flex; + width: 100%; + align-items: flex-start; + justify-content: space-between; + gap: 0.7rem; +} + +.create-images-run-history-detail h3, +.create-images-run-history-detail h4, +.create-images-run-history-detail p, +.create-images-run-history-output figcaption { + margin: 0; +} + +.create-images-run-history-detail h3, +.create-images-run-history-detail h4 { + color: var(--text-primary); + font-size: var(--text-small); + font-weight: 650; +} + +.create-images-run-history-detail > header p, +.create-images-run-recovery p { + margin-top: 0.18rem; + line-height: 1.45; +} + +.create-images-run-recovery > svg, +.create-images-run-history-detail[role="alert"] > svg { + width: 1rem; + height: 1rem; + flex: 0 0 auto; + color: var(--support-warning); +} + +.create-images-run-recovery button { + margin-top: 0.55rem; +} + +.create-images-run-history-output-groups { + display: grid; + width: 100%; + gap: 0.8rem; +} + +.create-images-run-history-output-groups > section { + display: grid; + gap: 0.4rem; +} + +.create-images-run-history-output-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.45rem; +} + +.create-images-run-history-output { + display: grid; + min-width: 0; + gap: 0.25rem; +} + +.create-images-run-history-output img, +.create-images-run-history-output-loading { + width: 100%; + aspect-ratio: 1; + border: 1px solid var(--border-field); + border-radius: var(--radius-control); + background: var(--surface-control); + object-fit: cover; +} + +.create-images-run-history-output figcaption, +.create-images-run-history-output-loading span { + overflow: hidden; + color: var(--text-tertiary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.create-images-run-history-output-loading { + display: grid; + place-items: center; + padding: 0.5rem; + text-align: center; +} + +.create-images-run-history-output-loading svg { + width: 1rem; + height: 1rem; +} + +.create-images-run-history-detail-empty { + width: 100%; +} + +.create-images-run-controls { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 0.35rem; +} + +@keyframes create-images-run-overlay-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes create-images-run-dialog-in { + from { + opacity: 0; + transform: translate(-50%, calc(-50% + 4px)) scale(0.98); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes create-images-run-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 760px) { + .create-images-run-dialog { + width: min(32rem, calc(100vw - 1.5rem)); + max-height: calc(100vh - 1.5rem); + padding: 1rem; + } + + .create-images-run-plan-row { + grid-template-columns: minmax(5.5rem, 0.55fr) minmax(0, 1.45fr); + } + + .create-images-run-history-meta { + gap: 0.3rem; + } + + .create-images-run-history-output-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .create-images-run-dialog { + top: auto; + right: 0.5rem; + bottom: 0.5rem; + left: 0.5rem; + width: auto; + max-height: calc(100vh - 1rem); + transform: none; + animation-name: create-images-run-sheet-in; + } + + .create-images-run-plan-row { + grid-template-columns: 1fr; + gap: 0.15rem; + } + + .create-images-run-dialog-actions { + position: sticky; + bottom: -1rem; + margin: 1rem -1rem -1rem; + padding: 0.75rem 1rem 1rem; + border-top: 1px solid var(--border-separator); + background: var(--surface-popover); + } + + .create-images-run-history-lead { + align-items: flex-start; + flex-wrap: wrap; + } + + .create-images-run-history-lead strong { + flex-basis: calc(100% - 7.5rem); + } + + .create-images-run-history-lead time { + width: 100%; + padding-left: 0.15rem; + } + + .create-images-run-controls { + max-width: calc(100vw - 1rem); + overflow-x: auto; + } +} + +@media (max-width: 390px) { + .create-images-run-discard-summary { + grid-template-columns: 1fr; + } + + .create-images-run-node-main { + align-items: flex-start; + flex-wrap: wrap; + } + + .create-images-run-node-copy { + flex-basis: calc(100% - 2.2rem); + } + + .create-images-run-node-main .create-images-run-status { + margin-left: 2.2rem; + } + + .create-images-run-node-progress, + .create-images-run-node-note, + .create-images-run-node-error { + margin-left: 0; + } +} + +@keyframes create-images-run-sheet-in { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .create-images-run-dialog-overlay, + .create-images-run-dialog, + .create-images-run-spinner { + animation: none; + } + + .create-images-run-review-check, + .create-images-run-path-option, + .create-images-run-overall-progress progress::-webkit-progress-value, + .create-images-run-node-progress progress::-webkit-progress-value, + .create-images-run-history-row { + transition: none; + } +} + +:root[data-reduce-motion="true"] .create-images-run-dialog-overlay, +:root[data-reduce-motion="true"] .create-images-run-dialog, +:root[data-reduce-motion="true"] .create-images-run-spinner { + animation: none; +} + +:root[data-reduce-motion="true"] .create-images-run-path-option, +:root[data-reduce-motion="true"] .create-images-run-review-check { + transition: none; +} + +@media (forced-colors: active) { + .create-images-run-status, + .create-images-run-node, + .create-images-run-error, + .create-images-run-mock-banner { + border: 1px solid CanvasText; + } + + .create-images-run-ambiguity-warning, + .create-images-run-ambiguity-mock-note, + .create-images-run-ambiguity-record, + .create-images-run-discard-warning, + .create-images-run-discard-local-note, + .create-images-run-discard-storage-note, + .create-images-run-discard-summary { + border: 1px solid CanvasText; + } + + .create-images-run-path-option { + border: 1px solid CanvasText; + } + + .create-images-run-history-detail, + .create-images-run-history-output img, + .create-images-run-history-output-loading { + border: 1px solid CanvasText; + } + + .create-images-run-node[data-status="running"], + .create-images-run-node[data-status="retry"], + .create-images-run-node[data-status="blocked"], + .create-images-run-node[data-status="failed"], + .create-images-run-node[data-status="succeeded"] { + border-inline-start-width: 4px; + } +} diff --git a/renderer/create-images/run-ui.test.tsx b/renderer/create-images/run-ui.test.tsx new file mode 100644 index 00000000..c7ac93cc --- /dev/null +++ b/renderer/create-images/run-ui.test.tsx @@ -0,0 +1,454 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { DOMImplementation } from "@xmldom/xmldom"; +import * as React from "react"; +import { + CREATE_IMAGES_SELECTED_NODE_ONLY_CHOICE, + createImagesRunScopeForPathChoice, + type CreateImagesDownstreamPathChoiceView, +} from "./run-path-core"; +import { CreateImagesDownstreamPathChooser } from "./run-path-chooser"; +import { CreateImagesAmbiguityAcknowledgement } from "./run-ambiguity-confirmation"; +import { CreateImagesDegradedRunDiscardConfirmation } from "./run-degraded-discard-confirmation"; + +const component = readFileSync(new URL("./run-ui.tsx", import.meta.url), "utf8"); +const core = readFileSync(new URL("./run-ui-core.ts", import.meta.url), "utf8"); +const styles = readFileSync(new URL("./run-ui.css", import.meta.url), "utf8"); + +function installMountedDom(): { + document: Document; + container: HTMLElement; + restore(): void; +} { + const document = new DOMImplementation().createDocument( + null, + "html", + null, + ) as unknown as Document; + const body = document.createElement("body"); + const container = document.createElement("div"); + body.appendChild(container); + document.documentElement.appendChild(body); + + const elementPrototype = Object.getPrototypeOf(document.createElement("div")) as HTMLElement & + Record; + elementPrototype.addEventListener = () => undefined; + elementPrototype.removeEventListener = () => undefined; + elementPrototype.focus = function focus() { + Object.defineProperty(document, "activeElement", { + configurable: true, + value: this, + writable: true, + }); + }; + Object.defineProperty(elementPrototype, "style", { + configurable: true, + get: () => ({}), + }); + const documentPrototype = Object.getPrototypeOf(document) as Document; + documentPrototype.addEventListener = () => undefined; + documentPrototype.removeEventListener = () => undefined; + Object.defineProperty(document, "body", { configurable: true, value: body }); + Object.defineProperty(document, "activeElement", { + configurable: true, + value: body, + writable: true, + }); + const window = { + document, + event: undefined, + HTMLIFrameElement: class HTMLIFrameElement {}, + addEventListener: () => undefined, + removeEventListener: () => undefined, + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => undefined, + }; + Object.defineProperty(document, "defaultView", { configurable: true, value: window }); + const globals = [ + "window", + "document", + "navigator", + "Node", + "Element", + "HTMLElement", + "requestAnimationFrame", + "cancelAnimationFrame", + ] as const; + const previous = new Map( + globals.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ); + const elementConstructor = Object.getPrototypeOf(document.documentElement).constructor; + Object.defineProperties(globalThis, { + window: { configurable: true, value: window }, + document: { configurable: true, value: document }, + navigator: { configurable: true, value: { userAgent: "node-create-images-path-test" } }, + Node: { configurable: true, value: elementConstructor }, + Element: { configurable: true, value: elementConstructor }, + HTMLElement: { configurable: true, value: elementConstructor }, + requestAnimationFrame: { configurable: true, value: window.requestAnimationFrame }, + cancelAnimationFrame: { configurable: true, value: window.cancelAnimationFrame }, + }); + return { + document, + container, + restore: () => { + for (const key of globals) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }, + }; +} + +function mountedOnChange( + input: HTMLInputElement, +): (event: { target: { value: string; checked?: boolean } }) => void { + const propertyKey = Object.getOwnPropertyNames(input).find((key) => + key.startsWith("__reactProps$"), + ); + assert.ok(propertyKey, "mounted input exposes React event props"); + const props = (input as unknown as Record)[propertyKey] as { + onChange?: (event: { target: { value: string; checked?: boolean } }) => void; + }; + assert.ok(props.onChange, "mounted input has an onChange handler"); + return props.onChange; +} + +test("run and stop confirmations are controlled Radix dialogs that gate shortcuts", () => { + assert.ok((component.match(/= 2); + assert.ok((component.match(/= 2); + assert.ok((component.match(/data-slot="dialog-content"/gu) ?? []).length >= 2); + assert.match(component, /open=\{open\}/u); + assert.match(component, /onOpenChange\(nextOpen\)/u); + assert.match( + component, + /onCloseAutoFocus=\{\(event\) => restoreFocus\(event, returnFocusRef\)\}/u, + ); + assert.match(component, /reviewRef\.current\?\.focus\(\)/u); + assert.match(component, /firstPathChoiceRef\.current\?\.focus\(\)/u); + assert.match(component, /cancelRef\.current\?\.focus\(\)/u); + assert.match(component, /disabled=\{!pathSelectionComplete \|\| !reviewed \|\| submitting\}/u); + assert.match(component, /onReviewedChange\(false\)/u); + assert.match(component, /No network request or billable provider work will occur\./u); + assert.match(component, /may still complete or incur cost/u); +}); + +test("mounted downstream chooser requires one explicit path and preserves controlled focus", async () => { + const mounted = installMountedDom(); + const { createRoot } = await import("react-dom/client"); + const { flushSync } = await import("react-dom"); + const root = createRoot(mounted.container); + const choices: readonly CreateImagesDownstreamPathChoiceView[] = [ + { + id: "path:generate-2>output-1", + downstreamPath: ["generate-2", "output-1"], + title: "Path 1 · to Output · output-1", + detail: "2 downstream nodes · Generate Image · generate-2 → Output · output-1", + }, + { + id: "path:gallery-1", + downstreamPath: ["gallery-1"], + title: "Path 2 · to Output Gallery · gallery-1", + detail: "1 downstream node · Output Gallery · gallery-1", + }, + ]; + + function Harness() { + const [selectedChoiceId, setSelectedChoiceId] = React.useState(); + const [reviewed, setReviewed] = React.useState(true); + return ( + <> + { + setReviewed(false); + setSelectedChoiceId(choiceId); + }} + /> + + {selectedChoiceId ?? "none"}:{reviewed ? "reviewed" : "review-required"} + + + ); + } + + try { + flushSync(() => root.render()); + const inputs = Array.from( + mounted.container.getElementsByTagName("input"), + ) as HTMLInputElement[]; + const labels = Array.from(mounted.container.getElementsByTagName("label")); + assert.equal(inputs.length, 3); + assert.equal(new Set(inputs.map((input) => input.getAttribute("id"))).size, inputs.length); + assert.deepEqual( + labels.map((label) => label.getAttribute("for")), + inputs.map((input) => input.getAttribute("id")), + ); + assert.ok(inputs.every((input) => input.checked === false)); + assert.match(mounted.container.textContent ?? "", /Choose one option/u); + assert.match(mounted.container.textContent ?? "", /Only the first 2 connected paths/u); + assert.match(mounted.container.textContent ?? "", /additional branch work/u); + + inputs[1]!.focus(); + flushSync(() => mountedOnChange(inputs[1]!)({ target: { value: "path:generate-2>output-1" } })); + assert.equal(mounted.document.activeElement, inputs[1]); + assert.equal(inputs[1]?.checked, true); + assert.equal(inputs[0]?.checked, false); + assert.match(mounted.container.textContent ?? "", /path:generate-2>output-1:review-required/u); + assert.doesNotMatch(mounted.container.textContent ?? "", /Choose one option/u); + + inputs[0]!.focus(); + flushSync(() => + mountedOnChange(inputs[0]!)({ + target: { value: CREATE_IMAGES_SELECTED_NODE_ONLY_CHOICE }, + }), + ); + assert.equal(mounted.document.activeElement, inputs[0]); + assert.equal(inputs[0]?.checked, true); + assert.equal(inputs[1]?.checked, false); + } finally { + flushSync(() => root.unmount()); + await new Promise((resolve) => setImmediate(resolve)); + mounted.restore(); + } + + assert.deepEqual( + createImagesRunScopeForPathChoice("prompt-1", CREATE_IMAGES_SELECTED_NODE_ONLY_CHOICE, choices), + { kind: "from-node", nodeId: "prompt-1" }, + ); + const pathScope = createImagesRunScopeForPathChoice( + "prompt-1", + "path:generate-2>output-1", + choices, + ); + assert.deepEqual(pathScope, { + kind: "from-node", + nodeId: "prompt-1", + downstreamPath: ["generate-2", "output-1"], + }); + assert.notEqual( + pathScope?.kind === "from-node" ? pathScope.downstreamPath : undefined, + choices[0]?.downstreamPath, + ); + assert.equal(createImagesRunScopeForPathChoice("prompt-1", "forged", choices), undefined); +}); + +test("mounted ambiguity acknowledgement is explicit, controlled, and consequence-complete", async () => { + const mounted = installMountedDom(); + const { createRoot } = await import("react-dom/client"); + const { flushSync } = await import("react-dom"); + const root = createRoot(mounted.container); + + function Harness() { + const [reviewed, setReviewed] = React.useState(false); + return ( + <> + + {reviewed ? "acknowledgement-reviewed" : "review-required"} + + ); + } + + try { + flushSync(() => root.render()); + const input = mounted.container.getElementsByTagName("input")[0] as HTMLInputElement; + assert.equal(input.checked, false); + assert.match( + mounted.container.textContent ?? "", + /does not cancel, reconcile, retry, or resubmit/u, + ); + assert.match(mounted.container.textContent ?? "", /may still complete/u); + assert.match(mounted.container.textContent ?? "", /duplicate images and incur another charge/u); + assert.match(mounted.container.textContent ?? "", /\$0 local mock/u); + assert.match(mounted.container.textContent ?? "", /sends no network request/u); + assert.match(mounted.container.textContent ?? "", /review-required/u); + + input.focus(); + flushSync(() => mountedOnChange(input)({ target: { value: "ignored", checked: true } })); + assert.equal(mounted.document.activeElement, input); + assert.equal(input.checked, true); + assert.match(mounted.container.textContent ?? "", /acknowledgement-reviewed/u); + } finally { + flushSync(() => root.unmount()); + await new Promise((resolve) => setImmediate(resolve)); + mounted.restore(); + } +}); + +test("mounted degraded-run discard requires explicit irreversible-consequence review", async () => { + const mounted = installMountedDom(); + const { createRoot } = await import("react-dom/client"); + const { flushSync } = await import("react-dom"); + const root = createRoot(mounted.container); + + function Harness() { + const [reviewed, setReviewed] = React.useState(false); + return ( + <> + + {reviewed ? "discard-reviewed" : "discard-review-required"} + + ); + } + + try { + flushSync(() => root.render()); + const input = mounted.container.getElementsByTagName("input")[0] as HTMLInputElement; + assert.equal(input.checked, false); + assert.match(mounted.container.textContent ?? "", /permanently removes/u); + assert.match(mounted.container.textContent ?? "", /only durable evidence/u); + assert.match(mounted.container.textContent ?? "", /duplicate images and incur another charge/u); + assert.match(mounted.container.textContent ?? "", /does not cancel provider work/u); + assert.match(mounted.container.textContent ?? "", /Unassociated run/u); + assert.match(mounted.container.textContent ?? "", /imported inputs and generated outputs/u); + assert.match( + mounted.container.textContent ?? "", + /Imported-input and generated-output references may be released/u, + ); + assert.match( + mounted.container.textContent ?? "", + /unique imported-input or generated-output references may be lost/u, + ); + assert.match(mounted.container.textContent ?? "", /\$0 local mock with no network request/u); + assert.match(mounted.container.textContent ?? "", /discard-review-required/u); + + input.focus(); + flushSync(() => mountedOnChange(input)({ target: { value: "ignored", checked: true } })); + assert.equal(mounted.document.activeElement, input); + assert.equal(input.checked, true); + assert.match(mounted.container.textContent ?? "", /discard-reviewed/u); + } finally { + flushSync(() => root.unmount()); + await new Promise((resolve) => setImmediate(resolve)); + mounted.restore(); + } +}); + +test("all node states carry a glyph and text rather than relying on color", () => { + for (const status of [ + "queued", + "running", + "retry", + "blocked", + "failed", + "cancelled", + "succeeded", + ]) { + assert.match(component, new RegExp(`${status}: \\{ label:`, "u")); + } + assert.match(component, /data-status=\{status\}/u); + assert.match(styles, /\.create-images-run-status\[data-status="running"\]/u); + assert.match(component, /aria-label=\{`Node status: \$\{label\}`\}/u); + assert.match(component, /aria-label=\{`Run status: \$\{presentation\.label\}`\}/u); + assert.match(component, / { + assert.match(component, /aria-live="polite"/u); + assert.match(component, /aria-atomic="true"/u); + assert.match(component, /aria-label="Node run progress"/u); + assert.match(component, /Terminal run history/u); + assert.match(component, /Durable summaries only\. History never repeats a request\./u); + assert.match(component, /