@vgai/editor 0.4.0 → 0.4.1

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.
Files changed (137) hide show
  1. package/dist/assets/{axe-BJjvBeOZ.js → axe-N4cNyzfs.js} +1 -1
  2. package/dist/assets/{browserAll-Dn0pgD87.js → browserAll-BIBT0Z2H.js} +1 -1
  3. package/dist/assets/{create-runtime-BF9OD-U8.js → create-runtime-DxLhdk4U.js} +1 -1
  4. package/dist/assets/{game-u6c2Patu.js → game-4NbNRROm.js} +1 -1
  5. package/dist/assets/{game-umrHIqFB.js → game-B3UH5LjS.js} +1 -1
  6. package/dist/assets/{game-BR0GBNgF.js → game-ByxRLJi1.js} +1 -1
  7. package/dist/assets/{game-DeTGdgYt.js → game-CJ5BqOYG.js} +1 -1
  8. package/dist/assets/{game-DQxhCYsZ.js → game-CSJm1kwB.js} +1 -1
  9. package/dist/assets/{game-CLNm2g0y.js → game-D9Mo4L3Q.js} +1 -1
  10. package/dist/assets/{game-B346oj_f.js → game-DJmU9BQF.js} +1 -1
  11. package/dist/assets/{game-BCNN63uV.js → game-ZqGLgxRj.js} +1 -1
  12. package/dist/assets/{game-loop-t5W_oqxz.js → game-loop-9YP76JB1.js} +1 -1
  13. package/dist/assets/{game-source-DzE9taML.js → game-source-DLKG9pmj.js} +1 -1
  14. package/dist/assets/{index-DbQzGLxv.js → index-8A4JLNIp.js} +902 -892
  15. package/dist/assets/{index-CgcH1RpF.js → index-Brzr17q0.js} +1 -1
  16. package/dist/assets/{index-D3tcNB2T.js → index-CnKtvrru.js} +1 -1
  17. package/dist/assets/{index-Fc8Ezr20.js → index-D-WR11Jw.js} +1 -1
  18. package/dist/assets/index-DiUacMXR.css +1 -0
  19. package/dist/assets/{ingest-siblings-CW_xbvrF.js → ingest-siblings-Cmx39OBK.js} +1 -1
  20. package/dist/assets/{mount-manifest-DgUscyaD.js → mount-manifest-kzdZgkkc.js} +1 -1
  21. package/dist/assets/{react-18-C4tVCii_.js → react-18-DvWsdwa_.js} +1 -1
  22. package/dist/assets/webworkerAll-C9GR-KrR.js +1 -0
  23. package/dist/assets/{world-documents-BSaA5YIF.js → world-documents-DM_aIMAW.js} +1 -1
  24. package/dist/index.html +2 -2
  25. package/dist-server/packaged.mjs +27125 -1075
  26. package/package.json +2 -2
  27. package/server/asset-history-snapshots.ts +189 -0
  28. package/server/asset-library-routes.ts +404 -53
  29. package/server/catalog-import-recipe.ts +97 -0
  30. package/server/catalog-reimport.ts +141 -0
  31. package/server/cloud-asset-catalog.ts +71 -5
  32. package/server/dev.ts +1 -1
  33. package/server/editor-server.ts +106 -51
  34. package/server/editor-sse.ts +5 -0
  35. package/server/generative-execution-context.ts +45 -0
  36. package/server/model-import-conversion.ts +345 -0
  37. package/server/packaged.ts +1 -1
  38. package/server/project-hmr-files.ts +3 -5
  39. package/server/project-output-writer.ts +440 -36
  40. package/server/project-tools.ts +529 -0
  41. package/server/server-utils.ts +14 -11
  42. package/src/EditorContext.tsx +4 -4
  43. package/src/action-registry.ts +12 -0
  44. package/src/asset-editor-context.tsx +9 -0
  45. package/src/asset-events.ts +3 -15
  46. package/src/asset-selection.ts +3 -0
  47. package/src/asset-workflow/asset-capabilities.ts +40 -0
  48. package/src/asset-workflow/asset-import-jobs.ts +108 -0
  49. package/src/asset-workflow/asset-library-query.ts +8 -1
  50. package/src/asset-workflow/asset-workflow-quality.ts +2 -0
  51. package/src/asset-workflow/import-contract.ts +2 -0
  52. package/src/asset-workflow/model-inspection.ts +92 -1
  53. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  54. package/src/asset-workflow/project-asset-commands.ts +1 -0
  55. package/src/asset-workflow/project-asset-health.ts +3 -6
  56. package/src/asset-workflow/project-asset-operations.ts +119 -4
  57. package/src/command-listener.ts +37 -9
  58. package/src/components/ApplicationMenus.tsx +13 -15
  59. package/src/components/AssetBrowser.tsx +301 -26
  60. package/src/components/AssetImportDetails.tsx +207 -92
  61. package/src/components/AssetImportJobStatus.tsx +55 -0
  62. package/src/components/CenterDocuments.tsx +28 -1
  63. package/src/components/CommandPalette.tsx +2 -2
  64. package/src/components/DefaultEditorLayout.tsx +4 -0
  65. package/src/components/InspectorToolSection.tsx +12 -10
  66. package/src/components/OnlineAssetBrowser.tsx +197 -11
  67. package/src/components/ToolHost.tsx +8 -8
  68. package/src/components/ViewportOverlay.tsx +13 -26
  69. package/src/components/ViewportPanel.tsx +19 -14
  70. package/src/components/ViewportShadingMenu.tsx +103 -0
  71. package/src/components/WorkspaceDock.tsx +103 -17
  72. package/src/components/asset-documents.tsx +38 -11
  73. package/src/components/asset-editor-persistence.ts +300 -5
  74. package/src/components/asset-selection-section.tsx +83 -9
  75. package/src/components/asset-viewers/AudioViewer.tsx +4 -7
  76. package/src/components/asset-viewers/ModelViewer.tsx +168 -5
  77. package/src/components/asset-viewers/OnlineAssetDetail.tsx +52 -12
  78. package/src/components/asset-viewers/SourceAssetViewer.tsx +7 -26
  79. package/src/components/asset-workflow.css +30 -3
  80. package/src/components/primitives/editor-icons.ts +1 -0
  81. package/src/components/project-tool-documents.tsx +298 -0
  82. package/src/components/status-contributions.tsx +38 -0
  83. package/src/components/tool-documents.tsx +20 -17
  84. package/src/components/tool-schema-form.tsx +258 -0
  85. package/src/components/workspace-dock.css +16 -0
  86. package/src/editor-api.ts +159 -22
  87. package/src/editor-store.ts +6 -5
  88. package/src/editor-viewport.ts +12 -7
  89. package/src/game-viewport-state.ts +28 -0
  90. package/src/model-thumbnail.ts +97 -9
  91. package/src/play-mode.ts +30 -3
  92. package/src/project-tool-discovery.ts +5 -5
  93. package/src/{project-operations.ts → project-tools.ts} +39 -26
  94. package/src/scene-sync.ts +0 -67
  95. package/src/storage/handle-storage.ts +5 -1
  96. package/src/storage/http-storage.ts +7 -2
  97. package/src/storage/mem-storage.ts +7 -1
  98. package/src/storage/types.ts +2 -0
  99. package/src/theme-css.ts +17 -7
  100. package/src/tool-loader.ts +152 -241
  101. package/src/viewport-shading-boundary.ts +15 -0
  102. package/src/widgets/index.ts +2 -1
  103. package/src/workspace-dock-controller.ts +8 -0
  104. package/src/workspace-document-registry.ts +4 -4
  105. package/src/workspace-state-persistence.ts +36 -10
  106. package/template/.claude/skills/editor/SKILL.md +13 -3
  107. package/template/AGENTS.md +100 -83
  108. package/template/package.json +16 -0
  109. package/template/public/scenes/default.vscn.json +1 -2
  110. package/template/scripts/playtest.ts +7 -37
  111. package/template/server/rooms/game-room.ts +2 -2
  112. package/template/src/data/README.md +3 -2
  113. package/template/src/data/tuning.data.json +1 -3
  114. package/template/src/data/tuning.schema.json +0 -12
  115. package/template/src/data/tuning.schema.ts +2 -9
  116. package/template/src/data/tuning.ts +1 -1
  117. package/template/src/scripts/registry.ts +0 -3
  118. package/template/src/tools/README.md +151 -16
  119. package/template/src/tools/autoplay.tool.ts +31 -0
  120. package/template/src/tools/autoplay.ts +15 -0
  121. package/template/src/tools/tuning.document.tsx +107 -0
  122. package/template/src/tools/tuning.tool.ts +24 -0
  123. package/template/src/tools/tuning.ts +9 -0
  124. package/template/src/ui/Button.stories.tsx +1 -1
  125. package/template/tests/acceptance/example.spec.ts +4 -59
  126. package/template/vgai.game.json +1 -1
  127. package/dist/assets/index-bAGhEnnO.css +0 -1
  128. package/dist/assets/webworkerAll-BA4aq7xq.js +0 -1
  129. package/dist/project-scripts.bundle.js +0 -3917
  130. package/server/project-operations.ts +0 -343
  131. package/src/components/project-operation-documents.tsx +0 -226
  132. package/template/src/operations/README.md +0 -88
  133. package/template/src/scripts/components/data-spinner.ts +0 -86
  134. package/template/src/scripts/components/spin-lap-math.ts +0 -27
  135. package/template/src/tools/example.tool.tsx +0 -97
  136. package/template/src/tools/spin.tool.tsx +0 -101
  137. package/template/tests/logic/example.test.ts +0 -31
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Atomic persistence boundary for files produced by project operations.
2
+ * Atomic persistence boundary for files produced by project tools.
3
3
  *
