@playcraft/cli 0.1.70 → 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.
Files changed (43) hide show
  1. package/README.md +20 -1
  2. package/dist/cli-root-help.js +1 -0
  3. package/dist/commands/references.js +3 -0
  4. package/dist/commands/skills.js +68 -4
  5. package/dist/commands/tools-batch.js +2 -0
  6. package/dist/project-skills/discovery.js +248 -0
  7. package/dist/project-skills/messages.js +80 -0
  8. package/dist/project-skills/remote-activation.js +500 -0
  9. package/dist/project-skills/remote-client.js +117 -0
  10. package/dist/project-skills/remote-commands.js +87 -0
  11. package/dist/project-skills/remote-prepare.js +223 -0
  12. package/dist/project-skills/search-command.js +47 -0
  13. package/dist/project-skills/search.js +96 -0
  14. package/dist/recording/project-digest.js +1 -1
  15. package/dist/references/messages.js +2 -0
  16. package/dist/references/protocol.js +39 -12
  17. package/dist/workspace-runtime/batch/freeze.js +94 -24
  18. package/dist/workspace-runtime/batch/records.js +12 -6
  19. package/dist/workspace-runtime/batch/routes.js +31 -11
  20. package/dist/workspace-runtime/batch/source-candidate.js +29 -16
  21. package/dist/workspace-runtime/batch/source-capture.js +251 -20
  22. package/dist/workspace-runtime/codex/app-server.js +30 -1
  23. package/dist/workspace-runtime/codex/model-broker.js +93 -6
  24. package/dist/workspace-runtime/codex/remix-references-broker.js +208 -67
  25. package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
  26. package/dist/workspace-runtime/serve.js +5 -0
  27. package/dist/workspace-runtime/server/directory-access.js +10 -0
  28. package/dist/workspace-runtime/server/dispatch.js +121 -28
  29. package/dist/workspace-runtime/server/http.js +345 -117
  30. package/dist/workspace-runtime/server/operation-record.js +3 -1
  31. package/dist/workspace-runtime/workspaces/composer-context.js +92 -41
  32. package/dist/workspace-runtime/workspaces/files.js +59 -23
  33. package/dist/workspace-runtime/workspaces/remix-references.js +162 -53
  34. package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
  35. package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
  36. package/dist/workspace-runtime/workspaces/skills.js +13 -176
  37. package/dist/workspace-runtime/workspaces/starter-template.js +74 -0
  38. package/package.json +4 -3
  39. package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  40. package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  41. package/project-templates/empty/gitignore +1 -0
  42. package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
  43. package/project-templates/threejs/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
@@ -1,14 +1,15 @@
1
1
  import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { createServer } from 'node:http';
3
- import { Readable } from 'node:stream';
3
+ import { Readable, Transform } from 'node:stream';
4
4
  import { pipeline } from 'node:stream/promises';
5
5
  import { basename, dirname } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { workspaceContextFromRoot } from '../workspaces/registry.js';
8
- import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
8
+ import { PLATFORM_ROUTES, modelResponsesPath, platformEndpoint, workspaceExecutionTraceOriginSchema } from '@playcraft/common/cloud-workspace/runtime';
9
9
  import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-broker.js';
10
10
  import { WorkspaceRemixReferencesBroker } from './remix-references-broker.js';
11
11
  import { WorkspaceExecutionContextBroker } from './execution-context-broker.js';
