@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.
- package/dist/assets/client.js +4 -2
- package/dist/assets/messages.js +2 -0
- package/dist/audio-source/messages.js +35 -0
- package/dist/commands/assets.js +1 -1
- package/dist/commands/audio-source.js +109 -0
- package/dist/commands/audio.js +11 -4
- package/dist/commands/build.js +4 -2
- package/dist/commands/tools-context.js +46 -0
- package/dist/commands/tools.js +2 -0
- package/dist/project-skills/builtin-import.js +69 -22
- package/dist/project-skills/commands.js +11 -2
- package/dist/project-skills/lifecycle.js +1 -1
- package/dist/project-skills/local-store.js +90 -31
- package/dist/project-skills/messages.js +16 -0
- package/dist/project-skills/migrate-lock.js +166 -0
- package/dist/project-skills/remote-cache.js +36 -5
- package/dist/utils/agent-api-client.js +18 -1
- package/dist/utils/agent-generation-provenance.js +8 -2
- package/dist/utils/audio-export.js +249 -0
- package/dist/utils/audio-processor.js +22 -99
- package/dist/utils/execution-context-bridge.js +28 -0
- package/dist/workspace-runtime/batch/control.js +13 -2
- package/dist/workspace-runtime/batch/generate.js +43 -2
- package/dist/workspace-runtime/batch/prepare.js +4 -0
- package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
- package/dist/workspace-runtime/batch/source-capture.js +6 -8
- package/dist/workspace-runtime/codex/app-server.js +26 -2
- package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
- package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
- package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +15 -2
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
- package/dist/workspace-runtime/previews/finalize.js +1 -1
- package/dist/workspace-runtime/previews/output.js +4 -1
- package/dist/workspace-runtime/previews/publish.js +5 -2
- package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
- package/dist/workspace-runtime/serve.js +6 -0
- package/dist/workspace-runtime/server/accept-operation.js +85 -13
- package/dist/workspace-runtime/server/audio-assets.js +211 -0
- package/dist/workspace-runtime/server/audio-export.js +136 -0
- package/dist/workspace-runtime/server/directory-access.js +222 -67
- package/dist/workspace-runtime/server/dispatch.js +252 -29
- package/dist/workspace-runtime/server/http.js +97 -3
- package/dist/workspace-runtime/server/operation-record.js +1 -1
- package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
- package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
- package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
- package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
- package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +76 -0
- package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
- package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
- package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
- package/dist/workspace-runtime/workspaces/audio-assets/service.js +327 -0
- package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
- package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
- package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
- package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
- package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
- package/dist/workspace-runtime/workspaces/files.js +3 -5
- package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
- package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
- package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
- package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
- package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
- package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
- package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
- package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
- package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
- package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
- package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
- package/dist/workspace-runtime/workspaces/skills.js +48 -13
- package/package.json +6 -4
- package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
- package/project-template/vite.config.ts +1 -1
- package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-template-v2/.playcraft/skills.lock.json +9 -2
- package/project-template-v2/vite.config.ts +1 -1
- package/project-templates/empty/AGENTS.md +2 -0
- package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-templates/phaser/vite.config.ts +1 -1
- package/project-templates/playable-template-phaser/vite.config.ts +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-templates/threejs/vite.config.ts +1 -1
- package/templates/preview-runtime/v1/README.md +12 -0
- package/templates/preview-runtime/v1/build-preview.mjs +17 -8
- package/templates/preview-runtime/v1/manifest.json +9 -6
- package/templates/preview-runtime/v1/release-guard.mjs +1 -1
- package/templates/preview-runtime/v1/runtime.d.mts +1 -3
- package/templates/preview-runtime/v1/runtime.d.ts +1 -3
- package/templates/preview-runtime/v1/runtime.mjs +1142 -257
- package/templates/preview-runtime/v1/types/host.d.ts +29 -0
- package/templates/preview-runtime/v1/types/index.d.ts +6 -0
- package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { isTurnControl, takesWorkspaceDirectory, takesExecutionSlot, transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { isTurnControl, takesWorkspaceDirectory, takesExecutionSlot, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
import { listMembers, readSnapshot } from '../batch/records.js';
|
|
5
5
|
const context = new AsyncLocalStorage();
|
|
6
6
|
const gates = new WeakMap();
|
|
@@ -8,9 +8,21 @@ const instances = new WeakMap();
|
|
|
8
8
|
const executing = new WeakMap();
|
|
9
9
|
const freezing = new WeakMap();
|
|
10
10
|
const TERMINAL = new Set(['completed', 'failed', 'interrupted']);
|
|
11
|
-
const EXCLUSIVE_OPERATIONS = new Set([
|
|
12
|
-
'
|
|
13
|
-
'
|
|
11
|
+
const EXCLUSIVE_OPERATIONS = new Set([
|
|
12
|
+
'level-editor.transaction',
|
|
13
|
+
'preview.publish',
|
|
14
|
+
'model.preview',
|
|
15
|
+
'model.prepare',
|
|
16
|
+
'directory.create',
|
|
17
|
+
'directory.rename',
|
|
18
|
+
'directory.delete',
|
|
19
|
+
'workspace.references.import',
|
|
20
|
+
'skills.update',
|
|
21
|
+
'parameter.verify',
|
|
22
|
+
'level-editor.verify',
|
|
23
|
+
'checkpoint.create',
|
|
24
|
+
'checkpoint.restore',
|
|
25
|
+
]);
|
|
14
26
|
export function managedDirectoryScope(state, value) {
|
|
15
27
|
if (state.config.workspaceMode !== 'multi')
|
|
16
28
|
return { directoryName: 'basic' };
|
|
@@ -18,22 +30,51 @@ export function managedDirectoryScope(state, value) {
|
|
|
18
30
|
return { workspaceId, directoryName };
|
|
19
31
|
}
|
|
20
32
|
function overlaps(a, b) {
|
|
21
|
-
return (a.workspaceId ?? '') === (b.workspaceId ?? '') &&
|
|
22
|
-
((a.directoryName ?? 'basic') === (b.directoryName ?? 'basic') ||
|
|
33
|
+
return ((a.workspaceId ?? '') === (b.workspaceId ?? '') &&
|
|
34
|
+
((a.directoryName ?? 'basic') === (b.directoryName ?? 'basic') ||
|
|
35
|
+
a.directoryName === 'root' ||
|
|
36
|
+
b.directoryName === 'root'));
|
|
23
37
|
}
|
|
24
38
|
function covers(parent, child) {
|
|
25
|
-
return (parent.workspaceId ?? '') === (child.workspaceId ?? '') &&
|
|
26
|
-
(parent.directoryName === 'root' ||
|
|
39
|
+
return ((parent.workspaceId ?? '') === (child.workspaceId ?? '') &&
|
|
40
|
+
(parent.directoryName === 'root' ||
|
|
41
|
+
(parent.directoryName ?? 'basic') === (child.directoryName ?? 'basic')));
|
|
42
|
+
}
|
|
43
|
+
// A terminal is an interactive channel, not an ordinary operation. Keep its lease for
|
|
44
|
+
// captures and destructive directory maintenance, but do not serialize normal work behind it.
|
|
45
|
+
function terminalIndependent(a, b) {
|
|
46
|
+
return ((a.owner.startsWith('terminal:') && b.terminalIndependent === true) ||
|
|
47
|
+
(b.owner.startsWith('terminal:') && a.terminalIndependent === true));
|
|
48
|
+
}
|
|
49
|
+
function operationTerminalIndependent(operation) {
|
|
50
|
+
return ![
|
|
51
|
+
'directory.rename',
|
|
52
|
+
'directory.delete',
|
|
53
|
+
'checkpoint.create',
|
|
54
|
+
'checkpoint.restore',
|
|
55
|
+
'preview.publish',
|
|
56
|
+
].includes(operation.kind);
|
|
27
57
|
}
|
|
28
58
|
export function operationDirectoryMode(operation) {
|
|
29
|
-
|
|
59
|
+
if (operation.kind === 'level-editor.transaction')
|
|
60
|
+
return 'exclusive';
|
|
61
|
+
return (operation.directoryAccessMode ??
|
|
62
|
+
(EXCLUSIVE_OPERATIONS.has(operation.kind) || operation.kind.startsWith('git.')
|
|
63
|
+
? 'exclusive'
|
|
64
|
+
: 'shared'));
|
|
30
65
|
}
|
|
31
66
|
function activeSessionOwner(state, gate, scope, sessionId, except) {
|
|
32
67
|
if (!sessionId)
|
|
33
68
|
return undefined;
|
|
34
|
-
const operation = state.journal
|
|
35
|
-
|
|
36
|
-
|
|
69
|
+
const operation = state.journal
|
|
70
|
+
.listOperations()
|
|
71
|
+
.map((item) => item.operation)
|
|
72
|
+
.find((item) => item.sessionId === sessionId &&
|
|
73
|
+
item.operationId !== except &&
|
|
74
|
+
['session.turn', 'session.review', 'session.compact'].includes(item.kind) &&
|
|
75
|
+
['dispatched', 'accepted', 'uncertain'].includes(item.status) &&
|
|
76
|
+
covers(item, scope) &&
|
|
77
|
+
gate.has(`operation:${item.operationId}`));
|
|
37
78
|
return operation ? `operation:${operation.operationId}` : undefined;
|
|
38
79
|
}
|
|
39
80
|
/** A directory has many ordinary writers, or one build, freeze, maintenance job or member Run. */
|
|
@@ -41,20 +82,20 @@ export class DirectoryAccessGate {
|
|
|
41
82
|
normalize;
|
|
42
83
|
held = new Map();
|
|
43
84
|
pending = [];
|
|
44
|
-
constructor(normalize = claim => claim) {
|
|
85
|
+
constructor(normalize = (claim) => claim) {
|
|
45
86
|
this.normalize = normalize;
|
|
46
87
|
}
|
|
47
88
|
claim(input) {
|
|
48
89
|
const claim = this.normalize(input);
|
|
49
90
|
const held = this.held.get(claim.owner);
|
|
50
91
|
if (held) {
|
|
51
|
-
if (!covers(held, claim) || held.mode === 'shared' && claim.mode === 'exclusive')
|
|
92
|
+
if (!covers(held, claim) || (held.mode === 'shared' && claim.mode === 'exclusive'))
|
|
52
93
|
return Promise.reject(new Error('directory_access_upgrade_forbidden'));
|
|
53
94
|
return Promise.resolve();
|
|
54
95
|
}
|
|
55
|
-
const existing = this.pending.find(item => item.owner === claim.owner);
|
|
96
|
+
const existing = this.pending.find((item) => item.owner === claim.owner);
|
|
56
97
|
if (existing) {
|
|
57
|
-
if (!covers(existing, claim) || existing.mode === 'shared' && claim.mode === 'exclusive')
|
|
98
|
+
if (!covers(existing, claim) || (existing.mode === 'shared' && claim.mode === 'exclusive'))
|
|
58
99
|
return Promise.reject(new Error('directory_access_upgrade_forbidden'));
|
|
59
100
|
if (existing.paused && !claim.paused) {
|
|
60
101
|
existing.paused = false;
|
|
@@ -64,16 +105,25 @@ export class DirectoryAccessGate {
|
|
|
64
105
|
}
|
|
65
106
|
let resolve;
|
|
66
107
|
let reject;
|
|
67
|
-
const ready = new Promise((done, fail) => {
|
|
108
|
+
const ready = new Promise((done, fail) => {
|
|
109
|
+
resolve = done;
|
|
110
|
+
reject = fail;
|
|
111
|
+
});
|
|
68
112
|
this.pending.push({ ...claim, ready, resolve, reject });
|
|
69
113
|
this.drain();
|
|
70
114
|
return ready;
|
|
71
115
|
}
|
|
72
|
-
restore(claim) {
|
|
73
|
-
|
|
116
|
+
restore(claim) {
|
|
117
|
+
this.held.set(claim.owner, this.normalize(claim));
|
|
118
|
+
}
|
|
119
|
+
has(owner) {
|
|
120
|
+
return this.held.has(owner);
|
|
121
|
+
}
|
|
74
122
|
permits(owner, scope, mode) {
|
|
75
123
|
const held = this.held.get(owner);
|
|
76
|
-
return Boolean(held &&
|
|
124
|
+
return Boolean(held &&
|
|
125
|
+
covers(held, this.normalize({ ...scope, owner, mode })) &&
|
|
126
|
+
(held.mode === 'exclusive' || mode === 'shared'));
|
|
77
127
|
}
|
|
78
128
|
tryClaim(claim) {
|
|
79
129
|
const ready = this.claim(claim);
|
|
@@ -84,7 +134,7 @@ export class DirectoryAccessGate {
|
|
|
84
134
|
return false;
|
|
85
135
|
}
|
|
86
136
|
adopt(owner, parentOwner) {
|
|
87
|
-
const child = this.pending.find(item => item.owner === owner);
|
|
137
|
+
const child = this.pending.find((item) => item.owner === owner);
|
|
88
138
|
const parent = this.held.get(parentOwner);
|
|
89
139
|
if (!child || !parent || parent.mode !== 'exclusive' || !covers(parent, child))
|
|
90
140
|
return;
|
|
@@ -92,21 +142,23 @@ export class DirectoryAccessGate {
|
|
|
92
142
|
this.drain();
|
|
93
143
|
}
|
|
94
144
|
cancelPending(owner) {
|
|
95
|
-
if (this.held.has(owner) || !this.pending.some(item => item.owner === owner))
|
|
145
|
+
if (this.held.has(owner) || !this.pending.some((item) => item.owner === owner))
|
|
96
146
|
return false;
|
|
97
147
|
this.release(owner);
|
|
98
148
|
return true;
|
|
99
149
|
}
|
|
100
150
|
release(owner) {
|
|
101
151
|
this.held.delete(owner);
|
|
102
|
-
const pending = this.pending.find(item => item.owner === owner);
|
|
152
|
+
const pending = this.pending.find((item) => item.owner === owner);
|
|
103
153
|
if (pending) {
|
|
104
154
|
this.pending.splice(this.pending.indexOf(pending), 1);
|
|
105
155
|
pending.reject(new Error('directory_access_cancelled'));
|
|
106
156
|
}
|
|
107
157
|
this.drain();
|
|
108
158
|
}
|
|
109
|
-
owners() {
|
|
159
|
+
owners() {
|
|
160
|
+
return [...this.held.keys(), ...this.pending.map((item) => item.owner)];
|
|
161
|
+
}
|
|
110
162
|
drain() {
|
|
111
163
|
const earlier = [];
|
|
112
164
|
for (const item of [...this.pending]) {
|
|
@@ -116,9 +168,26 @@ export class DirectoryAccessGate {
|
|
|
116
168
|
}
|
|
117
169
|
const parent = item.parentOwner ? this.held.get(item.parentOwner) : undefined;
|
|
118
170
|
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)) &&
|
|
120
|
-
(item
|
|
121
|
-
|
|
171
|
+
const conflict = [...this.held.values()].some((held) => !(family && (held.owner === parent.owner || held.parentOwner === parent.owner)) &&
|
|
172
|
+
overlaps(item, held) &&
|
|
173
|
+
!terminalIndependent(item, held) &&
|
|
174
|
+
(item.mode === 'exclusive' ||
|
|
175
|
+
held.mode === 'exclusive' ||
|
|
176
|
+
Boolean(item.sessionId && held.sessionId === item.sessionId)));
|
|
177
|
+
const queuedWriter = !family &&
|
|
178
|
+
earlier.some((prior) => {
|
|
179
|
+
if (!overlaps(item, prior) ||
|
|
180
|
+
terminalIndependent(item, prior) ||
|
|
181
|
+
(item.mode !== 'exclusive' && prior.mode !== 'exclusive'))
|
|
182
|
+
return false;
|
|
183
|
+
// A capture waiting on an open terminal must not form a permanent queue barrier
|
|
184
|
+
// for work that is allowed to run beside that terminal.
|
|
185
|
+
if (item.terminalIndependent &&
|
|
186
|
+
!prior.terminalIndependent &&
|
|
187
|
+
[...this.held.values()].some((held) => held.owner.startsWith('terminal:') && overlaps(prior, held)))
|
|
188
|
+
return false;
|
|
189
|
+
return true;
|
|
190
|
+
});
|
|
122
191
|
if (conflict || queuedWriter) {
|
|
123
192
|
earlier.push(item);
|
|
124
193
|
continue;
|
|
@@ -133,32 +202,48 @@ async function gateFor(state) {
|
|
|
133
202
|
let pending = gates.get(state);
|
|
134
203
|
if (!pending) {
|
|
135
204
|
pending = (async () => {
|
|
136
|
-
const gate = new DirectoryAccessGate(claim => state.config.workspaceMode === 'multi'
|
|
137
|
-
? claim
|
|
205
|
+
const gate = new DirectoryAccessGate((claim) => state.config.workspaceMode === 'multi'
|
|
206
|
+
? claim
|
|
207
|
+
: { ...claim, workspaceId: undefined, directoryName: 'basic' });
|
|
138
208
|
instances.set(state, gate);
|
|
139
209
|
const members = await listMembers(state);
|
|
140
210
|
for (const member of members)
|
|
141
211
|
if (member.holding)
|
|
142
212
|
gate.restore({ ...member, owner: `run:${member.runId}`, mode: 'exclusive' });
|
|
143
213
|
for (const { operation } of state.journal.listOperations()) {
|
|
144
|
-
if (!takesWorkspaceDirectory(operation.kind) ||
|
|
214
|
+
if (!takesWorkspaceDirectory(operation.kind) ||
|
|
215
|
+
isTurnControl(operation.kind) ||
|
|
145
216
|
!['dispatched', 'accepted', 'uncertain'].includes(operation.status) ||
|
|
146
|
-
members.some(member => member.holding &&
|
|
217
|
+
members.some((member) => member.holding &&
|
|
218
|
+
(member.operationId === operation.operationId ||
|
|
219
|
+
Boolean(member.sessionId && member.sessionId === operation.sessionId))))
|
|
147
220
|
continue;
|
|
148
|
-
gate.restore({
|
|
221
|
+
gate.restore({
|
|
222
|
+
...operation,
|
|
223
|
+
owner: `operation:${operation.operationId}`,
|
|
224
|
+
mode: operationDirectoryMode(operation),
|
|
225
|
+
terminalIndependent: operationTerminalIndependent(operation),
|
|
226
|
+
});
|
|
149
227
|
}
|
|
150
228
|
for (const terminal of state.journal.listTerminals())
|
|
151
229
|
if (terminal.status !== 'destroyed')
|
|
152
230
|
gate.restore({ ...terminal, owner: `terminal:${terminal.terminalId}`, mode: 'shared' });
|
|
153
231
|
for (const writer of state.journal.listManagedWrites())
|
|
154
232
|
if (!writer.stoppedAt) {
|
|
155
|
-
gate.restore({
|
|
233
|
+
gate.restore({
|
|
234
|
+
...managedDirectoryScope(state, writer.workspaceId),
|
|
235
|
+
owner: `managed:${writer.writeId}`,
|
|
236
|
+
mode: writer.directoryAccessMode ?? 'shared',
|
|
237
|
+
terminalIndependent: true,
|
|
238
|
+
});
|
|
156
239
|
}
|
|
157
240
|
const waiting = [];
|
|
158
241
|
for (const barrier of state.journal.directoryBarriers()) {
|
|
159
242
|
const snapshot = barrier.owner.startsWith('freeze:')
|
|
160
|
-
? await readSnapshot(state, barrier.owner.slice('freeze:'.length)).catch(() => undefined)
|
|
161
|
-
|
|
243
|
+
? await readSnapshot(state, barrier.owner.slice('freeze:'.length)).catch(() => undefined)
|
|
244
|
+
: undefined;
|
|
245
|
+
if (snapshot?.sourceWorkspaceId === barrier.workspaceId &&
|
|
246
|
+
snapshot.sourceDirectoryName === barrier.directoryName) {
|
|
162
247
|
await state.journal.deleteDirectoryBarrier(barrier.owner);
|
|
163
248
|
continue;
|
|
164
249
|
}
|
|
@@ -170,19 +255,31 @@ async function gateFor(state) {
|
|
|
170
255
|
await state.journal.deleteDirectoryBarrier(barrier.owner);
|
|
171
256
|
continue;
|
|
172
257
|
}
|
|
173
|
-
waiting.push({
|
|
258
|
+
waiting.push({
|
|
259
|
+
claim: { ...barrier, mode: 'exclusive', paused: true },
|
|
260
|
+
queuedAt: barrier.queuedAt ?? '',
|
|
261
|
+
});
|
|
174
262
|
const timeout = setTimeout(() => {
|
|
175
263
|
if (gate.cancelPending(barrier.owner))
|
|
176
264
|
void state.journal.deleteDirectoryBarrier(barrier.owner).catch(() => undefined);
|
|
177
|
-
}, Math.max(0, Date.parse(barrier.deadline ?? new Date(Date.now() + 60_000).toISOString()) -
|
|
265
|
+
}, Math.max(0, Date.parse(barrier.deadline ?? new Date(Date.now() + 60_000).toISOString()) -
|
|
266
|
+
Date.now()));
|
|
178
267
|
timeout.unref?.();
|
|
179
268
|
}
|
|
180
269
|
for (const pending of state.journal.deferredDirectoryOperations()) {
|
|
181
270
|
const operation = state.journal.getOperationById(pending.operationId);
|
|
182
271
|
if (operation?.status !== 'queued')
|
|
183
272
|
continue;
|
|
184
|
-
waiting.push({
|
|
185
|
-
|
|
273
|
+
waiting.push({
|
|
274
|
+
claim: {
|
|
275
|
+
...operation,
|
|
276
|
+
owner: `operation:${operation.operationId}`,
|
|
277
|
+
mode: operationDirectoryMode(operation),
|
|
278
|
+
terminalIndependent: operationTerminalIndependent(operation),
|
|
279
|
+
...(operation.kind.startsWith('session.') ? { sessionId: operation.sessionId } : {}),
|
|
280
|
+
},
|
|
281
|
+
queuedAt: pending.queuedAt ?? operation.createdAt,
|
|
282
|
+
});
|
|
186
283
|
}
|
|
187
284
|
for (const item of waiting.sort((a, b) => a.queuedAt.localeCompare(b.queuedAt)))
|
|
188
285
|
void gate.claim(item.claim).catch(() => undefined);
|
|
@@ -209,7 +306,9 @@ export function reconcileDirectoryAccess(state) {
|
|
|
209
306
|
gate.release(owner);
|
|
210
307
|
}
|
|
211
308
|
else if (owner.startsWith('managed:')) {
|
|
212
|
-
if (state.journal
|
|
309
|
+
if (state.journal
|
|
310
|
+
.listManagedWrites()
|
|
311
|
+
.find((writer) => writer.writeId === owner.slice('managed:'.length))?.stoppedAt)
|
|
213
312
|
gate.release(owner);
|
|
214
313
|
}
|
|
215
314
|
}
|
|
@@ -217,8 +316,15 @@ export function reconcileDirectoryAccess(state) {
|
|
|
217
316
|
export async function holdDirectoryAccess(state, scope, owner, mode = 'shared') {
|
|
218
317
|
const gate = await gateFor(state);
|
|
219
318
|
const inherited = context.getStore();
|
|
220
|
-
await gate.claim({
|
|
221
|
-
...
|
|
319
|
+
await gate.claim({
|
|
320
|
+
...scope,
|
|
321
|
+
owner,
|
|
322
|
+
mode,
|
|
323
|
+
...(owner.startsWith('managed:') ? { terminalIndependent: true } : {}),
|
|
324
|
+
...(inherited?.state === state && gate.permits(inherited.claim.owner, scope, mode)
|
|
325
|
+
? { parentOwner: inherited.claim.owner }
|
|
326
|
+
: {}),
|
|
327
|
+
});
|
|
222
328
|
return () => gate.release(owner);
|
|
223
329
|
}
|
|
224
330
|
/** A build may join its known installer that was queued behind the build's own exclusive lease. */
|
|
@@ -245,13 +351,24 @@ export async function assertOperationDirectoryAccess(state, operation, mode) {
|
|
|
245
351
|
export async function withDirectoryWrite(state, scope, action) {
|
|
246
352
|
const inherited = context.getStore();
|
|
247
353
|
const gate = await gateFor(state);
|
|
248
|
-
if (inherited?.state === state &&
|
|
354
|
+
if (inherited?.state === state &&
|
|
355
|
+
gate.permits(inherited.claim.owner, scope, scope.mode ?? 'shared'))
|
|
249
356
|
return action();
|
|
250
|
-
if (inherited?.state === state &&
|
|
357
|
+
if (inherited?.state === state &&
|
|
358
|
+
scope.mode === 'exclusive' &&
|
|
359
|
+
gate.permits(inherited.claim.owner, scope, 'shared'))
|
|
251
360
|
throw new Error('directory_busy');
|
|
252
|
-
const parentOwner = scope.mode !== 'exclusive'
|
|
253
|
-
|
|
254
|
-
|
|
361
|
+
const parentOwner = scope.mode !== 'exclusive'
|
|
362
|
+
? activeSessionOwner(state, gate, scope, scope.sessionId)
|
|
363
|
+
: undefined;
|
|
364
|
+
const claim = {
|
|
365
|
+
...scope,
|
|
366
|
+
sessionId: undefined,
|
|
367
|
+
owner: scope.owner ?? `write:${randomUUID()}`,
|
|
368
|
+
mode: scope.mode ?? 'shared',
|
|
369
|
+
...(scope.mode !== 'exclusive' ? { terminalIndependent: true } : {}),
|
|
370
|
+
...(parentOwner ? { parentOwner } : {}),
|
|
371
|
+
};
|
|
255
372
|
if (scope.wait === false && !gate.tryClaim(claim))
|
|
256
373
|
throw new Error('directory_busy');
|
|
257
374
|
if (scope.wait !== false)
|
|
@@ -265,7 +382,9 @@ export async function withDirectoryWrite(state, scope, action) {
|
|
|
265
382
|
}
|
|
266
383
|
}
|
|
267
384
|
export async function withOperationDirectoryAccess(state, operation, action, deferred) {
|
|
268
|
-
if (!takesWorkspaceDirectory(operation.kind) ||
|
|
385
|
+
if (!takesWorkspaceDirectory(operation.kind) ||
|
|
386
|
+
isTurnControl(operation.kind) ||
|
|
387
|
+
operation.kind === 'runtime.destroy')
|
|
269
388
|
return action();
|
|
270
389
|
const inherited = context.getStore();
|
|
271
390
|
const mode = operationDirectoryMode(operation);
|
|
@@ -278,11 +397,19 @@ export async function withOperationDirectoryAccess(state, operation, action, def
|
|
|
278
397
|
return state.journal.getOperationById(operation.operationId) ?? operation;
|
|
279
398
|
active.add(operation.operationId);
|
|
280
399
|
const parentOwner = !takesExecutionSlot(operation.kind) && mode === 'shared'
|
|
281
|
-
? activeSessionOwner(state, gate, operation, operation.sessionId, operation.operationId)
|
|
282
|
-
|
|
400
|
+
? activeSessionOwner(state, gate, operation, operation.sessionId, operation.operationId)
|
|
401
|
+
: undefined;
|
|
402
|
+
const claim = {
|
|
403
|
+
...operation,
|
|
404
|
+
owner: `operation:${operation.operationId}`,
|
|
405
|
+
mode: operationDirectoryMode(operation),
|
|
406
|
+
terminalIndependent: operationTerminalIndependent(operation),
|
|
283
407
|
...(operation.kind.startsWith('session.') ? { sessionId: operation.sessionId } : {}),
|
|
284
|
-
...(parentOwner ? { parentOwner } : {})
|
|
285
|
-
|
|
408
|
+
...(parentOwner ? { parentOwner } : {}),
|
|
409
|
+
};
|
|
410
|
+
if (inherited?.state === state &&
|
|
411
|
+
mode === 'shared' &&
|
|
412
|
+
gate.permits(inherited.claim.owner, operation, mode))
|
|
286
413
|
claim.parentOwner = inherited.claim.owner;
|
|
287
414
|
const ready = gate.claim(claim);
|
|
288
415
|
const run = async () => {
|
|
@@ -315,16 +442,20 @@ export async function withOperationDirectoryAccess(state, operation, action, def
|
|
|
315
442
|
gate.release(claim.owner);
|
|
316
443
|
throw error;
|
|
317
444
|
}
|
|
318
|
-
void run()
|
|
445
|
+
void run()
|
|
446
|
+
.catch(async (error) => {
|
|
319
447
|
const current = state.journal.getOperationById(operation.operationId);
|
|
320
448
|
if (!current || TERMINAL.has(current.status))
|
|
321
449
|
return;
|
|
322
|
-
const cancelled = current.status === 'queued' &&
|
|
450
|
+
const cancelled = current.status === 'queued' &&
|
|
451
|
+
error instanceof Error &&
|
|
452
|
+
error.message === 'directory_access_cancelled';
|
|
323
453
|
await state.journal.putOperation(deferred.scopeKey, transitionOperation(current, cancelled ? 'interrupted' : 'uncertain', {
|
|
324
454
|
error: cancelled ? 'cancelled' : 'native_receipt_lost',
|
|
325
455
|
}));
|
|
326
456
|
await state.journal.deleteDeferredDirectoryOperation(operation.operationId);
|
|
327
|
-
})
|
|
457
|
+
})
|
|
458
|
+
.catch(() => undefined);
|
|
328
459
|
return state.journal.getOperationById(operation.operationId) ?? operation;
|
|
329
460
|
}
|
|
330
461
|
/** A queued command is replayed only if it never started; accepted or uncertain work is only observed. */
|
|
@@ -332,7 +463,8 @@ export async function restoreDeferredDirectoryOperations(state, execute) {
|
|
|
332
463
|
for (const pending of state.journal.deferredDirectoryOperations()) {
|
|
333
464
|
const operation = state.journal.getOperationById(pending.operationId);
|
|
334
465
|
if (operation?.status === 'queued') {
|
|
335
|
-
if (operation.generation !== state.config.generation ||
|
|
466
|
+
if (operation.generation !== state.config.generation ||
|
|
467
|
+
operation.runtimeEpoch !== state.runtimeEpoch) {
|
|
336
468
|
await state.journal.putOperation(pending.scopeKey, transitionOperation(operation, 'failed', { error: 'generation_mismatch' }));
|
|
337
469
|
continue;
|
|
338
470
|
}
|
|
@@ -350,11 +482,13 @@ export function releaseBatchDirectory(state, member) {
|
|
|
350
482
|
instances.get(state)?.release(`run:${member.runId}`);
|
|
351
483
|
}
|
|
352
484
|
export async function withDirectoryFreeze(state, scope, action, quiesce, options) {
|
|
353
|
-
const pending = freezing.get(state) ??
|
|
485
|
+
const pending = freezing.get(state) ??
|
|
486
|
+
new Map();
|
|
354
487
|
freezing.set(state, pending);
|
|
355
488
|
const existing = pending.get(scope.batchId);
|
|
356
489
|
if (existing) {
|
|
357
|
-
if (existing.scope.workspaceId !== scope.workspaceId ||
|
|
490
|
+
if (existing.scope.workspaceId !== scope.workspaceId ||
|
|
491
|
+
(existing.scope.directoryName ?? 'basic') !== (scope.directoryName ?? 'basic'))
|
|
358
492
|
throw new Error('batch_source_identity_mismatch');
|
|
359
493
|
return existing.task;
|
|
360
494
|
}
|
|
@@ -370,13 +504,18 @@ export async function withDirectoryFreeze(state, scope, action, quiesce, options
|
|
|
370
504
|
}
|
|
371
505
|
async function performDirectoryFreeze(state, scope, action, quiesce, options) {
|
|
372
506
|
const deadline = options?.deadline ?? new Date(Date.now() + 60_000).toISOString();
|
|
373
|
-
const signal = AbortSignal.any([
|
|
507
|
+
const signal = AbortSignal.any([
|
|
508
|
+
...(options?.signal ? [options.signal] : []),
|
|
509
|
+
AbortSignal.timeout(Math.max(0, Date.parse(deadline) - Date.now())),
|
|
510
|
+
]);
|
|
374
511
|
if (signal.aborted)
|
|
375
512
|
throw new Error('directory_access_cancelled');
|
|
376
513
|
const owner = `freeze:${scope.batchId}`;
|
|
377
514
|
const gate = await gateFor(state);
|
|
378
|
-
const previous = state.journal.directoryBarriers().find(barrier => barrier.owner === owner);
|
|
379
|
-
if (previous &&
|
|
515
|
+
const previous = state.journal.directoryBarriers().find((barrier) => barrier.owner === owner);
|
|
516
|
+
if (previous &&
|
|
517
|
+
(previous.workspaceId !== scope.workspaceId ||
|
|
518
|
+
previous.directoryName !== (scope.directoryName ?? 'basic')))
|
|
380
519
|
throw new Error('batch_source_identity_mismatch');
|
|
381
520
|
// A restored capture may still have writers. Retrying its publication is not a new admission,
|
|
382
521
|
// and a missing candidate or a lost request cannot provide the old capture's stop proof.
|
|
@@ -393,22 +532,38 @@ async function performDirectoryFreeze(state, scope, action, quiesce, options) {
|
|
|
393
532
|
signal.addEventListener('abort', cancel, { once: true });
|
|
394
533
|
try {
|
|
395
534
|
if (!restoredCapture)
|
|
396
|
-
await state.journal.putDirectoryBarrier({
|
|
397
|
-
|
|
535
|
+
await state.journal.putDirectoryBarrier({
|
|
536
|
+
owner,
|
|
537
|
+
workspaceId: scope.workspaceId,
|
|
538
|
+
directoryName: scope.directoryName ?? 'basic',
|
|
539
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
540
|
+
phase: 'pending',
|
|
541
|
+
deadline,
|
|
542
|
+
});
|
|
398
543
|
await quiesce?.();
|
|
399
544
|
await claimed;
|
|
400
545
|
if (signal.aborted)
|
|
401
546
|
throw new Error('directory_access_cancelled');
|
|
402
547
|
started = true;
|
|
403
548
|
if (!restoredCapture)
|
|
404
|
-
await state.journal.putDirectoryBarrier({
|
|
405
|
-
|
|
549
|
+
await state.journal.putDirectoryBarrier({
|
|
550
|
+
owner,
|
|
551
|
+
workspaceId: scope.workspaceId,
|
|
552
|
+
directoryName: scope.directoryName ?? 'basic',
|
|
553
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
554
|
+
phase: 'capturing',
|
|
555
|
+
deadline,
|
|
556
|
+
});
|
|
406
557
|
return await withDirectoryOwner(state, scope, owner, action);
|
|
407
558
|
}
|
|
408
559
|
finally {
|
|
409
560
|
signal.removeEventListener('abort', cancel);
|
|
410
|
-
const snapshot = restoredCapture
|
|
411
|
-
|
|
561
|
+
const snapshot = restoredCapture
|
|
562
|
+
? await readSnapshot(state, scope.batchId).catch(() => undefined)
|
|
563
|
+
: undefined;
|
|
564
|
+
if (!restoredCapture ||
|
|
565
|
+
(snapshot?.sourceWorkspaceId === scope.workspaceId &&
|
|
566
|
+
snapshot.sourceDirectoryName === (scope.directoryName ?? 'basic'))) {
|
|
412
567
|
await state.journal.deleteDirectoryBarrier(owner);
|
|
413
568
|
gate.release(owner);
|
|
414
569
|
}
|