@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,724 @@
1
+ import { mkdir, readdir, readFile, rename, writeFile, lstat, realpath } from 'node:fs/promises';
2
+ import { join, resolve, relative, sep, isAbsolute } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import { platformEndpoint, hashOperationPayload, levelEditorCanonicalJson, levelEditorRetryPayloadSchema, levelEditorRequestRecordSchema, levelEditorTaskReportSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { workspaceContextFromRoot, workspaceRoot } from './registry.js';
7
+ import { levelEditorDigest, readLevelEditorFile, readWorkspaceLevelEditor, } from './level-editor.js';
8
+ import { readPrivateLevelEditorTask } from './level-editor-agent-store.js';
9
+ const recordSchema = z
10
+ .object({
11
+ action: z.enum(['initialize', 'evolve', 'repair']),
12
+ goal: z.string().min(1).max(4000),
13
+ editorId: z.string().optional(),
14
+ version: z.literal(1),
15
+ requestId: z.string().uuid(),
16
+ taskId: z.string().uuid(),
17
+ workspaceId: z.string(),
18
+ directoryName: z.string(),
19
+ sourceOperationId: z.string(),
20
+ sourceSessionId: z.string(),
21
+ sourceThreadId: z.string(),
22
+ sourceTurnId: z.string(),
23
+ toolCallId: z.string(),
24
+ generation: z.number(),
25
+ runtimeEpoch: z.number(),
26
+ status: z.enum([
27
+ 'requested',
28
+ 'queued',
29
+ 'dispatched',
30
+ 'failed',
31
+ 'stale',
32
+ 'uncertain',
33
+ 'cancelled',
34
+ ]),
35
+ operationId: z.string().optional(),
36
+ stage: z.enum(['generate', 'verify', 'preview', 'complete']).default('generate'),
37
+ taskOperationId: z.string().optional(),
38
+ verifyOperationId: z.string().optional(),
39
+ previewOperationId: z.string().optional(),
40
+ attemptId: z.string().uuid().optional(),
41
+ retryOfRequestId: z.string().uuid().optional(),
42
+ retryStage: z.enum(['verify', 'preview']).optional(),
43
+ failedStage: z.enum(['generate', 'verify', 'preview']).optional(),
44
+ retryableStage: z.enum(['verify', 'preview']).optional(),
45
+ verifyClientRequestId: z.string().optional(),
46
+ retryInputHash: z.string().optional(),
47
+ retryPayloadHash: z.string().optional(),
48
+ expectedSourceFingerprint: z.string().optional(),
49
+ expectedDefinitionHash: z.string().optional(),
50
+ expectedDocumentHashes: z.record(z.string(), z.string()).optional(),
51
+ error: z.string().optional(),
52
+ createdAt: z.string(),
53
+ updatedAt: z.string(),
54
+ })
55
+ .strict();
56
+ const loops = new WeakMap();
57
+ function stableId(value) { const text = levelEditorDigest(value); return `${text.slice(0, 8)}-${text.slice(8, 12)}-4${text.slice(13, 16)}-8${text.slice(17, 20)}-${text.slice(20, 32)}`; }
58
+ async function directory(state) {
59
+ const path = resolve(state.config.stateDir, 'level-editor-requests');
60
+ // private
61
+ await mkdir(path, { recursive: true, mode: 0o700 });
62
+ if ((await lstat(path)).isSymbolicLink())
63
+ throw new Error('level_editor_state_invalid');
64
+ const actual = await realpath(path), rel = relative(await realpath(state.config.workspaceRoot), actual);
65
+ if (rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)))
66
+ throw new Error('level_editor_state_not_private');
67
+ return actual;
68
+ }
69
+ async function write(state, record) {
70
+ const folder = await directory(state), temporary = join(folder, `.${randomUUID()}.tmp`);
71
+ const serialized = JSON.stringify(recordSchema.parse(record));
72
+ if (Buffer.byteLength(serialized) > 32 * 1024)
73
+ throw new Error('level_editor_request_budget');
74
+ // private
75
+ await writeFile(temporary, serialized, { mode: 0o600 });
76
+ await rename(temporary, join(folder, `${record.requestId}.json`));
77
+ }
78
+ export async function listLevelEditorRequests(state, workspaceId, directoryName) {
79
+ const folder = await directory(state), names = (await readdir(folder)).filter((name) => /^[a-f0-9-]{36}\.json$/.test(name));
80
+ if (names.length > 256)
81
+ throw new Error('level_editor_request_limit');
82
+ const records = [];
83
+ for (const name of names) {
84
+ const path = join(folder, name), stat = await lstat(path);
85
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 32 * 1024)
86
+ throw new Error('level_editor_state_invalid');
87
+ const record = recordSchema.parse(JSON.parse(await readFile(path, 'utf8')));
88
+ if ((!workspaceId || record.workspaceId === workspaceId) &&
89
+ (!directoryName || record.directoryName === directoryName)) {
90
+ const failedStage = failureStage(state, record);
91
+ records.push({
92
+ ...record,
93
+ failedStage,
94
+ retryableStage: record.status === 'failed' && (failedStage === 'verify' || failedStage === 'preview')
95
+ ? failedStage
96
+ : undefined,
97
+ });
98
+ }
99
+ }
100
+ const superseded = new Set(records.map((item) => item.retryOfRequestId).filter(Boolean));
101
+ return records
102
+ .map((item) => (superseded.has(item.requestId) ? { ...item, retryableStage: undefined } : item))
103
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
104
+ }
105
+ function failureStage(state, record) {
106
+ if (record.error?.startsWith('level_editor_local_operation_'))
107
+ return undefined;
108
+ if (record.status !== 'failed')
109
+ return record.failedStage;
110
+ if (record.failedStage)
111
+ return record.failedStage;
112
+ if (record.stage === 'verify')
113
+ return localStageOperation(state, record, 'generate').operation?.status === 'completed'
114
+ ? 'verify'
115
+ : 'generate';
116
+ if (record.stage === 'preview') {
117
+ const prior = localStageOperation(state, record, 'verify').operation;
118
+ const result = prior?.result;
119
+ return prior?.status === 'completed' && result?.status === 'verified' ? 'preview' : 'verify';
120
+ }
121
+ return record.stage === 'generate' ? 'generate' : undefined;
122
+ }
123
+ async function retryInput(state, record, root, allowOwnCheck = false) {
124
+ const task = await readPrivateLevelEditorTask(state, record.workspaceId, record.directoryName, record.taskId, root);
125
+ if (!task ||
126
+ task.record.sessionId !== record.sourceSessionId ||
127
+ task.record.generation !== record.generation ||
128
+ task.record.runtimeEpoch !== record.runtimeEpoch ||
129
+ ['running', 'uncertain', 'interrupted', 'stale', 'invalid'].includes(task.record.status) ||
130
+ (task.record.status === 'checking' && !allowOwnCheck))
131
+ throw new Error('level_editor_retry_task_unavailable');
132
+ const snapshot = await readWorkspaceLevelEditor(root, state.journal.fileRevisions?.() ?? {}, { state, workspaceId: record.workspaceId, directoryName: record.directoryName });
133
+ if (snapshot.status !== 'ready' ||
134
+ snapshot.sourceFingerprint !== record.expectedSourceFingerprint ||
135
+ snapshot.definitionHash !== record.expectedDefinitionHash ||
136
+ levelEditorCanonicalJson(snapshot.documentHashes) !==
137
+ levelEditorCanonicalJson(record.expectedDocumentHashes))
138
+ throw new Error('level_editor_retry_inputs_changed');
139
+ const reportBytes = await readLevelEditorFile(root, task.record.reportPath);
140
+ const report = levelEditorTaskReportSchema.parse(JSON.parse(reportBytes.toString('utf8')));
141
+ if (report.taskId !== record.taskId || report.missing.length)
142
+ throw new Error('level_editor_retry_report_invalid');
143
+ let size = reportBytes.length;
144
+ const sources = [];
145
+ for (const source of report.sources) {
146
+ const bytes = await readLevelEditorFile(root, source.path);
147
+ size += bytes.length;
148
+ if (size > 32 * 1024 * 1024 || levelEditorDigest(bytes) !== source.sha256)
149
+ throw new Error('level_editor_retry_inputs_changed');
150
+ sources.push([source.path, source.sha256]);
151
+ }
152
+ return {
153
+ task,
154
+ hash: levelEditorDigest(levelEditorCanonicalJson({
155
+ sourceFingerprint: snapshot.sourceFingerprint,
156
+ definitionHash: snapshot.definitionHash,
157
+ documentHashes: snapshot.documentHashes,
158
+ reportHash: levelEditorDigest(reportBytes),
159
+ sources: sources.sort(),
160
+ })),
161
+ };
162
+ }
163
+ /** A new explicit attempt owns a new record; the failed request and its operation receipts remain immutable. */
164
+ export async function retryLevelEditorRequest(state, operation, value, root) {
165
+ const input = levelEditorRetryPayloadSchema.parse(value), payloadHash = levelEditorDigest(levelEditorCanonicalJson(input));
166
+ if (operation.kind !== 'level-editor.request' ||
167
+ operation.clientRequestId !== input.attemptId ||
168
+ operation.sessionId ||
169
+ operation.environment !== state.config.environment ||
170
+ operation.userId !== state.config.userId ||
171
+ operation.generation !== state.config.generation ||
172
+ operation.runtimeEpoch !== state.runtimeEpoch)
173
+ throw new Error('level_editor_retry_scope_invalid');
174
+ return state.journal.exclusive('level-editor-request-store', async () => {
175
+ const records = await listLevelEditorRequests(state);
176
+ const previous = records.find((item) => item.requestId === input.attemptId);
177
+ if (previous) {
178
+ if (previous.workspaceId !== operation.workspaceId ||
179
+ previous.directoryName !== (operation.directoryName ?? 'basic') ||
180
+ previous.retryPayloadHash !== payloadHash)
181
+ throw new Error('level_editor_retry_conflict');
182
+ return levelEditorRequestRecordSchema.parse(previous);
183
+ }
184
+ const parent = records.find((item) => item.requestId === input.requestId);
185
+ if (!parent ||
186
+ parent.workspaceId !== operation.workspaceId ||
187
+ parent.directoryName !== (operation.directoryName ?? 'basic') ||
188
+ parent.generation !== state.config.generation ||
189
+ parent.runtimeEpoch !== state.runtimeEpoch ||
190
+ parent.status !== 'failed' ||
191
+ failureStage(state, parent) !== input.stage)
192
+ throw new Error('level_editor_retry_unavailable');
193
+ if (records.some((item) => item.retryOfRequestId === parent.requestId))
194
+ throw new Error('level_editor_retry_superseded');
195
+ if (records.length >= 256)
196
+ throw new Error('level_editor_request_limit');
197
+ if (records.some((item) => item.taskId === parent.taskId &&
198
+ (['requested', 'queued', 'uncertain'].includes(item.status) ||
199
+ (item.status === 'dispatched' && item.stage !== 'complete'))))
200
+ throw new Error('level_editor_retry_busy');
201
+ const source = state.journal.getOperationById(parent.sourceOperationId);
202
+ if (!source ||
203
+ source.kind !== 'session.turn' ||
204
+ source.environment !== state.config.environment ||
205
+ source.userId !== state.config.userId ||
206
+ source.generation !== parent.generation ||
207
+ source.runtimeEpoch !== parent.runtimeEpoch ||
208
+ source.status !== 'completed' ||
209
+ source.sessionId !== parent.sourceSessionId ||
210
+ source.nativeThreadId !== parent.sourceThreadId ||
211
+ source.nativeTurnId !== parent.sourceTurnId ||
212
+ source.workspaceId !== parent.workspaceId ||
213
+ (source.directoryName ?? 'basic') !== parent.directoryName)
214
+ throw new Error('level_editor_retry_source_invalid');
215
+ const generation = localStageOperation(state, parent, 'generate');
216
+ if (generation.error || generation.operation?.status !== 'completed')
217
+ throw new Error('level_editor_retry_generation_unconfirmed');
218
+ const now = new Date().toISOString();
219
+ const candidate = {
220
+ ...parent,
221
+ ...input,
222
+ requestId: input.attemptId,
223
+ retryOfRequestId: input.requestId,
224
+ retryStage: input.stage,
225
+ stage: input.stage,
226
+ status: 'queued',
227
+ failedStage: undefined,
228
+ retryableStage: undefined,
229
+ error: undefined,
230
+ operationId: undefined,
231
+ previewOperationId: undefined,
232
+ taskOperationId: generation.operation.operationId,
233
+ verifyOperationId: input.stage === 'preview' ? parent.verifyOperationId : undefined,
234
+ verifyClientRequestId: input.stage === 'verify'
235
+ ? `${parent.taskId}:verify:${input.attemptId}`
236
+ : (parent.verifyClientRequestId ?? `${parent.taskId}:verify`),
237
+ retryPayloadHash: payloadHash,
238
+ createdAt: now,
239
+ updatedAt: now,
240
+ };
241
+ const frozen = await retryInput(state, candidate, root);
242
+ if (input.stage === 'preview') {
243
+ const prior = localStageOperation(state, parent, 'verify');
244
+ if (prior.error ||
245
+ prior.operation?.status !== 'completed' ||
246
+ frozen.task.record.status !== 'verified' ||
247
+ !frozen.task.record.sourceDigest)
248
+ throw new Error('level_editor_retry_verification_required');
249
+ }
250
+ candidate.retryInputHash = frozen.hash;
251
+ await write(state, candidate);
252
+ wakeLevelEditorRequests(state);
253
+ return levelEditorRequestRecordSchema.parse(candidate);
254
+ });
255
+ }
256
+ /** Recheck the frozen retry input while the normal verification directory lease is held. */
257
+ export async function assertLevelEditorRetryInput(state, operation, value, root) {
258
+ const retryRequestId = value?.retryRequestId;
259
+ if (!retryRequestId)
260
+ return;
261
+ const record = (await listLevelEditorRequests(state)).find((item) => item.requestId === retryRequestId);
262
+ if (!record ||
263
+ operation.kind !== 'level-editor.verify' ||
264
+ operation.sessionId ||
265
+ operation.environment !== state.config.environment ||
266
+ operation.userId !== state.config.userId ||
267
+ record.retryStage !== 'verify' ||
268
+ record.workspaceId !== operation.workspaceId ||
269
+ record.directoryName !== (operation.directoryName ?? 'basic') ||
270
+ operation.clientRequestId !== record.verifyClientRequestId ||
271
+ record.generation !== state.config.generation ||
272
+ record.runtimeEpoch !== state.runtimeEpoch ||
273
+ value.taskId !== record.taskId ||
274
+ !['queued', 'dispatched'].includes(record.status))
275
+ throw new Error('level_editor_retry_scope_invalid');
276
+ if ((await retryInput(state, record, root)).hash !== record.retryInputHash)
277
+ throw new Error('level_editor_retry_inputs_changed');
278
+ }
279
+ /** Prove only that this control request registered its exact durable child; never execute it again. */
280
+ export async function reconcileLevelEditorRetryRequest(state, operation) {
281
+ if (operation.kind !== 'level-editor.request' ||
282
+ !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status) ||
283
+ operation.sessionId ||
284
+ operation.environment !== state.config.environment ||
285
+ operation.userId !== state.config.userId ||
286
+ operation.generation !== state.config.generation ||
287
+ operation.runtimeEpoch !== state.runtimeEpoch ||
288
+ state.journal.destroyed())
289
+ return undefined;
290
+ const records = await listLevelEditorRequests(state);
291
+ const children = records.filter((item) => item.requestId === operation.clientRequestId && item.attemptId === operation.clientRequestId);
292
+ const child = children.length === 1 ? children[0] : undefined;
293
+ if (!child ||
294
+ child.workspaceId !== operation.workspaceId ||
295
+ child.directoryName !== (operation.directoryName ?? 'basic') ||
296
+ child.generation !== operation.generation ||
297
+ child.runtimeEpoch !== operation.runtimeEpoch ||
298
+ !child.retryOfRequestId ||
299
+ !/^[a-f0-9]{64}$/.test(child.retryInputHash ?? ''))
300
+ return undefined;
301
+ const reconstructed = levelEditorRetryPayloadSchema.safeParse({
302
+ version: 1,
303
+ requestId: child.retryOfRequestId,
304
+ attemptId: child.attemptId,
305
+ stage: child.retryStage,
306
+ expectedSourceFingerprint: child.expectedSourceFingerprint,
307
+ expectedDefinitionHash: child.expectedDefinitionHash,
308
+ expectedDocumentHashes: child.expectedDocumentHashes,
309
+ });
310
+ if (!reconstructed.success ||
311
+ hashOperationPayload(reconstructed.data) !== operation.payloadHash ||
312
+ levelEditorDigest(levelEditorCanonicalJson(reconstructed.data)) !== child.retryPayloadHash)
313
+ return undefined;
314
+ const parents = records.filter((item) => item.requestId === child.retryOfRequestId);
315
+ const parent = parents.length === 1 ? parents[0] : undefined;
316
+ const fields = [
317
+ 'workspaceId',
318
+ 'directoryName',
319
+ 'taskId',
320
+ 'sourceOperationId',
321
+ 'sourceSessionId',
322
+ 'sourceThreadId',
323
+ 'sourceTurnId',
324
+ 'toolCallId',
325
+ 'generation',
326
+ 'runtimeEpoch',
327
+ 'action',
328
+ 'goal',
329
+ 'editorId',
330
+ ];
331
+ if (!parent || parent.status !== 'failed' || fields.some((key) => parent[key] !== child[key]))
332
+ return undefined;
333
+ const generated = localStageOperation(state, child, 'generate');
334
+ if (generated.error || generated.operation?.status !== 'completed')
335
+ return undefined;
336
+ const source = state.journal.getOperationById(child.sourceOperationId);
337
+ if (!source ||
338
+ source.kind !== 'session.turn' ||
339
+ source.status !== 'completed' ||
340
+ source.environment !== operation.environment ||
341
+ source.userId !== operation.userId ||
342
+ source.workspaceId !== operation.workspaceId ||
343
+ (source.directoryName ?? 'basic') !== child.directoryName ||
344
+ source.sessionId !== child.sourceSessionId ||
345
+ source.nativeThreadId !== child.sourceThreadId ||
346
+ source.nativeTurnId !== child.sourceTurnId ||
347
+ source.generation !== child.generation ||
348
+ source.runtimeEpoch !== child.runtimeEpoch)
349
+ return undefined;
350
+ return levelEditorRequestRecordSchema.parse(child);
351
+ }
352
+ /** The source is supplied by the native tool closure, never by generated tool arguments. */
353
+ export async function registerLevelEditorRequest(state, source, intent) {
354
+ const context = await workspaceContextFromRoot(state, await realpath(source.cwd));
355
+ if (context.directoryName === 'root')
356
+ throw new Error('level_editor_scope_invalid');
357
+ let operation = state.journal
358
+ .listOperations()
359
+ .map((item) => item.operation)
360
+ .find((item) => item.kind === 'session.turn' &&
361
+ item.sessionId === source.sessionId &&
362
+ item.nativeThreadId === source.threadId &&
363
+ item.nativeTurnId === source.turnId);
364
+ for (let attempt = 0; !operation && attempt < 20; attempt++) {
365
+ await new Promise((done) => setTimeout(done, 25));
366
+ operation = state.journal
367
+ .listOperations()
368
+ .map((item) => item.operation)
369
+ .find((item) => item.kind === 'session.turn' &&
370
+ item.sessionId === source.sessionId &&
371
+ item.nativeThreadId === source.threadId &&
372
+ item.nativeTurnId === source.turnId);
373
+ }
374
+ if (!operation ||
375
+ !['dispatched', 'accepted'].includes(operation.status) ||
376
+ operation.workspaceId !== context.workspaceId ||
377
+ (operation.directoryName ?? 'basic') !== context.directoryName ||
378
+ operation.workMode === 'plan' ||
379
+ operation.permissionMode === 'read-only')
380
+ throw new Error('level_editor_source_unavailable');
381
+ if (/^[a-f0-9-]{36}$/i.test(operation.clientRequestId) &&
382
+ (await readPrivateLevelEditorTask(state, context.workspaceId, context.directoryName, operation.clientRequestId)))
383
+ throw new Error('level_editor_managed_task_already_active');
384
+ return state.journal.exclusive('level-editor-request-store', async () => {
385
+ const records = await listLevelEditorRequests(state), key = JSON.stringify([
386
+ context.workspaceId,
387
+ context.directoryName,
388
+ operation.operationId,
389
+ intent.editorId ?? '',
390
+ intent.action,
391
+ ]);
392
+ const requestId = stableId(key), previous = records.find((item) => item.requestId === requestId);
393
+ if (previous) {
394
+ if (previous.goal !== intent.goal ||
395
+ previous.action !== intent.action ||
396
+ previous.editorId !== intent.editorId)
397
+ throw new Error('level_editor_request_conflict');
398
+ return { requestId, taskId: previous.taskId, status: previous.status };
399
+ }
400
+ if (records.length >= 256)
401
+ throw new Error('level_editor_request_limit');
402
+ const timestamp = new Date().toISOString();
403
+ const record = {
404
+ version: 1,
405
+ ...intent,
406
+ requestId,
407
+ taskId: stableId(`${key}:task`),
408
+ ...context,
409
+ sourceOperationId: operation.operationId,
410
+ sourceSessionId: source.sessionId,
411
+ sourceThreadId: source.threadId,
412
+ sourceTurnId: source.turnId,
413
+ toolCallId: source.toolCallId,
414
+ generation: state.config.generation,
415
+ runtimeEpoch: state.runtimeEpoch,
416
+ status: 'requested',
417
+ stage: 'generate',
418
+ createdAt: timestamp,
419
+ updatedAt: timestamp,
420
+ };
421
+ await write(state, record);
422
+ wakeLevelEditorRequests(state);
423
+ return {
424
+ requestId,
425
+ taskId: record.taskId,
426
+ status: record.status, message: 'The managed initialization starts after this turn completes normally. Do not initialize it again in this turn.',
427
+ };
428
+ });
429
+ }
430
+ /** Panel tasks use the same durable post-generation pipeline, without a second generated turn. */
431
+ export async function ensureLevelEditorTaskPipeline(state, operation) {
432
+ if (operation.kind !== 'session.turn' ||
433
+ operation.status !== 'completed' ||
434
+ !operation.sessionId ||
435
+ !operation.workspaceId ||
436
+ !operation.nativeThreadId ||
437
+ !operation.nativeTurnId)
438
+ return;
439
+ const task = await readPrivateLevelEditorTask(state, operation.workspaceId, operation.directoryName ?? 'basic', operation.clientRequestId);
440
+ if (!task)
441
+ return;
442
+ await state.journal.exclusive('level-editor-request-store', async () => {
443
+ const records = await listLevelEditorRequests(state);
444
+ if (records.some((item) => item.taskId === task.record.taskId))
445
+ return;
446
+ const timestamp = new Date().toISOString();
447
+ await write(state, {
448
+ version: 1,
449
+ action: task.request.action,
450
+ goal: task.request.goal ?? 'Initialize the project level editor',
451
+ ...(task.request.editorId ? { editorId: task.request.editorId } : {}),
452
+ requestId: stableId(`${task.record.taskId}:pipeline`),
453
+ taskId: task.record.taskId,
454
+ workspaceId: operation.workspaceId,
455
+ directoryName: operation.directoryName ?? 'basic',
456
+ sourceOperationId: operation.operationId,
457
+ sourceSessionId: operation.sessionId,
458
+ sourceThreadId: operation.nativeThreadId,
459
+ sourceTurnId: operation.nativeTurnId,
460
+ toolCallId: 'panel-initialization',
461
+ generation: state.config.generation,
462
+ runtimeEpoch: state.runtimeEpoch,
463
+ status: 'dispatched',
464
+ stage: 'verify',
465
+ taskOperationId: operation.operationId,
466
+ createdAt: timestamp,
467
+ updatedAt: timestamp,
468
+ });
469
+ });
470
+ wakeLevelEditorRequests(state);
471
+ }
472
+ /** Durable control queue; no engineering directory lease is acquired by request registration. */
473
+ export function startLevelEditorRequestQueue(state) {
474
+ if (loops.has(state))
475
+ return;
476
+ const loop = {
477
+ running: false,
478
+ closed: false,
479
+ controller: new AbortController(),
480
+ timer: setInterval(() => wakeLevelEditorRequests(state), 2_000),
481
+ unsubscribe: undefined,
482
+ };
483
+ loop.timer.unref?.();
484
+ loops.set(state, loop);
485
+ loop.unsubscribe = state.journal.onChange(() => wakeLevelEditorRequests(state));
486
+ wakeLevelEditorRequests(state);
487
+ }
488
+ export function stopLevelEditorRequestQueue(state) {
489
+ const loop = loops.get(state);
490
+ if (!loop)
491
+ return;
492
+ loop.closed = true;
493
+ loop.controller.abort();
494
+ clearInterval(loop.timer);
495
+ loop.unsubscribe?.();
496
+ loops.delete(state);
497
+ }
498
+ export function wakeLevelEditorRequests(state) {
499
+ const loop = loops.get(state);
500
+ if (!loop || loop.closed || loop.running)
501
+ return;
502
+ loop.running = true;
503
+ setTimeout(() => {
504
+ void drain(state)
505
+ .catch(() => undefined)
506
+ .finally(() => {
507
+ loop.running = false;
508
+ });
509
+ }, 0).unref?.();
510
+ }
511
+ function localStageOperation(state, record, stage) {
512
+ const kind = stage === 'generate' ? 'session.turn' : 'level-editor.verify';
513
+ const clientRequestId = stage === 'generate'
514
+ ? record.taskId
515
+ : (record.verifyClientRequestId ?? `${record.taskId}:verify`);
516
+ const sessionId = stage === 'generate' ? record.sourceSessionId : undefined;
517
+ const matches = (operation) => operation.kind === kind &&
518
+ operation.clientRequestId === clientRequestId &&
519
+ operation.environment === state.config.environment &&
520
+ operation.userId === state.config.userId &&
521
+ operation.workspaceId === record.workspaceId &&
522
+ (operation.directoryName ?? 'basic') === record.directoryName &&
523
+ operation.sessionId === sessionId &&
524
+ operation.generation === record.generation &&
525
+ operation.runtimeEpoch === record.runtimeEpoch;
526
+ const candidates = state.journal
527
+ .listOperations()
528
+ .map((item) => item.operation)
529
+ .filter(matches);
530
+ if (candidates.length > 1)
531
+ return { error: 'level_editor_local_operation_ambiguous' };
532
+ const previousId = stage === 'generate' ? record.taskOperationId : record.verifyOperationId;
533
+ const previous = previousId ? state.journal.getOperationById(previousId) : undefined;
534
+ if (previous && (!matches(previous) || previous.operationId !== candidates[0]?.operationId))
535
+ return { error: 'level_editor_local_operation_mismatch' };
536
+ // Older records may contain a control-service operation ID. Only the scoped native journal
537
+ // identifies a local prior stage; the authoritative backend ID remains record.operationId.
538
+ return { operation: candidates[0] };
539
+ }
540
+ async function drain(state) {
541
+ for (const record of await listLevelEditorRequests(state)) {
542
+ if (!loops.get(state) || state.journal.destroyed())
543
+ return;
544
+ if (!['requested', 'queued', 'dispatched'].includes(record.status) ||
545
+ record.stage === 'complete')
546
+ continue;
547
+ const change = async (status, error, patch = {}) => {
548
+ const failedStage = patch.failedStage ??
549
+ (status === 'failed' ? failureStage(state, { ...record, status }) : record.failedStage);
550
+ await write(state, {
551
+ ...record,
552
+ ...patch,
553
+ status,
554
+ error,
555
+ failedStage,
556
+ updatedAt: new Date().toISOString(),
557
+ });
558
+ };
559
+ if (record.generation !== state.config.generation ||
560
+ record.runtimeEpoch !== state.runtimeEpoch) {
561
+ await change('stale', 'level_editor_request_runtime_changed');
562
+ continue;
563
+ }
564
+ const source = state.journal.getOperationById(record.sourceOperationId);
565
+ if (!source ||
566
+ source.sessionId !== record.sourceSessionId ||
567
+ source.nativeThreadId !== record.sourceThreadId ||
568
+ source.nativeTurnId !== record.sourceTurnId) {
569
+ await change('stale', 'level_editor_source_unavailable');
570
+ continue;
571
+ }
572
+ if (source.status === 'uncertain') {
573
+ await change('uncertain', 'level_editor_source_uncertain');
574
+ continue;
575
+ }
576
+ if (source.status === 'failed' || source.status === 'interrupted') {
577
+ await change('cancelled', 'level_editor_source_interrupted');
578
+ continue;
579
+ }
580
+ if (source.status !== 'completed')
581
+ continue;
582
+ if (record.stage === 'generate' &&
583
+ state.journal
584
+ .listOperations()
585
+ .some(({ operation }) => operation.kind === 'session.turn' &&
586
+ operation.operationId !== source.operationId && !['completed', 'failed', 'interrupted'].includes(operation.status))) {
587
+ if (record.status !== 'queued')
588
+ await change('queued');
589
+ continue;
590
+ }
591
+ if (record.stage === 'verify' || record.stage === 'preview') {
592
+ const resolved = localStageOperation(state, record, record.stage === 'verify' ? 'generate' : 'verify');
593
+ if (resolved.error) {
594
+ await change('failed', resolved.error);
595
+ continue;
596
+ }
597
+ const prior = resolved.operation;
598
+ if (!prior || !['completed', 'failed', 'interrupted', 'uncertain'].includes(prior.status))
599
+ continue;
600
+ if (prior.status !== 'completed') {
601
+ await change(prior.status === 'uncertain' ? 'uncertain' : 'failed', prior.error ?? 'level_editor_prior_stage_failed');
602
+ continue;
603
+ }
604
+ if (record.stage === 'preview') {
605
+ const task = await readPrivateLevelEditorTask(state, record.workspaceId, record.directoryName, record.taskId);
606
+ if (task?.record.status !== 'verified') {
607
+ await change('failed', task?.record.error ?? 'level_editor_verification_failed', {
608
+ failedStage: 'verify',
609
+ });
610
+ continue;
611
+ }
612
+ }
613
+ }
614
+ if (record.status === 'requested')
615
+ await change('queued');
616
+ if (record.attemptId) {
617
+ try {
618
+ const ownCheck = record.stage === 'verify' ? localStageOperation(state, record, 'verify') : undefined;
619
+ const frozen = await retryInput(state, record, await workspaceRoot(state, record.workspaceId, record.directoryName), !!ownCheck?.operation &&
620
+ ['queued', 'dispatched', 'accepted'].includes(ownCheck.operation.status));
621
+ if (frozen.hash !== record.retryInputHash)
622
+ throw new Error('level_editor_retry_inputs_changed');
623
+ }
624
+ catch (error) {
625
+ await change('failed', error instanceof Error && /^level_editor_[a-z_]+$/.test(error.message)
626
+ ? error.message
627
+ : 'level_editor_retry_inputs_changed');
628
+ continue;
629
+ }
630
+ }
631
+ try {
632
+ const response = await fetch(platformEndpoint(state.config.publicBaseUrl, '/runtime/internal/level-editor-requests/dispatch'), {
633
+ method: 'POST',
634
+ redirect: 'error',
635
+ signal: AbortSignal.any([
636
+ AbortSignal.timeout(15_000),
637
+ loops.get(state).controller.signal,
638
+ ]),
639
+ headers: {
640
+ authorization: `Bearer ${state.config.controlToken}`,
641
+ 'content-type': 'application/json',
642
+ 'playcraft-env': state.config.environment,
643
+ },
644
+ body: JSON.stringify({
645
+ runtimeId: state.config.runtimeId,
646
+ generation: state.config.generation,
647
+ runtimeEpoch: state.runtimeEpoch,
648
+ requestId: record.requestId,
649
+ taskId: record.taskId,
650
+ workspaceId: record.workspaceId,
651
+ directoryName: record.directoryName,
652
+ sourceOperationId: record.sourceOperationId,
653
+ sourceSessionId: record.sourceSessionId,
654
+ sourceThreadId: record.sourceThreadId,
655
+ sourceTurnId: record.sourceTurnId,
656
+ toolCallId: record.toolCallId,
657
+ step: record.stage,
658
+ action: record.action,
659
+ goal: record.goal,
660
+ ...(record.editorId ? { editorId: record.editorId } : {}),
661
+ ...(record.attemptId
662
+ ? {
663
+ attemptId: record.attemptId,
664
+ retryOfRequestId: record.retryOfRequestId,
665
+ retryStage: record.retryStage,
666
+ retryInputHash: record.retryInputHash,
667
+ expectedSourceFingerprint: record.expectedSourceFingerprint,
668
+ expectedDefinitionHash: record.expectedDefinitionHash,
669
+ expectedDocumentHashes: record.expectedDocumentHashes,
670
+ }
671
+ : {}),
672
+ }),
673
+ });
674
+ const result = (await response.json().catch(() => ({})));
675
+ if (!loops.get(state))
676
+ return;
677
+ if (response.ok && typeof result.operationId === 'string') {
678
+ if (result.status === 'uncertain')
679
+ await change('uncertain', typeof result.error === 'string' ? result.error : 'level_editor_stage_uncertain');
680
+ else if (result.status === 'failed' || result.status === 'interrupted')
681
+ await change('failed', typeof result.error === 'string' ? result.error : 'level_editor_stage_failed');
682
+ else if (record.stage === 'generate' || record.stage === 'verify') {
683
+ const local = localStageOperation(state, record, record.stage);
684
+ if (local.error)
685
+ await change('failed', local.error, { operationId: result.operationId });
686
+ else
687
+ await change('dispatched', undefined, record.stage === 'generate'
688
+ ? {
689
+ stage: 'verify',
690
+ taskOperationId: local.operation?.operationId,
691
+ operationId: result.operationId,
692
+ }
693
+ : {
694
+ stage: 'preview',
695
+ verifyOperationId: local.operation?.operationId,
696
+ operationId: result.operationId,
697
+ });
698
+ }
699
+ else if (record.stage === 'preview') {
700
+ if (result.status === 'completed')
701
+ await change('dispatched', undefined, {
702
+ stage: 'complete',
703
+ previewOperationId: result.operationId,
704
+ operationId: result.operationId,
705
+ });
706
+ // Repeat the exact preview request while its authority record is pending.
707
+ }
708
+ }
709
+ else if (result.code === 'uncertain_operation')
710
+ await change('uncertain', 'level_editor_stage_uncertain');
711
+ else if (result.code === 'level_editor_retry_inputs_changed')
712
+ await change('failed', 'level_editor_retry_inputs_changed');
713
+ else if (response.status !== 409 && response.status < 500)
714
+ await change('failed', typeof result.code === 'string' ? result.code : 'level_editor_dispatch_denied');
715
+ else if (response.status >= 500 && record.error !== 'level_editor_control_unavailable')
716
+ await change('queued', 'level_editor_control_unavailable');
717
+ // A timeout, server error or lost reply retries the same task identity through control admission.
718
+ }
719
+ catch {
720
+ if (loops.has(state) && record.error !== 'level_editor_control_unavailable')
721
+ await change('queued', 'level_editor_control_unavailable');
722
+ }
723
+ }
724
+ }