@playcraft/cli 0.1.56-beta → 0.1.58-beta

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 +6 -4
  2. package/dist/commands/tools-3d.js +15 -0
  3. package/dist/commands/tools-generation.js +3 -2
  4. package/dist/commands/workspace.js +43 -0
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace/messages.js +10 -0
  11. package/dist/workspace-runtime/batch/deliver.js +140 -80
  12. package/dist/workspace-runtime/batch/freeze.js +18 -6
  13. package/dist/workspace-runtime/batch/generate.js +68 -64
  14. package/dist/workspace-runtime/batch/prepare.js +109 -137
  15. package/dist/workspace-runtime/batch/records.js +4 -0
  16. package/dist/workspace-runtime/batch/routes.js +10 -2
  17. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  18. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  19. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  20. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  21. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  22. package/dist/workspace-runtime/codex/app-server.js +1 -0
  23. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  24. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  25. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  26. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  27. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  28. package/dist/workspace-runtime/persistence/journal.js +34 -0
  29. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  30. package/dist/workspace-runtime/previews/output.js +2 -0
  31. package/dist/workspace-runtime/previews/publish.js +20 -5
  32. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  33. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  34. package/dist/workspace-runtime/serve.js +19 -32
  35. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  36. package/dist/workspace-runtime/server/directory-access.js +414 -0
  37. package/dist/workspace-runtime/server/dispatch.js +61 -23
  38. package/dist/workspace-runtime/server/endpoints.js +1 -0
  39. package/dist/workspace-runtime/server/http.js +52 -10
  40. package/dist/workspace-runtime/server/operation-record.js +2 -0
  41. package/dist/workspace-runtime/server/terminal-host.js +43 -8
  42. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  43. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  44. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  45. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  46. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  47. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  48. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  49. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  50. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  51. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  52. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  53. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  54. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  55. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  56. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  57. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  58. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  59. package/dist/workspace-runtime/workspaces/dependencies.js +105 -71
  60. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  61. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  62. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  63. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  64. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  65. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  66. package/dist/workspace-runtime/workspaces/files.js +4 -0
  67. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  68. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  69. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  70. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  71. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  72. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  73. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  74. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  75. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  76. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  77. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  78. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  79. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  80. package/dist/workspace-runtime/workspaces/platform-export.js +190 -0
  81. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  82. package/dist/workspace-runtime/workspaces/registry.js +11 -0
  83. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  84. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  85. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  86. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  87. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  88. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  89. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  90. package/package.json +5 -4
  91. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  92. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  93. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
  94. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  95. package/templates/preview-runtime/v1/README.md +4 -0
  96. package/templates/preview-runtime/v1/manifest.json +6 -6
  97. package/templates/preview-runtime/v1/runtime.mjs +368 -22
  98. package/templates/preview-runtime/v1/types/adapters.d.ts +4 -1
  99. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  100. package/templates/preview-runtime/v1/types/protocol.d.ts +19 -0
  101. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -0,0 +1,414 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { isTurnControl, takesWorkspaceDirectory, takesExecutionSlot, transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { listMembers, readSnapshot } from '../batch/records.js';
5
+ const context = new AsyncLocalStorage();
6
+ const gates = new WeakMap();
7
+ const instances = new WeakMap();
8
+ const executing = new WeakMap();
9
+ const freezing = new WeakMap();
10
+ const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
11
+ const EXCLUSIVE_OPERATIONS = new Set(['preview.publish', 'delivery.build', 'workspace.save', 'directory.create',
12
+ 'directory.rename', 'directory.delete', 'workspace.references.import', 'skills.update', 'parameter.verify',
13
+ 'checkpoint.create', 'checkpoint.restore']);
14
+ export function managedDirectoryScope(state, value) {
15
+ if (state.config.workspaceMode !== 'multi')
16
+ return { directoryName: 'basic' };
17
+ const [workspaceId, directoryName = 'basic'] = value.split(':');
18
+ return { workspaceId, directoryName };
19
+ }
20
+ function overlaps(a, b) {
21
+ return (a.workspaceId ?? '') === (b.workspaceId ?? '') &&
22
+ ((a.directoryName ?? 'basic') === (b.directoryName ?? 'basic') || a.directoryName === 'root' || b.directoryName === 'root');
23
+ }
24
+ function covers(parent, child) {
25
+ return (parent.workspaceId ?? '') === (child.workspaceId ?? '') &&
26
+ (parent.directoryName === 'root' || (parent.directoryName ?? 'basic') === (child.directoryName ?? 'basic'));
27
+ }
28
+ export function operationDirectoryMode(operation) {
29
+ return operation.directoryAccessMode ?? (EXCLUSIVE_OPERATIONS.has(operation.kind) || operation.kind.startsWith('git.') ? 'exclusive' : 'shared');
30
+ }
31
+ function activeSessionOwner(state, gate, scope, sessionId, except) {
32
+ if (!sessionId)
33
+ return undefined;
34
+ const operation = state.journal.listOperations().map(item => item.operation).find(item => item.sessionId === sessionId &&
35
+ item.operationId !== except && ['session.turn', 'session.review', 'session.compact'].includes(item.kind) &&
36
+ ['dispatched', 'accepted', 'uncertain'].includes(item.status) && covers(item, scope) && gate.has(`operation:${item.operationId}`));
37
+ return operation ? `operation:${operation.operationId}` : undefined;
38
+ }
39
+ /** A directory has many ordinary writers, or one build, freeze, maintenance job or member Run. */
40
+ export class DirectoryAccessGate {
41
+ normalize;
42
+ held = new Map();
43
+ pending = [];
44
+ constructor(normalize = claim => claim) {
45
+ this.normalize = normalize;
46
+ }
47
+ claim(input) {
48
+ const claim = this.normalize(input);
49
+ const held = this.held.get(claim.owner);
50
+ if (held) {
51
+ if (!covers(held, claim) || held.mode === 'shared' && claim.mode === 'exclusive')
52
+ return Promise.reject(new Error('directory_access_upgrade_forbidden'));
53
+ return Promise.resolve();
54
+ }
55
+ const existing = this.pending.find(item => item.owner === claim.owner);
56
+ if (existing) {
57
+ if (!covers(existing, claim) || existing.mode === 'shared' && claim.mode === 'exclusive')
58
+ return Promise.reject(new Error('directory_access_upgrade_forbidden'));
59
+ if (existing.paused && !claim.paused) {
60
+ existing.paused = false;
61
+ this.drain();
62
+ }
63
+ return existing.ready;
64
+ }
65
+ let resolve;
66
+ let reject;
67
+ const ready = new Promise((done, fail) => { resolve = done; reject = fail; });
68
+ this.pending.push({ ...claim, ready, resolve, reject });
69
+ this.drain();
70
+ return ready;
71
+ }
72
+ restore(claim) { this.held.set(claim.owner, this.normalize(claim)); }
73
+ has(owner) { return this.held.has(owner); }
74
+ permits(owner, scope, mode) {
75
+ const held = this.held.get(owner);
76
+ return Boolean(held && covers(held, this.normalize({ ...scope, owner, mode })) && (held.mode === 'exclusive' || mode === 'shared'));
77
+ }
78
+ tryClaim(claim) {
79
+ const ready = this.claim(claim);
80
+ if (this.has(claim.owner))
81
+ return true;
82
+ void ready.catch(() => undefined);
83
+ this.release(claim.owner);
84
+ return false;
85
+ }
86
+ adopt(owner, parentOwner) {
87
+ const child = this.pending.find(item => item.owner === owner);
88
+ const parent = this.held.get(parentOwner);
89
+ if (!child || !parent || parent.mode !== 'exclusive' || !covers(parent, child))
90
+ return;
91
+ child.parentOwner = parentOwner;
92
+ this.drain();
93
+ }
94
+ cancelPending(owner) {
95
+ if (this.held.has(owner) || !this.pending.some(item => item.owner === owner))
96
+ return false;
97
+ this.release(owner);
98
+ return true;
99
+ }
100
+ release(owner) {
101
+ this.held.delete(owner);
102
+ const pending = this.pending.find(item => item.owner === owner);
103
+ if (pending) {
104
+ this.pending.splice(this.pending.indexOf(pending), 1);
105
+ pending.reject(new Error('directory_access_cancelled'));
106
+ }
107
+ this.drain();
108
+ }
109
+ owners() { return [...this.held.keys(), ...this.pending.map(item => item.owner)]; }
110
+ drain() {
111
+ const earlier = [];
112
+ for (const item of [...this.pending]) {
113
+ if (item.paused) {
114
+ earlier.push(item);
115
+ continue;
116
+ }
117
+ const parent = item.parentOwner ? this.held.get(item.parentOwner) : undefined;
118
+ const family = parent && covers(parent, item) && (parent.mode === 'exclusive' || item.mode === 'shared');
119
+ const conflict = [...this.held.values()].some(held => !(family && (held.owner === parent.owner || held.parentOwner === parent.owner)) && overlaps(item, held) &&
120
+ (item.mode === 'exclusive' || held.mode === 'exclusive' || Boolean(item.sessionId && held.sessionId === item.sessionId)));
121
+ const queuedWriter = !family && earlier.some(prior => overlaps(item, prior) && (item.mode === 'exclusive' || prior.mode === 'exclusive'));
122
+ if (conflict || queuedWriter) {
123
+ earlier.push(item);
124
+ continue;
125
+ }
126
+ this.pending.splice(this.pending.indexOf(item), 1);
127
+ this.held.set(item.owner, item);
128
+ item.resolve();
129
+ }
130
+ }
131
+ }
132
+ async function gateFor(state) {
133
+ let pending = gates.get(state);
134
+ if (!pending) {
135
+ pending = (async () => {
136
+ const gate = new DirectoryAccessGate(claim => state.config.workspaceMode === 'multi'
137
+ ? claim : { ...claim, workspaceId: undefined, directoryName: 'basic' });
138
+ instances.set(state, gate);
139
+ const members = await listMembers(state);
140
+ for (const member of members)
141
+ if (member.holding)
142
+ gate.restore({ ...member, owner: `run:${member.runId}`, mode: 'exclusive' });
143
+ for (const { operation } of state.journal.listOperations()) {
144
+ if (!takesWorkspaceDirectory(operation.kind) || isTurnControl(operation.kind) ||
145
+ !['dispatched', 'accepted', 'uncertain'].includes(operation.status) ||
146
+ members.some(member => member.holding && (member.operationId === operation.operationId || Boolean(member.sessionId && member.sessionId === operation.sessionId))))
147
+ continue;
148
+ gate.restore({ ...operation, owner: `operation:${operation.operationId}`, mode: operationDirectoryMode(operation) });
149
+ }
150
+ for (const terminal of state.journal.listTerminals())
151
+ if (terminal.status !== 'destroyed')
152
+ gate.restore({ ...terminal, owner: `terminal:${terminal.terminalId}`, mode: 'shared' });
153
+ for (const writer of state.journal.listManagedWrites())
154
+ if (!writer.stoppedAt) {
155
+ gate.restore({ ...managedDirectoryScope(state, writer.workspaceId), owner: `managed:${writer.writeId}`, mode: 'shared' });
156
+ }
157
+ const waiting = [];
158
+ for (const barrier of state.journal.directoryBarriers()) {
159
+ const snapshot = barrier.owner.startsWith('freeze:')
160
+ ? await readSnapshot(state, barrier.owner.slice('freeze:'.length)).catch(() => undefined) : undefined;
161
+ if (snapshot?.sourceWorkspaceId === barrier.workspaceId && snapshot.sourceDirectoryName === barrier.directoryName) {
162
+ await state.journal.deleteDirectoryBarrier(barrier.owner);
163
+ continue;
164
+ }
165
+ if (barrier.phase !== 'pending') {
166
+ gate.restore({ ...barrier, mode: 'exclusive' });
167
+ continue;
168
+ }
169
+ if (barrier.deadline && Date.parse(barrier.deadline) <= Date.now()) {
170
+ await state.journal.deleteDirectoryBarrier(barrier.owner);
171
+ continue;
172
+ }
173
+ waiting.push({ claim: { ...barrier, mode: 'exclusive', paused: true }, queuedAt: barrier.queuedAt ?? '' });
174
+ const timeout = setTimeout(() => {
175
+ if (gate.cancelPending(barrier.owner))
176
+ void state.journal.deleteDirectoryBarrier(barrier.owner).catch(() => undefined);
177
+ }, Math.max(0, Date.parse(barrier.deadline ?? new Date(Date.now() + 60_000).toISOString()) - Date.now()));
178
+ timeout.unref?.();
179
+ }
180
+ for (const pending of state.journal.deferredDirectoryOperations()) {
181
+ const operation = state.journal.getOperationById(pending.operationId);
182
+ if (operation?.status !== 'queued')
183
+ continue;
184
+ waiting.push({ claim: { ...operation, owner: `operation:${operation.operationId}`, mode: operationDirectoryMode(operation),
185
+ ...(operation.kind.startsWith('session.') ? { sessionId: operation.sessionId } : {}) }, queuedAt: pending.queuedAt ?? operation.createdAt });
186
+ }
187
+ for (const item of waiting.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt)))
188
+ void gate.claim(item.claim).catch(() => undefined);
189
+ state.journal.onChange(() => reconcileDirectoryAccess(state));
190
+ return gate;
191
+ })();
192
+ gates.set(state, pending);
193
+ }
194
+ return pending;
195
+ }
196
+ /** The journal is the long-lived execution truth; only confirmed terminal writers release a lease. */
197
+ export function reconcileDirectoryAccess(state) {
198
+ const gate = instances.get(state);
199
+ if (!gate)
200
+ return;
201
+ for (const owner of gate.owners()) {
202
+ if (owner.startsWith('operation:')) {
203
+ const operation = state.journal.getOperationById(owner.slice('operation:'.length));
204
+ if (operation && TERMINAL.has(operation.status))
205
+ gate.release(owner);
206
+ }
207
+ else if (owner.startsWith('terminal:')) {
208
+ if (state.journal.getTerminal(owner.slice('terminal:'.length))?.status === 'destroyed')
209
+ gate.release(owner);
210
+ }
211
+ else if (owner.startsWith('managed:')) {
212
+ if (state.journal.listManagedWrites().find(writer => writer.writeId === owner.slice('managed:'.length))?.stoppedAt)
213
+ gate.release(owner);
214
+ }
215
+ }
216
+ }
217
+ export async function holdDirectoryAccess(state, scope, owner, mode = 'shared') {
218
+ const gate = await gateFor(state);
219
+ const inherited = context.getStore();
220
+ await gate.claim({ ...scope, owner, mode,
221
+ ...(inherited?.state === state && gate.permits(inherited.claim.owner, scope, mode) ? { parentOwner: inherited.claim.owner } : {}) });
222
+ return () => gate.release(owner);
223
+ }
224
+ /** A build may join its known installer that was queued behind the build's own exclusive lease. */
225
+ export async function adoptDirectoryAccess(state, owner) {
226
+ const inherited = context.getStore();
227
+ if (inherited?.state !== state || inherited.claim.mode !== 'exclusive')
228
+ return;
229
+ (await gateFor(state)).adopt(owner, inherited.claim.owner);
230
+ }
231
+ /** Cancelling a not-yet-spawned writer cannot release a process that is already running. */
232
+ export async function cancelPendingDirectoryAccess(state, owner) {
233
+ return (await gateFor(state)).cancelPending(owner);
234
+ }
235
+ export async function withDirectoryOwner(state, scope, owner, action) {
236
+ if (!(await gateFor(state)).permits(owner, scope, 'exclusive'))
237
+ throw new Error('directory_owner_not_held');
238
+ return context.run({ state, claim: { ...scope, owner, mode: 'exclusive' } }, action);
239
+ }
240
+ export async function withDirectoryWrite(state, scope, action) {
241
+ const inherited = context.getStore();
242
+ const gate = await gateFor(state);
243
+ if (inherited?.state === state && gate.permits(inherited.claim.owner, scope, scope.mode ?? 'shared'))
244
+ return action();
245
+ if (inherited?.state === state && scope.mode === 'exclusive' && gate.permits(inherited.claim.owner, scope, 'shared'))
246
+ throw new Error('directory_busy');
247
+ const parentOwner = scope.mode !== 'exclusive' ? activeSessionOwner(state, gate, scope, scope.sessionId) : undefined;
248
+ const claim = { ...scope, sessionId: undefined, owner: scope.owner ?? `write:${randomUUID()}`, mode: scope.mode ?? 'shared',
249
+ ...(parentOwner ? { parentOwner } : {}) };
250
+ if (scope.wait === false && !gate.tryClaim(claim))
251
+ throw new Error('directory_busy');
252
+ if (scope.wait !== false)
253
+ await gate.claim(claim);
254
+ const release = () => gate.release(claim.owner);
255
+ try {
256
+ return await context.run({ state, claim }, action);
257
+ }
258
+ finally {
259
+ release();
260
+ }
261
+ }
262
+ export async function withOperationDirectoryAccess(state, operation, action, deferred) {
263
+ if (!takesWorkspaceDirectory(operation.kind) || isTurnControl(operation.kind) || operation.kind === 'runtime.destroy')
264
+ return action();
265
+ const inherited = context.getStore();
266
+ const mode = operationDirectoryMode(operation);
267
+ const gate = await gateFor(state);
268
+ if (inherited?.state === state && gate.permits(inherited.claim.owner, operation, mode))
269
+ return action();
270
+ const active = executing.get(state) ?? new Set();
271
+ executing.set(state, active);
272
+ if (active.has(operation.operationId))
273
+ return state.journal.getOperationById(operation.operationId) ?? operation;
274
+ active.add(operation.operationId);
275
+ const parentOwner = !takesExecutionSlot(operation.kind) && mode === 'shared'
276
+ ? activeSessionOwner(state, gate, operation, operation.sessionId, operation.operationId) : undefined;
277
+ const claim = { ...operation, owner: `operation:${operation.operationId}`, mode: operationDirectoryMode(operation),
278
+ ...(operation.kind.startsWith('session.') ? { sessionId: operation.sessionId } : {}),
279
+ ...(parentOwner ? { parentOwner } : {}) };
280
+ if (inherited?.state === state && mode === 'shared' && gate.permits(inherited.claim.owner, operation, mode))
281
+ claim.parentOwner = inherited.claim.owner;
282
+ const ready = gate.claim(claim);
283
+ const run = async () => {
284
+ try {
285
+ await ready;
286
+ const current = state.journal.getOperationById(operation.operationId);
287
+ if (current && TERMINAL.has(current.status))
288
+ return current;
289
+ return await context.run({ state, claim }, action);
290
+ }
291
+ finally {
292
+ active.delete(operation.operationId);
293
+ const current = state.journal.getOperationById(operation.operationId);
294
+ if (!current || current.status === 'queued' || TERMINAL.has(current.status))
295
+ gate.release(claim.owner);
296
+ if (deferred && current?.status !== 'queued')
297
+ await state.journal.deleteDeferredDirectoryOperation(operation.operationId);
298
+ }
299
+ };
300
+ if (gate.has(claim.owner))
301
+ return run();
302
+ if (!deferred)
303
+ return run();
304
+ try {
305
+ await state.journal.deferDirectoryOperation(deferred);
306
+ }
307
+ catch (error) {
308
+ active.delete(operation.operationId);
309
+ void ready.catch(() => undefined);
310
+ gate.release(claim.owner);
311
+ throw error;
312
+ }
313
+ void run().catch(async (error) => {
314
+ const current = state.journal.getOperationById(operation.operationId);
315
+ if (!current || TERMINAL.has(current.status))
316
+ return;
317
+ await state.journal.putOperation(deferred.scopeKey, transitionOperation(current, 'uncertain', {
318
+ error: error instanceof Error && error.message === 'directory_access_cancelled' ? 'cancelled' : 'native_receipt_lost',
319
+ }));
320
+ }).catch(() => undefined);
321
+ return state.journal.getOperationById(operation.operationId) ?? operation;
322
+ }
323
+ /** A queued command is replayed only if it never started; accepted or uncertain work is only observed. */
324
+ export async function restoreDeferredDirectoryOperations(state, execute) {
325
+ for (const pending of state.journal.deferredDirectoryOperations()) {
326
+ const operation = state.journal.getOperationById(pending.operationId);
327
+ if (operation?.status === 'queued') {
328
+ if (operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch) {
329
+ await state.journal.putOperation(pending.scopeKey, transitionOperation(operation, 'failed', { error: 'generation_mismatch' }));
330
+ continue;
331
+ }
332
+ void execute(pending.scopeKey, operation, pending.payload, pending.recovery).catch(() => undefined);
333
+ }
334
+ else
335
+ await state.journal.deleteDeferredDirectoryOperation(pending.operationId);
336
+ }
337
+ }
338
+ export async function claimBatchDirectory(state, member) {
339
+ await holdDirectoryAccess(state, member, `run:${member.runId}`, 'exclusive');
340
+ }
341
+ export function releaseBatchDirectory(state, member) {
342
+ if (!member.holding && member.writersStopped)
343
+ instances.get(state)?.release(`run:${member.runId}`);
344
+ }
345
+ export async function withDirectoryFreeze(state, scope, action, quiesce, options) {
346
+ const pending = freezing.get(state) ?? new Map();
347
+ freezing.set(state, pending);
348
+ const existing = pending.get(scope.batchId);
349
+ if (existing) {
350
+ if (existing.scope.workspaceId !== scope.workspaceId || (existing.scope.directoryName ?? 'basic') !== (scope.directoryName ?? 'basic'))
351
+ throw new Error('batch_source_identity_mismatch');
352
+ return existing.task;
353
+ }
354
+ const task = performDirectoryFreeze(state, scope, action, quiesce, options);
355
+ pending.set(scope.batchId, { scope, task });
356
+ try {
357
+ return await task;
358
+ }
359
+ finally {
360
+ if (pending.get(scope.batchId)?.task === task)
361
+ pending.delete(scope.batchId);
362
+ }
363
+ }
364
+ async function performDirectoryFreeze(state, scope, action, quiesce, options) {
365
+ const deadline = options?.deadline ?? new Date(Date.now() + 60_000).toISOString();
366
+ const signal = AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(Math.max(0, Date.parse(deadline) - Date.now()))]);
367
+ if (signal.aborted)
368
+ throw new Error('directory_access_cancelled');
369
+ const owner = `freeze:${scope.batchId}`;
370
+ const gate = await gateFor(state);
371
+ const previous = state.journal.directoryBarriers().find(barrier => barrier.owner === owner);
372
+ if (previous && (previous.workspaceId !== scope.workspaceId || previous.directoryName !== (scope.directoryName ?? 'basic')))
373
+ throw new Error('batch_source_identity_mismatch');
374
+ // A restored capture may still have writers. Retrying its publication is not a new admission,
375
+ // and a missing candidate or a lost request cannot provide the old capture's stop proof.
376
+ const restoredCapture = Boolean(previous && previous.phase !== 'pending');
377
+ const claimed = gate.claim({ ...scope, owner, mode: 'exclusive' });
378
+ void claimed.catch(() => undefined);
379
+ let started = restoredCapture;
380
+ const cancel = () => {
381
+ if (started)
382
+ return;
383
+ gate.release(owner);
384
+ void state.journal.deleteDirectoryBarrier(owner).catch(() => undefined);
385
+ };
386
+ signal.addEventListener('abort', cancel, { once: true });
387
+ try {
388
+ if (!restoredCapture)
389
+ await state.journal.putDirectoryBarrier({ owner, workspaceId: scope.workspaceId,
390
+ directoryName: scope.directoryName ?? 'basic', runtimeEpoch: state.runtimeEpoch, phase: 'pending', deadline });
391
+ await quiesce?.();
392
+ await claimed;
393
+ if (signal.aborted)
394
+ throw new Error('directory_access_cancelled');
395
+ started = true;
396
+ if (!restoredCapture)
397
+ await state.journal.putDirectoryBarrier({ owner, workspaceId: scope.workspaceId,
398
+ directoryName: scope.directoryName ?? 'basic', runtimeEpoch: state.runtimeEpoch, phase: 'capturing', deadline });
399
+ return await withDirectoryOwner(state, scope, owner, action);
400
+ }
401
+ finally {
402
+ signal.removeEventListener('abort', cancel);
403
+ const snapshot = restoredCapture ? await readSnapshot(state, scope.batchId).catch(() => undefined) : undefined;
404
+ if (!restoredCapture || (snapshot?.sourceWorkspaceId === scope.workspaceId && snapshot.sourceDirectoryName === (scope.directoryName ?? 'basic'))) {
405
+ await state.journal.deleteDirectoryBarrier(owner);
406
+ gate.release(owner);
407
+ }
408
+ }
409
+ }
410
+ export async function clearDirectoryFreeze(state, batchId) {
411
+ const owner = `freeze:${batchId}`;
412
+ await state.journal.deleteDirectoryBarrier(owner);
413
+ instances.get(state)?.release(owner);
414
+ }
@@ -2,6 +2,7 @@ import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { abandonTurnChanges, beginTurnChanges, finishTurnChanges } from '../workspaces/turn-changes.js';
4
4
  import { shareWorkspaceGit } from '../workspaces/shared-git.js';
