@playcraft/cli 0.1.71 → 0.1.73
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/cli-root-help.js +1 -0
- package/dist/commands/references.js +3 -0
- package/dist/commands/tools-batch.js +2 -0
- package/dist/references/messages.js +2 -0
- package/dist/references/protocol.js +39 -12
- package/dist/workspace-runtime/batch/freeze.js +94 -24
- package/dist/workspace-runtime/batch/records.js +12 -6
- package/dist/workspace-runtime/batch/routes.js +31 -11
- package/dist/workspace-runtime/batch/source-candidate.js +29 -16
- package/dist/workspace-runtime/batch/source-capture.js +251 -20
- package/dist/workspace-runtime/codex/app-server.js +7 -2
- package/dist/workspace-runtime/codex/remix-references-broker.js +208 -67
- package/dist/workspace-runtime/persistence/journal.js +3 -0
- package/dist/workspace-runtime/serve.js +3 -1
- package/dist/workspace-runtime/server/directory-access.js +10 -0
- package/dist/workspace-runtime/server/http.js +335 -119
- package/dist/workspace-runtime/workspaces/composer-context.js +92 -41
- package/dist/workspace-runtime/workspaces/files.js +39 -9
- package/dist/workspace-runtime/workspaces/platform-export.js +33 -9
- package/dist/workspace-runtime/workspaces/remix-references.js +162 -53
- package/dist/workspace-runtime/workspaces/starter-template.js +74 -0
- package/package.json +4 -3
|
@@ -5,16 +5,30 @@ import { PLATFORM_ROUTES, platformEndpoint, WORKSPACE_REMIX_REFERENCE_BODY_MAX_B
|
|
|
5
5
|
import { withDirectoryWrite } from '../server/directory-access.js';
|
|
6
6
|
import { wakeSourceEventUploader } from '../server/source-uploader.js';
|
|
7
7
|
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
|
-
import { importWorkspaceRemixReference, listWorkspaceRemixReferences } from '../workspaces/remix-references.js';
|
|
8
|
+
import { importWorkspaceRemixReference, listWorkspaceRemixReferences, } from '../workspaces/remix-references.js';
|
|
9
9
|
import { shareWorkspaceGit } from '../workspaces/shared-git.js';
|
|
10
|
+
import { prepareStarterTemplate } from '../workspaces/starter-template.js';
|
|
10
11
|
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
11
12
|
import { verifyLauncherOrigin } from './launcher-client.js';
|
|
12
|
-
import { localRemixReference, RemixReferenceToolError, remixReferenceToolRequestSchema } from '../../references/protocol.js';
|
|
13
|
+
import { localRemixReference, RemixReferenceToolError, remixReferenceToolRequestSchema, } from '../../references/protocol.js';
|
|
13
14
|
const ACTIVE = new Set(['dispatched', 'accepted', 'uncertain']);
|
|
14
|
-
const candidatesSchema = z
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
const candidatesSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
items: z.array(z.unknown()).max(50),
|
|
18
|
+
total: z.number().int().nonnegative(),
|
|
19
|
+
page: z.number().int().positive(),
|
|
20
|
+
pageSize: z.number().int().positive(),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
const SOURCE_ERRORS = new Set([
|
|
24
|
+
'remix_reference_unavailable',
|
|
25
|
+
'remix_reference_too_large',
|
|
26
|
+
'remix_reference_incomplete',
|
|
27
|
+
'remix_reference_unsupported',
|
|
28
|
+
'remix_reference_source_changed',
|
|
29
|
+
'workspace_access_denied',
|
|
30
|
+
'workspace_permission_denied',
|
|
31
|
+
]);
|
|
18
32
|
/** Executes source-reference operations only for the shell and native turn owning this route. */
|
|
19
33
|
export class WorkspaceRemixReferencesBroker {
|
|
20
34
|
state;
|
|
@@ -24,27 +38,53 @@ export class WorkspaceRemixReferencesBroker {
|
|
|
24
38
|
operation(context, valid, write = false) {
|
|
25
39
|
const state = this.state;
|
|
26
40
|
const session = state.journal.getSession(context.sessionId);
|
|
27
|
-
const operations = state.journal
|
|
28
|
-
|
|
41
|
+
const operations = state.journal
|
|
42
|
+
.listOperations()
|
|
43
|
+
.map((item) => item.operation)
|
|
44
|
+
.filter((operation) => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind) &&
|
|
45
|
+
ACTIVE.has(operation.status) &&
|
|
46
|
+
operation.generation === state.config.generation &&
|
|
47
|
+
operation.runtimeEpoch === state.runtimeEpoch);
|
|
29
48
|
const operation = operations.length === 1 ? operations[0] : undefined;
|
|
30
|
-
if (!valid() ||
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
49
|
+
if (!valid() ||
|
|
50
|
+
context.refsBlocked ||
|
|
51
|
+
state.journal.destroyed() ||
|
|
52
|
+
!operation ||
|
|
53
|
+
operation.kind !== 'session.turn' ||
|
|
54
|
+
operation.sessionId !== context.sessionId ||
|
|
55
|
+
operation.generation !== state.config.generation ||
|
|
56
|
+
operation.runtimeEpoch !== state.runtimeEpoch ||
|
|
57
|
+
operation.clientRequestId !== context.clientRequestId ||
|
|
58
|
+
!operation.workspaceId ||
|
|
59
|
+
!operation.directoryName ||
|
|
60
|
+
operation.directoryName === 'root' ||
|
|
61
|
+
!operation.nativeThreadId ||
|
|
62
|
+
!operation.nativeTurnId ||
|
|
63
|
+
!session ||
|
|
64
|
+
session.threadId !== operation.nativeThreadId ||
|
|
65
|
+
session.activeTurnId !== operation.nativeTurnId ||
|
|
66
|
+
session.cwd !== context.cwd ||
|
|
67
|
+
session.workspaceId !== operation.workspaceId ||
|
|
68
|
+
(session.directoryName ?? 'basic') !== operation.directoryName ||
|
|
69
|
+
session.turnBlock === 'uncertain' ||
|
|
70
|
+
!['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
|
|
38
71
|
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
39
72
|
if (write) {
|
|
40
73
|
if ((operation.leaseFence ?? 0) < state.acceptedLeaseFence)
|
|
41
74
|
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
42
|
-
if (context.readOnly ||
|
|
75
|
+
if (context.readOnly ||
|
|
76
|
+
operation.workMode === 'plan' ||
|
|
77
|
+
operation.permissionMode === 'read-only' ||
|
|
78
|
+
session.permissionMode === 'read-only')
|
|
43
79
|
throw new RemixReferenceToolError('remix_reference_readonly');
|
|
44
|
-
if (state.journal
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
80
|
+
if (state.journal
|
|
81
|
+
.listSessions()
|
|
82
|
+
.some((other) => other.sessionId !== context.sessionId &&
|
|
83
|
+
other.workspaceId === operation.workspaceId &&
|
|
84
|
+
((other.directoryName ?? 'basic') === operation.directoryName ||
|
|
85
|
+
other.directoryName === 'root') &&
|
|
86
|
+
(other.turnBlock === 'uncertain' ||
|
|
87
|
+
['starting', 'running', 'waiting_for_user'].includes(other.turnStatus ?? ''))))
|
|
48
88
|
throw new RemixReferenceToolError('turn_already_active');
|
|
49
89
|
}
|
|
50
90
|
return operation;
|
|
@@ -52,16 +92,20 @@ export class WorkspaceRemixReferencesBroker {
|
|
|
52
92
|
async current(context, valid, write = false) {
|
|
53
93
|
const operation = this.operation(context, valid, write);
|
|
54
94
|
const scope = await workspaceContextFromRoot(this.state, context.cwd);
|
|
55
|
-
if (scope.workspaceId !== operation.workspaceId ||
|
|
95
|
+
if (scope.workspaceId !== operation.workspaceId ||
|
|
96
|
+
scope.directoryName !== operation.directoryName)
|
|
56
97
|
throw new RemixReferenceToolError('remix_reference_scope_mismatch');
|
|
57
|
-
if (write && await batchDirectoryHeld(this.state, scope.workspaceId, scope.directoryName))
|
|
98
|
+
if (write && (await batchDirectoryHeld(this.state, scope.workspaceId, scope.directoryName)))
|
|
58
99
|
throw new RemixReferenceToolError('batch_workspace_readonly');
|
|
59
100
|
this.operation(context, valid, write);
|
|
60
101
|
return { ...scope, operation };
|
|
61
102
|
}
|
|
62
103
|
async forward(request, response, context, valid) {
|
|
63
104
|
const reply = (status, value) => {
|
|
64
|
-
response.writeHead(status, {
|
|
105
|
+
response.writeHead(status, {
|
|
106
|
+
'content-type': 'application/json',
|
|
107
|
+
'cache-control': 'no-store',
|
|
108
|
+
});
|
|
65
109
|
response.end(JSON.stringify(value));
|
|
66
110
|
};
|
|
67
111
|
try {
|
|
@@ -77,41 +121,87 @@ export class WorkspaceRemixReferencesBroker {
|
|
|
77
121
|
if (!parsed.success)
|
|
78
122
|
throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
|
|
79
123
|
const input = parsed.data;
|
|
80
|
-
const write = input.action === 'import';
|
|
124
|
+
const write = input.action === 'import' || input.action === 'prepare-starter-template';
|
|
81
125
|
const scope = await this.current(context, valid, write);
|
|
82
126
|
const callerCwd = await realpath(input.cwd);
|
|
83
127
|
const childPath = relative(context.cwd, callerCwd);
|
|
84
|
-
if (isAbsolute(childPath) ||
|
|
85
|
-
|
|
128
|
+
if (isAbsolute(childPath) ||
|
|
129
|
+
childPath === '..' ||
|
|
130
|
+
childPath.startsWith('../') ||
|
|
131
|
+
(input.workspaceId !== undefined && input.workspaceId !== scope.workspaceId) ||
|
|
132
|
+
(input.directoryName !== undefined && input.directoryName !== scope.directoryName))
|
|
86
133
|
throw new RemixReferenceToolError('remix_reference_scope_mismatch');
|
|
87
134
|
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
88
|
-
if (!socketPath ||
|
|
89
|
-
|
|
90
|
-
|
|
135
|
+
if (!socketPath ||
|
|
136
|
+
!context.boundaryId ||
|
|
137
|
+
!request.socket.localPort ||
|
|
138
|
+
!request.socket.remotePort ||
|
|
139
|
+
!(await verifyLauncherOrigin({
|
|
140
|
+
socketPath,
|
|
141
|
+
boundaryId: context.boundaryId,
|
|
142
|
+
runtimeEpoch: this.state.runtimeEpoch,
|
|
143
|
+
serverPort: request.socket.localPort,
|
|
144
|
+
clientPort: request.socket.remotePort,
|
|
145
|
+
cwd: context.originCwd ?? callerCwd,
|
|
146
|
+
})))
|
|
91
147
|
throw new RemixReferenceToolError('remix_reference_turn_unavailable');
|
|
92
148
|
await this.current(context, valid, write);
|
|
93
149
|
if (input.action === 'list') {
|
|
94
150
|
const items = await this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, () => listWorkspaceRemixReferences(context.cwd, scope.directoryName));
|
|
95
151
|
this.operation(context, valid);
|
|
96
|
-
return reply(200, {
|
|
97
|
-
|
|
152
|
+
return reply(200, {
|
|
153
|
+
ok: true,
|
|
154
|
+
workspaceId: scope.workspaceId,
|
|
155
|
+
directoryName: scope.directoryName,
|
|
156
|
+
items: items.map((reference) => localRemixReference(context.cwd, reference)),
|
|
157
|
+
});
|
|
98
158
|
}
|
|
99
159
|
if (input.action === 'search') {
|
|
100
|
-
const result = candidatesSchema.parse(await this.upstream(scope, {
|
|
101
|
-
|
|
102
|
-
|
|
160
|
+
const result = candidatesSchema.parse(await this.upstream(scope, {
|
|
161
|
+
action: 'search',
|
|
162
|
+
source: input.source,
|
|
163
|
+
...(input.tagCategory === undefined
|
|
164
|
+
? {}
|
|
165
|
+
: { tagCategory: input.tagCategory, tagValues: input.tagValues }),
|
|
166
|
+
...(input.q === undefined ? {} : { q: input.q }),
|
|
167
|
+
page: input.page,
|
|
168
|
+
limit: input.limit,
|
|
169
|
+
}, 2 * 1024 * 1024));
|
|
103
170
|
await this.current(context, valid);
|
|
104
|
-
return reply(200, {
|
|
105
|
-
|
|
171
|
+
return reply(200, {
|
|
172
|
+
ok: true,
|
|
173
|
+
workspaceId: scope.workspaceId,
|
|
174
|
+
directoryName: scope.directoryName,
|
|
175
|
+
...result,
|
|
176
|
+
items: result.items.map((item) => workspaceRemixReferenceCandidateSchema.parse(item)),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (input.action === 'prepare-starter-template') {
|
|
180
|
+
const result = await withDirectoryWrite(this.state, {
|
|
181
|
+
workspaceId: scope.workspaceId, directoryName: scope.directoryName,
|
|
182
|
+
sessionId: context.sessionId, wait: false,
|
|
183
|
+
}, async () => {
|
|
184
|
+
await this.current(context, valid, true);
|
|
185
|
+
const raw = await this.upstream(scope, { action: 'starter-template' }, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES);
|
|
186
|
+
await this.current(context, valid, true);
|
|
187
|
+
return prepareStarterTemplate(context.cwd, raw, () => this.operation(context, valid, true));
|
|
188
|
+
});
|
|
189
|
+
return reply(200, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...result });
|
|
106
190
|
}
|
|
107
|
-
const result = await withDirectoryWrite(this.state, {
|
|
108
|
-
|
|
191
|
+
const result = await withDirectoryWrite(this.state, {
|
|
192
|
+
workspaceId: scope.workspaceId,
|
|
193
|
+
directoryName: scope.directoryName,
|
|
194
|
+
sessionId: context.sessionId,
|
|
195
|
+
wait: false,
|
|
196
|
+
}, async () => {
|
|
109
197
|
await this.current(context, valid, true);
|
|
110
198
|
const raw = await this.upstream(scope, { action: 'export', projectUuid: input.projectUuid, branchName: input.branchName }, WORKSPACE_REMIX_REFERENCE_BODY_MAX_BYTES);
|
|
111
199
|
await this.current(context, valid, true);
|
|
112
200
|
const snapshot = workspaceRemixReferenceExportSchema.parse(raw);
|
|
113
|
-
if (snapshot.source.environment !== this.state.config.environment ||
|
|
114
|
-
|
|
201
|
+
if (snapshot.source.environment !== this.state.config.environment ||
|
|
202
|
+
!('projectUuid' in snapshot.source) ||
|
|
203
|
+
snapshot.source.projectUuid !== input.projectUuid ||
|
|
204
|
+
snapshot.source.branchName !== input.branchName)
|
|
115
205
|
throw new RemixReferenceToolError('remix_reference_unavailable');
|
|
116
206
|
return this.state.journal.exclusive(`workspace:${scope.workspaceId}:${scope.directoryName}`, async () => {
|
|
117
207
|
await this.current(context, valid, true);
|
|
@@ -121,45 +211,91 @@ export class WorkspaceRemixReferencesBroker {
|
|
|
121
211
|
this.operation(context, valid, true);
|
|
122
212
|
});
|
|
123
213
|
this.operation(context, valid, true);
|
|
124
|
-
await this.state.journal.allocateSourceEvent({
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
214
|
+
await this.state.journal.allocateSourceEvent({
|
|
215
|
+
runtimeId: this.state.config.runtimeId,
|
|
216
|
+
generation: this.state.config.generation,
|
|
217
|
+
runtimeEpoch: this.state.runtimeEpoch,
|
|
218
|
+
type: 'workspace.references.imported',
|
|
219
|
+
occurredAt: new Date().toISOString(),
|
|
220
|
+
sessionId: context.sessionId,
|
|
221
|
+
workspaceId: scope.workspaceId,
|
|
222
|
+
operationId: scope.operation.operationId,
|
|
223
|
+
payload: {
|
|
224
|
+
sessionId: context.sessionId,
|
|
225
|
+
workspaceId: scope.workspaceId,
|
|
226
|
+
directoryName: scope.directoryName,
|
|
227
|
+
referenceId: reference.referenceId,
|
|
228
|
+
path: reference.path,
|
|
229
|
+
contentHash: reference.contentHash,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
await this.state.journal.allocateSourceEvent({
|
|
233
|
+
runtimeId: this.state.config.runtimeId,
|
|
234
|
+
generation: this.state.config.generation,
|
|
235
|
+
runtimeEpoch: this.state.runtimeEpoch,
|
|
236
|
+
type: 'files.changed',
|
|
237
|
+
occurredAt: new Date().toISOString(),
|
|
238
|
+
sessionId: context.sessionId,
|
|
239
|
+
workspaceId: scope.workspaceId,
|
|
240
|
+
operationId: scope.operation.operationId,
|
|
241
|
+
payload: {
|
|
242
|
+
workspaceId: scope.workspaceId,
|
|
243
|
+
directoryName: scope.directoryName,
|
|
244
|
+
paths: [reference.path, WORKSPACE_REMIX_REFERENCES_MANIFEST],
|
|
245
|
+
source: 'workspace.references.import',
|
|
246
|
+
operationId: scope.operation.operationId,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
137
249
|
wakeSourceEventUploader(this.state);
|
|
138
250
|
return reference;
|
|
139
251
|
});
|
|
140
252
|
});
|
|
141
|
-
return reply(200, {
|
|
142
|
-
|
|
253
|
+
return reply(200, {
|
|
254
|
+
ok: true,
|
|
255
|
+
workspaceId: scope.workspaceId,
|
|
256
|
+
directoryName: scope.directoryName,
|
|
257
|
+
reference: localRemixReference(context.cwd, result),
|
|
258
|
+
});
|
|
143
259
|
}
|
|
144
260
|
catch (error) {
|
|
145
261
|
if (response.headersSent) {
|
|
146
262
|
response.destroy();
|
|
147
263
|
return;
|
|
148
264
|
}
|
|
149
|
-
const code = error instanceof SyntaxError
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
265
|
+
const code = error instanceof SyntaxError
|
|
266
|
+
? 'invalid_remix_reference_tool_request'
|
|
267
|
+
: error instanceof RemixReferenceToolError
|
|
268
|
+
? error.code
|
|
269
|
+
: error instanceof Error &&
|
|
270
|
+
/^(?:remix_reference_[a-z_]+|directory_busy)$/.test(error.message)
|
|
271
|
+
? error.message
|
|
272
|
+
: 'remix_reference_tool_failed';
|
|
273
|
+
reply(code === 'invalid_remix_reference_tool_request'
|
|
274
|
+
? 400
|
|
275
|
+
: ['turn_already_active', 'batch_workspace_readonly', 'directory_busy'].includes(code)
|
|
276
|
+
? 409
|
|
277
|
+
: 403, { ok: false, error: { code } });
|
|
154
278
|
}
|
|
155
279
|
}
|
|
156
280
|
async upstream(scope, input, limit) {
|
|
157
281
|
const config = this.state.config;
|
|
158
282
|
const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.remixReferenceTools), {
|
|
159
|
-
method: 'POST',
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
283
|
+
method: 'POST',
|
|
284
|
+
redirect: 'error',
|
|
285
|
+
signal: AbortSignal.timeout(120_000),
|
|
286
|
+
headers: {
|
|
287
|
+
authorization: `Bearer ${config.controlToken}`,
|
|
288
|
+
'content-type': 'application/json',
|
|
289
|
+
'playcraft-env': config.environment,
|
|
290
|
+
},
|
|
291
|
+
body: JSON.stringify({
|
|
292
|
+
runtimeId: config.runtimeId,
|
|
293
|
+
generation: config.generation,
|
|
294
|
+
runtimeEpoch: this.state.runtimeEpoch,
|
|
295
|
+
workspaceId: scope.workspaceId,
|
|
296
|
+
directoryName: scope.directoryName,
|
|
297
|
+
...input,
|
|
298
|
+
}),
|
|
163
299
|
});
|
|
164
300
|
const chunks = [];
|
|
165
301
|
let bytes = 0;
|
|
@@ -175,8 +311,13 @@ export class WorkspaceRemixReferencesBroker {
|
|
|
175
311
|
}
|
|
176
312
|
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
177
313
|
if (!response.ok) {
|
|
178
|
-
const code = [
|
|
179
|
-
|
|
314
|
+
const code = [
|
|
315
|
+
value?.reason,
|
|
316
|
+
value?.code,
|
|
317
|
+
value?.message,
|
|
318
|
+
value?.error?.reason,
|
|
319
|
+
value?.error?.code,
|
|
320
|
+
].find((code) => typeof code === 'string' && SOURCE_ERRORS.has(code));
|
|
180
321
|
throw new RemixReferenceToolError(code ?? 'remix_reference_unavailable');
|
|
181
322
|
}
|
|
182
323
|
return value;
|
|
@@ -109,6 +109,9 @@ export class RuntimeJournal {
|
|
|
109
109
|
getWorkspace(workspaceId) {
|
|
110
110
|
return this.snapshot.workspaces[workspaceId];
|
|
111
111
|
}
|
|
112
|
+
listWorkspaces() {
|
|
113
|
+
return Object.values(this.snapshot.workspaces);
|
|
114
|
+
}
|
|
112
115
|
async putWorkspace(binding) {
|
|
113
116
|
const parsed = workspaceRegistrationSchema.parse(binding);
|
|
114
117
|
await this.run('upsertRecord', { bucket: 'workspaces', key: parsed.workspaceId, body: parsed });
|
|
@@ -33,7 +33,7 @@ import { revisionScope } from './workspaces/registry.js';
|
|
|
33
33
|
import { observeWorkspaceArtifacts } from './workspaces/artifact-events.js';
|
|
34
34
|
import { loadWorkspaceMcpConfig } from './codex/mcp-config.js';
|
|
35
35
|
import { startSourceEventUploader, stopSourceEventUploader } from './server/source-uploader.js';
|
|
36
|
-
import { platformExportTarget, readPlatformExport, startPlatformExport } from './workspaces/platform-export.js';
|
|
36
|
+
import { platformExportTarget, readPlatformExport, startPlatformExport, syncWorkspaceBindings } from './workspaces/platform-export.js';
|
|
37
37
|
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
38
38
|
import { executionContextToolHandler } from './workspaces/execution-context.js';
|
|
39
39
|
import { registerLevelEditorRequest, startLevelEditorRequestQueue, stopLevelEditorRequestQueue } from './workspaces/level-editor-requests.js';
|
|
@@ -352,6 +352,8 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
352
352
|
const port = await listenOn(server, config.listen.host, config.listen.port);
|
|
353
353
|
startLevelEditorRequestQueue(state);
|
|
354
354
|
startSkillActivationRequestQueue(state);
|
|
355
|
+
// After listening: the platform registers a missed binding through this server before it replies.
|
|
356
|
+
void syncWorkspaceBindings(state);
|
|
355
357
|
return {
|
|
356
358
|
config: state.config,
|
|
357
359
|
state,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { isTurnControl, takesWorkspaceDirectory, takesExecutionSlot, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
4
5
|
import { listMembers, readSnapshot } from '../batch/records.js';
|
|
5
6
|
const context = new AsyncLocalStorage();
|
|
6
7
|
const gates = new WeakMap();
|
|
@@ -481,6 +482,15 @@ export function releaseBatchDirectory(state, member) {
|
|
|
481
482
|
if (!member.holding && member.writersStopped)
|
|
482
483
|
instances.get(state)?.release(`run:${member.runId}`);
|
|
483
484
|
}
|
|
485
|
+
/**
|
|
486
|
+
* A lease rejection names its cause so callers can tell a running batch member apart from ordinary
|
|
487
|
+
* directory contention: the former is a production lock, not a failure. A damaged member record
|
|
488
|
+
* must not replace the rejection with a failed response.
|
|
489
|
+
*/
|
|
490
|
+
export async function directoryBusyCode(state, workspaceId, directoryName = 'basic') {
|
|
491
|
+
const held = await batchDirectoryHeld(state, workspaceId, directoryName).catch(() => false);
|
|
492
|
+
return held ? 'batch_workspace_readonly' : 'directory_busy';
|
|
493
|
+
}
|
|
484
494
|
export async function withDirectoryFreeze(state, scope, action, quiesce, options) {
|
|
485
495
|
const pending = freezing.get(state) ??
|
|
486
496
|
new Map();
|