@playcraft/cli 0.1.66 → 0.1.67

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 (101) hide show
  1. package/dist/assets/client.js +4 -2
  2. package/dist/assets/messages.js +2 -0
  3. package/dist/audio-source/messages.js +35 -0
  4. package/dist/commands/assets.js +1 -1
  5. package/dist/commands/audio-source.js +109 -0
  6. package/dist/commands/audio.js +11 -4
  7. package/dist/commands/build.js +4 -2
  8. package/dist/commands/tools-context.js +46 -0
  9. package/dist/commands/tools.js +2 -0
  10. package/dist/project-skills/builtin-import.js +69 -22
  11. package/dist/project-skills/commands.js +11 -2
  12. package/dist/project-skills/lifecycle.js +1 -1
  13. package/dist/project-skills/local-store.js +90 -31
  14. package/dist/project-skills/messages.js +16 -0
  15. package/dist/project-skills/migrate-lock.js +166 -0
  16. package/dist/project-skills/remote-cache.js +36 -5
  17. package/dist/utils/agent-api-client.js +18 -1
  18. package/dist/utils/agent-generation-provenance.js +8 -2
  19. package/dist/utils/audio-export.js +249 -0
  20. package/dist/utils/audio-processor.js +22 -99
  21. package/dist/utils/execution-context-bridge.js +28 -0
  22. package/dist/workspace-runtime/batch/control.js +13 -2
  23. package/dist/workspace-runtime/batch/generate.js +43 -2
  24. package/dist/workspace-runtime/batch/prepare.js +4 -0
  25. package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
  26. package/dist/workspace-runtime/batch/source-capture.js +6 -8
  27. package/dist/workspace-runtime/codex/app-server.js +26 -2
  28. package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
  29. package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
  30. package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
  31. package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
  32. package/dist/workspace-runtime/codex/model-broker.js +15 -2
  33. package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
  34. package/dist/workspace-runtime/previews/finalize.js +1 -1
  35. package/dist/workspace-runtime/previews/output.js +4 -1
  36. package/dist/workspace-runtime/previews/publish.js +5 -2
  37. package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
  38. package/dist/workspace-runtime/serve.js +6 -0
  39. package/dist/workspace-runtime/server/accept-operation.js +85 -13
  40. package/dist/workspace-runtime/server/audio-assets.js +211 -0
  41. package/dist/workspace-runtime/server/audio-export.js +136 -0
  42. package/dist/workspace-runtime/server/directory-access.js +222 -67
  43. package/dist/workspace-runtime/server/dispatch.js +252 -29
  44. package/dist/workspace-runtime/server/http.js +97 -3
  45. package/dist/workspace-runtime/server/operation-record.js +1 -1
  46. package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
  47. package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
  48. package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
  49. package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
  50. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +76 -0
  51. package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
  52. package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
  53. package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
  54. package/dist/workspace-runtime/workspaces/audio-assets/service.js +327 -0
  55. package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
  56. package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
  57. package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
  58. package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
  59. package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
  60. package/dist/workspace-runtime/workspaces/files.js +3 -5
  61. package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
  62. package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
  63. package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
  64. package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
  65. package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
  66. package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
  67. package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
  68. package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
  69. package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
  71. package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
  72. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
  73. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
  74. package/dist/workspace-runtime/workspaces/skills.js +48 -13
  75. package/package.json +6 -4
  76. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  77. package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
  78. package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
  79. package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
  80. package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
  81. package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
  82. package/project-template/vite.config.ts +1 -1
  83. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  84. package/project-template-v2/.playcraft/skills.lock.json +9 -2
  85. package/project-template-v2/vite.config.ts +1 -1
  86. package/project-templates/empty/AGENTS.md +2 -0
  87. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  88. package/project-templates/phaser/vite.config.ts +1 -1
  89. package/project-templates/playable-template-phaser/vite.config.ts +1 -1
  90. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
  91. package/project-templates/threejs/vite.config.ts +1 -1
  92. package/templates/preview-runtime/v1/README.md +12 -0
  93. package/templates/preview-runtime/v1/build-preview.mjs +17 -8
  94. package/templates/preview-runtime/v1/manifest.json +9 -6
  95. package/templates/preview-runtime/v1/release-guard.mjs +1 -1
  96. package/templates/preview-runtime/v1/runtime.d.mts +1 -3
  97. package/templates/preview-runtime/v1/runtime.d.ts +1 -3
  98. package/templates/preview-runtime/v1/runtime.mjs +1142 -257
  99. package/templates/preview-runtime/v1/types/host.d.ts +29 -0
  100. package/templates/preview-runtime/v1/types/index.d.ts +6 -0
  101. package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