5
+ import { holdDirectoryAccess, withOperationDirectoryAccess } from './directory-access.js';
5
6
  import { join } from 'node:path';
6
7
  import { z } from 'zod';
7
8
  import { fileWritePayloadSchema, fileBinaryWritePayloadSchema, directoryCreatePayloadSchema, directoryRenamePayloadSchema, directoryDeletePayloadSchema, directoryMutationReceiptSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, gitPullPayloadSchema, gitMergeAbortPayloadSchema, parameterPatchPayloadSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, workspacePermissionModeSchema, workspaceComposerSelectionSchema, workspaceReviewPayloadSchema, workspaceCompactPayloadSchema, workspaceSteerPayloadSchema, workspaceApprovalResponseSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, recoverUncertainOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, workspaceRemixReferenceImportPayloadSchema, deliveryBuildPayloadSchema, deliveryCancelPayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -11,6 +12,7 @@ import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
11
12
  import { resolveWorkspaceAttachments } from '../workspaces/attachments.js';
12
13
  import { importWorkspaceRemixReference, RemixReferenceError } from '../workspaces/remix-references.js';
13
14
  import { ComposerContextError, resolveComposerContext } from '../workspaces/composer-context.js';
15
+ import { ensureParameterInitializationSkill } from '../workspaces/parameter-initialization-skill.js';
14
16
  import { readWorkspaceSkillSnapshot, readWorkspaceSkills } from '../workspaces/skills.js';
15
17
  import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from '../workspaces/skill-artifact-loader.js';
16
18
  import { enableLocalProjectSkill, syncLocalProjectSkills } from '../../project-skills/local-store.js';
@@ -28,7 +30,7 @@ import { ThemeAssetIndexError } from '../workspaces/theme-asset-index.js';
28
30
  import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
29
31
  import { prepareParameterAgentTask, finishParameterAgentTask, verifyParameterAgentTask, } from '../workspaces/parameter-agent.js';
30
32
  import { previewFailureCode, previewWorkspaceRoot, runPreviewPublish, trackPreviewPublish, } from '../previews/publish.js';
31
- import { spawnBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
33
+ import { startManagedBackgroundWriter, stopBackgroundWriter, } from '../processes/managed-writes.js';
32
34
  import { wakeSourceEventUploader } from './source-uploader.js';
33
35
  import { batchDirectoryHeld } from '../batch/occupancy.js';
34
36
  import { BinaryWorkspaceWriteError, observeWorkspaceBinaryFile, writeWorkspaceBinaryFile } from '../workspaces/binary-write.js';
@@ -90,6 +92,8 @@ export async function recoverInterruptedParameterVerifications(journal) {
90
92
  for (const { scopeKey, operation } of journal.listOperations()) {
91
93
  if (operation.kind !== 'parameter.verify' || !['queued', 'dispatched', 'accepted', 'uncertain'].includes(operation.status))
92
94
  continue;
95
+ if (operation.status === 'queued' && journal.deferredDirectoryOperations().some(item => item.operationId === operation.operationId))
96
+ continue;
93
97
  await journal.putOperation(scopeKey, transitionOperation(operation, 'interrupted', { error: 'parameter_verification_interrupted' }));
94
98
  }
95
99
  }
@@ -105,12 +109,21 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
105
109
  if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed()) {
106
110
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: state.journal.destroyed() ? 'runtime_destroyed' : 'stale_lease' }));
107
111
  }
