@deepblend/dsh-blender-host 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1064 @@
1
+ /**
2
+ * Revision transaction — the atomic commit path (SPEC §13.2).
3
+ *
4
+ * ```
5
+ * validate the patch
6
+ * → resolve the idempotency key (a retry must not apply twice)
7
+ * → check baseRevision against current (a stale write must not overwrite)
8
+ * → apply operations to the stored SceneSpec in memory
9
+ * → JSON-Schema + semantic validation
10
+ * → compile in Blender into staging/
11
+ * → technical validation of the compiled scene
12
+ * → write the revision manifest
13
+ * → atomic rename staging → revisions/<id>
14
+ * → move the current pointer
15
+ * → record the idempotency outcome
16
+ * ```
17
+ *
18
+ * WHY NOTHING IS WRITTEN IN PLACE
19
+ * -------------------------------
20
+ * Every mutation happens either in memory or inside `<project>/staging/<rev>/`.
21
+ * The revision directory appears, fully formed, from a single `rename`. So the
22
+ * failure modes the SPEC cares about are structural rather than remembered:
23
+ *
24
+ * - "失败不污染当前 Revision" holds because the current revision's directory is
25
+ * never opened for writing at all — not because an error handler cleans up.
26
+ * - "不产生半提交 Revision" holds because a directory that has not been renamed
27
+ * is not a revision.
28
+ * - A crash leaves at most a staging directory, which `sweepStaging()` removes
29
+ * and which no reader ever consults.
30
+ *
31
+ * ORDERING: THE POINTER MOVES LAST
32
+ * --------------------------------
33
+ * `project.json` is updated only after the revision directory is published. The
34
+ * two writes are not one transaction, so one of them can be interrupted — and the
35
+ * order decides which interruption is survivable. Publishing then failing to move
36
+ * the pointer leaves an unreferenced but complete revision: visible, correct, and
37
+ * one `restoreRevision` away from usable. Moving the pointer first would leave a
38
+ * current revision that does not exist, which is unrecoverable.
39
+ *
40
+ * Owner: DeepBlend Studio — M1
41
+ * Plane: Host composition
42
+ */
43
+
44
+ import { existsSync, mkdirSync, readdirSync, renameSync, statSync } from 'node:fs'
45
+ import { join } from 'node:path'
46
+
47
+ import {
48
+ BlenderError,
49
+ BlenderErrorCode,
50
+ BlenderWarningCode,
51
+ PROJECT_RECORD_VERSION,
52
+ REVISION_MANIFEST_VERSION,
53
+ SCENE_PATCH_VERSION,
54
+ applyPatchToSpec,
55
+ buildOperationManifest,
56
+ compileSceneSpec,
57
+ sceneSpecCanonicalText,
58
+ sceneSpecDigest,
59
+ shortDigest,
60
+ specHash,
61
+ summarizeSceneSpec,
62
+ toCanonicalFailure,
63
+ validateScenePatch,
64
+ validateSceneSpec,
65
+ warning,
66
+ } from '@deepblend/dsh-blender-contracts'
67
+ import { GENESIS_REVISION, parseRevisionId } from './project-store.js'
68
+ import {
69
+ fileSha256,
70
+ fileSize,
71
+ isFile,
72
+ publishDirectory,
73
+ removeTree,
74
+ writeFileAtomic,
75
+ writeJsonAtomic,
76
+ } from './paths.js'
77
+
78
+ /**
79
+ * Derive the idempotency key for a patch when the caller did not supply one.
80
+ *
81
+ * This is a deliberate departure from "require the caller to invent a key", and
82
+ * it is a safety improvement rather than a convenience. A model retrying a tool
83
+ * call it is unsure about is NORMAL behaviour, and a scheme that made identical
84
+ * retries create duplicate revisions would punish exactly the cautious caller.
85
+ *
86
+ * The derived key covers the whole logical intent: the project, the revision the
87
+ * patch was computed against, who asked, and every operation. So:
88
+ * - an identical retry produces the same key and returns the recorded outcome
89
+ * without re-applying anything (SPEC §20 M1 "同一幂等键不会重复提交");
90
+ * - a genuinely different patch produces a different key and applies normally,
91
+ * even against the same base revision;
92
+ * - the same operations against a LATER base revision also produce a different
93
+ * key, because they describe a different state and must not be conflated.
94
+ *
95
+ * A caller that wants to re-apply the same operations on purpose passes its own
96
+ * `idempotencyKey` — which is also the only way to express that intent.
97
+ *
98
+ * @param {object} patch
99
+ * @returns {string}
100
+ */
101
+ export function deriveIdempotencyKey(patch) {
102
+ return `auto-${shortDigest({
103
+ projectId: patch.projectId,
104
+ baseRevision: patch.baseRevision,
105
+ actor: patch.actor ?? null,
106
+ stage: patch.stage ?? null,
107
+ operations: patch.operations,
108
+ })}`
109
+ }
110
+
111
+ /**
112
+ * Resolve a caller-supplied or derived idempotency key.
113
+ * @param {object} patch
114
+ * @returns {{ key: string, derived: boolean }}
115
+ */
116
+ export function resolveIdempotencyKey(patch) {
117
+ if (typeof patch.idempotencyKey === 'string' && patch.idempotencyKey.trim().length > 0) {
118
+ return { key: patch.idempotencyKey, derived: false }
119
+ }
120
+ return { key: deriveIdempotencyKey(patch), derived: true }
121
+ }
122
+
123
+ /**
124
+ * The revision transaction runner.
125
+ *
126
+ * One instance per `blenderStudio` service; it holds no state of its own beyond
127
+ * the stores and the runtime it was built with, so a second project cannot leak
128
+ * into the first.
129
+ */
130
+ export class RevisionTransaction {
131
+ /**
132
+ * @param {object} options
133
+ * @param {import('./project-store.js').ProjectStore} options.store
134
+ * @param {object} options.runtime - the `blenderRuntime` service.
135
+ * @param {{ compileTimeoutMs?: number, projectsRoot: string }} options.config
136
+ */
137
+ constructor({ store, runtime, config }) {
138
+ this.store = store
139
+ this.runtime = runtime
140
+ this.config = config
141
+ }
142
+
143
+ /** The store's own account of what is current; never cached here. */
144
+ currentRevision(projectId) {
145
+ return this.store.currentRevision(projectId)
146
+ }
147
+
148
+ /**
149
+ * Remove abandoned staging directories.
150
+ *
151
+ * Called before each transaction rather than on a timer: a staging directory
152
+ * can only be abandoned by a process that died, and the next transaction is
153
+ * exactly when we know we are alive and it is stale.
154
+ *
155
+ * @param {string} projectId
156
+ * @param {string} [keep] - a staging directory to preserve (this transaction's).
157
+ */
158
+ sweepStaging(projectId, keep) {
159
+ const staging = join(this.store.projectDirectory(projectId), 'staging')
160
+ if (!existsSync(staging)) return
161
+ for (const entry of readdirSafe(staging)) {
162
+ if (entry === keep) continue
163
+ removeTree(join(staging, entry))
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Create a project by committing its first revision.
169
+ *
170
+ * `project_create` is not a separate kind of operation: a project without a
171
+ * revision would be a directory with no scene, and every read path would then
172
+ * need a special case for it. Committing r0001 immediately means the invariant
173
+ * "a project has a current revision" holds from the moment it is visible.
174
+ *
175
+ * @param {object} input
176
+ * @param {string} input.title
177
+ * @param {string} [input.goal]
178
+ * @param {object} [input.sceneSpec] - a full SceneSpec to seed the project with.
179
+ * @param {string} [input.projectId] - an explicit id; derived from the title otherwise.
180
+ * @param {boolean} [input.saveCheckpoint]
181
+ * @param {boolean} [input.renderPreview]
182
+ * @param {string} [input.jobId]
183
+ * @param {AbortSignal} [input.signal]
184
+ * @returns {Promise<{ projectId: string, record: object, revision: object, job: object, warnings: object[] }>}
185
+ */
186
+ async createProject(input) {
187
+ const title = String(input.title ?? '').trim()
188
+ if (title.length === 0) {
189
+ throw new BlenderError(BlenderErrorCode.PROJECT_ID_INVALID, 'A project needs a non-empty title.')
190
+ }
191
+
192
+ const projectId = input.projectId ?? this.store.allocateProjectId(title)
193
+ if (this.store.exists(projectId)) {
194
+ throw new BlenderError(
195
+ BlenderErrorCode.PROJECT_EXISTS,
196
+ `A project named "${projectId}" already exists.`,
197
+ { detail: { projectId } },
198
+ )
199
+ }
200
+
201
+ const seeded = input.sceneSpec ?? defaultSceneSpec({ projectId, title, goal: input.goal })
202
+ const identified = {
203
+ ...seeded,
204
+ project: { ...seeded.project, id: projectId, title, ...input.goal !== undefined ? { goal: input.goal } : {} },
205
+ }
206
+
207
+ const validation = validateSceneSpec(identified)
208
+ if (!validation.ok) {
209
+ throw new BlenderError(
210
+ BlenderErrorCode.SCENE_SPEC_INVALID,
211
+ `The initial SceneSpec for "${projectId}" is not valid:\n${validation.summary}`,
212
+ { detail: { errors: validation.errors, notices: validation.notices } },
213
+ )
214
+ }
215
+
216
+ // RESOLVE BEFORE STORING. This is not cosmetic: the first revision is the
217
+ // spec every later revision is a delta from, so if it were stored unresolved
218
+ // while its digest was computed from the resolved form, every read-back would
219
+ // re-derive a DIFFERENT digest and the revision history would look corrupted.
220
+ // That was a real bug here — `revision r0001 still re-reads and re-compiles
221
+ // deterministically` in fixture.e2e.mjs is the guard that caught it.
222
+ //
223
+ // Resolving is idempotent (it only materialises documented defaults and aims
224
+ // targeted cameras), so a spec that already went through it is unaffected.
225
+ const compiled = compileSceneSpec(identified)
226
+
227
+ this.store.ensureRoot()
228
+ this.store.createSkeleton(projectId)
229
+
230
+ const now = new Date().toISOString()
231
+ this.store.writeRecord(projectId, {
232
+ schemaVersion: PROJECT_RECORD_VERSION,
233
+ projectId,
234
+ title,
235
+ goal: input.goal ?? null,
236
+ createdAt: now,
237
+ updatedAt: now,
238
+ currentRevision: GENESIS_REVISION,
239
+ revisionCount: 0,
240
+ runtimeProtocolVersion: 'deepblend.blender/v1',
241
+ projectsRoot: this.store.projectsRoot,
242
+ })
243
+
244
+ const outcome = await this.commit({
245
+ projectId,
246
+ baseRevision: GENESIS_REVISION,
247
+ operations: [],
248
+ spec: compiled.spec,
249
+ specHashBefore: null,
250
+ specHashAfter: specHash(compiled.spec),
251
+ kind: 'project_create',
252
+ summary: `Initialise project "${title}"`,
253
+ actor: input.actor ?? null,
254
+ stage: 'BRIEF',
255
+ note: input.goal ?? null,
256
+ notices: [...validation.notices, ...compiled.notices],
257
+ saveCheckpoint: input.saveCheckpoint !== false,
258
+ renderPreview: input.renderPreview === true,
259
+ jobId: input.jobId,
260
+ signal: input.signal,
261
+ })
262
+
263
+ return { projectId, ...outcome }
264
+ }
265
+
266
+ /**
267
+ * Apply a ScenePatch as one atomic revision.
268
+ *
269
+ * @param {object} patch - a ScenePatch v1 document.
270
+ * @param {{ jobId?: string, signal?: AbortSignal }} [options]
271
+ * @returns {Promise<object>}
272
+ */
273
+ async applyScenePatch(patch, options = {}) {
274
+ const structural = validateScenePatch(patch)
275
+ if (!structural.ok) {
276
+ throw new BlenderError(
277
+ BlenderErrorCode.SCENE_PATCH_INVALID,
278
+ `The ScenePatch is not well formed:\n${structural.summary}`,
279
+ { detail: { errors: structural.errors } },
280
+ )
281
+ }
282
+
283
+ const projectId = patch.projectId
284
+ const record = this.store.readRecord(projectId)
285
+ const { key, derived } = resolveIdempotencyKey(patch)
286
+
287
+ // ---------------------------------------------------------------------
288
+ // ORDER MATTERS HERE: idempotency is checked BEFORE the conflict check.
289
+ //
290
+ // A caller that retries a patch it is unsure about is behaving well, and a
291
+ // retry usually arrives AFTER the first attempt succeeded — which means the
292
+ // project has already moved past the patch's `baseRevision`. Checking
293
+ // conflict first would answer that retry with REVISION_CONFLICT, and the
294
+ // caller's only move would be to re-read and re-apply, producing the exact
295
+ // duplicate revision the idempotency key exists to prevent.
296
+ //
297
+ // Asking "have I already done exactly this?" first makes the retry return
298
+ // its own earlier outcome, which is the honest answer: the work is done, and
299
+ // here is what it produced.
300
+ // ---------------------------------------------------------------------
301
+ const recorded = this.store.readIdempotencyRecord(projectId, key)
302
+ if (recorded !== null) {
303
+ return {
304
+ ...recorded.outcome,
305
+ idempotentReplay: true,
306
+ idempotencyKey: key,
307
+ idempotencyKeyDerived: derived,
308
+ warnings: [warning(
309
+ BlenderWarningCode.SCENE_COMPILER_DECISION,
310
+ `this exact patch was already applied as ${recorded.outcome.revision?.revision ?? recorded.outcome.revision} ` +
311
+ `at ${recorded.recordedAt}; the recorded outcome was returned and nothing was committed again`,
312
+ { revision: recorded.outcome.revision?.revision ?? recorded.outcome.revision, recordedAt: recorded.recordedAt },
313
+ )],
314
+ replayOf: recorded.outcome.revision?.revision ?? recorded.outcome.revision,
315
+ }
316
+ }
317
+
318
+ // ---- conflict: a stale view is refused, never merged -------------------
319
+ if (patch.baseRevision !== record.currentRevision) {
320
+ throw new BlenderError(
321
+ BlenderErrorCode.REVISION_CONFLICT,
322
+ `The patch was computed against ${patch.baseRevision} but "${projectId}" is at ${record.currentRevision}. ` +
323
+ 'Read the scene again and re-apply against the current revision — this revision is refused rather than merged, ' +
324
+ 'because merging silently would discard whatever changed in between.',
325
+ {
326
+ detail: {
327
+ projectId,
328
+ baseRevision: patch.baseRevision,
329
+ currentRevision: record.currentRevision,
330
+ hint: 'call blender_scene_get, then re-issue the patch with the returned revision as baseRevision',
331
+ },
332
+ },
333
+ )
334
+ }
335
+
336
+ const baseSpec = this.store.readRevisionSpec(projectId, record.currentRevision)
337
+ const compiled = compileSceneSpec(baseSpec)
338
+
339
+ // ---- apply in memory ---------------------------------------------------
340
+ /** @type {object} */
341
+ let nextSpec
342
+ /** @type {object[]} */
343
+ let operationRecords
344
+ let digestBefore
345
+ let digestAfter
346
+ let specHashBefore
347
+ let specHashAfter
348
+ try {
349
+ const applied = applyPatchToSpec(compiled.spec, patch)
350
+ nextSpec = applied.spec
351
+ operationRecords = applied.operations
352
+ digestBefore = applied.digestBefore
353
+ digestAfter = applied.digestAfter
354
+ specHashBefore = applied.specHashBefore
355
+ specHashAfter = applied.specHashAfter
356
+ } catch (cause) {
357
+ const failure = toCanonicalFailure(cause, { isBlenderError: value => value instanceof BlenderError, fallbackCode: 'SCENE_PATCH_REJECTED' })
358
+ throw new BlenderError(
359
+ failure.code === 'SCENE_PATCH_REJECTED' ? BlenderErrorCode.SCENE_PATCH_REJECTED : failure.code,
360
+ `The patch was rejected and no revision was created: ${failure.message}`,
361
+ { detail: { projectId, baseRevision: patch.baseRevision, path: failure.detail?.path ?? null } },
362
+ )
363
+ }
364
+
365
+ // ---- a declared asset hash must be the file's hash ---------------------
366
+ //
367
+ // `asset.add` carries a `sha256` the patch APPLIER cannot check: it is a pure function over documents with no
368
+ // filesystem, which is the right shape for it. But the hash it stores is provenance a later reader trusts —
369
+ // the delivery manifest, a human auditing where the bytes came from — and `blender_asset_ingest` COMPUTES the
370
+ // hash of what it wrote, so the two copies can disagree with nothing comparing them. MEASURED before this:
371
+ // nothing did, on either side, and the Python never reads `assets[].sha256` at all.
372
+ //
373
+ // Checked here, in the transaction, because this is the layer that has both the document and the store. A
374
+ // path with no file is left alone: declaring an asset before its bytes exist is a different question, and the
375
+ // compiler refuses that one loudly.
376
+ for (const operation of patch.operations ?? []) {
377
+ if (operation?.op !== 'asset.add') continue
378
+ const declared = operation.asset?.sha256
379
+ const assetPath = operation.asset?.path
380
+ if (typeof declared !== 'string' || typeof assetPath !== 'string') continue
381
+ const absolute = join(this.store.projectDirectory(projectId), assetPath)
382
+ if (!existsSync(absolute)) continue
383
+ const actual = fileSha256(absolute)
384
+ if (actual !== declared) {
385
+ throw new BlenderError(
386
+ BlenderErrorCode.ASSET_HASH_MISMATCH,
387
+ `asset "${operation.asset.id}" declares sha256 ${declared}, but ${assetPath} is ${actual}. Nothing was ` +
388
+ 'committed. The declaration is provenance a later reader trusts, so it has to be the file\u2019s own ' +
389
+ 'hash — blender_asset_ingest reports the right one for what it wrote.',
390
+ { detail: { projectId, assetId: operation.asset.id, path: assetPath, declared, actual } },
391
+ )
392
+ }
393
+ }
394
+
395
+ // ---- resolve the RESULT before anything reads it -----------------------
396
+ //
397
+ // The base was compiled above, but the patch result was not — and every `*.add`
398
+ // operation inserts the caller's object verbatim. So a patch could store a
399
+ // document that no other part of the system is prepared to read:
400
+ //
401
+ // `camera.add` without `transform` → `summarizeSceneSpec` threw a TypeError on
402
+ // `camera.transform.location`, taking the whole commit response down with it.
403
+ // `entity.add` with a generator that omits its shape's size field — legal, both
404
+ // are optional — left `boundsOf` computing `undefined * n` = NaN, and a NaN
405
+ // reaches the model as `null` because that is what JSON does to it. The tool
406
+ // call SUCCEEDED and the harness rejected its own result as "not lossless
407
+ // JSON", which is precisely the symptom of a NaN.
408
+ // And the property M1 exists to guarantee — digest(stored) == digest(compile
409
+ // (stored)), so a revision can always be re-read and re-derived — was false
410
+ // for every revision with an added object.
411
+ //
412
+ // Compiling here is the same fix M1 applied to `createProject` (decision D19),
413
+ // for the same reason: whoever writes the document owns resolving it, because a
414
+ // reader that has to guess is a reader that will guess differently.
415
+ //
416
+ // Compilation materialises DOCUMENTED defaults only, and is idempotent, so a
417
+ // patch that adds nothing new is unaffected — the digest of an unrelated change
418
+ // does not move.
419
+ const resolved = compileSceneSpec(nextSpec)
420
+ nextSpec = resolved.spec
421
+ digestAfter = sceneSpecDigest(nextSpec)
422
+ specHashAfter = specHash(nextSpec)
423
+
424
+ const validation = validateSceneSpec(nextSpec)
425
+ if (!validation.ok) {
426
+ throw new BlenderError(
427
+ BlenderErrorCode.SCENE_SPEC_INVALID,
428
+ `Applying the patch would produce an invalid SceneSpec, so no revision was created:\n${validation.summary}`,
429
+ { detail: { errors: validation.errors, notices: validation.notices } },
430
+ )
431
+ }
432
+
433
+ const summary = patch.note ?? describeOperations(operationRecords)
434
+ const outcome = await this.commit({
435
+ projectId,
436
+ baseRevision: patch.baseRevision,
437
+ operations: patch.operations,
438
+ spec: nextSpec,
439
+ kind: 'scene_patch',
440
+ summary,
441
+ actor: patch.actor ?? null,
442
+ stage: patch.stage ?? null,
443
+ note: patch.note ?? null,
444
+ saveCheckpoint: patch.saveCheckpoint !== false,
445
+ renderPreview: patch.renderPreview === true,
446
+ digestBefore,
447
+ digestAfter,
448
+ operationRecords,
449
+ notices: [...validation.notices, ...compiled.notices, ...resolved.notices],
450
+ derivedIdempotencyKey: derived,
451
+ jobId: options.jobId,
452
+ signal: options.signal,
453
+ })
454
+
455
+ this.store.writeIdempotencyRecord(projectId, key, {
456
+ // The FULL outcome, not a pointer to it. A replay must be able to answer
457
+ // exactly as the original call did — same revision, same previews, same
458
+ // warnings — without re-deriving anything, because re-deriving is how a
459
+ // "replay" quietly becomes a second apply.
460
+ outcome: {
461
+ projectId,
462
+ revision: outcome.revision,
463
+ revisionNumber: outcome.revision.revisionNumber,
464
+ digest: outcome.revision.digest,
465
+ summary,
466
+ job: outcome.job,
467
+ warnings: outcome.warnings,
468
+ sceneSummary: outcome.sceneSummary,
469
+ },
470
+ kind: 'scene_patch',
471
+ })
472
+
473
+ return { ...outcome, idempotentReplay: false, idempotencyKey: key, idempotencyKeyDerived: derived }
474
+ }
475
+
476
+ /**
477
+ * The shared commit path used by both `createProject` and `applyScenePatch`.
478
+ *
479
+ * @param {object} plan
480
+ * @returns {Promise<{ revision: object, job: object, warnings: object[], sceneSummary: object }>}
481
+ */
482
+ async commit(plan) {
483
+ const { projectId } = plan
484
+ const record = this.store.readRecord(projectId)
485
+ const revision = this.store.nextRevisionId(projectId)
486
+ const revisionNumber = /** @type {number} */ (parseRevisionId(revision))
487
+
488
+ this.sweepStaging(projectId)
489
+ const staging = join(this.store.projectDirectory(projectId), 'staging', revision)
490
+ removeTree(staging)
491
+ mkdirSync(staging, { recursive: true })
492
+
493
+ const jobId = plan.jobId ?? this.store.allocateJobId(projectId, plan.kind === 'project_create' ? 'compile_scene' : 'apply_scene_patch')
494
+ const startedAt = new Date().toISOString()
495
+ const startedMs = Date.now()
496
+ /** @type {object[]} */
497
+ const warnings = []
498
+ for (const notice of plan.notices ?? []) {
499
+ if (notice.severity === 'notice') {
500
+ warnings.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, notice.message, { code: notice.code, path: notice.path }))
501
+ }
502
+ }
503
+
504
+ const writeJob = (fields) => {
505
+ const job = {
506
+ schemaVersion: 'deepblend.job/v1',
507
+ jobId,
508
+ projectId,
509
+ action: plan.kind === 'project_create' ? 'compile_scene' : 'apply_scene_patch',
510
+ revision: fields.revision ?? null,
511
+ status: fields.status,
512
+ errorCode: fields.errorCode ?? null,
513
+ message: fields.message ?? null,
514
+ startedAt,
515
+ finishedAt: fields.status === 'running' ? null : new Date().toISOString(),
516
+ durationMs: fields.status === 'running' ? null : Date.now() - startedMs,
517
+ idempotencyKey: plan.derivedIdempotencyKey === false ? (plan.explicitIdempotencyKey ?? null) : null,
518
+ baseRevision: plan.baseRevision,
519
+ artifacts: fields.artifacts ?? [],
520
+ warnings,
521
+ }
522
+ this.store.writeJob(projectId, job)
523
+ return job
524
+ }
525
+
526
+ writeJob({ status: 'running' })
527
+ if (!plan.saveCheckpoint && !plan.renderPreview) {
528
+ // Nothing needs Blender. Committing a spec-only revision is a legitimate
529
+ // and useful operation — a scene edit that has not been rendered yet — and
530
+ // spending a Blender launch on it would be pure cost.
531
+ warnings.push(warning(
532
+ BlenderWarningCode.SCENE_COMPILER_DECISION,
533
+ 'no checkpoint and no preview were requested, so this revision stores the SceneSpec only; ' +
534
+ 'there is no .blend to open until a later revision or an explicit compile',
535
+ ))
536
+ }
537
+
538
+ let compileReport = null
539
+ let technical = null
540
+ /** @type {object[]} */
541
+ const artifacts = []
542
+
543
+ if (plan.saveCheckpoint || plan.renderPreview) {
544
+ const specPath = join(staging, 'scene-spec.json')
545
+ writeFileAtomic(specPath, sceneSpecCanonicalText(plan.spec))
546
+ const checkpointStaging = join(staging, 'scene.blend')
547
+
548
+ try {
549
+ const run = await this.runtime.compileScene({
550
+ sceneSpecPath: specPath,
551
+ profile: plan.spec.renderProfiles?.preview === undefined ? undefined : 'preview',
552
+ projectRoot: this.store.projectDirectory(projectId),
553
+ jobId,
554
+ signal: plan.signal,
555
+ onWorkingDirectory: info => {
556
+ const produced = join(info.directory, 'result.blend')
557
+ if (isFile(produced)) {
558
+ // Same filesystem (both are inside the project's staging tree), so
559
+ // this rename is atomic and cannot half-copy a large file.
560
+ renameSync(produced, checkpointStaging)
561
+ }
562
+ },
563
+ })
564
+ compileReport = run.report
565
+ technical = run.report?.validation ?? null
566
+ for (const entry of run.envelope.warnings ?? []) warnings.push(entry)
567
+ for (const entry of run.envelope.notices ?? []) {
568
+ warnings.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, entry.message, { code: entry.code }))
569
+ }
570
+
571
+ // SPEC §15.2 "Mesh 面数限制". The number is already here — the compile report carries
572
+ // it for the revision manifest — so this compares a measurement rather than estimating
573
+ // one. Thrown INSIDE the try on purpose: the existing catch removes the staging tree,
574
+ // writes a failed job and refuses the revision, which is exactly what a scene that is
575
+ // too heavy should produce. Nothing below this line runs, so no manifest is written and
576
+ // no checkpoint is published.
577
+ const polygons = compileReport?.sceneFingerprint?.totalPolygons
578
+ // A REPORT THAT DOES NOT CARRY THE COUNT IS REFUSED, not skipped. The guard below is the only thing
579
+ // standing between a five-times-too-heavy scene and a project that pays for it on every later compile,
580
+ // and `typeof polygons === 'number'` alone made a report with a RENAMED or missing field look like a
581
+ // scene that is fine — the check would pass by not running. The provider emits this field on every
582
+ // compile (`bootstrap.py`'s `scene_fingerprint`), so its absence means the two sides disagree about the
583
+ // protocol rather than that the scene is light. MEASURED: the field-name agreement between
584
+ // `scene_fingerprint`'s output and this reader was asserted nowhere before `render-job.test.mjs` gained
585
+ // the cross-language check; this is the other half — the guard fails loudly instead of silently.
586
+ if (compileReport !== null && compileReport !== undefined && typeof polygons !== 'number') {
587
+ throw new BlenderError(
588
+ BlenderErrorCode.PROTOCOL_VERSION_MISMATCH,
589
+ 'the compile report carries no sceneFingerprint.totalPolygons, so this deployment cannot tell ' +
590
+ 'whether the scene is within maxMeshPolygons and will not commit it. The report and this Host ' +
591
+ 'disagree about the protocol — check that the provider package and the host package are the ' +
592
+ 'same version.',
593
+ { detail: { projectId, revision, fingerprint: compileReport.sceneFingerprint ?? null } },
594
+ )
595
+ }
596
+ if (typeof polygons === 'number' && polygons > this.config.maxMeshPolygons) {
597
+ throw new BlenderError(
598
+ BlenderErrorCode.SCENE_TOO_HEAVY,
599
+ `the compiled scene carries ${polygons.toLocaleString('en-US')} polygons, above the ` +
600
+ `${this.config.maxMeshPolygons.toLocaleString('en-US')} this deployment accepts ` +
601
+ '(deepblend.maxMeshPolygons, SPEC §15.2). Nothing was committed. Reduce the geometry — ' +
602
+ 'a denser object is usually an imported asset, and blender_asset_ingest reports what it brought in.',
603
+ { detail: { polygons, maxMeshPolygons: this.config.maxMeshPolygons, projectId, revision } },
604
+ )
605
+ }
606
+ } catch (cause) {
607
+ const failure = toCanonicalFailure(cause, {
608
+ isBlenderError: value => value instanceof BlenderError,
609
+ fallbackCode: 'BLENDER_SCRIPT_ERROR',
610
+ })
611
+ removeTree(staging)
612
+ const job = writeJob({
613
+ status: 'failed',
614
+ revision: null,
615
+ errorCode: failure.code,
616
+ message: failure.message,
617
+ })
618
+ this.recordFailedAttempt(projectId, { revision, baseRevision: plan.baseRevision, job, failure, plan })
619
+
620
+ // A FIRST COMPILE THAT FAILS LEAVES NO PROJECT BEHIND. `createProject` writes the
621
+ // skeleton and the record before it compiles, so a failure — a timeout, a refused
622
+ // import, a scene over `maxMeshPolygons` — used to leave a project with
623
+ // `currentRevision: r0000, revisionCount: 0` on disk. MEASURED: reading it back threw
624
+ // `REVISION_ID_INVALID` (not a "no revisions yet" answer), and retrying the same id
625
+ // threw `PROJECT_EXISTS`, so the id was burned and the project list showed a project
626
+ // that errors when opened. Both halves are worse than the failure itself, and both
627
+ // contradict the rule the rest of this file keeps: a refusal costs a message, not your
628
+ // work.
629
+ //
630
+ // The guard is `revisionCount === 0` rather than "this was a project_create", because
631
+ // that is the property that matters: once a revision exists the project is real and
632
+ // must survive a failed LATER compile exactly as it does today. What is not kept is the
633
+ // failed job record — it lives inside the project directory, and a job record for a
634
+ // project that does not exist is not something anyone can read.
635
+ if (plan.kind === 'project_create') this._discardEmptyProject(projectId)
636
+
637
+ throw new BlenderError(
638
+ failure.code === 'BLENDER_SCRIPT_ERROR' ? BlenderErrorCode.SCRIPT_ERROR : failure.code,
639
+ `Compiling revision ${revision} failed, so the project is unchanged (current revision ${record.currentRevision}): ${failure.message}`,
640
+ { detail: { projectId, revision, jobId: job.jobId, path: failure.detail?.path ?? null, artifacts: job.artifacts } },
641
+ )
642
+ }
643
+
644
+ if (isFile(checkpointStaging)) {
645
+ artifacts.push({
646
+ kind: 'blend',
647
+ path: `revisions/${revision}/scene.blend`,
648
+ bytes: statSync(checkpointStaging).size,
649
+ sha256: fileSha256(checkpointStaging),
650
+ mime: 'application/x-blender',
651
+ })
652
+ }
653
+ } else {
654
+ writeFileAtomic(join(staging, 'scene-spec.json'), sceneSpecCanonicalText(plan.spec))
655
+ }
656
+
657
+ // ---- optional preview, rendered from the checkpoint just produced -------
658
+ /** @type {object[]} */
659
+ const previews = []
660
+ if (plan.renderPreview) {
661
+ const checkpointStaging = join(staging, 'scene.blend')
662
+ if (!isFile(checkpointStaging)) {
663
+ throw new BlenderError(
664
+ BlenderErrorCode.REVISION_CHECKPOINT_MISSING,
665
+ `A preview was requested for ${revision}, but the compile produced no checkpoint to render from.`,
666
+ { detail: { projectId, revision, jobId } },
667
+ )
668
+ }
669
+ try {
670
+ previews.push(await this.renderPreviewInto({
671
+ projectId, revision, jobId, staging,
672
+ spec: plan.spec,
673
+ checkpointPath: checkpointStaging,
674
+ cameraId: plan.cameraId,
675
+ signal: plan.signal,
676
+ warnings,
677
+ }))
678
+ } catch (cause) {
679
+ const failure = toCanonicalFailure(cause, {
680
+ isBlenderError: value => value instanceof BlenderError,
681
+ fallbackCode: 'BLENDER_SCRIPT_ERROR',
682
+ })
683
+ removeTree(staging)
684
+ const job = writeJob({ status: 'failed', revision: null, errorCode: failure.code, message: failure.message })
685
+ this.recordFailedAttempt(projectId, { revision, baseRevision: plan.baseRevision, job, failure, plan })
686
+ if (plan.kind === 'project_create') this._discardEmptyProject(projectId)
687
+ throw new BlenderError(
688
+ failure.code === 'BLENDER_SCRIPT_ERROR' ? BlenderErrorCode.SCRIPT_ERROR : failure.code,
689
+ `Rendering the preview for revision ${revision} failed, so the project is unchanged ` +
690
+ `(current revision ${record.currentRevision}): ${failure.message}`,
691
+ { detail: { projectId, revision, jobId: job.jobId } },
692
+ )
693
+ }
694
+ }
695
+
696
+ for (const preview of previews) artifacts.push(preview.artifact)
697
+
698
+ // ---- write the revision's own documents --------------------------------
699
+ const digest = digestAfter(plan, warnings)
700
+ const sceneSummary = summarizeSceneSpec(plan.spec, { revision, revisionNumber, digest })
701
+ const manifest = {
702
+ schemaVersion: REVISION_MANIFEST_VERSION,
703
+ revision,
704
+ revisionNumber,
705
+ projectId,
706
+ title: record.title,
707
+ baseRevision: plan.baseRevision,
708
+ kind: plan.kind,
709
+ createdAt: new Date().toISOString(),
710
+ digest,
711
+ digestBefore: plan.digestBefore ?? digest,
712
+ digestAfter: digest,
713
+ specHashBefore: plan.specHashBefore ?? null,
714
+ specHashAfter: plan.specHashAfter ?? specHash(plan.spec),
715
+ // `sceneChanged` answers "did the geometry, lighting and animation change?"
716
+ // — the question a render cache must ask. `specChanged` answers "did the
717
+ // stored document change at all?", which is also true for a frame-range or
718
+ // render-profile edit. Both are in the manifest because they are different
719
+ // questions and a caller may need either.
720
+ sceneChanged: (plan.digestBefore ?? digest) !== digest,
721
+ specChanged: plan.specHashBefore === null || plan.specHashBefore === undefined
722
+ ? true
723
+ : plan.specHashBefore !== (plan.specHashAfter ?? specHash(plan.spec)),
724
+ summary: plan.summary,
725
+ actor: plan.actor ?? null,
726
+ stage: plan.stage ?? null,
727
+ note: plan.note ?? null,
728
+ sceneSpecVersion: plan.spec.schemaVersion,
729
+ counts: sceneSummary.counts,
730
+ subjectBounds: sceneSummary.subjectBounds,
731
+ project: sceneSummary.project,
732
+ validation: technical === null ? null : {
733
+ ok: technical.ok !== false,
734
+ errorCount: (technical.errors ?? []).length,
735
+ counts: technical.counts ?? null,
736
+ geometry: technical.geometry ?? null,
737
+ frameRange: technical.frameRange ?? null,
738
+ fps: technical.fps ?? null,
739
+ engine: technical.engine ?? null,
740
+ activeCamera: technical.activeCamera ?? null,
741
+ animatedObjects: technical.animatedObjects ?? null,
742
+ cameraParameters: technical.cameraParameters ?? null,
743
+ },
744
+ renderConfig: compileReport?.renderConfig ?? null,
745
+ sceneFingerprint: compileReport?.sceneFingerprint ?? null,
746
+ checkpoint: isFile(join(staging, 'scene.blend')) ? `revisions/${revision}/scene.blend` : null,
747
+ previews: previews.map(entry => entry.artifact),
748
+ jobId,
749
+ runtime: {
750
+ blenderVersion: null,
751
+ engine: compileReport?.engine ?? null,
752
+ requestedEngine: compileReport?.requestedEngine ?? null,
753
+ protocolVersion: 'deepblend.blender/v1',
754
+ },
755
+ }
756
+
757
+ if (plan.kind === 'scene_patch') {
758
+ writeJsonAtomic(join(staging, 'operation-manifest.json'), buildOperationManifest({
759
+ operations: plan.operationRecords ?? [],
760
+ request: plan.operations === undefined ? {} : {
761
+ idempotencyKey: plan.derivedIdempotencyKey === false ? '<caller-supplied>' : '<derived>',
762
+ actor: plan.actor,
763
+ stage: plan.stage,
764
+ note: plan.note,
765
+ saveCheckpoint: plan.saveCheckpoint,
766
+ renderPreview: plan.renderPreview,
767
+ },
768
+ revision: {
769
+ revision,
770
+ baseRevision: plan.baseRevision,
771
+ digestBefore: manifest.digestBefore,
772
+ digestAfter: manifest.digestAfter,
773
+ specHashBefore: manifest.specHashBefore,
774
+ specHashAfter: manifest.specHashAfter,
775
+ },
776
+ notices: plan.notices ?? [],
777
+ }))
778
+ writeJsonAtomic(join(staging, 'request.json'), {
779
+ schemaVersion: SCENE_PATCH_VERSION,
780
+ ...plan.operations === undefined ? {} : { operations: plan.operations },
781
+ baseRevision: plan.baseRevision,
782
+ saveCheckpoint: plan.saveCheckpoint,
783
+ renderPreview: plan.renderPreview,
784
+ actor: plan.actor ?? null,
785
+ stage: plan.stage ?? null,
786
+ note: plan.note ?? null,
787
+ })
788
+ } else {
789
+ writeJsonAtomic(join(staging, 'request.json'), {
790
+ kind: 'project_create',
791
+ title: record.title,
792
+ goal: record.goal,
793
+ baseRevision: GENESIS_REVISION,
794
+ })
795
+ }
796
+
797
+ writeJsonAtomic(join(staging, 'validation.json'), {
798
+ schemaVersion: 'deepblend.validation/v1',
799
+ revision,
800
+ semantic: { ok: true, notices: plan.notices ?? [] },
801
+ technical,
802
+ })
803
+
804
+ // The manifest is written LAST inside staging: while it is absent, the
805
+ // directory is definitionally incomplete, which is the marker a future
806
+ // recovery sweep would use to identify an abandoned staging directory.
807
+ writeJsonAtomic(join(staging, 'revision-manifest.json'), manifest)
808
+
809
+ // ---- publish -----------------------------------------------------------
810
+ const finalPath = this.store.revisionDirectory(projectId, revision)
811
+ publishDirectory(staging, finalPath)
812
+
813
+ const nextRecord = {
814
+ ...record,
815
+ currentRevision: revision,
816
+ revisionCount: (record.revisionCount ?? 0) + 1,
817
+ updatedAt: new Date().toISOString(),
818
+ specHash: manifest.specHashAfter,
819
+ }
820
+ this.store.writeRecord(projectId, nextRecord)
821
+
822
+ const job = writeJob({
823
+ status: 'succeeded',
824
+ revision,
825
+ artifacts: artifacts.map(entry => ({ ...entry, path: entry.path })),
826
+ })
827
+
828
+ return {
829
+ revision: { ...manifest, isCurrent: true },
830
+ record: nextRecord,
831
+ job,
832
+ warnings,
833
+ sceneSummary,
834
+ technical,
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Render one preview into a revision's staging `previews/` directory.
840
+ * @param {object} input
841
+ * @returns {Promise<{ artifact: object, report: object }>}
842
+ */
843
+ async renderPreviewInto(input) {
844
+ const { projectId, revision, staging, spec } = input
845
+ const profile = spec.renderProfiles?.preview
846
+ if (profile === undefined) {
847
+ throw new BlenderError(
848
+ BlenderErrorCode.RENDER_PROFILE_MISSING,
849
+ 'The SceneSpec defines no preview render profile, so no preview can be rendered.',
850
+ )
851
+ }
852
+
853
+ const cameraId = input.cameraId ?? spec.cameras?.[0]?.id
854
+ if (cameraId === undefined) {
855
+ throw new BlenderError(BlenderErrorCode.SCENE_CAMERA_MISSING, 'The SceneSpec declares no camera to render from.')
856
+ }
857
+
858
+ const engineInfo = await this.runtime.resolveEngineKey(profile.engine, { signal: input.signal })
859
+ if (engineInfo.warning !== null) input.warnings?.push(engineInfo.warning)
860
+
861
+ const previewsDir = join(staging, 'previews')
862
+ mkdirSync(previewsDir, { recursive: true })
863
+ const filename = `${cameraId}.png`
864
+ const outputPath = join(previewsDir, filename)
865
+
866
+ const run = await this.runtime.renderPreview({
867
+ checkpointPath: input.checkpointPath,
868
+ outputPath,
869
+ cameraId,
870
+ engine: engineInfo.blenderEngine === null ? undefined : profile.engine,
871
+ width: profile.resolution?.[0],
872
+ height: profile.resolution?.[1],
873
+ samples: profile.samples,
874
+ jobId: input.jobId,
875
+ signal: input.signal,
876
+ })
877
+
878
+ for (const entry of run.envelope.warnings ?? []) input.warnings?.push(entry)
879
+ for (const entry of run.envelope.notices ?? []) {
880
+ input.warnings?.push(warning(BlenderWarningCode.SCENE_COMPILER_DECISION, entry.message, { code: entry.code }))
881
+ }
882
+
883
+ const report = run.report ?? {}
884
+ const bytes = fileSize(outputPath)
885
+ if (bytes === null || bytes === 0) {
886
+ throw new BlenderError(
887
+ BlenderErrorCode.RENDER_NO_OUTPUT,
888
+ `The renderer reported success but produced no image at ${filename}.`,
889
+ )
890
+ }
891
+
892
+ return {
893
+ report,
894
+ artifact: {
895
+ kind: 'preview',
896
+ path: `revisions/${revision}/previews/${filename}`,
897
+ cameraId,
898
+ frame: report.frame ?? null,
899
+ width: report.width ?? null,
900
+ height: report.height ?? null,
901
+ engine: report.engine ?? profile.engine,
902
+ samples: report.renderConfig?.samples ?? profile.samples ?? null,
903
+ bytes,
904
+ sha256: fileSha256(outputPath),
905
+ mime: 'image/png',
906
+ },
907
+ }
908
+ }
909
+
910
+ /**
911
+ * Record a failed attempt so a failure is auditable without being a revision.
912
+ *
913
+ * Written to `jobs/`, NOT to `revisions/`: a failure must be visible and must
914
+ * never look like a version of the project.
915
+ */
916
+ /**
917
+ * Discard a project whose FIRST commit failed, so the id is not burned.
918
+ *
919
+ * WHY THIS IS A METHOD AND NOT TWO COPIES: it was written for the COMPILE failure path, with a
920
+ * measured reason — `createProject` writes the skeleton and the record before it compiles, so a
921
+ * failure used to leave `currentRevision: r0000, revisionCount: 0` on disk; reading it back threw
922
+ * `REVISION_ID_INVALID` (not a "no revisions yet" answer), retrying the same id threw
923
+ * `PROJECT_EXISTS`, and the project list showed a project that errors when opened.
924
+ *
925
+ * The PREVIEW failure path — a render that dies after a successful compile — kept the half-project,
926
+ * because the guard only existed in the other catch. MEASURED by asserting the promise ("a refused
927
+ * first commit costs a message, not your work") instead of the code's shape: `store.exists()` was
928
+ * still true and retrying the id failed. The rule is one fact, so it lives here and both paths call
929
+ * it.
930
+ *
931
+ * `revisionCount === 0` is the guard rather than "this was a project_create": once a revision
932
+ * exists the project is real and must survive a failed later commit.
933
+ *
934
+ * @param {string} projectId
935
+ */
936
+ _discardEmptyProject(projectId) {
937
+ if (this.store.readRecord(projectId)?.revisionCount === 0) {
938
+ removeTree(this.store.projectDirectory(projectId))
939
+ }
940
+ }
941
+
942
+ recordFailedAttempt(projectId, input) {
943
+ try {
944
+ writeJsonAtomic(
945
+ join(this.store.projectDirectory(projectId), 'jobs', `${input.job.jobId}.attempt.json`),
946
+ {
947
+ schemaVersion: 'deepblend.failed-attempt/v1',
948
+ projectId,
949
+ attemptedRevision: input.revision,
950
+ baseRevision: input.baseRevision,
951
+ jobId: input.job.jobId,
952
+ errorCode: input.failure.code,
953
+ message: input.failure.message,
954
+ at: new Date().toISOString(),
955
+ operations: input.plan.operations ?? null,
956
+ summary: input.plan.summary ?? null,
957
+ },
958
+ )
959
+ } catch {
960
+ // Losing the audit record must not replace the real error the caller sees.
961
+ }
962
+ }
963
+ }
964
+
965
+ /** readdir that tolerates absence. */
966
+ function readdirSafe(directory) {
967
+ try {
968
+ return readdirSync(directory)
969
+ } catch {
970
+ return []
971
+ }
972
+ }
973
+
974
+ /** A readable one-line description of what a patch did. */
975
+ function describeOperations(records) {
976
+ if (records.length === 0) return 'No operations.'
977
+ if (records.length === 1) return records[0].summary
978
+ return `${records.length} operations: ${records.map(record => record.summary).join('; ')}`
979
+ }
980
+
981
+ /** Digest helper kept separate so the manifest reads as data, not as computation. */
982
+ function digestAfter(plan, _warnings) {
983
+ return sceneSpecDigest(plan.spec)
984
+ }
985
+
986
+ /**
987
+ * The SceneSpec a `project_create` starts from when the caller supplied none.
988
+ *
989
+ * Deliberately minimal and deliberately VALID: one cube, one camera, one light,
990
+ * both render profiles. A brand-new project must be renderable immediately, or
991
+ * the first thing every caller does is repair the scaffold — and a scaffold that
992
+ * needs repairing is a worse starting point than no scaffold at all.
993
+ *
994
+ * @param {{ projectId: string, title: string, goal?: string }} input
995
+ * @returns {object}
996
+ */
997
+ export function defaultSceneSpec({ projectId, title, goal }) {
998
+ return {
999
+ schemaVersion: 'deepblend.scene/v1',
1000
+ project: {
1001
+ id: projectId,
1002
+ title,
1003
+ ...goal !== undefined ? { goal } : {},
1004
+ units: 'metric',
1005
+ fps: 24,
1006
+ frameStart: 1,
1007
+ frameEnd: 48,
1008
+ aspectRatio: '16:9',
1009
+ },
1010
+ materials: [
1011
+ { id: 'default-surface', shader: 'principled', parameters: { baseColor: [0.62, 0.62, 0.64, 1], metallic: 0.0, roughness: 0.5 } },
1012
+ ],
1013
+ entities: [
1014
+ {
1015
+ id: 'subject',
1016
+ type: 'generator',
1017
+ generator: { shape: 'cube', size: 2 },
1018
+ materialId: 'default-surface',
1019
+ transform: { location: [0, 0, 1], rotationEuler: [0, 0, 0], scale: [1, 1, 1] },
1020
+ tags: ['hero-product'],
1021
+ },
1022
+ ],
1023
+ lights: [
1024
+ {
1025
+ id: 'key-light',
1026
+ type: 'area',
1027
+ transform: { location: [2.5, -3, 4], rotationEuler: [0, 0, 0], scale: [1, 1, 1] },
1028
+ energy: 400,
1029
+ color: [1, 1, 1],
1030
+ size: 2,
1031
+ },
1032
+ ],
1033
+ cameras: [
1034
+ {
1035
+ id: 'camera-main',
1036
+ lens: 50,
1037
+ sensorWidth: 36,
1038
+ clipping: [0.1, 100],
1039
+ transform: { location: [4.2, -4.2, 3.4], rotationEuler: [0, 0, 0], scale: [1, 1, 1] },
1040
+ targetEntityId: 'subject',
1041
+ },
1042
+ ],
1043
+ shots: [{ id: 'shot-01', cameraId: 'camera-main', frameRange: [1, 48], description: 'Default framing of the subject.' }],
1044
+ animationTracks: [],
1045
+ renderProfiles: {
1046
+ preview: {
1047
+ engine: 'cycles',
1048
+ resolution: [640, 360],
1049
+ samples: 48,
1050
+ filmTransparent: false,
1051
+ colorManagement: { viewTransform: 'Standard' },
1052
+ maxSamplesBudget: 256,
1053
+ },
1054
+ final: {
1055
+ engine: 'cycles',
1056
+ resolution: [1920, 1080],
1057
+ samples: 256,
1058
+ filmTransparent: false,
1059
+ colorManagement: { viewTransform: 'AgX' },
1060
+ maxSamplesBudget: 1024,
1061
+ },
1062
+ },
1063
+ }
1064
+ }