{"version":3,"file":"dashboard-images.d.ts","sourceRoot":"","sources":["../../src/server/dashboard-images.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,uBAAuB,CAAC;AACxE,OAAO,EAEN,KAAK,qBAAqB,EAI1B,MAAM,oBAAoB,CAAC;AAE5B,eAAO,MAAM,2BAA2B,QAAmB,CAAC;AAC5D,eAAO,MAAM,6BAA6B,OAAO,CAAC;AAClD,eAAO,MAAM,0BAA0B,QAAmB,CAAC;AAC3D,eAAO,MAAM,0BAA0B,8DAKrC,CAAC;AAEH,MAAM,WAAW,mBAAmB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACpC,KAAK,EAAE,UAAU,CAAC;IAClB,QAAQ,EAAE,0BAA0B,CAAC,UAAU,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,+BAA+B;IAC/C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB;AA2ED,oGAAoG;AACpG,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,oBAAoB,GAAG,SAAS,CASxG;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,MAAM,CAE5E;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,0BAA0B,CAY7F;AAED,qBAAa,2BAA4B,SAAQ,KAAK;CAAG;AACzD,qBAAa,0BAA2B,SAAQ,KAAK;CAAG;AAExD,oEAAoE;AACpE,qBAAa,qBAAqB;IAWhC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IAV1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkC;IACzD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoD;IACpF,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAoD;IACnF,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,KAAK,CAAK;IAElB,YACkB,QAAQ,EAAE,qBAAqB,EAChD,OAAO,GAAE,+BAAoC,EAI7C;IAED,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,IAAI,WAAW,IAAI,MAAM,CAExB;IAED,gFAAgF;IAChF,YAAY,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,KAAK,EAAE,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAEhG;IAED,4DAA4D;IAC5D,OAAO,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,mBAAmB,GAAG,CAAC,CAGlD;IAEK,QAAQ,CACb,KAAK,EAAE,mBAAmB,EAC1B,EAAE,EAAE,MAAM,EACV,iBAAiB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GACvC,OAAO,CAAC,oBAAoB,CAAC,CAmB/B;IAEK,OAAO,CACZ,KAAK,EAAE,mBAAmB,EAC1B,EAAE,EAAE,MAAM,EACV,iBAAiB,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GACvC,OAAO,CAAC,oBAAoB,CAAC,CAuC/B;IAED,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAOtC;IAED,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAErB;IAED,OAAO,CAAC,WAAW;IA4BnB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,aAAa;YAaP,eAAe;IA2B7B,OAAO,CAAC,UAAU;IAuBlB,OAAO,CAAC,KAAK;IAoBb,OAAO,CAAC,WAAW;CAQnB","sourcesContent":["import { createHash } from \"node:crypto\";\nimport type { DashboardImageReferenceDto } from \"../shared/protocol.js\";\nimport {\n\ttype GeneratedImagePreview,\n\ttype ImagePreviewGenerator,\n\tMAX_PREVIEW_BYTES,\n\tMAX_PREVIEW_HEIGHT,\n\tMAX_PREVIEW_WIDTH,\n} from \"./image-preview.js\";\n\nexport const DASHBOARD_IMAGE_CACHE_BYTES = 64 * 1024 * 1024;\nexport const DASHBOARD_IMAGE_CACHE_RECORDS = 2000;\nexport const DASHBOARD_IMAGE_ID_PATTERN = /^[0-9a-f]{64}$/;\nexport const DASHBOARD_IMAGE_MIME_TYPES = new Set<DashboardImageReferenceDto[\"mimeType\"]>([\n\t\"image/png\",\n\t\"image/jpeg\",\n\t\"image/gif\",\n\t\"image/webp\",\n]);\n\nexport interface DashboardImageScope {\n\truntimeKey: string;\n\tagentId?: string;\n}\n\nexport interface DashboardImageBinary {\n\tbytes: Uint8Array;\n\tmimeType: DashboardImageReferenceDto[\"mimeType\"];\n}\n\nexport interface DashboardImageRepositoryOptions {\n\tmaxBytes?: number;\n\tmaxRecords?: number;\n}\n\ninterface CachedVariant extends DashboardImageBinary {\n\tlastUsed: number;\n}\n\ninterface CachedImage {\n\tid: string;\n\tmimeType: DashboardImageReferenceDto[\"mimeType\"];\n\tsize: number;\n\tscopes: Set<string>;\n\toriginal?: CachedVariant;\n\tpreview?: CachedVariant;\n}\n\nconst DROP = Symbol(\"drop-dashboard-image\");\nconst JPEG_EOI = Buffer.from([0xff, 0xd9]);\n\nfunction scopeKey(scope: DashboardImageScope): string {\n\treturn `${scope.runtimeKey}\\0${scope.agentId ?? \"\"}`;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n\treturn Boolean(value) && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction hasRasterSignature(mimeType: DashboardImageReferenceDto[\"mimeType\"], bytes: Uint8Array): boolean {\n\tconst buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);\n\tswitch (mimeType) {\n\t\tcase \"image/png\": {\n\t\t\tconst signature =\n\t\t\t\tbytes.length >= 24 &&\n\t\t\t\t[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((byte, i) => bytes[i] === byte);\n\t\t\treturn (\n\t\t\t\tsignature &&\n\t\t\t\tbuffer.subarray(12, 16).toString(\"ascii\") === \"IHDR\" &&\n\t\t\t\tbuffer.readUInt32BE(16) > 0 &&\n\t\t\t\tbuffer.readUInt32BE(20) > 0 &&\n\t\t\t\tbuffer.indexOf(Buffer.from(\"IEND\"), 24) >= 0\n\t\t\t);\n\t\t}\n\t\tcase \"image/jpeg\": {\n\t\t\tif (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) return false;\n\t\t\t// EOI does not have to be the final byte. Phone JPEGs commonly append\n\t\t\t// auxiliary gain-map, motion-photo, or vendor metadata after the primary\n\t\t\t// image. Browsers and Photon decode the JPEG through EOI and preserve the\n\t\t\t// trailing bytes when the exact original is requested.\n\t\t\treturn buffer.lastIndexOf(JPEG_EOI) >= 2;\n\t\t}\n\t\tcase \"image/gif\": {\n\t\t\tconst header = buffer.subarray(0, 6).toString(\"ascii\");\n\t\t\treturn (\n\t\t\t\tbytes.length >= 14 &&\n\t\t\t\t(header === \"GIF87a\" || header === \"GIF89a\") &&\n\t\t\t\tbuffer.readUInt16LE(6) > 0 &&\n\t\t\t\tbuffer.readUInt16LE(8) > 0 &&\n\t\t\t\tbytes[bytes.length - 1] === 0x3b\n\t\t\t);\n\t\t}\n\t\tcase \"image/webp\": {\n\t\t\tif (\n\t\t\t\tbytes.length < 20 ||\n\t\t\t\tbuffer.subarray(0, 4).toString(\"ascii\") !== \"RIFF\" ||\n\t\t\t\tbuffer.subarray(8, 12).toString(\"ascii\") !== \"WEBP\"\n\t\t\t) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst chunk = buffer.subarray(12, 16).toString(\"ascii\");\n\t\t\treturn (\n\t\t\t\tbuffer.readUInt32LE(4) + 8 <= bytes.length && (chunk === \"VP8 \" || chunk === \"VP8L\" || chunk === \"VP8X\")\n\t\t\t);\n\t\t}\n\t}\n}\n\n/** Strict standard base64 decode: whitespace, data URLs, and non-canonical padding are rejected. */\nexport function decodeDashboardImage(value: unknown, mimeType: unknown): DashboardImageBinary | undefined {\n\tif (typeof value !== \"string\" || typeof mimeType !== \"string\") return undefined;\n\tif (!DASHBOARD_IMAGE_MIME_TYPES.has(mimeType as DashboardImageReferenceDto[\"mimeType\"])) return undefined;\n\tif (value.length === 0 || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return undefined;\n\tconst bytes = Buffer.from(value, \"base64\");\n\tif (bytes.toString(\"base64\") !== value) return undefined;\n\tconst exactMimeType = mimeType as DashboardImageReferenceDto[\"mimeType\"];\n\tif (!hasRasterSignature(exactMimeType, bytes)) return undefined;\n\treturn { bytes, mimeType: exactMimeType };\n}\n\nexport function dashboardImageId(mimeType: string, bytes: Uint8Array): string {\n\treturn createHash(\"sha256\").update(mimeType).update(Uint8Array.of(0)).update(bytes).digest(\"hex\");\n}\n\nexport function isDashboardImageReference(value: unknown): value is DashboardImageReferenceDto {\n\tif (!isRecord(value)) return false;\n\treturn (\n\t\tvalue.type === \"image_reference\" &&\n\t\ttypeof value.id === \"string\" &&\n\t\tDASHBOARD_IMAGE_ID_PATTERN.test(value.id) &&\n\t\ttypeof value.mimeType === \"string\" &&\n\t\tDASHBOARD_IMAGE_MIME_TYPES.has(value.mimeType as DashboardImageReferenceDto[\"mimeType\"]) &&\n\t\ttypeof value.size === \"number\" &&\n\t\tNumber.isSafeInteger(value.size) &&\n\t\tvalue.size > 0\n\t);\n}\n\nexport class DashboardImageNotFoundError extends Error {}\nexport class DashboardImagePreviewError extends Error {}\n\n/** Synchronous projection + bounded original/preview repository. */\nexport class DashboardImageService {\n\tprivate readonly images = new Map<string, CachedImage>();\n\tprivate readonly originalFlights = new Map<string, Promise<DashboardImageBinary>>();\n\tprivate readonly previewFlights = new Map<string, Promise<DashboardImageBinary>>();\n\tprivate readonly maxBytes: number;\n\tprivate readonly maxRecords: number;\n\tprivate usedBytes = 0;\n\tprivate usedRecords = 0;\n\tprivate clock = 0;\n\n\tconstructor(\n\t\tprivate readonly previews: ImagePreviewGenerator,\n\t\toptions: DashboardImageRepositoryOptions = {},\n\t) {\n\t\tthis.maxBytes = options.maxBytes ?? DASHBOARD_IMAGE_CACHE_BYTES;\n\t\tthis.maxRecords = options.maxRecords ?? DASHBOARD_IMAGE_CACHE_RECORDS;\n\t}\n\n\tget byteSize(): number {\n\t\treturn this.usedBytes;\n\t}\n\n\tget recordCount(): number {\n\t\treturn this.usedRecords;\n\t}\n\n\t/** Project a browser-facing event without mutating the authoritative source. */\n\tprojectEvent(event: Record<string, unknown>, scope: DashboardImageScope): Record<string, unknown> {\n\t\treturn this.projectNode(event, scope) as Record<string, unknown>;\n\t}\n\n\t/** Project messages/snapshots before JSON serialization. */\n\tproject<T>(value: T, scope: DashboardImageScope): T {\n\t\tconst projected = this.projectNode(value, scope);\n\t\treturn (projected === DROP ? undefined : projected) as T;\n\t}\n\n\tasync original(\n\t\tscope: DashboardImageScope,\n\t\tid: string,\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<DashboardImageBinary> {\n\t\tconst cached = this.cachedVariant(scope, id, \"original\");\n\t\tif (cached) return cached;\n\t\t// Single-flight authoritative recovery per scope+id. In originals display\n\t\t// mode the browser auto-fetches every tool-result image, so N concurrent\n\t\t// cache misses for distinct ids would each trigger a full authoritative\n\t\t// (getMessages) re-fetch over the same RPC pipe. Coalescing the concurrent\n\t\t// misses for one id collapses them into a single load. The key is\n\t\t// scope-bound so requesters from another runtime/agent never piggyback.\n\t\tconst key = `${scopeKey(scope)}\\0${id}`;\n\t\tconst flight = this.originalFlights.get(key);\n\t\tif (flight) return flight;\n\t\tconst promise = this.recoverOriginal(scope, id, loadAuthoritative);\n\t\tthis.originalFlights.set(key, promise);\n\t\ttry {\n\t\t\treturn await promise;\n\t\t} finally {\n\t\t\tthis.originalFlights.delete(key);\n\t\t}\n\t}\n\n\tasync preview(\n\t\tscope: DashboardImageScope,\n\t\tid: string,\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<DashboardImageBinary> {\n\t\tconst cached = this.cachedVariant(scope, id, \"preview\");\n\t\tif (cached) return cached;\n\t\t// Authorize/recover this scope before joining a content-global flight. A\n\t\t// guessed ID from another runtime must not piggyback its in-flight preview.\n\t\tconst original = await this.original(scope, id, loadAuthoritative);\n\t\tconst recoveredPreview = this.cachedVariant(scope, id, \"preview\");\n\t\tif (recoveredPreview) return recoveredPreview;\n\t\tconst flight = this.previewFlights.get(id);\n\t\tif (flight) return flight;\n\t\tconst promise = (async () => {\n\t\t\tlet generated: GeneratedImagePreview;\n\t\t\ttry {\n\t\t\t\tgenerated = await this.previews.generate(original.bytes, original.mimeType);\n\t\t\t} catch (error) {\n\t\t\t\tthrow new DashboardImagePreviewError(\n\t\t\t\t\t`Preview generation failed: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (\n\t\t\t\tgenerated.bytes.byteLength > MAX_PREVIEW_BYTES ||\n\t\t\t\tgenerated.width < 1 ||\n\t\t\t\tgenerated.height < 1 ||\n\t\t\t\tgenerated.width > MAX_PREVIEW_WIDTH ||\n\t\t\t\tgenerated.height > MAX_PREVIEW_HEIGHT ||\n\t\t\t\t(generated.mimeType !== \"image/png\" && generated.mimeType !== \"image/jpeg\")\n\t\t\t) {\n\t\t\t\tthrow new DashboardImagePreviewError(\"Preview worker returned an invalid or over-budget image\");\n\t\t\t}\n\t\t\tconst binary: DashboardImageBinary = { bytes: generated.bytes, mimeType: generated.mimeType };\n\t\t\tthis.putVariant(id, scope, \"preview\", binary);\n\t\t\treturn binary;\n\t\t})();\n\t\tthis.previewFlights.set(id, promise);\n\t\ttry {\n\t\t\treturn await promise;\n\t\t} finally {\n\t\t\tthis.previewFlights.delete(id);\n\t\t}\n\t}\n\n\tremoveRuntime(runtimeKey: string): void {\n\t\tfor (const image of [...this.images.values()]) {\n\t\t\tfor (const key of image.scopes) {\n\t\t\t\tif (key === runtimeKey || key.startsWith(`${runtimeKey}\\0`)) image.scopes.delete(key);\n\t\t\t}\n\t\t\tif (image.scopes.size === 0) this.deleteImage(image);\n\t\t}\n\t}\n\n\tclose(): Promise<void> {\n\t\treturn this.previews.close();\n\t}\n\n\tprivate projectNode(value: unknown, scope: DashboardImageScope): unknown | typeof DROP {\n\t\tif (Array.isArray(value)) {\n\t\t\tconst projected: unknown[] = [];\n\t\t\tfor (const item of value) {\n\t\t\t\tconst next = this.projectNode(item, scope);\n\t\t\t\tif (next !== DROP) projected.push(next);\n\t\t\t}\n\t\t\treturn projected;\n\t\t}\n\t\tif (!isRecord(value)) return value;\n\t\tif (value.type === \"image\") {\n\t\t\tconst image = decodeDashboardImage(value.data, value.mimeType);\n\t\t\tif (!image) return DROP;\n\t\t\treturn this.insertOriginal(scope, image);\n\t\t}\n\t\tif (value.type === \"image_reference\") return isDashboardImageReference(value) ? { ...value } : DROP;\n\t\tconst childScope =\n\t\t\tvalue.type === \"background_agent_event\" && typeof value.agentId === \"string\"\n\t\t\t\t? { runtimeKey: scope.runtimeKey, agentId: value.agentId }\n\t\t\t\t: scope;\n\t\tconst copy: Record<string, unknown> = {};\n\t\tfor (const [key, item] of Object.entries(value)) {\n\t\t\tconst next = this.projectNode(item, key === \"event\" ? childScope : scope);\n\t\t\tif (next !== DROP) copy[key] = next;\n\t\t}\n\t\treturn copy;\n\t}\n\n\tprivate insertOriginal(scope: DashboardImageScope, binary: DashboardImageBinary): DashboardImageReferenceDto {\n\t\tconst id = dashboardImageId(binary.mimeType, binary.bytes);\n\t\tlet image = this.images.get(id);\n\t\tif (!image) {\n\t\t\timage = { id, mimeType: binary.mimeType, size: binary.bytes.byteLength, scopes: new Set() };\n\t\t\tthis.images.set(id, image);\n\t\t}\n\t\timage.scopes.add(scopeKey(scope));\n\t\tif (!image.original) this.putVariant(id, scope, \"original\", binary);\n\t\telse image.original.lastUsed = ++this.clock;\n\t\treturn { type: \"image_reference\", id, mimeType: binary.mimeType, size: binary.bytes.byteLength };\n\t}\n\n\tprivate cachedVariant(\n\t\tscope: DashboardImageScope,\n\t\tid: string,\n\t\tvariant: \"original\" | \"preview\",\n\t): DashboardImageBinary | undefined {\n\t\tconst image = this.images.get(id);\n\t\tif (!image || !image.scopes.has(scopeKey(scope))) return undefined;\n\t\tconst cached = image[variant];\n\t\tif (!cached) return undefined;\n\t\tcached.lastUsed = ++this.clock;\n\t\treturn { bytes: cached.bytes, mimeType: cached.mimeType };\n\t}\n\n\tprivate async recoverOriginal(\n\t\tscope: DashboardImageScope,\n\t\tid: string,\n\t\tloadAuthoritative: () => Promise<unknown>,\n\t): Promise<DashboardImageBinary> {\n\t\tconst source = await loadAuthoritative();\n\t\tlet recovered: DashboardImageBinary | undefined;\n\t\tconst visit = (value: unknown): void => {\n\t\t\tif (recovered) return;\n\t\t\tif (Array.isArray(value)) {\n\t\t\t\tfor (const item of value) visit(item);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!isRecord(value)) return;\n\t\t\tif (value.type === \"image\") {\n\t\t\t\tconst image = decodeDashboardImage(value.data, value.mimeType);\n\t\t\t\tif (image && dashboardImageId(image.mimeType, image.bytes) === id) recovered = image;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tfor (const item of Object.values(value)) visit(item);\n\t\t};\n\t\tvisit(source);\n\t\tif (!recovered) throw new DashboardImageNotFoundError(\"Image is no longer available from this transcript\");\n\t\tthis.insertOriginal(scope, recovered);\n\t\treturn recovered;\n\t}\n\n\tprivate putVariant(\n\t\tid: string,\n\t\tscope: DashboardImageScope,\n\t\tvariant: \"original\" | \"preview\",\n\t\tbinary: DashboardImageBinary,\n\t): void {\n\t\tlet image = this.images.get(id);\n\t\tif (!image) {\n\t\t\timage = { id, mimeType: binary.mimeType, size: binary.bytes.byteLength, scopes: new Set() };\n\t\t\tthis.images.set(id, image);\n\t\t}\n\t\timage.scopes.add(scopeKey(scope));\n\t\tconst previous = image[variant];\n\t\tif (previous) {\n\t\t\tthis.usedBytes -= previous.bytes.byteLength;\n\t\t} else {\n\t\t\tthis.usedRecords += 1;\n\t\t}\n\t\timage[variant] = { bytes: binary.bytes, mimeType: binary.mimeType, lastUsed: ++this.clock };\n\t\tthis.usedBytes += binary.bytes.byteLength;\n\t\tthis.evict();\n\t}\n\n\tprivate evict(): void {\n\t\twhile (this.usedBytes > this.maxBytes || this.usedRecords > this.maxRecords) {\n\t\t\tlet oldest: { image: CachedImage; variant: \"original\" | \"preview\"; lastUsed: number } | undefined;\n\t\t\tfor (const image of this.images.values()) {\n\t\t\t\tfor (const variant of [\"original\", \"preview\"] as const) {\n\t\t\t\t\tconst candidate = image[variant];\n\t\t\t\t\tif (candidate && (!oldest || candidate.lastUsed < oldest.lastUsed)) {\n\t\t\t\t\t\toldest = { image, variant, lastUsed: candidate.lastUsed };\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (!oldest) break;\n\t\t\tconst removed = oldest.image[oldest.variant]!;\n\t\t\tthis.usedBytes -= removed.bytes.byteLength;\n\t\t\tthis.usedRecords -= 1;\n\t\t\tdelete oldest.image[oldest.variant];\n\t\t\tif (!oldest.image.original && !oldest.image.preview) this.images.delete(oldest.image.id);\n\t\t}\n\t}\n\n\tprivate deleteImage(image: CachedImage): void {\n\t\tfor (const variant of [image.original, image.preview]) {\n\t\t\tif (!variant) continue;\n\t\t\tthis.usedBytes -= variant.bytes.byteLength;\n\t\t\tthis.usedRecords -= 1;\n\t\t}\n\t\tthis.images.delete(image.id);\n\t}\n}\n"]}