@playcraft/cli 0.1.71 → 0.1.72

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.
@@ -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.object({ items: z.array(z.unknown()).max(50),
15
- total: z.number().int().nonnegative(), page: z.number().int().positive(), pageSize: z.number().int().positive() }).strict();
16
- const SOURCE_ERRORS = new Set(['remix_reference_unavailable', 'remix_reference_too_large', 'remix_reference_incomplete',
17
- 'remix_reference_unsupported', 'remix_reference_source_changed', 'workspace_access_denied', 'workspace_permission_denied']);
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.listOperations().map(item => item.operation).filter(operation => ['session.turn', 'session.review', 'session.compact'].includes(operation.kind)
28
- && ACTIVE.has(operation.status) && operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch);
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() || context.refsBlocked || state.journal.destroyed() || !operation || operation.kind !== 'session.turn' || operation.sessionId !== context.sessionId
31
- || operation.generation !== state.config.generation || operation.runtimeEpoch !== state.runtimeEpoch
32
- || operation.clientRequestId !== context.clientRequestId || !operation.workspaceId || !operation.directoryName
33
- || operation.directoryName === 'root' || !operation.nativeThreadId || !operation.nativeTurnId
34
- || !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
35
- || session.cwd !== context.cwd || session.workspaceId !== operation.workspaceId
36
- || (session.directoryName ?? 'basic') !== operation.directoryName || session.turnBlock === 'uncertain'
37
- || !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
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 || operation.workMode === 'plan' || operation.permissionMode === 'read-only' || session.permissionMode === 'read-only')
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.listSessions().some(other => other.sessionId !== context.sessionId
45
- && other.workspaceId === operation.workspaceId
46
- && ((other.directoryName ?? 'basic') === operation.directoryName || other.directoryName === 'root')
47
- && (other.turnBlock === 'uncertain' || ['starting', 'running', 'waiting_for_user'].includes(other.turnStatus ?? ''))))
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 || scope.directoryName !== operation.directoryName)
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, { 'content-type': 'application/json', 'cache-control': 'no-store' });
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) || childPath === '..' || childPath.startsWith('../') || input.workspaceId !== undefined && input.workspaceId !== scope.workspaceId
85
- || input.directoryName !== undefined && input.directoryName !== scope.directoryName)
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 || !context.boundaryId || !request.socket.localPort || !request.socket.remotePort
89
- || !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId, runtimeEpoch: this.state.runtimeEpoch,
90
- serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? callerCwd }))
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, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
97
- items: items.map(reference => localRemixReference(context.cwd, reference)) });
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, { action: 'search', source: input.source,
101
- ...(input.tagCategory === undefined ? {} : { tagCategory: input.tagCategory, tagValues: input.tagValues }),
102
- ...(input.q === undefined ? {} : { q: input.q }), page: input.page, limit: input.limit }, 2 * 1024 * 1024));
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, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...result,
105
- items: result.items.map(item => workspaceRemixReferenceCandidateSchema.parse(item)) });
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, { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
108
- sessionId: context.sessionId, wait: false }, async () => {
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 || snapshot.source.projectUuid !== input.projectUuid
114
- || snapshot.source.branchName !== input.branchName)
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({ runtimeId: this.state.config.runtimeId,
125
- generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
126
- type: 'workspace.references.imported', occurredAt: new Date().toISOString(),
127
- sessionId: context.sessionId, workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
128
- payload: { sessionId: context.sessionId, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
129
- referenceId: reference.referenceId, path: reference.path, contentHash: reference.contentHash } });
130
- await this.state.journal.allocateSourceEvent({ runtimeId: this.state.config.runtimeId,
131
- generation: this.state.config.generation, runtimeEpoch: this.state.runtimeEpoch,
132
- type: 'files.changed', occurredAt: new Date().toISOString(), sessionId: context.sessionId,
133
- workspaceId: scope.workspaceId, operationId: scope.operation.operationId,
134
- payload: { workspaceId: scope.workspaceId, directoryName: scope.directoryName,
135
- paths: [reference.path, WORKSPACE_REMIX_REFERENCES_MANIFEST], source: 'workspace.references.import',
136
- operationId: scope.operation.operationId } });
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, { ok: true, workspaceId: scope.workspaceId, directoryName: scope.directoryName,
142
- reference: localRemixReference(context.cwd, result) });
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 ? 'invalid_remix_reference_tool_request' : error instanceof RemixReferenceToolError ? error.code
150
- : error instanceof Error && /^(?:remix_reference_[a-z_]+|directory_busy)$/.test(error.message)
151
- ? error.message : 'remix_reference_tool_failed';
152
- reply(code === 'invalid_remix_reference_tool_request' ? 400
153
- : ['turn_already_active', 'batch_workspace_readonly', 'directory_busy'].includes(code) ? 409 : 403, { ok: false, error: { code } });
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', redirect: 'error', signal: AbortSignal.timeout(120_000),
160
- headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
161
- body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
162
- workspaceId: scope.workspaceId, directoryName: scope.directoryName, ...input }),
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 = [value?.reason, value?.code, value?.message, value?.error?.reason, value?.error?.code]
179
- .find(code => typeof code === 'string' && SOURCE_ERRORS.has(code));
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;
@@ -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();