12
+ import { WorkspaceSkillDiscoveryBroker } from './skill-discovery-broker.js';
12
13
  const credentialSchema = z.object({
13
14
  token: z.string().min(1), expiresAt: z.string().datetime(),
14
15
  configId: z.string().uuid(), model: z.string().min(1).max(128),
@@ -16,6 +17,7 @@ const credentialSchema = z.object({
16
17
  claims: z.object({
17
18
  environment: z.string(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
18
19
  generation: z.number().int(), runtimeEpoch: z.number().int(),
20
+ traceOrigin: z.custom(value => workspaceExecutionTraceOriginSchema.safeParse(value).success).optional(),
19
21
  }),
20
22
  });
21
23
  const MAX_MODEL_REQUEST_BYTES = 128 * 1024 * 1024;
@@ -32,11 +34,13 @@ export class WorkspaceModelBroker {
32
34
  generation;
33
35
  references;
34
36
  executionContext;
37
+ skills;
35
38
  constructor(state) {
36
39
  this.state = state;
37
40
  this.generation = new WorkspaceGenerationProvenanceBroker(state);
38
41
  this.references = new WorkspaceRemixReferencesBroker(state);
39
42
  this.executionContext = new WorkspaceExecutionContextBroker(state);
43
+ this.skills = new WorkspaceSkillDiscoveryBroker(state);
40
44
  }
41
45
  async start() {
42
46
  if (this.server)
@@ -81,7 +85,8 @@ export class WorkspaceModelBroker {
81
85
  const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
82
86
  if (!route || route.epoch !== this.state.runtimeEpoch) {
83
87
  route = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
84
- generationCapability: randomBytes(32).toString('base64url'), referencesCapability: randomBytes(32).toString('base64url') };
88
+ generationCapability: randomBytes(32).toString('base64url'), referencesCapability: randomBytes(32).toString('base64url'),
89
+ skillsCapability: randomBytes(32).toString('base64url') };
85
90
  this.routes.set(routeKey, route);
86
91
  }
87
92
  // Each session pins its prepared model; shared credential renewal cannot change an active turn.
@@ -123,6 +128,8 @@ export class WorkspaceModelBroker {
123
128
  PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
124
129
  PLAYCRAFT_REMIX_REFERENCES_ENDPOINT: `${this.baseUrl}/${routeKey}/remix-references`,
125
130
  PLAYCRAFT_REMIX_REFERENCES_CAPABILITY: route.referencesCapability,
131
+ PLAYCRAFT_SKILLS_ENDPOINT: `${this.baseUrl}/${routeKey}/skill-discovery`,
132
+ PLAYCRAFT_SKILLS_CAPABILITY: route.skillsCapability,
126
133
  } } : {}),
127
134
  'model_providers.playcraft_workspace': {
128
135
  name: 'PlayCraft Workspace', base_url: `${this.baseUrl}/${routeKey}`,
@@ -137,6 +144,16 @@ export class WorkspaceModelBroker {
137
144
  && this.state.runtimeEpoch === entry.epoch && this.state.journal.getWorkspace(entry.workspaceId)
138
145
  && this.entries.get(entry.cacheKey) === entry);
139
146
  }
147
+ /** Called immediately before the native turn/start write, within this broker's process clock. */
148
+ markCodexDispatched(cwd, sessionId, clientRequestId) {
149
+ const workspaceId = /^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd);
150
+ const route = this.routes.get(`${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`);
151
+ const entry = route?.selected?.entry;
152
+ if (!entry || !this.valid(entry) || entry.sessionId !== sessionId || entry.clientRequestId !== clientRequestId)
153
+ return;
154
+ entry.codexDispatchedAt = new Date().toISOString();
155
+ entry.codexDispatchMark = performance.now();
156
+ }
140
157
  async credential(entry, refresh = false) {
141
158
  if (!this.valid(entry))
142
159
  throw new Error('model_access_denied');
@@ -179,13 +196,16 @@ export class WorkspaceModelBroker {
179
196
  return credential;
180
197
  }
181
198
  async forward(request, response) {
199
+ const receivedMark = performance.now();
200
+ const receivedAt = new Date().toISOString();
201
+ const elapsed = (start) => Math.max(0, Math.round(performance.now() - start));
182
202
  const abort = new AbortController();
183
203
  this.requests.add(abort);
184
204
  let activeRoute;
185
205
  const cancel = () => abort.abort();
186
206
  response.once('close', cancel);
187
207
  try {
188
- const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references|execution-context)$/.exec(request.url ?? '');
208
+ const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references|execution-context|skill-discovery\/(?:match|selection|artifact|activate))$/.exec(request.url ?? '');
189
209
  const route = match ? this.routes.get(match[1]) : undefined;
190
210
  const selected = route?.selected;
191
211
  const entry = selected?.entry;
@@ -193,8 +213,9 @@ export class WorkspaceModelBroker {
193
213
  const generationRequest = match?.[2] === 'generation-provenance';
194
214
  const contextRequest = match?.[2] === 'execution-context';
195
215
  const referencesRequest = match?.[2] === 'remix-references';
216
+ const skillsRequest = match?.[2]?.startsWith('skill-discovery/');
196
217
  const expected = Buffer.from(route ? `Bearer ${generationRequest || contextRequest ? route.generationCapability
197
- : referencesRequest ? route.referencesCapability : route.capability}` : '');
218
+ : referencesRequest ? route.referencesCapability : skillsRequest ? route.skillsCapability : route.capability}` : '');
198
219
  if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
199
220
  || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
200
221
  response.writeHead(403);
@@ -228,6 +249,16 @@ export class WorkspaceModelBroker {
228
249
  await this.references.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected);
229
250
  return;
230
251
  }
252
+ if (skillsRequest) {
253
+ if (!selected?.feedback) {
254
+ response.writeHead(403);
255
+ response.end();
256
+ return;
257
+ }
258
+ const action = match[2].slice('skill-discovery/'.length);
259
+ await this.skills.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected, action);
260
+ return;
261
+ }
231
262
  const rejectOversizedBody = () => {
232
263
  response.writeHead(413, { 'content-type': 'application/json', 'cache-control': 'no-store' });
233
264
  response.end(JSON.stringify({ error: { message: 'model_request_too_large',
@@ -242,7 +273,10 @@ export class WorkspaceModelBroker {
242
273
  const active = this.sessionRequests.get(route) ?? new Set();
243
274
  active.add(abort);
244
275
  this.sessionRequests.set(route, active);
276
+ const credentialMark = performance.now();
245
277
  const credential = await this.credential(entry);
278
+ const credentialWaitMs = elapsed(credentialMark);
279
+ const requestReadMark = performance.now();
246
280
  const chunks = [];
247
281
  let size = 0;
248
282
  for await (const chunk of request) {
@@ -253,6 +287,7 @@ export class WorkspaceModelBroker {
253
287
  }
254
288
  chunks.push(Buffer.from(chunk));
255
289
  }
290
+ const requestReadMs = elapsed(requestReadMark);
256
291
  if (!this.valid(entry))
257
292
  throw new Error('model_access_denied');
258
293
  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
@@ -280,16 +315,37 @@ export class WorkspaceModelBroker {
280
315
  };
281
316
  }
282
317
  const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
318
+ const dispatchedAt = new Date().toISOString();
319
+ const dispatchedMark = performance.now();
320
+ const beforeDispatchMs = elapsed(receivedMark);
321
+ const candidateOrigin = credential.claims.traceOrigin;
322
+ const origin = candidateOrigin?.sessionId === entry.sessionId && candidateOrigin?.clientRequestId === entry.clientRequestId
323
+ ? candidateOrigin : undefined;
324
+ const broker = { receivedAt, dispatchedAt,
325
+ credentialWaitMs, requestReadMs, beforeDispatchMs };
326
+ let attemptId;
327
+ let outcome = 'error';
283
328
  try {
284
329
  const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
285
330
  method: 'POST', redirect: 'error', signal: abort.signal,
286
331
  headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json',
332
+ ...(origin ? { 'x-playcraft-model-journey': JSON.stringify({ version: 1,
333
+ runtimeOperationId: origin.runtimeOperationId, sessionId: origin.sessionId, clientRequestId: origin.clientRequestId,
334
+ ...(entry.codexDispatchedAt ? { codexDispatchedAt: entry.codexDispatchedAt } : {}),
335
+ brokerReceivedAt: receivedAt, brokerDispatchedAt: dispatchedAt, credentialWaitMs, requestReadMs, beforeDispatchMs,
336
+ ...(entry.codexDispatchMark === undefined ? {} : { codexDispatchToBrokerMs: Math.max(0, Math.round(receivedMark - entry.codexDispatchMark)) }),
337
+ }) } : {}),
287
338
  ...(selected?.feedback ? {
288
339
  'x-playcraft-cloud-session-id': selected.feedback.sessionId,
289
340
  'x-playcraft-client-request-id': selected.feedback.clientRequestId,
290
341
  } : {}) },
291
342
  body: JSON.stringify(body),
292
343
  });
344
+ broker.responseHeadersAt = new Date().toISOString();
345
+ broker.proxyResponseHeadersMs = elapsed(dispatchedMark);
346
+ const reference = upstream.headers.get('x-model-attempt-id');
347
+ if (reference && z.string().uuid().safeParse(reference).success)
348
+ attemptId = reference;
293
349
  if (upstream.status === 401 || upstream.status === 403)
294
350
  entry.credential = undefined;
295
351
  const admissionReason = upstream.headers.get('x-model-admission-reason')?.trim();
@@ -305,12 +361,33 @@ export class WorkspaceModelBroker {
305
361
  ...(retryAfter ? { 'retry-after': retryAfter } : {}),
306
362
  });
307
363
  if (upstream.body)
308
- await pipeline(Readable.fromWeb(upstream.body), response);
364
+ await pipeline(Readable.fromWeb(upstream.body), new Transform({
365
+ transform(chunk, _encoding, callback) {
366
+ if (chunk.length && broker.firstBodyByteAt === undefined) {
367
+ broker.firstBodyByteAt = new Date().toISOString();
368
+ broker.proxyFirstBodyByteMs = elapsed(dispatchedMark);
369
+ }
370
+ callback(null, chunk);
371
+ },
372
+ }), response);
309
373
  else
310
374
  response.end();
375
+ outcome = 'completed';
376
+ }
377
+ catch (error) {
378
+ outcome = abort.signal.aborted ? 'interrupted' : 'error';
379
+ throw error;
311
380
  }
312
381
  finally {
313
382
  clearTimeout(expires);
383
+ broker.completedAt = new Date().toISOString();
384
+ broker.proxyResponseDurationMs = elapsed(dispatchedMark);
385
+ if (origin && attemptId) {
386
+ // Telemetry is sent after transfer and cannot delay or change the Codex model response.
387
+ void this.reportObservation(credential.token, { version: 1, attemptId,
388
+ runtimeOperationId: origin.runtimeOperationId, sessionId: origin.sessionId,
389
+ clientRequestId: origin.clientRequestId, broker, outcome });
390
+ }
314
391
  }
315
392
  }
316
393
  catch {
@@ -332,6 +409,16 @@ export class WorkspaceModelBroker {
332
409
  }
333
410
  }
334
411
  }
412
+ async reportObservation(token, input) {
413
+ try {
414
+ const response = await fetch(platformEndpoint(this.state.config.publicBaseUrl, '/api/llm-proxy/runtime-observations'), {
415
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(3_000),
416
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(input),
417
+ });
418
+ await response.body?.cancel();
419
+ }
420
+ catch { /* Missing observations remain unknown; never replay a model request for telemetry. */ }
421
+ }
335
422
  cancelSession(cwd, sessionId) {
336
423
  const routeKey = `${/^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd)}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
337
424
  const route = this.routes.get(routeKey);
@@ -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;