@playcraft/cli 0.1.57-beta → 0.1.59-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 (89) hide show
  1. package/dist/commands/workspace.js +43 -0
  2. package/dist/utils/agent-generation-provenance.js +1 -1
  3. package/dist/workspace/messages.js +10 -0
  4. package/dist/workspace-runtime/batch/deliver.js +140 -80
  5. package/dist/workspace-runtime/batch/freeze.js +18 -6
  6. package/dist/workspace-runtime/batch/generate.js +68 -64
  7. package/dist/workspace-runtime/batch/policy.js +71 -22
  8. package/dist/workspace-runtime/batch/prepare.js +108 -93
  9. package/dist/workspace-runtime/batch/records.js +4 -0
  10. package/dist/workspace-runtime/batch/routes.js +10 -2
  11. package/dist/workspace-runtime/batch/source-candidate.js +2 -0
  12. package/dist/workspace-runtime/batch/source-capture.js +1 -0
  13. package/dist/workspace-runtime/batch/source-failure.js +2 -0
  14. package/dist/workspace-runtime/batch/source-publish.js +3 -0
  15. package/dist/workspace-runtime/batch/writer-proof.js +37 -0
  16. package/dist/workspace-runtime/codex/app-server.js +278 -20
  17. package/dist/workspace-runtime/codex/config-toml.js +4 -0
  18. package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
  19. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  20. package/dist/workspace-runtime/codex/loopback.js +2 -0
  21. package/dist/workspace-runtime/codex/model-broker.js +7 -4
  22. package/dist/workspace-runtime/persistence/journal.js +34 -0
  23. package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
  24. package/dist/workspace-runtime/previews/output.js +2 -0
  25. package/dist/workspace-runtime/previews/publish.js +20 -5
  26. package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
  27. package/dist/workspace-runtime/processes/managed-writes.js +47 -5
  28. package/dist/workspace-runtime/serve.js +13 -3
  29. package/dist/workspace-runtime/server/accept-operation.js +22 -2
  30. package/dist/workspace-runtime/server/directory-access.js +414 -0
  31. package/dist/workspace-runtime/server/dispatch.js +74 -29
  32. package/dist/workspace-runtime/server/endpoints.js +1 -0
  33. package/dist/workspace-runtime/server/http.js +80 -10
  34. package/dist/workspace-runtime/server/operation-record.js +2 -0
  35. package/dist/workspace-runtime/server/terminal-host.js +124 -8
  36. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  37. package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
  38. package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
  39. package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
  40. package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
  41. package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
  42. package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
  43. package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
  44. package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
  45. package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
  46. package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
  47. package/dist/workspace-runtime/workspaces/attachments.js +9 -0
  48. package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
  49. package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
  50. package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
  51. package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
  52. package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
  53. package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
  54. package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
  55. package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
  56. package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
  57. package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
  58. package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
  59. package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
  60. package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
  61. package/dist/workspace-runtime/workspaces/files.js +4 -0
  62. package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
  63. package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
  64. package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
  65. package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
  66. package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
  67. package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
  68. package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
  69. package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
  70. package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
  71. package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
  72. package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
  73. package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
  74. package/dist/workspace-runtime/workspaces/parameters.js +95 -3
  75. package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
  76. package/dist/workspace-runtime/workspaces/prepare.js +4 -0
  77. package/dist/workspace-runtime/workspaces/registry.js +55 -3
  78. package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
  79. package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
  80. package/dist/workspace-runtime/workspaces/skills.js +14 -15
  81. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  82. package/dist/workspace-runtime/workspaces/transfer.js +4 -1
  83. package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
  84. package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
  85. package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
  86. package/package.json +5 -4
  87. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
  88. package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
  89. package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
@@ -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', '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
+ }