@@ -0,0 +1,424 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { LevelEditorError, applyLevelEditorCommandBatch, applyLevelEditorHistoryBatch, levelEditorCanonicalJson, hashOperationPayload, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { historyDirectory, readHistoryText, readLevelEditorHistory, writeLevelEditorHistory, levelEditorHistoryRequestDigest, assertLevelEditorHistoryScope, } from './level-editor-history-store.js';
7
+ import { acquireParameterSkillLease } from './parameter-initialization-skill-transaction.js';
8
+ import { readParameterAgentFile } from './parameter-agent-inputs.js';
9
+ import { serializeLevelEditorDocument, parseLevelEditorDocument } from './level-editor-format.js';
10
+ import { validateLevelEditorExtensions } from './level-editor-extensions.js';
11
+ import { resolveWorkspaceFile } from './files.js';
12
+ import { openSharedFile } from './shared-write.js';
13
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
14
+ const contexts = new Map();
15
+ export function registerLevelEditorContext(root, context) {
16
+ contexts.delete(root);
17
+ contexts.set(root, context);
18
+ if (contexts.size > 128)
19
+ contexts.delete(contexts.keys().next().value);
20
+ }
21
+ export function levelEditorContext(root, context) {
22
+ return context ?? contexts.get(root);
23
+ }
24
+ async function location(root, context) {
25
+ const current = await historyDirectory(root, context);
26
+ const parent = join(dirname(current), 'atomic');
27
+ // private
28
+ await mkdir(parent, { recursive: true, mode: 0o700 });
29
+ const parentStat = await lstat(parent);
30
+ if (!parentStat.isDirectory() || parentStat.isSymbolicLink())
31
+ throw new LevelEditorError('level_editor_history_state_invalid');
32
+ const directory = join(parent, hash(await realpath(root)));
33
+ // private
34
+ await mkdir(directory, { recursive: true, mode: 0o700 });
35
+ const stat = await lstat(directory);
36
+ if (!stat.isDirectory() || stat.isSymbolicLink())
37
+ throw new LevelEditorError('level_editor_history_state_invalid');
38
+ return { directory, current, pending: join(directory, 'active.json') };
39
+ }
40
+ async function pending(root, context) {
41
+ if (!context.state.config.stateDir)
42
+ return undefined;
43
+ try {
44
+ await lstat(resolve(context.state.config.stateDir, 'level-editor-history'));
45
+ }
46
+ catch (error) {
47
+ if (error.code === 'ENOENT')
48
+ return undefined;
49
+ throw error;
50
+ }
51
+ const paths = await location(root, context);
52
+ try {
53
+ const value = JSON.parse(await readHistoryText(paths.pending));
54
+ if (!value || typeof value !== 'object' || Array.isArray(value))
55
+ throw new Error();
56
+ const record = value;
57
+ if (typeof record.requestId !== 'string' ||
58
+ record.requestId.length > 128 ||
59
+ typeof record.scope !== 'string' ||
60
+ !/^[a-f0-9]{64}$/.test(record.scope))
61
+ throw new Error();
62
+ if (record.scope !== basename(paths.current))
63
+ throw new LevelEditorError('level_editor_recovery_scope_conflict');
64
+ return { ...paths, requestId: record.requestId };
65
+ }
66
+ catch (error) {
67
+ if (error.code === 'ENOENT')
68
+ return undefined;
69
+ if (error instanceof LevelEditorError)
70
+ throw error;
71
+ throw new LevelEditorError('level_editor_recovery_conflict');
72
+ }
73
+ }
74
+ export async function hasPendingLevelEditorTransaction(root, context) {
75
+ return !!(await pending(root, context));
76
+ }
77
+ export async function assertNoPendingLevelEditorTransaction(root, context) {
78
+ if (await pending(root, context))
79
+ throw new LevelEditorError('level_editor_recovery_required');
80
+ }
81
+ async function syncDirectory(path) {
82
+ const handle = await open(path, constants.O_RDONLY);
83
+ try {
84
+ await handle.sync();
85
+ }
86
+ finally {
87
+ await handle.close();
88
+ }
89
+ }
90
+ async function writeMarker(path, text) {
91
+ const temporary = `${path}.${randomUUID()}.tmp`;
92
+ try {
93
+ // private
94
+ const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
95
+ try {
96
+ await handle.writeFile(text);
97
+ await handle.sync();
98
+ }
99
+ finally {
100
+ await handle.close();
101
+ }
102
+ await rename(temporary, path);
103
+ await syncDirectory(dirname(path));
104
+ }
105
+ finally {
106
+ await rm(temporary, { force: true });
107
+ }
108
+ }
109
+ function expectedContract(snapshot, request) {
110
+ if (snapshot.status !== 'ready' ||
111
+ !snapshot.definition ||
112
+ snapshot.definition.editorId !== request.editorId ||
113
+ snapshot.definition.dataContractVersion !== request.dataContractVersion)
114
+ throw new LevelEditorError('level_editor_not_ready');
115
+ for (const key of [
116
+ 'sourceFingerprint',
117
+ 'bindingContractHash',
118
+ 'schemaHash',
119
+ 'executionContractHash',
120
+ ])
121
+ if (snapshot[key] !== request[key])
122
+ throw new LevelEditorError('level_editor_source_conflict');
123
+ const ids = Object.keys(snapshot.definition.documents).sort();
124
+ if (JSON.stringify(ids) !== JSON.stringify(Object.keys(request.expectedDocuments).sort()))
125
+ throw new LevelEditorError('level_editor_baseline_incomplete');
126
+ }
127
+ function written(receipt) {
128
+ const results = receipt.batch.files.map(({ documentId, path, contentHash, size, revision }) => ({
129
+ documentId,
130
+ path,
131
+ contentHash,
132
+ size,
133
+ revision,
134
+ }));
135
+ return { ...results[0], results };
136
+ }
137
+ async function finish(root, context, receipt, read) {
138
+ const request = receipt.request;
139
+ if (request.version !== 2 || !receipt.batch)
140
+ throw new LevelEditorError('level_editor_history_invalid');
141
+ assertLevelEditorHistoryScope(context);
142
+ if (context.state.journal?.destroyed() ||
143
+ (context.operation.leaseFence ?? 0) < context.state.acceptedLeaseFence)
144
+ throw new LevelEditorError('level_editor_source_conflict');
145
+ const snapshot = await read();
146
+ expectedContract(snapshot, request);
147
+ const batch = receipt.batch;
148
+ for (const [id, baseline] of Object.entries(batch.baselines)) {
149
+ if (snapshot.definition.documents[id]?.path !== baseline.path)
150
+ throw new LevelEditorError('level_editor_recovery_conflict');
151
+ const file = batch.files.find((file) => file.documentId === id);
152
+ const revision = snapshot.revisions[id];
153
+ if ((snapshot.documentHashes[id] !== baseline.contentHash &&
154
+ snapshot.documentHashes[id] !== file?.contentHash) ||
155
+ (snapshot.documentHashes[id] === file?.contentHash
156
+ ? revision !== baseline.revision && revision !== baseline.revision + 1
157
+ : revision !== baseline.revision))
158
+ throw new LevelEditorError('level_editor_recovery_conflict');
159
+ }
160
+ const next = { ...snapshot.documents };
161
+ for (const file of batch.files) {
162
+ const before = await parseLevelEditorDocument(root, snapshot.definition.documents[file.documentId], Buffer.from(file.beforeBytes, 'base64').toString('utf8'));
163
+ const after = await parseLevelEditorDocument(root, snapshot.definition.documents[file.documentId], Buffer.from(file.afterBytes, 'base64').toString('utf8'));
164
+ const entry = batch.documents.find((item) => item.documentId === file.documentId);
165
+ if (levelEditorCanonicalJson(before) !== levelEditorCanonicalJson(entry.before) ||
166
+ levelEditorCanonicalJson(after) !== levelEditorCanonicalJson(entry.after))
167
+ throw new LevelEditorError('level_editor_history_invalid');
168
+ next[file.documentId] = after;
169
+ }
170
+ const before = {
171
+ ...snapshot.documents,
172
+ ...Object.fromEntries(batch.documents.map((item) => [item.documentId, item.before])),
173
+ };
174
+ let authorized;
175
+ if (request.history) {
176
+ const original = await readLevelEditorHistory(root, context, request.history.requestId);
177
+ if (!original?.batch ||
178
+ original.phase !== 'confirmed' ||
179
+ original.request.history ||
180
+ levelEditorCanonicalJson(original.batch.commands) !==
181
+ levelEditorCanonicalJson(request.commands))
182
+ throw new LevelEditorError('level_editor_history_missing');
183
+ for (const key of [
184
+ 'editorId',
185
+ 'dataContractVersion',
186
+ 'sourceFingerprint',
187
+ 'bindingContractHash',
188
+ 'schemaHash',
189
+ 'executionContractHash',
190
+ ])
191
+ if (original.request[key] !== request[key])
192
+ throw new LevelEditorError('level_editor_source_conflict');
193
+ authorized = applyLevelEditorHistoryBatch(snapshot.definition, before, original.batch, request.history.direction, snapshot.schemas);
194
+ }
195
+ else
196
+ authorized = applyLevelEditorCommandBatch(snapshot.definition, before, batch.commands, snapshot.schemas);
197
+ if (authorized.results.length !== batch.documents.length ||
198
+ authorized.results.some((item) => levelEditorCanonicalJson(item.data) !== levelEditorCanonicalJson(next[item.documentId])))
199
+ throw new LevelEditorError('level_editor_history_invalid');
200
+ if ((await validateLevelEditorExtensions(root, snapshot.definition, next)).some((item) => item.severity === 'error'))
201
+ throw new LevelEditorError('level_editor_validation_failed');
202
+ for (const file of batch.files) {
203
+ const path = await resolveWorkspaceFile(root, file.path), stat = await lstat(path);
204
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1)
205
+ throw new LevelEditorError('level_editor_file_invalid');
206
+ const current = hash(await readParameterAgentFile(root, file.path));
207
+ if (current === file.contentHash)
208
+ continue;
209
+ if (current !== batch.baselines[file.documentId].contentHash)
210
+ throw new LevelEditorError('level_editor_recovery_conflict');
211
+ const temporary = `${path}.${randomUUID()}.tmp`;
212
+ try {
213
+ const handle = await openSharedFile(root, temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
214
+ try {
215
+ await handle.writeFile(Buffer.from(file.afterBytes, 'base64'));
216
+ await handle.sync();
217
+ }
218
+ finally {
219
+ await handle.close();
220
+ }
221
+ if (hash(await readParameterAgentFile(root, file.path)) !== current)
222
+ throw new LevelEditorError('level_editor_recovery_conflict');
223
+ assertLevelEditorHistoryScope(context);
224
+ if (context.state.journal?.destroyed() ||
225
+ (context.operation.leaseFence ?? 0) < context.state.acceptedLeaseFence)
226
+ throw new LevelEditorError('level_editor_source_conflict');
227
+ await rename(temporary, path);
228
+ await syncDirectory(dirname(path));
229
+ }
230
+ finally {
231
+ await rm(temporary, { force: true });
232
+ }
233
+ }
234
+ const latest = await read();
235
+ expectedContract(latest, request);
236
+ for (const [id, baseline] of Object.entries(batch.baselines))
237
+ if (latest.documentHashes[id] !==
238
+ (batch.files.find((file) => file.documentId === id)?.contentHash ?? baseline.contentHash))
239
+ throw new LevelEditorError('level_editor_recovery_conflict');
240
+ await writeLevelEditorHistory(root, context, { ...receipt, phase: 'confirmed' });
241
+ const paths = await location(root, context);
242
+ await rm(paths.pending, { force: true });
243
+ await syncDirectory(paths.directory);
244
+ return written(receipt);
245
+ }
246
+ export async function recoverPendingLevelEditorTransaction(root, context, read) {
247
+ registerLevelEditorContext(root, context);
248
+ const active = await pending(root, context);
249
+ if (!active)
250
+ return;
251
+ const lease = await acquireParameterSkillLease(active.directory, async () => {
252
+ throw new LevelEditorError('level_editor_recovery_conflict');
253
+ });
254
+ try {
255
+ const current = await pending(root, context);
256
+ if (!current)
257
+ return;
258
+ const probeContext = 'operation' in context ? context : undefined;
259
+ // The marker is only a read barrier; the operation journal and private history remain authoritative.
260
+ const raw = JSON.parse(await readHistoryText(join(active.current, `${hash(active.requestId)}.json`)));
261
+ const operation = (probeContext?.operation.clientRequestId === active.requestId
262
+ ? probeContext.operation
263
+ : undefined) ?? context.state.journal?.getOperationById(raw.batch?.operationId ?? '');
264
+ if (!operation ||
265
+ operation.kind !== 'level-editor.transaction' ||
266
+ operation.clientRequestId !== active.requestId)
267
+ throw new LevelEditorError('level_editor_recovery_conflict');
268
+ const owner = { state: context.state, operation };
269
+ const receipt = await readLevelEditorHistory(root, owner, active.requestId);
270
+ if (!receipt?.batch || operation.payloadHash !== hashOperationPayload(receipt.request))
271
+ throw new LevelEditorError('level_editor_recovery_conflict');
272
+ await finish(root, owner, receipt, read);
273
+ }
274
+ finally {
275
+ await lease.release();
276
+ }
277
+ }
278
+ export async function transactLevelEditorBatch(root, revisions, request, context, read) {
279
+ if (!context)
280
+ throw new LevelEditorError('level_editor_atomic_context_required');
281
+ registerLevelEditorContext(root, context);
282
+ assertLevelEditorHistoryScope(context);
283
+ if (context.operation.kind !== 'level-editor.transaction' ||
284
+ context.operation.clientRequestId !== request.requestId ||
285
+ context.operation.payloadHash !== hashOperationPayload(request))
286
+ throw new LevelEditorError('level_editor_history_scope_invalid');
287
+ if (context.state.journal.destroyed() ||
288
+ (context.operation.leaseFence ?? 0) < context.state.acceptedLeaseFence)
289
+ throw new LevelEditorError('level_editor_source_conflict');
290
+ const paths = await location(root, context);
291
+ const lease = await acquireParameterSkillLease(paths.directory, async () => {
292
+ throw new LevelEditorError('level_editor_recovery_conflict');
293
+ });
294
+ try {
295
+ const active = await pending(root, context);
296
+ if (active && active.requestId !== request.requestId)
297
+ throw new LevelEditorError('level_editor_recovery_required');
298
+ const previous = await readLevelEditorHistory(root, context, request.requestId);
299
+ if (previous) {
300
+ if (previous.requestDigest !== levelEditorHistoryRequestDigest(request) ||
301
+ !previous.batch ||
302
+ previous.batch.operationId !== context.operation.operationId)
303
+ throw new LevelEditorError('level_editor_history_request_conflict');
304
+ if (previous.phase === 'confirmed') {
305
+ const snapshot = await read();
306
+ expectedContract(snapshot, request);
307
+ if (Object.entries(previous.batch.baselines).some(([id, baseline]) => {
308
+ const file = previous.batch.files.find((file) => file.documentId === id), revision = snapshot.revisions[id];
309
+ return (snapshot.documentHashes[id] !== (file?.contentHash ?? baseline.contentHash) ||
310
+ (file
311
+ ? revision !== baseline.revision && revision !== file.revision
312
+ : revision !== baseline.revision));
313
+ }))
314
+ throw new LevelEditorError('revision_conflict');
315
+ return written(previous);
316
+ }
317
+ if (!active)
318
+ await writeMarker(paths.pending, JSON.stringify({ requestId: request.requestId, scope: basename(paths.current) }));
319
+ return await finish(root, context, previous, read);
320
+ }
321
+ const snapshot = await read();
322
+ expectedContract(snapshot, request);
323
+ for (const [id, baseline] of Object.entries(request.expectedDocuments))
324
+ if (snapshot.documentHashes[id] !== baseline.contentHash ||
325
+ snapshot.revisions[id] !== baseline.revision)
326
+ throw new LevelEditorError('revision_conflict');
327
+ let outcome;
328
+ if (request.history) {
329
+ const original = await readLevelEditorHistory(root, context, request.history.requestId);
330
+ if (!original?.batch ||
331
+ original.phase !== 'confirmed' ||
332
+ original.request.history ||
333
+ levelEditorCanonicalJson(original.batch.commands) !==
334
+ levelEditorCanonicalJson(request.commands))
335
+ throw new LevelEditorError('level_editor_history_missing');
336
+ for (const key of [
337
+ 'editorId',
338
+ 'dataContractVersion',
339
+ 'sourceFingerprint',
340
+ 'bindingContractHash',
341
+ 'schemaHash',
342
+ 'executionContractHash',
343
+ ])
344
+ if (original.request[key] !== request[key])
345
+ throw new LevelEditorError('level_editor_source_conflict');
346
+ outcome = applyLevelEditorHistoryBatch(snapshot.definition, snapshot.documents, original.batch, request.history.direction, snapshot.schemas);
347
+ }
348
+ else
349
+ outcome = applyLevelEditorCommandBatch(snapshot.definition, snapshot.documents, request.commands, snapshot.schemas);
350
+ if (!outcome.results.length)
351
+ throw new LevelEditorError('level_editor_empty_command');
352
+ const files = [];
353
+ const documents = [];
354
+ let bytes = 0;
355
+ const next = { ...snapshot.documents };
356
+ for (const result of outcome.results) {
357
+ const declaration = snapshot.definition.documents[result.documentId];
358
+ if (!declaration ||
359
+ declaration.path === snapshot.definitionPath ||
360
+ Object.values(snapshot.definition.documents).some((item) => item.schema === declaration.path) ||
361
+ declaration.path === '.playcraft/workspace.json')
362
+ throw new LevelEditorError('level_editor_file_invalid');
363
+ const before = await readParameterAgentFile(root, declaration.path);
364
+ if (hash(before) !== request.expectedDocuments[result.documentId]?.contentHash)
365
+ throw new LevelEditorError('revision_conflict');
366
+ const after = Buffer.from(await serializeLevelEditorDocument(root, declaration, before.toString('utf8'), result.changes, result.data));
367
+ bytes += before.length + after.length;
368
+ if (bytes > 4 * 1024 * 1024 || after.length > 2 * 1024 * 1024)
369
+ throw new LevelEditorError('level_editor_document_budget');
370
+ files.push({
371
+ documentId: result.documentId,
372
+ path: declaration.path,
373
+ beforeBytes: before.toString('base64'),
374
+ afterBytes: after.toString('base64'),
375
+ contentHash: hash(after),
376
+ size: after.length,
377
+ revision: request.expectedDocuments[result.documentId].revision + 1,
378
+ });
379
+ documents.push({
380
+ documentId: result.documentId,
381
+ before: snapshot.documents[result.documentId],
382
+ after: result.data,
383
+ });
384
+ next[result.documentId] = result.data;
385
+ }
386
+ if ((await validateLevelEditorExtensions(root, snapshot.definition, next)).some((item) => item.severity === 'error'))
387
+ throw new LevelEditorError('level_editor_validation_failed');
388
+ const baselines = Object.fromEntries(Object.entries(snapshot.definition.documents).map(([id, doc]) => [
389
+ id,
390
+ { path: doc.path, ...request.expectedDocuments[id] },
391
+ ]));
392
+ const first = files[0];
393
+ const receipt = {
394
+ request,
395
+ requestDigest: levelEditorHistoryRequestDigest(request),
396
+ phase: 'prepared',
397
+ command: request.commands[0],
398
+ documentId: first.documentId,
399
+ before: null,
400
+ after: null,
401
+ path: first.path,
402
+ contentHash: first.contentHash,
403
+ size: first.size,
404
+ batch: {
405
+ commands: request.commands,
406
+ documents,
407
+ files,
408
+ baselines,
409
+ operationId: context.operation.operationId,
410
+ },
411
+ };
412
+ await writeLevelEditorHistory(root, context, receipt);
413
+ await writeMarker(paths.pending, JSON.stringify({ requestId: request.requestId, scope: basename(paths.current) }));
414
+ try {
415
+ return await finish(root, context, receipt, read);
416
+ }
417
+ catch {
418
+ throw new LevelEditorError('level_editor_transaction_uncertain');
419
+ }
420
+ }
421
+ finally {
422
+ await lease.release();
423
+ }
424
+ }
@@ -0,0 +1,140 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { LevelEditorError, isLevelEditorJson, levelEditorDiagnosticSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { readParameterAgentFile } from './parameter-agent-inputs.js';
5
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
6
+ const LIMIT = 4 * 1024 * 1024;
7
+ const OUTPUT_LIMIT = 4 * 1024 * 1024;
8
+ // Project modules receive JSON values only. No host callbacks, imports, filesystem, process or network globals.
9
+ const BOOTSTRAP = String.raw `
10
+ const vm = require('node:vm');
11
+ let input = ''; process.stdin.setEncoding('utf8');
12
+ process.stdin.on('data', chunk => { input += chunk; if (Buffer.byteLength(input) > 5*1024*1024) process.exit(2); });
13
+ process.stdin.on('end', async () => {
14
+ const keepalive = setInterval(() => {}, 1000);
15
+ try {
16
+ const request = JSON.parse(input);
17
+ const context = vm.createContext(Object.assign(Object.create(null), { __input: JSON.stringify(request.input) }), { codeGeneration: { strings: false, wasm: false } });
18
+ const module = new vm.SourceTextModule(request.source, { context, importModuleDynamically: () => { throw new Error('imports_forbidden'); } });
19
+ if (module.dependencySpecifiers.length) throw new Error('imports_forbidden');
20
+ await module.link(() => { throw new Error('imports_forbidden'); });
21
+ await module.evaluate({ timeout: request.timeoutMs });
22
+ context.__call = module.namespace[request.method] ?? (request.method === 'validate' ? module.namespace.default : undefined);
23
+ if (typeof context.__call !== 'function') throw new Error('missing_export');
24
+ const result = await new vm.Script('Promise.resolve(__call(JSON.parse(__input))).then(value => JSON.stringify(value))').runInContext(context, { timeout: request.timeoutMs });
25
+ if (typeof result !== 'string' || Buffer.byteLength(result) > 4*1024*1024) throw new Error('output_limit');
26
+ process.stdout.write(result);
27
+ } catch { process.exitCode = 1; }
28
+ finally { clearInterval(keepalive); }
29
+ });
30
+ `;
31
+ export function levelEditorExtensionPaths(definition) {
32
+ return [
33
+ ...new Set([
34
+ ...Object.values(definition.documents).flatMap((document) => document.provider ? [document.provider.module] : []),
35
+ ...(definition.extensions?.validators ?? []).map((validator) => validator.module),
36
+ ...Object.values(definition.extensions?.views ?? {}).map((view) => view.entry),
37
+ ]),
38
+ ].sort();
39
+ }
40
+ export async function executeLevelEditorModule(root, module, method, input, timeoutMs = 1000) {
41
+ if (!module.endsWith('.mjs') ||
42
+ !Number.isSafeInteger(timeoutMs) ||
43
+ timeoutMs < 10 ||
44
+ timeoutMs > 10_000)
45
+ throw new LevelEditorError('level_editor_extension_module_invalid');
46
+ const bytes = await readParameterAgentFile(root, module, 256 * 1024);
47
+ const request = JSON.stringify({ source: bytes.toString('utf8'), method, input, timeoutMs });
48
+ if (Buffer.byteLength(request) > LIMIT)
49
+ throw new LevelEditorError('level_editor_extension_input_budget');
50
+ const output = await new Promise((resolve, reject) => {
51
+ const child = spawn(process.execPath, ['--experimental-vm-modules', '--no-addons', '--max-old-space-size=64', '-e', BOOTSTRAP], {
52
+ cwd: root,
53
+ env: { LANG: 'C.UTF-8', TZ: 'UTC' },
54
+ stdio: ['pipe', 'pipe', 'pipe'],
55
+ });
56
+ const chunks = [];
57
+ let size = 0, errorSize = 0;
58
+ let failure;
59
+ const fail = (code) => {
60
+ failure ??= new LevelEditorError(code);
61
+ child.kill('SIGKILL');
62
+ };
63
+ const timer = setTimeout(() => fail('level_editor_extension_timeout'), timeoutMs + 150);
64
+ child.stdout.on('data', (chunk) => {
65
+ size += chunk.length;
66
+ if (size > OUTPUT_LIMIT)
67
+ fail('level_editor_extension_output_budget');
68
+ else
69
+ chunks.push(chunk);
70
+ });
71
+ child.stderr.on('data', (chunk) => {
72
+ errorSize += chunk.length;
73
+ if (errorSize > 64 * 1024)
74
+ fail('level_editor_extension_output_budget');
75
+ });
76
+ child.on('error', () => {
77
+ clearTimeout(timer);
78
+ reject(new LevelEditorError('level_editor_extension_failed'));
79
+ });
80
+ child.on('close', (code) => {
81
+ clearTimeout(timer);
82
+ if (failure)
83
+ reject(failure);
84
+ else if (code !== 0)
85
+ reject(new LevelEditorError('level_editor_extension_failed'));
86
+ else
87
+ resolve(Buffer.concat(chunks));
88
+ });
89
+ child.stdin.on('error', () => { });
90
+ child.stdin.end(request);
91
+ });
92
+ if (hash(await readParameterAgentFile(root, module, 256 * 1024)) !== hash(bytes))
93
+ throw new LevelEditorError('level_editor_sources_changed');
94
+ try {
95
+ const value = JSON.parse(output.toString('utf8'));
96
+ if (!isLevelEditorJson(value))
97
+ throw new Error();
98
+ return value;
99
+ }
100
+ catch {
101
+ throw new LevelEditorError('level_editor_extension_output_invalid');
102
+ }
103
+ }
104
+ export async function validateLevelEditorExtensions(root, definition, documents) {
105
+ const diagnostics = [];
106
+ for (const validator of definition.extensions?.validators ?? []) {
107
+ const result = (await executeLevelEditorModule(root, validator.module, 'validate', { documents, definition }, validator.timeoutMs));
108
+ if (!result ||
109
+ Object.keys(result).some((key) => key !== 'diagnostics') ||
110
+ !Array.isArray(result.diagnostics) ||
111
+ result.diagnostics.length > 64)
112
+ throw new LevelEditorError('level_editor_extension_output_invalid');
113
+ const nodes = new Set();
114
+ const visit = (node) => {
115
+ nodes.add(node.id);
116
+ node.children?.forEach(visit);
117
+ };
118
+ visit(definition.view);
119
+ for (const item of result.diagnostics) {
120
+ const parsed = levelEditorDiagnosticSchema.safeParse(item);
121
+ if (!parsed.success)
122
+ throw new LevelEditorError('level_editor_extension_output_invalid');
123
+ const value = parsed.data;
124
+ if ((value.documentId && !Object.hasOwn(definition.documents, value.documentId)) ||
125
+ (value.bindingId && !Object.hasOwn(definition.bindings, value.bindingId)) ||
126
+ (value.documentId &&
127
+ value.bindingId &&
128
+ definition.bindings[value.bindingId]?.documentId !== value.documentId) ||
129
+ (value.componentId && !nodes.has(value.componentId)) ||
130
+ value.path?.some((part) => typeof part === 'number'
131
+ ? !Number.isSafeInteger(part) || part < 0
132
+ : ['__proto__', 'prototype', 'constructor'].includes(part)))
133
+ throw new LevelEditorError('level_editor_extension_output_invalid');
134
+ diagnostics.push(value);
135
+ if (diagnostics.length > 256)
136
+ throw new LevelEditorError('level_editor_extension_output_invalid');
137
+ }
138
+ }
139
+ return diagnostics;
140
+ }
@@ -0,0 +1,75 @@
1
+ import JSON5 from 'json5';
2
+ import { isAlias, isCollection, isNode, isPair, isScalar, parseDocument, visit } from 'yaml';
3
+ import { LevelEditorError, isLevelEditorJson, levelEditorCanonicalJson, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { editLevelEditorJson5 } from './level-editor-json5.js';
5
+ import { executeLevelEditorModule } from './level-editor-extensions.js';
6
+ function yamlDocument(text) {
7
+ const document = parseDocument(text, {
8
+ version: '1.2',
9
+ uniqueKeys: true,
10
+ strict: true,
11
+ customTags: [],
12
+ });
13
+ if (document.errors.length || document.warnings.length)
14
+ throw new LevelEditorError('level_editor_yaml_invalid');
15
+ visit(document, (_key, node) => {
16
+ if (isAlias(node) ||
17
+ (isPair(node) && (!isScalar(node.key) || typeof node.key.value !== 'string')) ||
18
+ (isNode(node) &&
19
+ (('anchor' in node && node.anchor) ||
20
+ (node.tag && !node.tag.startsWith('tag:yaml.org,2002:')))))
21
+ throw new LevelEditorError('level_editor_yaml_feature_unsupported');
22
+ });
23
+ return document;
24
+ }
25
+ export async function parseLevelEditorDocument(root, declaration, text) {
26
+ let value;
27
+ if (declaration.provider)
28
+ value = await executeLevelEditorModule(root, declaration.provider.module, 'parse', { text }, declaration.provider.timeoutMs);
29
+ else if (declaration.format === 'yaml')
30
+ value = yamlDocument(text).toJS({ maxAliasCount: 0 });
31
+ else if (declaration.format === 'json')
32
+ value = JSON.parse(text);
33
+ else if (declaration.format === 'json5' || declaration.format === undefined)
34
+ value = JSON5.parse(text);
35
+ else
36
+ throw new LevelEditorError('level_editor_format_unsupported');
37
+ if (!isLevelEditorJson(value))
38
+ throw new LevelEditorError('level_editor_document_invalid');
39
+ return value;
40
+ }
41
+ export async function serializeLevelEditorDocument(root, declaration, source, changes, data) {
42
+ let text;
43
+ if (declaration.provider) {
44
+ const value = await executeLevelEditorModule(root, declaration.provider.module, 'serialize', { text: source, data, changes }, declaration.provider.timeoutMs);
45
+ if (typeof value !== 'string')
46
+ throw new LevelEditorError('level_editor_extension_output_invalid');
47
+ text = value;
48
+ }
49
+ else if (declaration.format === 'yaml') {
50
+ const document = yamlDocument(source);
51
+ for (const change of changes) {
52
+ const old = document.getIn(change.path, true);
53
+ if (isCollection(old)) {
54
+ let commented = false;
55
+ visit(old, (_key, node) => {
56
+ if (isNode(node) && (node.comment || node.commentBefore))
57
+ commented = true;
58
+ });
59
+ if (commented)
60
+ throw new LevelEditorError('level_editor_comment_preservation_required');
61
+ }
62
+ if (change.op === 'remove')
63
+ document.deleteIn(change.path);
64
+ else
65
+ document.setIn(change.path, change.value);
66
+ }
67
+ text = document.toString({ lineWidth: 0 });
68
+ }
69
+ else
70
+ text = editLevelEditorJson5(source, changes, data);
71
+ if (levelEditorCanonicalJson(await parseLevelEditorDocument(root, declaration, text)) !==
72
+ levelEditorCanonicalJson(data))
73
+ throw new LevelEditorError('level_editor_serialization_mismatch');
74
+ return text;
75
+ }