@playcraft/cli 0.1.66 → 0.1.68

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 (113) 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-program.js +82 -0
  6. package/dist/commands/audio-source.js +109 -0
  7. package/dist/commands/audio.js +13 -4
  8. package/dist/commands/build.js +4 -2
  9. package/dist/commands/tools-context.js +46 -0
  10. package/dist/commands/tools.js +2 -0
  11. package/dist/project-skills/builtin-import.js +69 -22
  12. package/dist/project-skills/commands.js +11 -2
  13. package/dist/project-skills/lifecycle.js +1 -1
  14. package/dist/project-skills/local-store.js +90 -31
  15. package/dist/project-skills/messages.js +16 -0
  16. package/dist/project-skills/migrate-lock.js +166 -0
  17. package/dist/project-skills/remote-cache.js +36 -5
  18. package/dist/utils/agent-api-client.js +18 -1
  19. package/dist/utils/agent-generation-provenance.js +8 -2
  20. package/dist/utils/audio-export.js +249 -0
  21. package/dist/utils/audio-processor.js +22 -99
  22. package/dist/utils/execution-context-bridge.js +28 -0
  23. package/dist/workspace-runtime/batch/control.js +13 -2
  24. package/dist/workspace-runtime/batch/generate.js +43 -2
  25. package/dist/workspace-runtime/batch/prepare.js +4 -0
  26. package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
  27. package/dist/workspace-runtime/batch/source-capture.js +6 -8
  28. package/dist/workspace-runtime/codex/app-server.js +26 -2
  29. package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
  30. package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
  31. package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
  32. package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
  33. package/dist/workspace-runtime/codex/model-broker.js +15 -2
  34. package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
  35. package/dist/workspace-runtime/previews/finalize.js +1 -1
  36. package/dist/workspace-runtime/previews/output.js +4 -1
  37. package/dist/workspace-runtime/previews/publish.js +5 -2
  38. package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
  39. package/dist/workspace-runtime/serve.js +6 -0
  40. package/dist/workspace-runtime/server/accept-operation.js +85 -13
  41. package/dist/workspace-runtime/server/audio-assets.js +257 -0
  42. package/dist/workspace-runtime/server/audio-export.js +136 -0
  43. package/dist/workspace-runtime/server/directory-access.js +222 -67
  44. package/dist/workspace-runtime/server/dispatch.js +252 -29
  45. package/dist/workspace-runtime/server/http.js +97 -3
  46. package/dist/workspace-runtime/server/operation-record.js +1 -1
  47. package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
  48. package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
  49. package/dist/workspace-runtime/workspaces/asset-library-import.js +27 -7
  50. package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
  51. package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
  52. package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +80 -0
  53. package/dist/workspace-runtime/workspaces/audio-assets/library-import.js +149 -0
  54. package/dist/workspace-runtime/workspaces/audio-assets/library-package.js +26 -0
  55. package/dist/workspace-runtime/workspaces/audio-assets/program-artifact-audit.js +90 -0
  56. package/dist/workspace-runtime/workspaces/audio-assets/program-browser.js +194 -0
  57. package/dist/workspace-runtime/workspaces/audio-assets/program-build-inputs.js +122 -0
  58. package/dist/workspace-runtime/workspaces/audio-assets/program-engine.js +39 -0
  59. package/dist/workspace-runtime/workspaces/audio-assets/program-package.js +289 -0
  60. package/dist/workspace-runtime/workspaces/audio-assets/program-variant-build.js +217 -0
  61. package/dist/workspace-runtime/workspaces/audio-assets/references.js +107 -0
  62. package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
  63. package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
  64. package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
  65. package/dist/workspace-runtime/workspaces/audio-assets/service.js +459 -0
  66. package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
  67. package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
  68. package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
  69. package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
  70. package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
  71. package/dist/workspace-runtime/workspaces/files.js +3 -5
  72. package/dist/workspace-runtime/workspaces/git.js +7 -0
  73. package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
  74. package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
  75. package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
  76. package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
  77. package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
  78. package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
  79. package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
  80. package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
  81. package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
  82. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
  83. package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
  84. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
  85. package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
  86. package/dist/workspace-runtime/workspaces/skills.js +48 -13
  87. package/package.json +9 -4
  88. package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +92 -4
  89. package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
  90. package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
  91. package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
  92. package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
  93. package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
  94. package/project-template/vite.config.ts +1 -1
  95. package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +92 -4
  96. package/project-template-v2/.playcraft/skills.lock.json +9 -2
  97. package/project-template-v2/vite.config.ts +1 -1
  98. package/project-templates/empty/AGENTS.md +2 -0
  99. package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +92 -4
  100. package/project-templates/phaser/vite.config.ts +1 -1
  101. package/project-templates/playable-template-phaser/vite.config.ts +1 -1
  102. package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +92 -4
  103. package/project-templates/threejs/vite.config.ts +1 -1
  104. package/templates/preview-runtime/v1/README.md +12 -0
  105. package/templates/preview-runtime/v1/build-preview.mjs +17 -8
  106. package/templates/preview-runtime/v1/manifest.json +9 -6
  107. package/templates/preview-runtime/v1/release-guard.mjs +1 -1
  108. package/templates/preview-runtime/v1/runtime.d.mts +1 -3
  109. package/templates/preview-runtime/v1/runtime.d.ts +1 -3
  110. package/templates/preview-runtime/v1/runtime.mjs +1142 -257
  111. package/templates/preview-runtime/v1/types/host.d.ts +29 -0
  112. package/templates/preview-runtime/v1/types/index.d.ts +6 -0
  113. package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
