@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.
- package/README.md +20 -1
- package/dist/cli-root-help.js +1 -0
- package/dist/commands/references.js +3 -0
- package/dist/commands/skills.js +68 -4
- package/dist/commands/tools-batch.js +2 -0
- package/dist/project-skills/discovery.js +248 -0
- package/dist/project-skills/messages.js +80 -0
- package/dist/project-skills/remote-activation.js +500 -0
- package/dist/project-skills/remote-client.js +117 -0
- package/dist/project-skills/remote-commands.js +87 -0
- package/dist/project-skills/remote-prepare.js +223 -0
- package/dist/project-skills/search-command.js +47 -0
- package/dist/project-skills/search.js +96 -0
- package/dist/recording/project-digest.js +1 -1
- 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 +30 -1
- package/dist/workspace-runtime/codex/model-broker.js +93 -6
- package/dist/workspace-runtime/codex/remix-references-broker.js +208 -67
- package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/directory-access.js +10 -0
- package/dist/workspace-runtime/server/dispatch.js +121 -28
- package/dist/workspace-runtime/server/http.js +345 -117
- package/dist/workspace-runtime/server/operation-record.js +3 -1
- package/dist/workspace-runtime/workspaces/composer-context.js +92 -41
- package/dist/workspace-runtime/workspaces/files.js +59 -23
- package/dist/workspace-runtime/workspaces/remix-references.js +162 -53
- package/dist/workspace-runtime/workspaces/skill-activation-recovery.js +54 -0
- package/dist/workspace-runtime/workspaces/skill-activation-requests.js +512 -0
- package/dist/workspace-runtime/workspaces/skills.js +13 -176
- package/dist/workspace-runtime/workspaces/starter-template.js +74 -0
- package/package.json +4 -3
- package/project-template/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-template-v2/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- package/project-templates/empty/gitignore +1 -0
- package/project-templates/phaser/.claude/skills/playcraft-skill-recommender/SKILL.md +28 -6
- 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),
|
|
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
|
|
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;
|