4
4
  * Generators remain ordinary project/package code. They hand their completed
5
5
  * bytes to this writer only when they are ready to commit; the editor host
@@ -8,20 +8,58 @@
8
8
  * visible to the asset watcher.
9
9
  */
10
10
 
11
- import { randomUUID } from 'node:crypto';
12
- import { copyFile, mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
13
- import { dirname, posix, resolve, sep } from 'node:path';
11
+ import { createHash, randomUUID } from 'node:crypto';
12
+ import { createReadStream } from 'node:fs';
13
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
14
+ import { dirname, extname, posix, relative, resolve, sep } from 'node:path';
15
+ import {
16
+ emptyProjectProvenanceDocument,
17
+ PROJECT_PROVENANCE_PATH,
18
+ ProjectProvenanceDocumentSchema,
19
+ ProjectProvenanceOperationSchema,
20
+ } from '../../vgai-sdk/src/project/provenance';
14
21
  import type {
15
22
  ProjectGeneratedOutput,
16
23
  ProjectGeneratedOutputFile,
17
24
  ProjectOutputFile,
25
+ ProjectOutputProvenanceContext,
18
26
  ProjectOutputWriter,
19
27
  } from '../../vgai-sdk/src/types';
20
-
21
- const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
28
+ import { consumeGenerativeExecutions } from './generative-execution-context';
22
29
 
23
30
  type NormalizedOutputFile = ProjectOutputFile & { path: string };
24
31
 
32
+ interface HashedProjectFile extends ProjectGeneratedOutputFile {
33
+ sha256: string;
34
+ }
35
+
36
+ const projectCommitQueues = new Map<string, Promise<unknown>>();
37
+
38
+ function withCapturedExecutions(
39
+ provenanceContext: ProjectOutputProvenanceContext,
40
+ ): ProjectOutputProvenanceContext {
41
+ const captured = consumeGenerativeExecutions();
42
+ const explicit = provenanceContext.executions
43
+ ? [...provenanceContext.executions]
44
+ : provenanceContext.execution
45
+ ? [provenanceContext.execution]
46
+ : [];
47
+ if (
48
+ captured.length > 0 &&
49
+ explicit.length > 0 &&
50
+ JSON.stringify(captured) !== JSON.stringify(explicit)
51
+ ) {
52
+ throw new Error('Explicit and automatically captured provider execution facts disagree.');
53
+ }
54
+ const executions = captured.length > 0 ? captured : explicit;
55
+ const effective = { ...provenanceContext };
56
+ delete effective.execution;
57
+ delete effective.executions;
58
+ if (executions.length === 1) effective.execution = executions[0]!;
59
+ if (executions.length > 1) effective.executions = executions;
60
+ return effective;
61
+ }
62
+
25
63
  function normalizeOutputPath(path: string): string {
26
64
  const slashPath = path.split(sep).join('/');
27
65
  const normalized = posix.normalize(slashPath).replace(/^\.\//, '');
@@ -44,12 +82,24 @@ function byteLength(content: string | Uint8Array): number {
44
82
  return typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
45
83
  }
46
84
 
47
- async function exists(path: string): Promise<boolean> {
85
+ function sha256(content: string | Uint8Array): string {
86
+ return createHash('sha256').update(content).digest('hex');
87
+ }
88
+
89
+ async function sha256File(path: string): Promise<string> {
90
+ const hash = createHash('sha256');
91
+ for await (const chunk of createReadStream(path)) hash.update(chunk as Buffer);
92
+ return hash.digest('hex');
93
+ }
94
+
95
+ async function existingFile(path: string): Promise<boolean> {
48
96
  try {
49
- await stat(path);
97
+ const info = await stat(path);
98
+ if (!info.isFile()) throw new Error(`Generated output target is not a file: ${path}`);
50
99
  return true;
51
- } catch {
52
- return false;
100
+ } catch (error) {
101
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false;
102
+ throw error;
53
103
  }
54
104
  }
55
105
 
@@ -71,11 +121,6 @@ function validateBatch(files: readonly ProjectOutputFile[]): {
71
121
  paths.add(file.path);
72
122
  totalBytes += byteLength(file.content);
73
123
  }
74
- if (totalBytes > MAX_OUTPUT_BYTES) {
75
- throw new Error(
76
- `Generated output batch is ${totalBytes} bytes; the atomic project-output limit is ${MAX_OUTPUT_BYTES} bytes.`,
77
- );
78
- }
79
124
  return {
80
125
  normalized,
81
126
  totalBytes,
@@ -110,24 +155,152 @@ async function commitBatch(
110
155
  const backup = resolve(backupRoot, file.path);
111
156
  await mkdir(dirname(target), { recursive: true });
112
157
  let prior: string | null = null;
113
- if (await exists(target)) {
158
+ if (await existingFile(target)) {
114
159
  await mkdir(dirname(backup), { recursive: true });
115
- await copyFile(target, backup);
160
+ await rename(target, backup);
116
161
  prior = backup;
117
162
  }
118
- await rename(staged, target);
163
+ try {
164
+ await rename(staged, target);
165
+ } catch (error) {
166
+ if (prior) await rename(prior, target);
167
+ throw error;
168
+ }
119
169
  committed.push({ target, backup: prior });
120
170
  }
121
171
  } catch (error) {
122
172
  for (const entry of committed.reverse()) {
123
- if (entry.backup) await copyFile(entry.backup, entry.target);
124
- else await rm(entry.target, { force: true });
173
+ await rm(entry.target, { force: true });
174
+ if (entry.backup) await rename(entry.backup, entry.target);
125
175
  }
126
176
  throw error;
127
177
  }
128
178
  }
129
179
 
130
- export function createProjectOutputWriter(projectRoot: string): ProjectOutputWriter {
180
+ async function readProvenance(projectRoot: string) {
181
+ try {
182
+ const parsed: unknown = JSON.parse(
183
+ await readFile(resolve(projectRoot, PROJECT_PROVENANCE_PATH), 'utf8'),
184
+ );
185
+ return ProjectProvenanceDocumentSchema.parse(parsed);
186
+ } catch (error) {
187
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') throw error;
188
+ return emptyProjectProvenanceDocument();
189
+ }
190
+ }
191
+
192
+ const SECRET_FIELD = /(authorization|credential|password|secret|token|api.?key|fal.?key)/i;
193
+ const SIGNED_URL_PARAMETER = /(signature|credential|token|expires|x-amz-)/i;
194
+
195
+ function sanitizeString(value: string): string {
196
+ try {
197
+ const url = new URL(value);
198
+ if (
199
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
200
+ [...url.searchParams.keys()].some((key) => SIGNED_URL_PARAMETER.test(key))
201
+ ) {
202
+ return `${url.origin}${url.pathname}?[redacted signed query]`;
203
+ }
204
+ } catch {
205
+ // Most provenance strings are not URLs.
206
+ }
207
+ return value;
208
+ }
209
+
210
+ function isBinaryValue(value: object): boolean {
211
+ return (
212
+ value instanceof ArrayBuffer ||
213
+ ArrayBuffer.isView(value) ||
214
+ (typeof Blob !== 'undefined' && value instanceof Blob)
215
+ );
216
+ }
217
+
218
+ function sanitizeProvenanceValue(value: unknown, seen: WeakSet<object>): unknown {
219
+ if (typeof value === 'string') return sanitizeString(value);
220
+ if (value === null || typeof value === 'boolean' || typeof value === 'number') return value;
221
+ if (value === undefined) return undefined;
222
+ if (typeof value !== 'object') return `[omitted ${typeof value}]`;
223
+ if (isBinaryValue(value)) return '[binary omitted]';
224
+ if (seen.has(value)) return '[circular omitted]';
225
+ seen.add(value);
226
+ if (Array.isArray(value)) {
227
+ return value.map((entry) => sanitizeProvenanceValue(entry, seen) ?? null);
228
+ }
229
+ const sanitized: Record<string, unknown> = {};
230
+ for (const [key, entry] of Object.entries(value)) {
231
+ if (SECRET_FIELD.test(key)) {
232
+ sanitized[key] = '[redacted]';
233
+ continue;
234
+ }
235
+ const next = sanitizeProvenanceValue(entry, seen);
236
+ if (next !== undefined) sanitized[key] = next;
237
+ }
238
+ return sanitized;
239
+ }
240
+
241
+ function jsonSafeInput(input: unknown): unknown {
242
+ if (input === undefined) return undefined;
243
+ return sanitizeProvenanceValue(input, new WeakSet());
244
+ }
245
+
246
+ function provenanceOperation(
247
+ provenanceContext: ProjectOutputProvenanceContext,
248
+ outputs: readonly HashedProjectFile[],
249
+ ) {
250
+ return ProjectProvenanceOperationSchema.parse({
251
+ createdAt: new Date().toISOString(),
252
+ operation: {
253
+ name: provenanceContext.operationName,
254
+ ...(provenanceContext.operationSource ? { source: provenanceContext.operationSource } : {}),
255
+ },
256
+ ...(provenanceContext.execution ? { execution: provenanceContext.execution } : {}),
257
+ ...(provenanceContext.executions ? { executions: provenanceContext.executions } : {}),
258
+ ...(provenanceContext.input === undefined
259
+ ? {}
260
+ : { input: jsonSafeInput(provenanceContext.input) }),
261
+ outputs,
262
+ });
263
+ }
264
+
265
+ async function nextProvenanceFile(
266
+ projectRoot: string,
267
+ provenanceOperationId: string,
268
+ provenanceContext: ProjectOutputProvenanceContext,
269
+ outputs: readonly HashedProjectFile[],
270
+ ): Promise<NormalizedOutputFile> {
271
+ const provenance = await readProvenance(projectRoot);
272
+ const nextProvenance = ProjectProvenanceDocumentSchema.parse({
273
+ ...provenance,
274
+ operations: {
275
+ ...provenance.operations,
276
+ [provenanceOperationId]: provenanceOperation(provenanceContext, outputs),
277
+ },
278
+ });
279
+ return {
280
+ path: PROJECT_PROVENANCE_PATH,
281
+ content: `${JSON.stringify(nextProvenance, null, 2)}\n`,
282
+ mediaType: 'application/json',
283
+ role: 'provenance',
284
+ };
285
+ }
286
+
287
+ async function serializeProjectCommit<T>(projectRoot: string, work: () => Promise<T>): Promise<T> {
288
+ const previous = projectCommitQueues.get(projectRoot) ?? Promise.resolve();
289
+ const current = previous.catch(() => undefined).then(work);
290
+ projectCommitQueues.set(projectRoot, current);
291
+ try {
292
+ return await current;
293
+ } finally {
294
+ if (projectCommitQueues.get(projectRoot) === current) projectCommitQueues.delete(projectRoot);
295
+ }
296
+ }
297
+
298
+ export function createProjectOutputWriter(
299
+ projectRoot: string,
300
+ provenanceContext: ProjectOutputProvenanceContext = {
301
+ operationName: 'project.output.write',
302
+ },
303
+ ): ProjectOutputWriter {
131
304
  return {
132
305
  async write(files, options = {}): Promise<ProjectGeneratedOutput> {
133
306
  const { normalized, summaries, totalBytes } = validateBatch(files);
@@ -135,22 +308,253 @@ export function createProjectOutputWriter(projectRoot: string): ProjectOutputWri
135
308
  return { files: summaries, totalBytes, dryRun: true };
136
309
  }
137
310
 
138
- const transactionRoot = resolve(
139
- projectRoot,
140
- '.vgai',
141
- 'tmp',
142
- `project-output-${randomUUID()}`,
143
- );
144
- const stagedRoot = resolve(transactionRoot, 'staged');
145
- const backupRoot = resolve(transactionRoot, 'backup');
146
- try {
147
- await stageBatch(stagedRoot, normalized);
148
- await commitBatch(projectRoot, stagedRoot, backupRoot, normalized);
149
- } finally {
150
- await rm(transactionRoot, { recursive: true, force: true });
151
- }
311
+ const effectiveProvenanceContext = withCapturedExecutions(provenanceContext);
312
+
313
+ return serializeProjectCommit(projectRoot, async () => {
314
+ const provenanceOperationId = randomUUID();
315
+ const hashedFiles: HashedProjectFile[] = normalized.map((file) => ({
316
+ path: file.path,
317
+ bytes: byteLength(file.content),
318
+ sha256: sha256(file.content),
319
+ ...(file.mediaType ? { mediaType: file.mediaType } : {}),
320
+ ...(file.role ? { role: file.role } : {}),
321
+ }));
322
+ const provenanceFile = await nextProvenanceFile(
323
+ projectRoot,
324
+ provenanceOperationId,
325
+ effectiveProvenanceContext,
326
+ hashedFiles,
327
+ );
328
+ const committedFiles = [...normalized, provenanceFile];
329
+ const transactionRoot = resolve(
330
+ projectRoot,
331
+ '.vgai',
332
+ 'tmp',
333
+ `project-output-${provenanceOperationId}`,
334
+ );
335
+ const stagedRoot = resolve(transactionRoot, 'staged');
336
+ const backupRoot = resolve(transactionRoot, 'backup');
337
+ try {
338
+ await stageBatch(stagedRoot, committedFiles);
339
+ await commitBatch(projectRoot, stagedRoot, backupRoot, committedFiles);
340
+ } finally {
341
+ await rm(transactionRoot, { recursive: true, force: true });
342
+ }
152
343
 
153
- return { files: summaries, totalBytes, dryRun: false };
344
+ return {
345
+ files: summaries,
346
+ totalBytes,
347
+ dryRun: false,
348
+ provenanceOperationId,
349
+ };
350
+ });
154
351
  },
155
352
  };
156
353
  }
354
+
355
+ function stagedMediaType(path: string): string | undefined {
356
+ switch (extname(path).toLowerCase()) {
357
+ case '.glb':
358
+ return 'model/gltf-binary';
359
+ case '.gltf':
360
+ return 'model/gltf+json';
361
+ case '.png':
362
+ return 'image/png';
363
+ case '.webp':
364
+ return 'image/webp';
365
+ case '.jpg':
366
+ case '.jpeg':
367
+ return 'image/jpeg';
368
+ case '.json':
369
+ return 'application/json';
370
+ case '.hdr':
371
+ return 'image/vnd.radiance';
372
+ default:
373
+ return undefined;
374
+ }
375
+ }
376
+
377
+ function stagedRole(path: string): ProjectOutputFile['role'] {
378
+ if (path.endsWith('.prefab.json')) return 'prefab';
379
+ if (path.endsWith('/.vgai-thumbnail.webp')) return 'other';
380
+ return 'asset';
381
+ }
382
+
383
+ async function describeStagedDirectory(
384
+ stagedDirectory: string,
385
+ destinationProjectPath: string,
386
+ ): Promise<HashedProjectFile[]> {
387
+ const outputs: HashedProjectFile[] = [];
388
+ async function walk(directory: string): Promise<void> {
389
+ const entries = await readdir(directory, { withFileTypes: true });
390
+ for (const entry of entries) {
391
+ const absolute = resolve(directory, entry.name);
392
+ if (entry.isDirectory()) {
393
+ await walk(absolute);
394
+ continue;
395
+ }
396
+ if (!entry.isFile()) {
397
+ throw new Error(
398
+ `Staged project output contains an unsupported filesystem entry: ${absolute}`,
399
+ );
400
+ }
401
+ const relativeFile = relative(stagedDirectory, absolute).split(sep).join('/');
402
+ const path = posix.join(destinationProjectPath, relativeFile);
403
+ const info = await stat(absolute);
404
+ const mediaType = stagedMediaType(path);
405
+ outputs.push({
406
+ path,
407
+ bytes: info.size,
408
+ sha256: await sha256File(absolute),
409
+ ...(mediaType ? { mediaType } : {}),
410
+ role: stagedRole(path),
411
+ });
412
+ }
413
+ }
414
+ await walk(stagedDirectory);
415
+ outputs.sort((left, right) => left.path.localeCompare(right.path));
416
+ if (outputs.length === 0) throw new Error('Staged project output directory is empty.');
417
+ return outputs;
418
+ }
419
+
420
+ async function pathExists(path: string): Promise<boolean> {
421
+ try {
422
+ await stat(path);
423
+ return true;
424
+ } catch (error) {
425
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false;
426
+ throw error;
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Commits an already-streamed directory and the central provenance rewrite as
432
+ * one rollback-safe project transaction. Catalog acquisition uses this path so
433
+ * large assets never need to be buffered into ProjectOutputFile.content.
434
+ */
435
+ export async function commitStagedProjectDirectory(options: {
436
+ projectRoot: string;
437
+ stagedDirectory: string;
438
+ destination: string;
439
+ provenance: ProjectOutputProvenanceContext;
440
+ }): Promise<ProjectGeneratedOutput> {
441
+ const destinationProjectPath = normalizeOutputPath(
442
+ relative(options.projectRoot, options.destination).split(sep).join('/'),
443
+ );
444
+ const outputs = await describeStagedDirectory(options.stagedDirectory, destinationProjectPath);
445
+ const summaries: ProjectGeneratedOutputFile[] = outputs.map(
446
+ ({ sha256: _sha256, ...file }) => file,
447
+ );
448
+ const totalBytes = summaries.reduce((total, file) => total + file.bytes, 0);
449
+
450
+ return serializeProjectCommit(options.projectRoot, async () => {
451
+ if (await pathExists(options.destination)) {
452
+ throw new Error(`Destination already exists and was not overwritten: ${options.destination}`);
453
+ }
454
+ const provenanceOperationId = randomUUID();
455
+ const provenanceFile = await nextProvenanceFile(
456
+ options.projectRoot,
457
+ provenanceOperationId,
458
+ options.provenance,
459
+ outputs,
460
+ );
461
+ const transactionRoot = resolve(
462
+ options.projectRoot,
463
+ '.vgai',
464
+ 'tmp',
465
+ `project-directory-${provenanceOperationId}`,
466
+ );
467
+ const stagedRoot = resolve(transactionRoot, 'staged');
468
+ const backupRoot = resolve(transactionRoot, 'backup');
469
+ let directoryCommitted = false;
470
+ try {
471
+ await stageBatch(stagedRoot, [provenanceFile]);
472
+ await mkdir(dirname(options.destination), { recursive: true });
473
+ await rename(options.stagedDirectory, options.destination);
474
+ directoryCommitted = true;
475
+ await commitBatch(options.projectRoot, stagedRoot, backupRoot, [provenanceFile]);
476
+ } catch (error) {
477
+ if (directoryCommitted) {
478
+ try {
479
+ await rename(options.destination, options.stagedDirectory);
480
+ } catch (rollbackError) {
481
+ throw new AggregateError(
482
+ [error, rollbackError],
483
+ 'Project directory transaction and its rollback both failed.',
484
+ );
485
+ }
486
+ }
487
+ throw error;
488
+ } finally {
489
+ await rm(transactionRoot, { recursive: true, force: true });
490
+ }
491
+ return {
492
+ files: summaries,
493
+ totalBytes,
494
+ dryRun: false,
495
+ provenanceOperationId,
496
+ };
497
+ });
498
+ }
499
+
500
+ /** Atomically replaces an imported asset directory and appends its reimport provenance. */
501
+ export async function replaceStagedProjectDirectory(options: {
502
+ projectRoot: string;
503
+ stagedDirectory: string;
504
+ destination: string;
505
+ provenance: ProjectOutputProvenanceContext;
506
+ }): Promise<ProjectGeneratedOutput> {
507
+ const destinationProjectPath = normalizeOutputPath(
508
+ relative(options.projectRoot, options.destination).split(sep).join('/'),
509
+ );
510
+ const outputs = await describeStagedDirectory(options.stagedDirectory, destinationProjectPath);
511
+ const summaries: ProjectGeneratedOutputFile[] = outputs.map(
512
+ ({ sha256: _sha256, ...file }) => file,
513
+ );
514
+ const totalBytes = summaries.reduce((total, file) => total + file.bytes, 0);
515
+
516
+ return serializeProjectCommit(options.projectRoot, async () => {
517
+ if (!(await pathExists(options.destination))) {
518
+ throw new Error(`Reimport target does not exist: ${options.destination}`);
519
+ }
520
+ const provenanceOperationId = randomUUID();
521
+ const provenanceFile = await nextProvenanceFile(
522
+ options.projectRoot,
523
+ provenanceOperationId,
524
+ options.provenance,
525
+ outputs,
526
+ );
527
+ const transactionRoot = resolve(
528
+ options.projectRoot,
529
+ '.vgai',
530
+ 'tmp',
531
+ `project-reimport-${provenanceOperationId}`,
532
+ );
533
+ const stagedRoot = resolve(transactionRoot, 'staged');
534
+ const backupRoot = resolve(transactionRoot, 'backup');
535
+ const directoryBackup = resolve(transactionRoot, 'previous-asset');
536
+ let previousMoved = false;
537
+ let replacementCommitted = false;
538
+ try {
539
+ await stageBatch(stagedRoot, [provenanceFile]);
540
+ await mkdir(dirname(directoryBackup), { recursive: true });
541
+ await rename(options.destination, directoryBackup);
542
+ previousMoved = true;
543
+ await rename(options.stagedDirectory, options.destination);
544
+ replacementCommitted = true;
545
+ await commitBatch(options.projectRoot, stagedRoot, backupRoot, [provenanceFile]);
546
+ } catch (error) {
547
+ if (replacementCommitted) await rm(options.destination, { recursive: true, force: true });
548
+ if (previousMoved) await rename(directoryBackup, options.destination);
549
+ throw error;
550
+ } finally {
551
+ await rm(transactionRoot, { recursive: true, force: true });
552
+ }
553
+ return {
554
+ files: summaries,
555
+ totalBytes,
556
+ dryRun: false,
557
+ provenanceOperationId,
558
+ };
559
+ });
560
+ }