108
- return executeScopedOperation(state, scopeKey, operation, payload, recovery);
112
+ if (operation.kind === 'session.turn' && !recovery && operation.sessionId &&
113
+ state.journal.getSession(operation.sessionId)?.recoverability === 'history_only')
114
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'native_not_recoverable' }));
115
+ return withOperationDirectoryAccess(state, operation, () => {
116
+ if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence || state.journal.destroyed())
117
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: state.journal.destroyed() ? 'runtime_destroyed' : 'stale_lease' }));
118
+ return executeScopedOperation(state, scopeKey, operation, payload, recovery);
119
+ }, { operationId: operation.operationId, scopeKey, payload, ...(recovery ? { recovery } : {}) });
109
120
  };
110
121
  if (['runtime.destroy', 'session.interrupt', 'session.approval.respond', 'session.steer', 'preview.publish', 'delivery.build', 'delivery.cancel'].includes(operation.kind)) {
111
122
  return run();
112
123
  }
113
- return state.journal.exclusive(`workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
124
+ return state.journal.exclusive(operation.kind.startsWith('session.') && operation.sessionId
125
+ ? `session-execution:${operation.sessionId}`
126
+ : `workspace:${revisionScope(state, operation.workspaceId, operation.directoryName) ?? 'single'}`, run);
114
127
  }
115
128
  async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
116
129
  let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
@@ -381,12 +394,12 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
381
394
  }
382
395
  found = rebound;
383
396
  }
384
- const binding = found;
385
397
  // Also covers a new thread whose first turn failed before starting: the next turn still carries it.
386
- const context = recovery && binding?.recoveryContextPending ? recovery.context : '';
387
- if (!binding) {
398
+ const context = recovery && found?.recoveryContextPending ? recovery.context : '';
399
+ if (!found) {
388
400
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'unknown_session' }));
389
401
  }
402
+ let binding = found;
390
403
  if ((binding.directoryName ?? 'basic') !== (operation.directoryName ?? 'basic')) {
391
404
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'context_directory_mismatch' }));
392
405
  }
@@ -398,17 +411,30 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
398
411
  }
399
412
  const root = await sessionWorkspaceRoot(state, operation.workspaceId, operation.directoryName);
400
413
  let composer;
414
+ let initializationSkill;
401
415
  try {
416
+ let turnContext = selection.data.context;
417
+ if (parameterTask.data?.action === 'initialize') {
418
+ const selectedSkill = await ensureParameterInitializationSkill(root);
419
+ initializationSkill = { ...selectedSkill,
420
+ path: join(root, '.playcraft/skills', selectedSkill.lockHash, selectedSkill.entryKey, 'SKILL.md') };
421
+ turnContext = { ...turnContext, files: turnContext?.files ?? [], skills: [
422
+ ...(turnContext?.skills ?? []).filter(skill => skill.entryKey !== selectedSkill.entryKey), selectedSkill,
423
+ ] };
424
+ }
402
425
  composer = await resolveComposerContext({
403
426
  root,
404
- directoryName: binding.directoryName ?? 'basic', context: selection.data.context,
427
+ directoryName: binding.directoryName ?? 'basic', context: turnContext,
405
428
  skillCache: join(state.config.stateDir, 'skill-artifacts'),
406
429
  loadSkillArtifact: entry => loadWorkspaceSkillArtifact(state, operation.workspaceId ?? 'workspace', entry),
407
430
  });
408
431
  }
409
432
  catch (error) {
410
433
  return persist(state, scopeKey, transitionOperation(operation, 'failed', {
411
- error: error instanceof ComposerContextError ? error.message : 'invalid_composer_context',
434
+ error: error instanceof ComposerContextError ? error.message : parameterTask.data?.action === 'initialize'
435
+ ? error instanceof Error && /^(?:workspace_skills_|skills_revision_conflict$)/.test(error.message)
436
+ ? error.message : parameterAgentError(error)
437
+ : 'invalid_composer_context',
412
438
  }));
413
439
  }
414
440
  let attachments;
@@ -446,7 +472,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
446
472
  let parameterInstructions = '';
447
473
  if (parameterTask.data) {
448
474
  try {
449
- parameterInstructions = (await prepareParameterAgentTask(state, operation, parameterTask.data, root)).instructions;
475
+ parameterInstructions = (await prepareParameterAgentTask(state, operation, parameterTask.data, root, { initializationSkill })).instructions;
450
476
  }
451
477
  catch (error) {
452
478
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: parameterAgentError(error) }));
@@ -458,7 +484,7 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
458
484
  });
459
485
  let result;
460
486
  try {
461
- result = await native(state).startTurn({
487
+ const startTurn = () => native(state).startTurn({
462
488
  sessionId,
463
489
  threadId: binding.threadId,
464
490
  text: composer.append(batchContext + context + String(body.text ?? '') + attachmentText, [engineeringBatchContext, parameterInstructions].filter(Boolean).join('\n\n')),
@@ -490,6 +516,18 @@ async function executeTurn(state, scopeKey, operation, payload, recovery) {
490
516
  await state.journal.putSession({ ...binding, modelConfigId, reasoningEffort: selectedEffort, permissionMode: selectedMode, turnStatus: 'starting' });
491
517
  },
492
518
  });
519
+ result = await startTurn();
520
+ // The native runtime cannot persist an empty thread before its first turn. Adding the
521
+ // selected skill can therefore require replacing that empty thread, before any model work.
522
+ if (initializationSkill && result.outcome === 'failed' && result.error === 'workspace_skills_changed'
523
+ && !(state.journal.getOperation(scopeKey) ?? operation).nativeRequestId) {
524
+ const started = await native(state).startThread({ sessionId, cwd: root,
525
+ modelConfigId: selected.data ?? binding.modelConfigId, reasoningEffort: requestedEffort, permissionMode: requestedMode });
526
+ binding = { ...binding, threadId: started.threadId, modelConfigId: started.modelConfigId ?? binding.modelConfigId,
527
+ reasoningEffort: started.reasoningEffort ?? requestedEffort, permissionMode: started.permissionMode ?? requestedMode };
528
+ await state.journal.putSession(binding);
529
+ result = await startTurn();
530
+ }
493
531
  }
494
532
  catch (error) {
495
533
  if (!parameterTask.data)
@@ -1113,7 +1151,7 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
1113
1151
  : 'invalid_workspace_path';
1114
1152
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error }));
1115
1153
  }
1116
- const workspaceId = operation.workspaceId ?? 'workspace';
1154
+ const workspaceId = revisionScope(state, operation.workspaceId, operation.directoryName) ?? operation.workspaceId ?? 'workspace';
1117
1155
  const writer = parsed.data.writer ?? 'manual';
1118
1156
  if (parsed.data.release) {
1119
1157
  const lease = state.journal.getManagedWrite(parsed.data.writeId);
@@ -1147,17 +1185,14 @@ async function executeFileWrite(state, scopeKey, operation, payload) {
1147
1185
  contentHash: written.contentHash,
1148
1186
  }, revisionScope(state, operation.workspaceId, operation.directoryName));
1149
1187
  if (parsed.data.hold && parsed.data.writeId) {
1150
- let pid;
1188
+ const record = { writeId: parsed.data.writeId, workspaceId, source: writer, startedAt: new Date().toISOString() };
1151
1189
  if (writer === 'background') {
1152
- pid = spawnBackgroundWriter(await resolveWorkspaceFile(root, parsed.data.path));
1190
+ await startManagedBackgroundWriter(state, record, await resolveWorkspaceFile(root, parsed.data.path));
1191
+ }
1192
+ else {
1193
+ await holdDirectoryAccess(state, operation, `managed:${parsed.data.writeId}`, 'shared');
1194
+ await state.journal.putManagedWrite(record);
1153
1195
  }
1154
- await state.journal.putManagedWrite({
1155
- writeId: parsed.data.writeId,
1156
- workspaceId,
1157
- source: writer,
1158
- startedAt: new Date().toISOString(),
1159
- pid,
1160
- });
1161
1196
  }
1162
1197
  const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted', { expectedRevision: revision }));
1163
1198
  if (artifactFormat(parsed.data.path) && operation.workspaceId) {
@@ -1349,7 +1384,9 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
1349
1384
  if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
1350
1385
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'parameter_data_missing' }));
1351
1386
  }
1352
- const dataPath = document.dataPath;
1387
+ const dataPath = 'documentId' in parsed.data ? document.runtimeI18n?.themePath : document.dataPath;
1388
+ if (!dataPath)
1389
+ return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'parameter_i18n_global_invalid' }));
1353
1390
  return withObservedFile(state, operation.workspaceId, dataPath, async ({ revision: currentRevision, file }) => {
1354
1391
  if (parsed.data.expectedRevision !== currentRevision) {
1355
1392
  return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'revision_conflict' }));
@@ -1386,6 +1423,7 @@ async function executeParameterPatch(state, scopeKey, operation, payload) {
1386
1423
  revision,
1387
1424
  contentHash: written.contentHash,
1388
1425
  size: written.size,
1426
+ ...('documentId' in parsed.data ? { documentId: parsed.data.documentId } : {}),
1389
1427
  ...('changes' in parsed.data
1390
1428
  ? { fieldPaths: parsed.data.changes.map((change) => change.fieldPath) }
1391
1429
  : { fieldPath: parsed.data.fieldPath }),
@@ -1416,7 +1454,7 @@ async function executePreviewPublish(state, scopeKey, operation) {
1416
1454
  trackPreviewPublish(state, runPreviewPublish(state, accepted, root).then(async () => {
1417
1455
  await persist(state, scopeKey, transitionOperation(accepted, 'completed'));
1418
1456
  }, async (error) => {
1419
- await persist(state, scopeKey, transitionOperation(accepted, 'failed', { error: previewFailureCode(error) }));
1457
+ await persist(state, scopeKey, transitionOperation(accepted, previewFailureCode(error) === 'preview_process_unknown' ? 'uncertain' : 'failed', { error: previewFailureCode(error) }));
1420
1458
  }).catch(() => undefined));
1421
1459
  return accepted;
1422
1460
  }
@@ -1438,7 +1476,7 @@ async function executeDeliveryBuild(state, scopeKey, operation, payload) {
1438
1476
  }).then(async (result) => {
1439
1477
  const error = result.record.error
1440
1478
  ?? (result.record.status === 'succeeded' ? undefined : 'delivery_build_failed');
1441
- await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : 'failed', error ? { error } : {}));
1479
+ await persist(state, scopeKey, transitionOperation(accepted, result.record.status === 'succeeded' ? 'completed' : error === 'delivery_process_unknown' ? 'uncertain' : 'failed', error ? { error } : {}));
1442
1480
  }, async (error) => {
1443
1481
  await persist(state, scopeKey, transitionOperation(accepted, 'failed', {
1444
1482
  error: error instanceof DeliveryBuildError ? error.code : 'delivery_build_failed',
@@ -12,6 +12,7 @@ export async function applyRuntimeEndpointPatch(state, patch) {
12
12
  throw new Error('endpoint_config_path_missing');
13
13
  }
14
14
  const temporary = `${state.configPath}.${process.pid}.tmp`;
15
+ // private
15
16
  await writeFile(temporary, `${JSON.stringify(next)}\n`, { mode: 0o600 });
16
17
  await chmod(temporary, 0o600);
17
18
  await rename(temporary, state.configPath);