返回 oh-my-ppt
fulfillment-service.ts
根目录 / src / main / image-generation / fulfillment-service.ts
1 import crypto from 'crypto'
2 import log from 'electron-log/main.js'
3 import fs from 'fs'
4 import path from 'path'
5 import { nanoid } from 'nanoid'
6 import type {
7 ImageFulfillmentIntentRecord,
8 ImageFulfillmentJobRecord,
9 PPTDatabase
10 } from '../db/database'
11 import { imageHistoryLockKey, JobCoordinator } from '../agent-runtime'
12 import { resolveImageGenerationProvider } from '../agent-runtime/provider/image'
13 import { allowLocalAssetRoot } from '../io/local-asset-roots'
14 import { getImageModelDisplayName, resolveConfiguredImageModel } from './model-config'
15 import type { ParsedVisualIntent, VisualIntentParseResult } from './visual-intent'
16 import {
17 adoptFinalizedImageAssets,
18 stripImageIntentDrafts,
19 validateFinalizedImageHtml,
20 type FinalizationAsset
21 } from './finalization-validator'
22
23 const LEASE_DURATION_SECONDS = 10 * 60
24 const FINALIZATION_LEASE_DURATION_SECONDS = 30 * 60
25
26 type FulfillmentDb = Pick<
27 PPTDatabase,
28 | 'createImageFulfillmentJob'
29 | 'getImageFulfillmentJob'
30 | 'getImageModelConfig'
31 | 'claimImageFulfillmentJob'
32 | 'completeImageFulfillmentJob'
33 | 'transitionImageFulfillmentJob'
34 | 'transitionImageFulfillmentIntent'
35 >
36
37 export type FinalizeAutomaticImageArgs = {
38 db: FulfillmentDb
39 coordinator: JobCoordinator
40 decryptApiKey(value: string): string
41 resolveSessionProjectDir(sessionId: string): Promise<string>
42 sessionId: string
43 sessionPageId: string
44 runId: string
45 pageId: string
46 pageHtmlPath: string
47 layoutId: string
48 layoutContractVersion: number
49 imageModelConfigId: string
50 parseResult: VisualIntentParseResult
51 retryOfJobId?: string
52 retryOfIntentIdBySlot?: Record<string, string>
53 idempotencyKey?: string
54 validateCandidateHtml?: (html: string) => string[]
55 signal?: AbortSignal
56 refineImageLayout?: ImageLayoutRefinement
57 }
58
59 export type ImageLayoutRefinement = (assets: Array<{
60 slotId: string
61 layoutSlotId: string
62 relativePath: string
63 role: string
64 prompt: string
65 }>) => Promise<void>
66
67 export type AutomaticImageFinalizationResult = {
68 status: 'none' | 'completed' | 'degraded' | 'cancelled'
69 jobId?: string
70 error?: string
71 reused?: boolean
72 }
73
74 type StagedAsset = FinalizationAsset & {
75 intent: ImageFulfillmentIntentRecord
76 stagingPath: string
77 finalPath: string
78 mimeType: string
79 width: number
80 height: number
81 prompt: string
82 }
83
84 const errorMessage = (error: unknown): string =>
85 error instanceof Error && error.message ? error.message : String(error)
86
87 const isAbortError = (error: unknown): boolean => /abort|cancel/i.test(errorMessage(error))
88
89 const throwIfAborted = (signal?: AbortSignal): void => {
90 if (signal?.aborted) throw new Error('Image fulfillment cancelled')
91 }
92
93 const throwIfJobCancelled = async (db: FulfillmentDb, jobId: string): Promise<void> => {
94 const job = await db.getImageFulfillmentJob(jobId)
95 if (job?.cancel_requested_at) throw new Error('Image fulfillment cancelled')
96 }
97
98 const writeFileAtomically = async (targetPath: string, content: string): Promise<void> => {
99 const tempPath = `${targetPath}.${crypto.randomUUID()}.tmp`
100 await fs.promises.mkdir(path.dirname(targetPath), { recursive: true })
101 try {
102 await fs.promises.writeFile(tempPath, content, 'utf-8')
103 await fs.promises.rename(tempPath, targetPath)
104 } finally {
105 await fs.promises.rm(tempPath, { force: true }).catch(() => undefined)
106 }
107 }
108
109 const sanitizeExtension = (extension: string): string =>
110 /^\.[a-z0-9]{2,5}$/i.test(extension) ? extension.toLowerCase() : '.png'
111
112 type ImageDimensions = { width: number; height: number }
113
114 const readPngDimensions = (bytes: Buffer): ImageDimensions | null => {
115 if (
116 bytes.length < 24 ||
117 bytes.subarray(0, 8).compare(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) !== 0
118 ) {
119 return null
120 }
121 const width = bytes.readUInt32BE(16)
122 const height = bytes.readUInt32BE(20)
123 return width > 0 && height > 0 ? { width, height } : null
124 }
125
126 const readJpegDimensions = (bytes: Buffer): ImageDimensions | null => {
127 if (bytes.length < 9 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null
128 let offset = 2
129 while (offset + 9 <= bytes.length) {
130 while (offset < bytes.length && bytes[offset] !== 0xff) offset += 1
131 while (offset < bytes.length && bytes[offset] === 0xff) offset += 1
132 const marker = bytes[offset]
133 offset += 1
134 if (marker === undefined || marker === 0xd9 || marker === 0xda) return null
135 if (offset + 2 > bytes.length) return null
136 const length = bytes.readUInt16BE(offset)
137 if (length < 2 || offset + length > bytes.length) return null
138 if (
139 (marker >= 0xc0 && marker <= 0xc3) ||
140 (marker >= 0xc5 && marker <= 0xc7) ||
141 (marker >= 0xc9 && marker <= 0xcb) ||
142 (marker >= 0xcd && marker <= 0xcf)
143 ) {
144 const height = bytes.readUInt16BE(offset + 3)
145 const width = bytes.readUInt16BE(offset + 5)
146 return width > 0 && height > 0 ? { width, height } : null
147 }
148 offset += length
149 }
150 return null
151 }
152
153 const readWebpDimensions = (bytes: Buffer): ImageDimensions | null => {
154 if (
155 bytes.length < 30 ||
156 bytes.subarray(0, 4).toString('ascii') !== 'RIFF' ||
157 bytes.subarray(8, 12).toString('ascii') !== 'WEBP'
158 ) {
159 return null
160 }
161 const chunk = bytes.subarray(12, 16).toString('ascii')
162 if (chunk === 'VP8X') {
163 const width = bytes.readUIntLE(24, 3) + 1
164 const height = bytes.readUIntLE(27, 3) + 1
165 return width > 0 && height > 0 ? { width, height } : null
166 }
167 if (chunk === 'VP8 ' && bytes.length >= 30) {
168 const width = bytes.readUInt16LE(26) & 0x3fff
169 const height = bytes.readUInt16LE(28) & 0x3fff
170 return width > 0 && height > 0 ? { width, height } : null
171 }
172 if (chunk === 'VP8L' && bytes.length >= 25 && bytes[20] === 0x2f) {
173 const bits = bytes.readUInt32LE(21)
174 const width = (bits & 0x3fff) + 1
175 const height = ((bits >> 14) & 0x3fff) + 1
176 return width > 0 && height > 0 ? { width, height } : null
177 }
178 return null
179 }
180
181 const resolveImageDimensions = (bytes: Buffer): ImageDimensions | null =>
182 readPngDimensions(bytes) || readJpegDimensions(bytes) || readWebpDimensions(bytes)
183
184 const toImagePrompt = (intent: ParsedVisualIntent): string =>
185 [
186 intent.subject,
187 intent.textZone ? `Preserve text zone: ${intent.textZone}.` : '',
188 intent.subjectZone ? `Place subject in: ${intent.subjectZone}.` : '',
189 intent.negativeSpace ? `Negative space: ${intent.negativeSpace}.` : '',
190 intent.avoid.length > 0 ? `Avoid: ${intent.avoid.join(', ')}.` : ''
191 ]
192 .filter(Boolean)
193 .join('\n')
194
195 const finalizationAssets = (assets: StagedAsset[]): FinalizationAsset[] =>
196 assets.map((asset) => ({
197 slotId: asset.slotId,
198 layoutSlotId: asset.intent.layout_slot_id,
199 layer: asset.intent.layer === 'background' ? 'background' : 'visual',
200 relativePath: asset.relativePath
201 }))
202
203 const fallbackHtml = async (pageHtmlPath: string, html: string): Promise<void> =>
204 writeFileAtomically(pageHtmlPath, stripImageIntentDrafts(html))
205
206 const transitionAll = async (
207 db: FulfillmentDb,
208 intents: ImageFulfillmentIntentRecord[],
209 status: 'fallback' | 'failed' | 'cancelled',
210 error?: string
211 ): Promise<void> => {
212 await Promise.all(
213 intents.map((intent) =>
214 db.transitionImageFulfillmentIntent({
215 intentId: intent.id,
216 from: ['pending', 'generating', 'generated'],
217 status,
218 error: error || null
219 })
220 )
221 )
222 }
223
224 const createJob = async (
225 args: FinalizeAutomaticImageArgs,
226 intents: ParsedVisualIntent[],
227 invalidIntents = false
228 ): Promise<{
229 job: ImageFulfillmentJobRecord
230 intents: ImageFulfillmentIntentRecord[]
231 created: boolean
232 }> => {
233 const modelConfig = await resolveConfiguredImageModel(
234 { db: args.db, decryptApiKey: args.decryptApiKey },
235 args.imageModelConfigId
236 )
237 const contentHash = crypto
238 .createHash('sha256')
239 .update(await fs.promises.readFile(args.pageHtmlPath, 'utf-8'))
240 .digest('hex')
241 .slice(0, 16)
242 const created = await args.db.createImageFulfillmentJob({
243 runId: args.runId,
244 sessionId: args.sessionId,
245 sessionPageId: args.sessionPageId,
246 pageId: args.pageId,
247 layoutId: args.layoutId,
248 layoutContractVersion: args.layoutContractVersion,
249 imageModelConfigId: modelConfig.id,
250 imageProvider: modelConfig.provider,
251 imageModel: getImageModelDisplayName(modelConfig),
252 idempotencyKey:
253 args.idempotencyKey ||
254 `${args.runId}:${args.sessionPageId}:${contentHash}:${invalidIntents ? 'invalid' : 'valid'}`,
255 retryOfJobId: args.retryOfJobId || null,
256 intents: intents.map((intent, index) => ({
257 slotId: intent.slotId || `invalid-${index + 1}`,
258 layoutSlotId: intent.layoutSlotId || 'invalid',
259 role: intent.role || 'invalid',
260 layer: intent.layer || 'visual',
261 requestVersion: args.layoutContractVersion,
262 sizeHint: null,
263 subject: intent.subject || 'Invalid automatic image request',
264 textZone: intent.textZone || null,
265 subjectZone: intent.subjectZone || null,
266 negativeSpace: intent.negativeSpace || null,
267 avoidJson: JSON.stringify(intent.avoid || []),
268 requestJson: intent.requestJson || '{}',
269 retryOfIntentId: args.retryOfIntentIdBySlot?.[intent.slotId] || null
270 }))
271 })
272 return created
273 }
274
275 const createInvalidIntentAudit = (args: FinalizeAutomaticImageArgs): ParsedVisualIntent[] =>
276 args.parseResult.invalidIntents.map((intent, index) => ({
277 slotId: intent.slotId || `invalid-${index + 1}`,
278 layoutSlotId: intent.layoutSlotId || 'invalid',
279 role: 'spot-illustration',
280 layer: 'visual',
281 subject: 'Invalid automatic image request',
282 avoid: [],
283 requestJson: intent.requestJson || JSON.stringify({ errors: intent.errors })
284 }))
285
286 const moveCommittedAssets = async (assets: StagedAsset[]): Promise<void> => {
287 for (const asset of assets) {
288 await fs.promises.mkdir(path.dirname(asset.finalPath), { recursive: true })
289 await fs.promises.rename(asset.stagingPath, asset.finalPath)
290 }
291 }
292
293 const restoreCommittedAssetsToStaging = async (assets: StagedAsset[]): Promise<boolean> => {
294 const results = await Promise.allSettled(
295 assets.map(async (asset) => {
296 if (!fs.existsSync(asset.finalPath)) return
297 await fs.promises.mkdir(path.dirname(asset.stagingPath), { recursive: true })
298 await fs.promises.rename(asset.finalPath, asset.stagingPath)
299 })
300 )
301 return results.every((result) => result.status === 'fulfilled')
302 }
303
304 /** Generates image assets in staging, then validates a scoped page-agent refinement of their slot placement. */
305 export const finalizeAutomaticImageIntents = async (
306 args: FinalizeAutomaticImageArgs
307 ): Promise<AutomaticImageFinalizationResult> => {
308 const originalHtml = await fs.promises.readFile(args.pageHtmlPath, 'utf-8')
309 if (args.parseResult.status === 'none') {
310 log.info('[images:fulfillment] no image intents to fulfill', {
311 sessionId: args.sessionId,
312 runId: args.runId,
313 pageId: args.pageId,
314 layoutId: args.layoutId
315 })
316 return { status: 'none' }
317 }
318
319 if (args.parseResult.status !== 'valid') {
320 if (args.parseResult.status === 'forbidden') {
321 throw new Error(args.parseResult.errors.join('; ') || 'Image intent drafts are forbidden.')
322 }
323 const audit = await createJob(args, createInvalidIntentAudit(args), true)
324 const error = args.parseResult.errors.join('; ') || 'Invalid image intent draft'
325 log.warn('[images:fulfillment] invalid image intent draft', {
326 sessionId: args.sessionId,
327 runId: args.runId,
328 pageId: args.pageId,
329 jobId: audit.job.id,
330 error
331 })
332 await fallbackHtml(args.pageHtmlPath, originalHtml)
333 await transitionAll(args.db, audit.intents, 'failed', error)
334 await args.db.transitionImageFulfillmentJob({
335 jobId: audit.job.id,
336 from: ['pending'],
337 status: 'degraded',
338 error
339 })
340 return { status: 'degraded', jobId: audit.job.id, error }
341 }
342
343 const created = await createJob(args, args.parseResult.intents)
344 if (!created.job || !created.intents.length) return { status: 'none' }
345 log.info('[images:fulfillment] job prepared', {
346 sessionId: args.sessionId,
347 runId: args.runId,
348 pageId: args.pageId,
349 jobId: created.job.id,
350 created: created.created,
351 layoutId: args.layoutId,
352 intentCount: created.intents.length,
353 imageModelConfigId: args.imageModelConfigId
354 })
355 if (!created.created) {
356 const status =
357 created.job.status === 'completed'
358 ? 'completed'
359 : created.job.status === 'cancelled'
360 ? 'cancelled'
361 : created.job.status === 'degraded' || created.job.status === 'failed'
362 ? 'degraded'
363 : 'none'
364 log.info('[images:fulfillment] reusing existing job', {
365 sessionId: args.sessionId,
366 runId: args.runId,
367 pageId: args.pageId,
368 jobId: created.job.id,
369 status
370 })
371 return { status, jobId: created.job.id, error: created.job.error || undefined, reused: true }
372 }
373 const projectDir = await args.resolveSessionProjectDir(args.sessionId)
374 const stagingDir = path.join(projectDir, 'images', '.staging', created.job.id)
375 const manifestPath = path.join(stagingDir, 'manifest.json')
376 const manifestRelativePath = path.relative(projectDir, manifestPath)
377 const leaseOwner = `image-fulfillment:${created.job.id}`
378 const reservation = await args.coordinator.reserve({
379 jobId: created.job.id,
380 domain: 'image',
381 owner: { kind: 'image-fulfillment', id: created.job.id },
382 claims: { write: [imageHistoryLockKey(args.sessionId)] },
383 wait: 'block',
384 signal: args.signal
385 })
386 if (reservation.status === 'busy') {
387 log.warn('[images:fulfillment] image generation lock is busy', {
388 sessionId: args.sessionId,
389 runId: args.runId,
390 pageId: args.pageId,
391 jobId: created.job.id
392 })
393 await fallbackHtml(args.pageHtmlPath, originalHtml)
394 await transitionAll(args.db, created.intents, 'fallback', 'Image generation is busy')
395 await args.db.transitionImageFulfillmentJob({
396 jobId: created.job.id,
397 from: ['pending'],
398 status: 'degraded',
399 error: 'Image generation is busy'
400 })
401 return { status: 'degraded', jobId: created.job.id, error: 'Image generation is busy' }
402 }
403
404 const lease = reservation.lease
405 const stagedAssets: StagedAsset[] = []
406 let jobInFinalizing = false
407 let candidatePath = ''
408 try {
409 throwIfAborted(lease.signal)
410 const claimed = await args.db.claimImageFulfillmentJob({
411 jobId: created.job.id,
412 leaseOwner,
413 leaseDurationSec: LEASE_DURATION_SECONDS
414 })
415 if (!claimed) throw new Error('Image fulfillment job could not be claimed')
416 const modelConfig = await resolveConfiguredImageModel(
417 { db: args.db, decryptApiKey: args.decryptApiKey },
418 args.imageModelConfigId
419 )
420 const adapter = resolveImageGenerationProvider(modelConfig.provider)
421 const displayModel = getImageModelDisplayName(modelConfig)
422 const imageSize = adapter.getDefaultSize(modelConfig)
423 await fs.promises.mkdir(stagingDir, { recursive: true })
424
425 const manifest = {
426 version: 1,
427 jobId: created.job.id,
428 sessionId: args.sessionId,
429 pageId: args.pageId,
430 pageHtmlPath: args.pageHtmlPath,
431 fallbackHtmlPath: path.join(stagingDir, 'fallback.html'),
432 assets: [] as Array<{
433 slotId: string
434 stagingPath: string
435 finalPath: string
436 relativePath: string
437 mimeType: string
438 width: number
439 height: number
440 }>
441 }
442 // The recovery contract must exist before any provider output enters staging.
443 await writeFileAtomically(manifest.fallbackHtmlPath, stripImageIntentDrafts(originalHtml))
444 await writeFileAtomically(manifestPath, JSON.stringify(manifest, null, 2))
445 const tracked = await args.db.transitionImageFulfillmentJob({
446 jobId: created.job.id,
447 from: ['running'],
448 status: 'running',
449 finalizationManifestPath: manifestRelativePath,
450 imageProvider: modelConfig.provider,
451 imageModel: displayModel,
452 leaseOwner,
453 leaseExpiresAt: Math.floor(Date.now() / 1000) + LEASE_DURATION_SECONDS
454 })
455 if (!tracked) throw new Error('Image fulfillment execution snapshot could not be persisted')
456
457 for (let index = 0; index < args.parseResult.intents.length; index += 1) {
458 throwIfAborted(lease.signal)
459 await throwIfJobCancelled(args.db, created.job.id)
460 const intent = created.intents[index]
461 const request = args.parseResult.intents[index]
462 if (!intent || !request) throw new Error('Image fulfillment intent is missing')
463 await args.db.transitionImageFulfillmentIntent({
464 intentId: intent.id,
465 from: ['pending'],
466 status: 'generating'
467 })
468 const prompt = toImagePrompt(request)
469 log.info('[images:fulfillment] provider request', {
470 sessionId: args.sessionId,
471 runId: args.runId,
472 pageId: args.pageId,
473 jobId: created.job.id,
474 slotId: request.slotId,
475 layoutSlotId: request.layoutSlotId,
476 provider: modelConfig.provider,
477 model: displayModel,
478 size: imageSize,
479 count: 1,
480 prompt
481 })
482 const results = await adapter.generate(modelConfig, {
483 prompt,
484 count: 1,
485 size: imageSize,
486 negativePrompt: request.avoid.join(', ') || undefined,
487 signal: lease.signal
488 })
489 log.info('[images:fulfillment] provider response', {
490 sessionId: args.sessionId,
491 runId: args.runId,
492 pageId: args.pageId,
493 jobId: created.job.id,
494 slotId: request.slotId,
495 assetCount: results.length,
496 firstAssetBytes: results[0]?.bytes.length || 0,
497 firstAssetMimeType: results[0]?.mimeType || null
498 })
499 throwIfAborted(lease.signal)
500 await throwIfJobCancelled(args.db, created.job.id)
501 const generated = results[0]
502 if (!generated) throw new Error(`Image provider returned no asset for ${request.slotId}`)
503 const dimensions = resolveImageDimensions(generated.bytes)
504 if (!dimensions) throw new Error(`Image provider returned an invalid image for ${request.slotId}`)
505 const fileName = `generated-${args.pageId}-${nanoid(10)}${sanitizeExtension(generated.extension)}`
506 const stagingPath = path.join(stagingDir, fileName)
507 const finalPath = path.join(projectDir, 'images', fileName)
508 await fs.promises.writeFile(stagingPath, generated.bytes)
509 const staged: StagedAsset = {
510 slotId: request.slotId,
511 relativePath: `./images/${fileName}`,
512 intent,
513 stagingPath,
514 finalPath,
515 mimeType: generated.mimeType,
516 width: dimensions.width,
517 height: dimensions.height,
518 prompt
519 }
520 stagedAssets.push(staged)
521 await args.db.transitionImageFulfillmentIntent({
522 intentId: intent.id,
523 from: ['generating'],
524 status: 'generated',
525 assetPath: staged.relativePath,
526 mimeType: staged.mimeType,
527 width: staged.width,
528 height: staged.height
529 })
530 }
531
532 manifest.assets = stagedAssets.map((asset) => ({
533 slotId: asset.slotId,
534 stagingPath: asset.stagingPath,
535 finalPath: asset.finalPath,
536 relativePath: asset.relativePath,
537 mimeType: asset.mimeType,
538 width: asset.width,
539 height: asset.height
540 }))
541 await writeFileAtomically(manifestPath, JSON.stringify(manifest, null, 2))
542 const movedToFinalizing = await args.db.transitionImageFulfillmentJob({
543 jobId: created.job.id,
544 from: ['running'],
545 status: 'finalizing',
546 finalizationManifestPath: manifestRelativePath,
547 leaseOwner,
548 leaseExpiresAt:
549 Math.floor(Date.now() / 1000) + FINALIZATION_LEASE_DURATION_SECONDS
550 })
551 if (!movedToFinalizing) throw new Error('Image fulfillment job could not enter finalization')
552 jobInFinalizing = true
553 lease.release()
554
555 throwIfAborted(args.signal)
556 await throwIfJobCancelled(args.db, created.job.id)
557 const adoption = adoptFinalizedImageAssets(originalHtml, finalizationAssets(stagedAssets))
558 const validateCandidate = (html: string) => {
559 const validation = validateFinalizedImageHtml(html, finalizationAssets(stagedAssets))
560 const contractErrors = args.validateCandidateHtml?.(html) || []
561 return { validation, contractErrors }
562 }
563 const deterministicHtml = adoption.html
564 let candidateHtml = deterministicHtml
565 candidatePath = `${args.pageHtmlPath}.${created.job.id}.finalizing`
566 await writeFileAtomically(candidatePath, candidateHtml)
567 let candidateEvaluation = validateCandidate(candidateHtml)
568 const deterministicCandidateValid =
569 adoption.errors.length === 0 &&
570 candidateEvaluation.validation.valid &&
571 candidateEvaluation.validation.unusedSlotIds.length === 0 &&
572 candidateEvaluation.contractErrors.length === 0
573
574 if (args.refineImageLayout && deterministicCandidateValid) {
575 // The page agent reads the image-bearing candidate but can only make local edit_file changes.
576 await writeFileAtomically(args.pageHtmlPath, candidateHtml)
577 try {
578 await args.refineImageLayout(
579 stagedAssets.map((asset) => ({
580 slotId: asset.slotId,
581 layoutSlotId: asset.intent.layout_slot_id,
582 relativePath: asset.relativePath,
583 role: asset.intent.role,
584 prompt: asset.prompt
585 }))
586 )
587 throwIfAborted(args.signal)
588 await throwIfJobCancelled(args.db, created.job.id)
589 const refinedHtml = await fs.promises.readFile(args.pageHtmlPath, 'utf-8')
590 const refinedEvaluation = validateCandidate(refinedHtml)
591 const refinedCandidateValid =
592 refinedEvaluation.validation.valid &&
593 refinedEvaluation.validation.unusedSlotIds.length === 0 &&
594 refinedEvaluation.contractErrors.length === 0
595 if (refinedCandidateValid) {
596 candidateHtml = refinedHtml
597 candidateEvaluation = refinedEvaluation
598 await writeFileAtomically(candidatePath, candidateHtml)
599 } else {
600 const errors = [
601 ...refinedEvaluation.validation.errors,
602 ...refinedEvaluation.contractErrors
603 ]
604 log.warn('[images:fulfillment] refinement rejected; using deterministic candidate', {
605 sessionId: args.sessionId,
606 runId: args.runId,
607 pageId: args.pageId,
608 jobId: created.job.id,
609 error: errors.join('; ') || 'Refinement did not retain the generated image layout'
610 })
611 await writeFileAtomically(args.pageHtmlPath, deterministicHtml)
612 }
613 } catch (error) {
614 await throwIfJobCancelled(args.db, created.job.id)
615 if (isAbortError(error) || args.signal?.aborted) throw error
616 log.warn('[images:fulfillment] refinement failed; using deterministic candidate', {
617 sessionId: args.sessionId,
618 runId: args.runId,
619 pageId: args.pageId,
620 jobId: created.job.id,
621 error: errorMessage(error)
622 })
623 await writeFileAtomically(args.pageHtmlPath, deterministicHtml)
624 }
625 }
626 const { validation, contractErrors } = candidateEvaluation
627 if (
628 adoption.errors.length > 0 ||
629 !validation.valid ||
630 validation.unusedSlotIds.length > 0 ||
631 contractErrors.length > 0
632 ) {
633 await fs.promises.rm(candidatePath, { force: true }).catch(() => undefined)
634 await fallbackHtml(args.pageHtmlPath, originalHtml)
635 const error =
636 [...adoption.errors, ...validation.errors, ...contractErrors].join('; ') ||
637 'Generated images were not adopted by the final layout'
638 await Promise.all(
639 created.intents.map((intent) =>
640 args.db.transitionImageFulfillmentIntent({
641 intentId: intent.id,
642 from: ['generated'],
643 status: validation.unusedSlotIds.includes(intent.slot_id)
644 ? 'layout_failed'
645 : 'fallback',
646 error
647 })
648 )
649 )
650 await args.db.transitionImageFulfillmentJob({
651 jobId: created.job.id,
652 from: ['finalizing'],
653 status: 'degraded',
654 error
655 })
656 await fs.promises.rm(stagingDir, { recursive: true, force: true })
657 log.warn('[images:fulfillment] final layout rejected generated images', {
658 sessionId: args.sessionId,
659 runId: args.runId,
660 pageId: args.pageId,
661 jobId: created.job.id,
662 error
663 })
664 return { status: 'degraded', jobId: created.job.id, error }
665 }
666
667 const commitReservation = await args.coordinator.reserve({
668 jobId: `${created.job.id}:commit`,
669 domain: 'image',
670 owner: { kind: 'image-fulfillment', id: `${created.job.id}:commit` },
671 claims: { write: [imageHistoryLockKey(args.sessionId)] },
672 wait: 'block',
673 signal: args.signal
674 })
675 if (commitReservation.status === 'busy') throw new Error('Image finalization commit is busy')
676 try {
677 throwIfAborted(commitReservation.lease.signal)
678 await throwIfJobCancelled(args.db, created.job.id)
679 await moveCommittedAssets(stagedAssets)
680 throwIfAborted(commitReservation.lease.signal)
681 await throwIfJobCancelled(args.db, created.job.id)
682 allowLocalAssetRoot(path.join(projectDir, 'images'))
683 await fs.promises.rename(candidatePath, args.pageHtmlPath)
684 throwIfAborted(commitReservation.lease.signal)
685 await throwIfJobCancelled(args.db, created.job.id)
686 const completed = await args.db.completeImageFulfillmentJob({
687 jobId: created.job.id,
688 sessionId: args.sessionId,
689 pageId: args.pageId,
690 modelConfigId: args.imageModelConfigId,
691 provider: modelConfig.provider,
692 model: displayModel,
693 assets: stagedAssets.map((asset) => ({
694 intentId: asset.intent.id,
695 prompt: asset.prompt,
696 assetPath: asset.relativePath,
697 mimeType: asset.mimeType,
698 width: asset.width,
699 height: asset.height
700 }))
701 })
702 if (!completed) throw new Error('Image fulfillment was cancelled before completion')
703 } finally {
704 commitReservation.lease.release()
705 }
706 await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch((cleanupError) => {
707 // Completion is already durable in SQLite and the page points only at final assets.
708 // Preserve the private staging directory for startup cleanup instead of undoing the commit.
709 console.warn('[image:fulfillment] completed job staging cleanup failed', {
710 jobId: created.job.id,
711 message: errorMessage(cleanupError)
712 })
713 })
714 log.info('[images:fulfillment] completed', {
715 sessionId: args.sessionId,
716 runId: args.runId,
717 pageId: args.pageId,
718 jobId: created.job.id,
719 assetCount: stagedAssets.length,
720 assets: stagedAssets.map((asset) => asset.relativePath)
721 })
722 return { status: 'completed', jobId: created.job.id }
723 } catch (error) {
724 const message = errorMessage(error)
725 const cancelled = isAbortError(error) || args.signal?.aborted === true || lease.signal.aborted
726 const restoredAssets = await restoreCommittedAssetsToStaging(stagedAssets)
727 if (candidatePath) await fs.promises.rm(candidatePath, { force: true }).catch(() => undefined)
728 await fallbackHtml(args.pageHtmlPath, originalHtml)
729 await transitionAll(args.db, created.intents, cancelled ? 'cancelled' : 'fallback', message)
730 await args.db.transitionImageFulfillmentJob({
731 jobId: created.job.id,
732 from: jobInFinalizing ? ['finalizing'] : ['pending', 'running'],
733 status: cancelled ? 'cancelled' : 'degraded',
734 error: message
735 })
736 if (!jobInFinalizing || restoredAssets) {
737 await fs.promises.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined)
738 }
739 log.warn('[images:fulfillment] failed', {
740 sessionId: args.sessionId,
741 runId: args.runId,
742 pageId: args.pageId,
743 jobId: created.job.id,
744 cancelled,
745 error: message
746 })
747 return { status: cancelled ? 'cancelled' : 'degraded', jobId: created.job.id, error: message }
748 } finally {
749 lease.release()
750 }
751 }
752
752 lines TYPESCRIPT