@@ -0,0 +1,459 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { parseSoundDocument, serializeSoundDocument, soundSourceRenderHash, soundDurationSeconds, isSoundDocumentPath, audioRenderKey, canonicalAudioJson, assertAudioProtection, assertScoreProtection, audioReferenceActionSchema, audioReferenceActionReceiptSchema, workspaceAudioBindingSchema, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { writeSharedFile } from '../shared-write.js';
4
+ import { AudioStore, audioError, audioProjectPath, readAudioSource, digest } from './storage.js';
5
+ import { publishAudioRepresentations } from './representations.js';
6
+ import { audioArtifactResources } from './artifact-package.js';
7
+ import { readAudioArtifactPackage } from './artifact-package.js';
8
+ import { createAudioLibrarySnapshot } from './library-package.js';
9
+ import { readImportedAudioLibrarySource } from './library-import.js';
10
+ import { readAudioReferences, readAudioReferenceFile, verifyAudioReference } from './references.js';
11
+ import { writeWorkspaceBinaryFile } from '../binary-write.js';
12
+ import { readAudioBindingUsage } from './binding-usage.js';
13
+ const safeId = (id) => typeof id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(id);
14
+ const targetId = (target) => target.kind === 'workspace-file' ? `file:${target.path}` : `candidate:${target.taskId}:${target.candidateId}`;
15
+ const summary = (version) => ({ revision: version.revision, contentHash: version.contentHash, createdAt: version.createdAt });
16
+ const publicState = async (record, projectRoot) => {
17
+ const { sourceText: _text, ...current } = record.current;
18
+ const usage = record.target.kind === 'workspace-file' && record.usage
19
+ ? await readAudioBindingUsage(projectRoot, record.target.path, current.contentHash, record.history, record.usage) : undefined;
20
+ return { version: 1, target: record.target, ...current, history: record.history, ...(usage ? { usage } : {}),
21
+ ...(record.libraryOrigin ? { libraryOrigin: record.libraryOrigin } : {}),
22
+ ...(record.representation ? { representation: record.representation } : {}),
23
+ ...(record.adoptionReceipt ? { adoptionReceipt: record.adoptionReceipt } : {}) };
24
+ };
25
+ /** The transport supplies the workspace write lease; this store preserves source-specific outcomes. */
26
+ export class WorkspaceAudioAssets {
27
+ projectRoot;
28
+ scope;
29
+ options;
30
+ store;
31
+ serial = Promise.resolve();
32
+ constructor(projectRoot, stateRoot, scope, options) {
33
+ this.projectRoot = projectRoot;
34
+ this.scope = scope;
35
+ this.options = options;
36
+ this.store = new AudioStore(stateRoot, scope);
37
+ }
38
+ exclusive(action) {
39
+ const next = this.serial.catch(() => undefined).then(action);
40
+ this.serial = next;
41
+ return next;
42
+ }
43
+ validateTarget(target) {
44
+ if (!target || !['workspace-file', 'generation-candidate'].includes(target.kind))
45
+ throw audioError('invalid_audio_target', 400);
46
+ if (target.kind === 'generation-candidate') {
47
+ if (!safeId(target.taskId) || !safeId(target.candidateId))
48
+ throw audioError('invalid_audio_target', 400);
49
+ }
50
+ else if (!isSoundDocumentPath(target.path ?? ''))
51
+ throw audioError('unsupported_audio_format', 400);
52
+ return target;
53
+ }
54
+ version(document, revision, sourceText) {
55
+ const text = sourceText ?? serializeSoundDocument(document);
56
+ const parsed = parseSoundDocument(text);
57
+ return { document: parsed, sourceText: text, revision, contentHash: digest(text), sourceRenderHash: soundSourceRenderHash(parsed),
58
+ createdAt: new Date().toISOString(), renderState: 'pending' };
59
+ }
60
+ async saveRecord(record) {
61
+ const id = targetId(record.target);
62
+ await this.store.write('versions', `${id}:${record.current.revision}`, record.current);
63
+ await this.store.write('records', id, record);
64
+ }
65
+ async observe(target) {
66
+ this.validateTarget(target);
67
+ let record = await this.store.read('records', targetId(target));
68
+ if (target.kind === 'workspace-file') {
69
+ const raw = await readAudioSource(this.projectRoot, target.path);
70
+ if (raw === undefined)
71
+ throw audioError('audio_source_not_found', 404);
72
+ if (!record || record.current.contentHash !== digest(raw)) {
73
+ const version = this.version(undefined, (record?.current.revision ?? 0) + 1, raw);
74
+ this.checkFormatPath(target.path, version.document);
75
+ record = { ...record, target, current: version, representation: undefined, adoptionReceipt: undefined,
76
+ history: [...(record?.history ?? []), summary(version)] };
77
+ try {
78
+ const imported = await readImportedAudioLibrarySource(this.projectRoot, target.path, version.contentHash);
79
+ if (imported) {
80
+ record.libraryOrigin = imported.origin;
81
+ if (imported.current) {
82
+ await this.store.bytes('auditions', imported.current.audition.contentHash, imported.current.wav);
83
+ record.current = { ...version, renderState: 'ready', audition: imported.current.audition };
84
+ record.representation = imported.current.representation;
85
+ }
86
+ }
87
+ }
88
+ catch (error) {
89
+ record.current = { ...version, renderState: 'failed', renderError: error instanceof Error && /^audio_library_/.test(error.message) ? error.message : 'audio_library_dependency_invalid' };
90
+ }
91
+ await this.saveRecord(record);
92
+ }
93
+ }
94
+ if (!record)
95
+ throw audioError('audio_candidate_not_found', 404);
96
+ if (canonicalAudioJson(record.target) !== canonicalAudioJson(target))
97
+ throw audioError('invalid_audio_target', 400);
98
+ return record;
99
+ }
100
+ read(target) { return this.exclusive(async () => publicState(await this.observe(target), this.projectRoot)); }
101
+ references(target) {
102
+ return this.exclusive(async () => {
103
+ if (target.kind !== 'workspace-file')
104
+ throw audioError('invalid_audio_target', 400);
105
+ const record = await this.observe(target);
106
+ return readAudioReferences(this.projectRoot, target.path, record.current, record.history, hash => this.previousBinding(hash));
107
+ });
108
+ }
109
+ async previousBinding(hash) {
110
+ try {
111
+ return await readFile(this.store.blobPath('bindings', hash));
112
+ }
113
+ catch (error) {
114
+ if (error.code === 'ENOENT')
115
+ return undefined;
116
+ throw error;
117
+ }
118
+ }
119
+ referenceAction(raw) {
120
+ return this.exclusive(async () => {
121
+ const input = audioReferenceActionSchema.parse(raw), payloadHash = digest(canonicalAudioJson(input));
122
+ let intent = await this.store.read('reference-requests', input.requestId);
123
+ if (intent && intent.payloadHash !== payloadHash)
124
+ throw audioError('audio_request_conflict');
125
+ if (intent?.status === 'complete')
126
+ return audioReferenceActionReceiptSchema.parse(intent.result);
127
+ const record = await this.observe(input.target), bindingPath = `source/audio-bindings/${input.eventName}.json`;
128
+ const current = await readAudioReferenceFile(this.projectRoot, bindingPath), currentHash = current ? digest(current) : null;
129
+ const alreadyWritten = intent?.status === 'pending' && currentHash === digest(Buffer.from(intent.entry, 'base64'));
130
+ if (!alreadyWritten) {
131
+ this.expected(record, { action: 'bind', requestId: input.requestId, target: input.target,
132
+ expectedRevision: input.expectedRevision, expectedContentHash: input.expectedContentHash });
133
+ if (currentHash !== input.expectedBindingContentHash)
134
+ throw audioError('audio_binding_conflict');
135
+ }
136
+ if (!intent) {
137
+ let bytes;
138
+ if (input.action === 'replace') {
139
+ if (record.current.renderState !== 'ready' || !record.current.audition || !record.representation
140
+ || record.representation.sourceContentHash !== record.current.contentHash)
141
+ throw audioError('audio_audition_unavailable', 409);
142
+ const entry = workspaceAudioBindingSchema.parse({ version: 1, operationId: input.requestId, eventName: input.eventName,
143
+ sourcePath: input.target.path, sourceContentHash: record.current.contentHash, revision: record.current.revision,
144
+ auditionContentHash: record.current.audition.contentHash, modulePath: record.representation.modulePath,
145
+ representationSetHash: record.representation.representationSetHash, integrationStatus: 'integration_required', previousBinding: currentHash });
146
+ bytes = Buffer.from(`${canonicalAudioJson(entry)}\n`);
147
+ await verifyAudioReference(this.projectRoot, input.target.path, record.history, bytes);
148
+ }
149
+ else {
150
+ const snapshot = await readAudioReferences(this.projectRoot, input.target.path, record.current, record.history, hash => this.previousBinding(hash));
151
+ const tracked = snapshot.references.find(reference => reference.eventName === input.eventName);
152
+ if (!tracked?.history.some(version => version.bindingContentHash === input.previousBindingHash))
153
+ throw audioError('invalid_audio_binding', 400);
154
+ const previous = await this.previousBinding(input.previousBindingHash);
155
+ if (!previous || digest(previous) !== input.previousBindingHash)
156
+ throw audioError('invalid_audio_binding', 400);
157
+ await verifyAudioReference(this.projectRoot, input.target.path, record.history, previous);
158
+ bytes = Buffer.from(previous);
159
+ }
160
+ if (current)
161
+ await this.store.bytes('bindings', currentHash, current);
162
+ intent = { payloadHash, status: 'pending', beforeHash: currentHash, entry: bytes.toString('base64') };
163
+ await this.store.write('reference-requests', input.requestId, intent);
164
+ }
165
+ const bytes = Buffer.from(intent.entry, 'base64'), binding = await verifyAudioReference(this.projectRoot, input.target.path, record.history, bytes);
166
+ if (binding.eventName !== input.eventName)
167
+ throw audioError('invalid_audio_binding', 400);
168
+ if (!alreadyWritten) {
169
+ try {
170
+ await writeWorkspaceBinaryFile(this.projectRoot, { path: bindingPath, contentBase64: intent.entry, expectedContentHash: intent.beforeHash });
171
+ }
172
+ catch (error) {
173
+ if (error.code === 'content_conflict')
174
+ throw audioError('audio_binding_conflict');
175
+ throw error;
176
+ }
177
+ }
178
+ record.usage = { status: 'integration_required', bindingPath, eventName: input.eventName, sourceContentHash: binding.sourceContentHash,
179
+ ...(binding.previousBinding ? { previousContentHash: binding.previousBinding } : {}) };
180
+ await this.saveRecord(record);
181
+ await this.options.onWrite?.(input.target.path, record.current, [bindingPath], input.requestId);
182
+ const result = audioReferenceActionReceiptSchema.parse({ version: 1, requestId: input.requestId, action: input.action, target: input.target,
183
+ eventName: input.eventName, bindingPath, bindingContentHash: digest(bytes), sourceContentHash: binding.sourceContentHash,
184
+ revision: binding.revision, modulePath: binding.modulePath, representationSetHash: binding.representationSetHash, integrationStatus: 'integration_required' });
185
+ await this.store.write('reference-requests', input.requestId, { ...intent, status: 'complete', result });
186
+ return result;
187
+ });
188
+ }
189
+ librarySnapshot(target, revision, contentHash) {
190
+ return this.exclusive(async () => {
191
+ const record = await this.observe(target), version = record.current;
192
+ if (version.revision !== revision || version.contentHash !== contentHash)
193
+ throw audioError('audio_revision_conflict');
194
+ if (version.renderState !== 'ready' || !version.audition)
195
+ throw audioError('audio_audition_unavailable', 404);
196
+ const sourcePath = target.kind === 'workspace-file' ? target.path
197
+ : `source/audio/${target.candidateId}.${version.document.format === 'playcraft.score' ? 'pcscore' : 'pcaudio'}.json`;
198
+ if (record.representation?.sourceContentHash === contentHash) {
199
+ const entry = `${record.representation.modulePath.slice(0, -'sound.mjs'.length)}sound.${version.document.format === 'playcraft.score' ? 'pcscore' : 'pcaudio'}.json`;
200
+ const packaged = await readAudioArtifactPackage(this.projectRoot, entry);
201
+ const files = [...packaged.files].map(([path, bytes]) => ({ path: path.slice(path.lastIndexOf('/') + 1),
202
+ mimeType: path.endsWith('.wav') ? 'audio/wav' : path.endsWith('.mjs') ? 'text/javascript' : 'application/json',
203
+ size: bytes.length, contentHash: digest(bytes), content: bytes.toString('base64') }));
204
+ return { version: 1, target, revision, sourcePath, files };
205
+ }
206
+ const wav = await readFile(this.store.blobPath('auditions', version.audition.contentHash));
207
+ if (digest(wav) !== version.audition.contentHash || wav.length !== version.audition.size)
208
+ throw audioError('audio_audition_unavailable', 404);
209
+ return createAudioLibrarySnapshot({ target, revision, sourcePath, document: version.document, sourceText: version.sourceText,
210
+ contentHash, sourceRenderHash: version.sourceRenderHash, audition: version.audition, wav });
211
+ });
212
+ }
213
+ async content(target, revision, representation = 'audition') {
214
+ const record = await this.observe(target), version = revision === undefined || revision === record.current.revision ? record.current :
215
+ await this.store.read('versions', `${targetId(target)}:${revision}`);
216
+ if (!version)
217
+ throw audioError('audio_version_not_found', 404);
218
+ if (representation === 'source')
219
+ return { bytes: Buffer.from(version.sourceText), mimeType: 'application/json', contentHash: version.contentHash };
220
+ if (!version?.audition || version.renderState !== 'ready')
221
+ throw audioError('audio_audition_unavailable', 404);
222
+ const bytes = await readFile(this.store.blobPath('auditions', version.audition.contentHash));
223
+ if (digest(bytes) !== version.audition.contentHash || bytes.length !== version.audition.size)
224
+ throw audioError('audio_audition_unavailable', 404);
225
+ return { bytes, mimeType: version.audition.mimeType, contentHash: version.audition.contentHash };
226
+ }
227
+ expected(record, action) {
228
+ if (record.current.revision !== action.expectedRevision || record.current.contentHash !== action.expectedContentHash)
229
+ throw audioError('audio_revision_conflict');
230
+ }
231
+ checkFormatPath(path, document) {
232
+ if (!path.endsWith(document.format === 'playcraft.score' ? '.pcscore.json' : '.pcaudio.json'))
233
+ throw audioError('unsupported_audio_format', 400);
234
+ }
235
+ checkProtection(previous, next, action) {
236
+ if (previous.format !== next.format)
237
+ throw audioError('unsupported_content_kind', 400);
238
+ if (previous.format === 'playcraft.score' && next.format === 'playcraft.score') {
239
+ const protection = action.protection;
240
+ if (protection && ('lockedVoiceIds' in protection || 'preserveTargets' in protection))
241
+ throw audioError('invalid_audio_target', 400);
242
+ assertScoreProtection(previous, next, protection);
243
+ }
244
+ else if (previous.format === 'playcraft.audio' && next.format === 'playcraft.audio') {
245
+ const protection = action.protection;
246
+ if (protection && ('lockedTrackIds' in protection || 'preserveMelody' in protection || 'selection' in protection))
247
+ throw audioError('invalid_audio_target', 400);
248
+ assertAudioProtection(previous, next, protection);
249
+ }
250
+ }
251
+ async action(input, signal) {
252
+ if (!safeId(input.requestId))
253
+ throw audioError('invalid_audio_request', 400);
254
+ return this.exclusive(async () => {
255
+ const payloadHash = digest(canonicalAudioJson(input)), known = await this.store.read('requests', input.requestId);
256
+ if (known && known.payloadHash !== payloadHash)
257
+ throw audioError('idempotency_conflict');
258
+ if (known?.status === 'complete' && known.result)
259
+ return known.result;
260
+ if (known?.transaction)
261
+ return this.recover(input.requestId, known);
262
+ if (known?.candidateRecord) {
263
+ const current = await this.store.read('records', targetId(known.candidateRecord.target));
264
+ if (current && current.current.revision > known.candidateRecord.current.revision)
265
+ throw audioError('audio_revision_conflict');
266
+ await this.saveRecord(known.candidateRecord);
267
+ const result = await publicState(known.candidateRecord, this.projectRoot);
268
+ await this.store.write('requests', input.requestId, { payloadHash, status: 'complete', result });
269
+ return result;
270
+ }
271
+ if (signal?.aborted)
272
+ throw audioError('audio_render_cancelled');
273
+ await this.store.write('requests', input.requestId, { payloadHash, status: 'pending' });
274
+ let record;
275
+ if (input.action === 'create-candidate') {
276
+ if (!safeId(input.taskId) || !safeId(input.candidateId))
277
+ throw audioError('invalid_audio_target', 400);
278
+ const target = { kind: 'generation-candidate', taskId: input.taskId, candidateId: input.candidateId };
279
+ const existing = await this.store.read('records', targetId(target));
280
+ const version = this.version(input.document, 1);
281
+ if (existing && (!known || existing.current.revision !== 1 || existing.current.contentHash !== version.contentHash))
282
+ throw audioError('audio_candidate_exists');
283
+ record = existing ?? { target, current: version, history: [summary(version)] };
284
+ if (!existing)
285
+ await this.saveRecord(record);
286
+ }
287
+ else {
288
+ const target = this.validateTarget(input.target);
289
+ if (target.kind === 'workspace-file' && target.path.startsWith('dist/'))
290
+ throw audioError('immutable_audio_representation', 400);
291
+ await this.recoverPendingFor(target);
292
+ record = await this.observe(target);
293
+ this.expected(record, input);
294
+ if (input.action === 'render') {
295
+ record = await this.render(record, signal);
296
+ if (record.target.kind === 'workspace-file' && record.current.renderState === 'ready' && record.current.audition) {
297
+ const wav = await readFile(this.store.blobPath('auditions', record.current.audition.contentHash));
298
+ record.representation = await publishAudioRepresentations(this.projectRoot, record.target.path, { ...record.current, audition: record.current.audition }, wav);
299
+ await this.saveRecord(record);
300
+ await this.options.onWrite?.(record.target.path, record.current, record.representation.files.map(file => file.path), input.requestId);
301
+ }
302
+ }
303
+ else if (input.action === 'adopt')
304
+ return this.adopt(record, input, payloadHash);
305
+ else if (input.action === 'save' || input.action === 'rollback') {
306
+ const old = input.action === 'rollback' ? await this.store.read('versions', `${targetId(record.target)}:${input.versionRevision}`) : undefined;
307
+ if (input.action === 'rollback' && !old)
308
+ throw audioError('audio_version_not_found', 404);
309
+ const version = this.version(old?.document ?? input.document, record.current.revision + 1, old?.sourceText);
310
+ this.checkProtection(record.current.document, version.document, input);
311
+ const next = { ...record, current: version, representation: undefined, adoptionReceipt: undefined, history: [...record.history, summary(version)] };
312
+ if (record.target.kind === 'workspace-file') {
313
+ const path = record.target.path;
314
+ const pending = { payloadHash, status: 'pending', transaction: { record: next, path, beforeHash: record.current.contentHash,
315
+ sourceText: version.sourceText, paths: [path] } };
316
+ await this.store.write('requests', input.requestId, pending);
317
+ await this.store.write('pending', path, { requestId: input.requestId });
318
+ return this.recover(input.requestId, pending);
319
+ }
320
+ record = next;
321
+ await this.store.write('requests', input.requestId, { payloadHash, status: 'pending', candidateRecord: record });
322
+ await this.saveRecord(record);
323
+ }
324
+ else if (input.action === 'bind') {
325
+ record = await this.bind(record, input);
326
+ }
327
+ else
328
+ throw audioError('invalid_audio_action', 400);
329
+ }
330
+ const result = await publicState(record, this.projectRoot);
331
+ await this.store.write('requests', input.requestId, { payloadHash, status: 'complete', result });
332
+ return result;
333
+ });
334
+ }
335
+ async render(record, signal) {
336
+ if (record.current.renderState === 'ready' && record.current.audition) {
337
+ const existing = await readFile(this.store.blobPath('auditions', record.current.audition.contentHash));
338
+ if (digest(existing) !== record.current.audition.contentHash)
339
+ throw audioError('audio_audition_unavailable', 404);
340
+ return record;
341
+ }
342
+ record.current = { ...record.current, renderState: 'running', renderError: undefined, audition: undefined };
343
+ await this.saveRecord(record);
344
+ try {
345
+ const rendered = await this.options.render(record.current.document, signal);
346
+ const expectedFrames = Math.ceil(soundDurationSeconds(record.current.document) * 44100);
347
+ if (rendered.metrics.frames !== expectedFrames || rendered.metrics.channels !== 1 || rendered.metrics.sampleRate !== 44100 || !Number.isFinite(rendered.metrics.peak))
348
+ throw audioError('audio_render_failed', 422);
349
+ if (rendered.metrics.peak > .98)
350
+ throw audioError('render_peak_exceeded', 422);
351
+ const contentHash = digest(rendered.wav);
352
+ await this.store.bytes('auditions', contentHash, rendered.wav);
353
+ const renderKey = audioRenderKey({ sourceRenderHash: record.current.sourceRenderHash, rendererBuildHash: rendered.rendererBuildHash,
354
+ chromiumVersion: rendered.chromiumVersion, sampleRate: 44100, channels: 1, frames: expectedFrames, format: 'pcm-f32' });
355
+ record.current = { ...record.current, renderState: 'ready', audition: { mimeType: 'audio/wav', size: rendered.wav.length,
356
+ contentHash, pcmContentHash: rendered.pcmContentHash, rendererBuildHash: rendered.rendererBuildHash,
357
+ chromiumVersion: rendered.chromiumVersion, renderKey, metrics: rendered.metrics } };
358
+ }
359
+ catch (error) {
360
+ const code = error instanceof Error && /^[a-z][a-z0-9_]{0,127}$/.test(error.message) ? error.message : 'audio_render_failed';
361
+ record.current = { ...record.current, renderState: signal?.aborted || code === 'audio_render_cancelled' ? 'cancelled' : 'failed', renderError: code };
362
+ }
363
+ await this.saveRecord(record);
364
+ return record;
365
+ }
366
+ async recoverPendingFor(target) {
367
+ if (target.kind !== 'workspace-file')
368
+ return;
369
+ const pending = await this.store.read('pending', target.path);
370
+ if (!pending)
371
+ return;
372
+ const request = await this.store.read('requests', pending.requestId);
373
+ if (request?.status === 'pending' && request.transaction)
374
+ await this.recover(pending.requestId, request);
375
+ }
376
+ async recover(requestId, receipt) {
377
+ const tx = receipt.transaction;
378
+ const source = await readAudioSource(this.projectRoot, tx.path), hash = source === undefined ? null : digest(source);
379
+ if (hash !== tx.beforeHash && hash !== tx.record.current.contentHash)
380
+ throw audioError('audio_revision_conflict');
381
+ if (hash !== tx.record.current.contentHash) {
382
+ const target = await audioProjectPath(this.projectRoot, tx.path, true);
383
+ await writeSharedFile(this.projectRoot, target, tx.sourceText);
384
+ await this.options.onPhase?.('source-published');
385
+ }
386
+ await this.saveRecord(tx.record);
387
+ const result = await publicState(tx.record, this.projectRoot);
388
+ await this.store.write('requests', requestId, { ...receipt, status: 'complete', result });
389
+ await this.options.onWrite?.(tx.path, tx.record.current, tx.paths, requestId);
390
+ return result;
391
+ }
392
+ async adopt(record, input, payloadHash) {
393
+ if (record.target.kind !== 'generation-candidate' || record.current.renderState !== 'ready' || !record.current.audition)
394
+ throw audioError('audio_audition_unavailable');
395
+ const path = input.path ?? `source/audio/${record.target.candidateId}.${record.current.document.format === 'playcraft.score' ? 'pcscore' : 'pcaudio'}.json`;
396
+ this.checkFormatPath(path, record.current.document);
397
+ if (path.startsWith('dist/'))
398
+ throw audioError('immutable_audio_representation', 400);
399
+ await audioProjectPath(this.projectRoot, path);
400
+ await this.recoverPendingFor({ kind: 'workspace-file', path });
401
+ const previousRaw = await readAudioSource(this.projectRoot, path);
402
+ const previous = previousRaw === undefined ? undefined : await this.observe({ kind: 'workspace-file', path });
403
+ if ((input.expectedDestinationContentHash ?? null) !== (previous?.current.contentHash ?? null) ||
404
+ (input.expectedDestinationRevision ?? 0) !== (previous?.current.revision ?? 0))
405
+ throw audioError('audio_revision_conflict');
406
+ const audition = record.current.audition, bytes = await readFile(this.store.blobPath('auditions', audition.contentHash));
407
+ const representation = await publishAudioRepresentations(this.projectRoot, path, { ...record.current, audition }, bytes);
408
+ const version = { ...record.current, revision: (previous?.current.revision ?? 0) + 1, createdAt: new Date().toISOString() };
409
+ const paths = [...representation.files.map(file => file.path), path];
410
+ const adopted = { target: { kind: 'workspace-file', path }, current: version,
411
+ representation, usage: previous?.usage, history: [...(previous?.history ?? []), summary(version)], adoptionReceipt: { operationId: input.requestId, path,
412
+ taskId: record.target.taskId, candidateId: record.target.candidateId, candidateRevision: record.current.revision,
413
+ sourceContentHash: record.current.contentHash, representationSetHash: representation.representationSetHash, files: representation.files } };
414
+ const receipt = { payloadHash, status: 'pending', transaction: { record: adopted, path,
415
+ beforeHash: previous?.current.contentHash ?? null, sourceText: record.current.sourceText, paths } };
416
+ await this.store.write('requests', input.requestId, receipt);
417
+ await this.store.write('pending', path, { requestId: input.requestId });
418
+ await this.options.onPhase?.('representations-published');
419
+ return this.recover(input.requestId, receipt);
420
+ }
421
+ async bind(record, input) {
422
+ if (record.target.kind !== 'workspace-file' || record.current.renderState !== 'ready' || !record.current.audition ||
423
+ !record.representation || record.representation.sourceContentHash !== record.current.contentHash)
424
+ throw audioError('audio_audition_unavailable');
425
+ if (!input.eventName || !/^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/.test(input.eventName))
426
+ throw audioError('invalid_audio_binding', 400);
427
+ const primary = record.representation.files.find(file => /\/sound\.(?:pcaudio|pcscore)\.json$/.test(file.path));
428
+ if (!primary)
429
+ throw audioError('invalid_audio_package', 400);
430
+ await audioArtifactResources(this.projectRoot, primary.path);
431
+ const bindingPath = `source/audio-bindings/${input.eventName}.json`;
432
+ const previous = await audioProjectPath(this.projectRoot, bindingPath, true);
433
+ const previousBytes = await readFile(previous).catch(error => { if (error.code === 'ENOENT')
434
+ return undefined; throw error; });
435
+ let previousEntry;
436
+ try {
437
+ previousEntry = previousBytes ? JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(previousBytes)) : undefined;
438
+ }
439
+ catch { /* Preserve the original bytes below. */ }
440
+ if (previousEntry?.operationId === input.requestId && previousEntry.sourcePath === record.target.path && previousEntry.sourceContentHash === record.current.contentHash) {
441
+ record.usage = { status: 'integration_required', bindingPath, eventName: input.eventName, sourceContentHash: record.current.contentHash,
442
+ ...(previousEntry.previousBinding ? { previousContentHash: previousEntry.previousBinding } : {}) };
443
+ await this.saveRecord(record);
444
+ return record;
445
+ }
446
+ const entry = { version: 1, operationId: input.requestId, eventName: input.eventName, sourcePath: record.target.path,
447
+ sourceContentHash: record.current.contentHash, revision: record.current.revision, auditionContentHash: record.current.audition.contentHash,
448
+ modulePath: record.representation.modulePath, representationSetHash: record.representation.representationSetHash,
449
+ integrationStatus: 'integration_required', previousBinding: previousBytes ? digest(previousBytes) : null };
450
+ if (previousBytes)
451
+ await this.store.bytes('bindings', digest(previousBytes), previousBytes);
452
+ await writeSharedFile(this.projectRoot, previous, `${canonicalAudioJson(entry)}\n`);
453
+ record.usage = { status: 'integration_required', bindingPath, eventName: input.eventName,
454
+ sourceContentHash: record.current.contentHash, ...(previousBytes ? { previousContentHash: digest(previousBytes) } : {}) };
455
+ await this.saveRecord(record);
456
+ await this.options.onWrite?.(record.target.path, record.current, [bindingPath], input.requestId);
457
+ return record;
458
+ }
459
+ }
@@ -0,0 +1,125 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readFile, realpath, rename, rm } from 'node:fs/promises';
4
+ import { dirname, join, resolve, relative, sep } from 'node:path';
5
+ import { workspaceRelativePathSchema, isSoundDocumentPath } from '@playcraft/common/cloud-workspace/runtime';
6
+ export const digest = (value) => createHash('sha256').update(value).digest('hex');
7
+ export const audioError = (code, status = 409) => Object.assign(new Error(code), { code, status });
8
+ /** Private audio state is indexed by hashes, never by caller-controlled path components. */
9
+ export class AudioStore {
10
+ root;
11
+ constructor(stateRoot, scope) { this.root = join(stateRoot, 'audio-assets', digest(scope)); }
12
+ path(bucket, identity) { return join(this.root, bucket, `${digest(identity)}.json`); }
13
+ async read(bucket, identity) {
14
+ const file = this.path(bucket, identity);
15
+ try {
16
+ const stat = await lstat(file);
17
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 16 * 1024 * 1024)
18
+ throw audioError('audio_state_unavailable');
19
+ return JSON.parse(await readFile(file, 'utf8'));
20
+ }
21
+ catch (error) {
22
+ if (error.code === 'ENOENT')
23
+ return undefined;
24
+ throw error;
25
+ }
26
+ }
27
+ async write(bucket, identity, value) {
28
+ const target = this.path(bucket, identity);
29
+ // private: controller state is outside the shared project tree.
30
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
31
+ await atomicPrivate(target, JSON.stringify(value));
32
+ }
33
+ async bytes(bucket, identity, bytes) {
34
+ const target = join(this.root, bucket, digest(identity));
35
+ // private: candidate blobs remain controller-managed until adoption.
36
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
37
+ const existing = await readFile(target).catch(error => { if (error.code === 'ENOENT')
38
+ return undefined; throw error; });
39
+ if (existing) {
40
+ if (digest(existing) !== digest(bytes))
41
+ throw audioError('audio_blob_conflict');
42
+ return target;
43
+ }
44
+ await atomicPrivate(target, bytes);
45
+ return target;
46
+ }
47
+ blobPath(bucket, identity) { return join(this.root, bucket, digest(identity)); }
48
+ }
49
+ async function atomicPrivate(target, value) {
50
+ const temporary = `${target}.${randomUUID()}.tmp`;
51
+ try {
52
+ // private: atomic state replacement is readable only by the controller.
53
+ const file = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
54
+ try {
55
+ await file.writeFile(value);
56
+ await file.sync();
57
+ }
58
+ finally {
59
+ await file.close();
60
+ }
61
+ await rename(temporary, target);
62
+ const parent = await open(dirname(target), 'r');
63
+ try {
64
+ await parent.sync();
65
+ }
66
+ finally {
67
+ await parent.close();
68
+ }
69
+ }
70
+ finally {
71
+ await rm(temporary, { force: true });
72
+ }
73
+ }
74
+ /** Reject symlinks even when their destination happens to remain inside the project. */
75
+ export async function audioProjectPath(root, path, createParents = false) {
76
+ const parsed = workspaceRelativePathSchema.safeParse(path);
77
+ if (!parsed.success || parsed.data.split('/').some(part => ['.git', '.playcraft', 'node_modules'].includes(part)))
78
+ throw audioError('invalid_audio_path', 400);
79
+ const base = await realpath(root);
80
+ let target = base;
81
+ const parts = parsed.data.split('/');
82
+ for (let index = 0; index < parts.length; index++) {
83
+ target = join(target, parts[index]);
84
+ let stat = await lstat(target).catch(error => { if (error.code === 'ENOENT')
85
+ return undefined; throw error; });
86
+ if (!stat && createParents && index < parts.length - 1) {
87
+ // shared: project source and package parents are used by both identities.
88
+ await mkdir(target, { mode: 0o2770 });
89
+ stat = await lstat(target);
90
+ }
91
+ if (stat?.isSymbolicLink() || stat && index < parts.length - 1 && !stat.isDirectory())
92
+ throw audioError('invalid_audio_path', 400);
93
+ }
94
+ const rel = relative(base, resolve(target));
95
+ if (!rel || rel.startsWith(`..${sep}`) || rel === '..')
96
+ throw audioError('invalid_audio_path', 400);
97
+ return target;
98
+ }
99
+ export async function readAudioSource(root, path) {
100
+ if (!isSoundDocumentPath(path))
101
+ throw audioError('unsupported_audio_format', 400);
102
+ const target = await audioProjectPath(root, path);
103
+ const stat = await lstat(target).catch(error => { if (error.code === 'ENOENT')
104
+ return undefined; throw error; });
105
+ if (!stat)
106
+ return undefined;
107
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 65_536)
108
+ throw audioError('invalid_audio_document', 400);
109
+ const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
110
+ try {
111
+ const bytes = await file.readFile();
112
+ const after = await file.stat();
113
+ if (bytes.length > 65_536 || stat.ino !== after.ino || stat.size !== after.size || stat.mtimeMs !== after.mtimeMs)
114
+ throw audioError('audio_revision_conflict');
115
+ try {
116
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
117
+ }
118
+ catch {
119
+ throw audioError('invalid_audio_document', 400);
120
+ }
121
+ }
122
+ finally {
123
+ await file.close();
124
+ }
125
+ }
@@ -3,6 +3,7 @@ import { copyFile, cp, lstat, mkdir, readdir, readlink, symlink } from 'node:fs/
3
3
  import { dirname, join, relative, sep } from 'node:path';
4
4
  import { listWorkspaceBuildInputs } from './git.js';
5
5
  import { ensureSharedDirectory } from './shared-write.js';
6
+ import { readAudioBuildInputs } from './audio-assets/build-inputs.js';
6
7
  /** Staging under the workspace from earlier runtimes' sandbox delivery builds; never a build input. */
7
8
  const STAGING_DIRECTORY = '.playcraft-delivery';
8
9
  /** Never copied into the build copy: history, outputs and caches that the build neither reads nor owns. */
@@ -72,4 +73,5 @@ export async function isolateWorkspaceForBuild(root, destination) {
72
73
  }
73
74
  });
74
75
  await Promise.all(workers);
76
+ return readAudioBuildInputs(root, destination);
75
77
  }