@playcraft/cli 0.1.70 → 0.1.71
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/commands/skills.js +68 -4
- 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/workspace-runtime/batch/source-capture.js +1 -1
- package/dist/workspace-runtime/codex/app-server.js +25 -1
- package/dist/workspace-runtime/codex/model-broker.js +93 -6
- package/dist/workspace-runtime/codex/skill-discovery-broker.js +174 -0
- package/dist/workspace-runtime/serve.js +5 -0
- package/dist/workspace-runtime/server/dispatch.js +121 -28
- package/dist/workspace-runtime/server/http.js +14 -0
- package/dist/workspace-runtime/server/operation-record.js +3 -1
- package/dist/workspace-runtime/workspaces/files.js +20 -14
- 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/package.json +3 -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);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { remoteSkillMatchRequestSchema, remoteSkillMatchResultSchema, remoteSkillSelectionRequestSchema, remoteSkillArtifactRequestSchema, remoteSkillActivationRequestSchema, parseRemoteSkillSelectionResult, } from '@playcraft/common/project-skills';
|
|
6
|
+
import { platformEndpoint, skillActivationRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
|
+
import { workspaceContextFromRoot } from '../workspaces/registry.js';
|
|
8
|
+
import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.js';
|
|
9
|
+
import { verifyLauncherOrigin } from './launcher-client.js';
|
|
10
|
+
import { registerSkillActivationRequest } from '../workspaces/skill-activation-requests.js';
|
|
11
|
+
const requestSchema = z.object({ cwd: z.string().min(1).max(4096), input: z.unknown() }).strict();
|
|
12
|
+
const MAX_METADATA_BYTES = 4 * 1024 * 1024;
|
|
13
|
+
const SELECTION_TTL_MS = 15 * 60_000;
|
|
14
|
+
/** Resolves candidates with control identity; shell capability never authorizes changing the active lock. */
|
|
15
|
+
export class WorkspaceSkillDiscoveryBroker {
|
|
16
|
+
state;
|
|
17
|
+
selections = new Map();
|
|
18
|
+
constructor(state) {
|
|
19
|
+
this.state = state;
|
|
20
|
+
}
|
|
21
|
+
operation(context, valid) {
|
|
22
|
+
const state = this.state, session = state.journal.getSession(context.sessionId);
|
|
23
|
+
const operations = state.journal.listOperations().map(item => item.operation).filter(operation => operation.kind === 'session.turn' && operation.sessionId === context.sessionId && operation.clientRequestId === context.clientRequestId
|
|
24
|
+
&& operation.generation === state.config.generation && operation.runtimeEpoch === state.runtimeEpoch
|
|
25
|
+
&& ['dispatched', 'accepted'].includes(operation.status));
|
|
26
|
+
const operation = operations.length === 1 ? operations[0] : undefined;
|
|
27
|
+
if (!valid() || context.refsBlocked || state.journal.destroyed() || !operation?.workspaceId || !operation.directoryName
|
|
28
|
+
|| operation.directoryName === 'root' || !operation.nativeThreadId || !operation.nativeTurnId
|
|
29
|
+
|| !session || session.threadId !== operation.nativeThreadId || session.activeTurnId !== operation.nativeTurnId
|
|
30
|
+
|| session.cwd !== context.cwd || session.workspaceId !== operation.workspaceId
|
|
31
|
+
|| (session.directoryName ?? 'basic') !== operation.directoryName || session.turnBlock
|
|
32
|
+
|| !['running', 'waiting_for_user'].includes(session.turnStatus ?? ''))
|
|
33
|
+
throw new Error('skill_discovery_turn_unavailable');
|
|
34
|
+
return operation;
|
|
35
|
+
}
|
|
36
|
+
async current(context, valid) {
|
|
37
|
+
const operation = this.operation(context, valid);
|
|
38
|
+
const scope = await workspaceContextFromRoot(this.state, context.cwd);
|
|
39
|
+
if (scope.workspaceId !== operation.workspaceId || scope.directoryName !== operation.directoryName)
|
|
40
|
+
throw new Error('skill_discovery_scope_mismatch');
|
|
41
|
+
this.operation(context, valid);
|
|
42
|
+
return scope;
|
|
43
|
+
}
|
|
44
|
+
selectionKey(context, selectionId) {
|
|
45
|
+
return JSON.stringify([this.state.config.runtimeId, this.state.config.generation, this.state.runtimeEpoch,
|
|
46
|
+
context.cwd, context.sessionId, context.clientRequestId, selectionId]);
|
|
47
|
+
}
|
|
48
|
+
async forward(request, response, context, valid, action) {
|
|
49
|
+
const reply = (status, value) => {
|
|
50
|
+
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
51
|
+
response.end(JSON.stringify(value));
|
|
52
|
+
};
|
|
53
|
+
try {
|
|
54
|
+
if (request.socket.remoteAddress !== '127.0.0.1')
|
|
55
|
+
throw new Error('skill_discovery_turn_unavailable');
|
|
56
|
+
let text = '';
|
|
57
|
+
for await (const chunk of request) {
|
|
58
|
+
text += chunk.toString();
|
|
59
|
+
if (Buffer.byteLength(text) > 32 * 1024)
|
|
60
|
+
throw new Error('skill_discovery_invalid_request');
|
|
61
|
+
}
|
|
62
|
+
const parsed = requestSchema.safeParse(JSON.parse(text));
|
|
63
|
+
if (!parsed.success)
|
|
64
|
+
throw new Error('skill_discovery_invalid_request');
|
|
65
|
+
const scope = await this.current(context, valid);
|
|
66
|
+
const callerCwd = await realpath(parsed.data.cwd);
|
|
67
|
+
const childPath = relative(context.cwd, callerCwd);
|
|
68
|
+
if (isAbsolute(childPath) || childPath === '..' || childPath.startsWith('../'))
|
|
69
|
+
throw new Error('skill_discovery_scope_mismatch');
|
|
70
|
+
const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
71
|
+
if (!socketPath || !context.boundaryId || !request.socket.localPort || !request.socket.remotePort
|
|
72
|
+
|| !await verifyLauncherOrigin({ socketPath, boundaryId: context.boundaryId, runtimeEpoch: this.state.runtimeEpoch,
|
|
73
|
+
serverPort: request.socket.localPort, clientPort: request.socket.remotePort, cwd: context.originCwd ?? callerCwd }))
|
|
74
|
+
throw new Error('skill_discovery_turn_unavailable');
|
|
75
|
+
await this.current(context, valid);
|
|
76
|
+
if (action === 'activate') {
|
|
77
|
+
const input = remoteSkillActivationRequestSchema.safeParse(parsed.data.input);
|
|
78
|
+
if (!input.success)
|
|
79
|
+
throw new Error('skill_discovery_invalid_request');
|
|
80
|
+
const operation = this.operation(context, valid);
|
|
81
|
+
if (context.readOnly || operation.workMode === 'plan' || operation.permissionMode === 'read-only'
|
|
82
|
+
|| this.state.journal.getSession(context.sessionId)?.permissionMode === 'read-only')
|
|
83
|
+
throw new Error('skill_discovery_readonly');
|
|
84
|
+
const selected = this.selections.get(this.selectionKey(context, input.data.selectionId));
|
|
85
|
+
if (!selected || selected.expiresAt <= Date.now())
|
|
86
|
+
throw new Error('skill_discovery_selection_unavailable');
|
|
87
|
+
const result = await registerSkillActivationRequest(this.state, {
|
|
88
|
+
cwd: context.cwd, sessionId: context.sessionId, clientRequestId: context.clientRequestId, requestedSkillRefs: selected.requestedSkillRefs,
|
|
89
|
+
}, selected.selection);
|
|
90
|
+
await this.current(context, valid);
|
|
91
|
+
return reply(200, skillActivationRegistrationSchema.parse(result));
|
|
92
|
+
}
|
|
93
|
+
if (action === 'artifact') {
|
|
94
|
+
const input = remoteSkillArtifactRequestSchema.safeParse(parsed.data.input);
|
|
95
|
+
if (!input.success)
|
|
96
|
+
throw new Error('skill_discovery_invalid_request');
|
|
97
|
+
const selection = this.selections.get(this.selectionKey(context, input.data.selectionId));
|
|
98
|
+
const entry = selection && selection.expiresAt > Date.now() ? selection.entries.find(item => item.entryKey === input.data.entryKey) : undefined;
|
|
99
|
+
if (!entry)
|
|
100
|
+
throw new Error('skill_discovery_selection_unavailable');
|
|
101
|
+
const archive = await loadWorkspaceSkillArtifact(this.state, scope.workspaceId, entry, scope.directoryName);
|
|
102
|
+
await this.current(context, valid);
|
|
103
|
+
selection.expiresAt = Date.now() + SELECTION_TTL_MS;
|
|
104
|
+
return reply(200, { content: archive.toString('base64'), artifactHash: createHash('sha256').update(archive).digest('hex') });
|
|
105
|
+
}
|
|
106
|
+
const query = action === 'match' ? remoteSkillMatchRequestSchema.safeParse(parsed.data.input)
|
|
107
|
+
: remoteSkillSelectionRequestSchema.safeParse(parsed.data.input);
|
|
108
|
+
if (!query.success)
|
|
109
|
+
throw new Error('skill_discovery_invalid_request');
|
|
110
|
+
const raw = await this.upstream(scope, action, query.data);
|
|
111
|
+
await this.current(context, valid);
|
|
112
|
+
if (action === 'match')
|
|
113
|
+
return reply(200, remoteSkillMatchResultSchema.parse(raw));
|
|
114
|
+
const value = parseRemoteSkillSelectionResult(raw);
|
|
115
|
+
const selection = remoteSkillSelectionRequestSchema.parse(query.data);
|
|
116
|
+
for (const requested of selection.skills) {
|
|
117
|
+
if (!value.lock.entries.some(entry => entry.revision.kind === 'release' && entry.revision.skillRef === requested.skillRef
|
|
118
|
+
&& entry.revision.version === requested.version && entry.revision.artifactHash === requested.artifactHash)
|
|
119
|
+
|| !value.skills.some(item => item.skillVersionId === requested.skillVersionId && item.skillRef === requested.skillRef
|
|
120
|
+
&& item.version === requested.version && item.artifactHash === requested.artifactHash))
|
|
121
|
+
throw new Error('skill_discovery_invalid_response');
|
|
122
|
+
}
|
|
123
|
+
for (const [key, cached] of this.selections)
|
|
124
|
+
if (cached.expiresAt <= Date.now())
|
|
125
|
+
this.selections.delete(key);
|
|
126
|
+
if (this.selections.size >= 64)
|
|
127
|
+
this.selections.delete(this.selections.keys().next().value);
|
|
128
|
+
this.selections.set(this.selectionKey(context, value.selectionId), { expiresAt: Date.now() + SELECTION_TTL_MS, entries: value.lock.entries,
|
|
129
|
+
selection: value, requestedSkillRefs: selection.skills.map(skill => skill.skillRef) });
|
|
130
|
+
return reply(200, value);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (response.headersSent) {
|
|
134
|
+
response.destroy();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const code = error instanceof SyntaxError ? 'skill_discovery_invalid_request'
|
|
138
|
+
: error instanceof Error && /^(?:skill_discovery_|workspace_skills_)[a-z_]+$/.test(error.message)
|
|
139
|
+
? error.message : 'skill_discovery_unavailable';
|
|
140
|
+
reply(code === 'skill_discovery_invalid_request' ? 400 : code === 'skill_discovery_unavailable' ? 503 : 403, { error: { code } });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async upstream(scope, action, input) {
|
|
144
|
+
const config = this.state.config;
|
|
145
|
+
const response = await fetch(platformEndpoint(config.publicBaseUrl, `/runtime/internal/skill-discovery/${action}`), {
|
|
146
|
+
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(45_000),
|
|
147
|
+
headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
|
|
148
|
+
body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: this.state.runtimeEpoch,
|
|
149
|
+
workspaceId: scope.workspaceId, directoryName: scope.directoryName, input }),
|
|
150
|
+
});
|
|
151
|
+
if (!response.ok || !response.body) {
|
|
152
|
+
await response.body?.cancel().catch(() => undefined);
|
|
153
|
+
throw new Error('skill_discovery_unavailable');
|
|
154
|
+
}
|
|
155
|
+
const chunks = [];
|
|
156
|
+
let bytes = 0;
|
|
157
|
+
const reader = response.body.getReader();
|
|
158
|
+
try {
|
|
159
|
+
while (true) {
|
|
160
|
+
const chunk = await reader.read();
|
|
161
|
+
if (chunk.done)
|
|
162
|
+
break;
|
|
163
|
+
bytes += chunk.value.byteLength;
|
|
164
|
+
if (bytes > MAX_METADATA_BYTES)
|
|
165
|
+
throw new Error('skill_discovery_invalid_response');
|
|
166
|
+
chunks.push(Buffer.from(chunk.value));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await reader.cancel().catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -37,6 +37,8 @@ import { platformExportTarget, readPlatformExport, startPlatformExport } from '.
|
|
|
37
37
|
import { scheduledTaskProposalHandler } from './workspaces/scheduled-task-proposals.js';
|
|
38
38
|
import { executionContextToolHandler } from './workspaces/execution-context.js';
|
|
39
39
|
import { registerLevelEditorRequest, startLevelEditorRequestQueue, stopLevelEditorRequestQueue } from './workspaces/level-editor-requests.js';
|
|
40
|
+
import { startSkillActivationRequestQueue, stopSkillActivationRequestQueue } from './workspaces/skill-activation-requests.js';
|
|
41
|
+
import { recoverInterruptedSkillActivations } from './workspaces/skill-activation-recovery.js';
|
|
40
42
|
import { proceduralSkillHandler } from './workspaces/procedural-3d/host.js';
|
|
41
43
|
import { recoverModelPreviewOperations } from './workspaces/procedural-3d/model-preview-recovery.js';
|
|
42
44
|
import { recoverModelPreparationOperations, stopModelPreparations } from './workspaces/model-preparation.js';
|
|
@@ -83,6 +85,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
83
85
|
executionQueue: Promise.resolve(),
|
|
84
86
|
instanceToken: randomUUID(),
|
|
85
87
|
};
|
|
88
|
+
await recoverInterruptedSkillActivations(state);
|
|
86
89
|
const recoveredImports = new Map();
|
|
87
90
|
await recoverModelPreviewOperations(state, { identityMatches: previousIdentityMatches,
|
|
88
91
|
runtimeEpoch: config.runtimeEpoch, previousRuntimeEpoch: previousEpoch,
|
|
@@ -348,12 +351,14 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
348
351
|
});
|
|
349
352
|
const port = await listenOn(server, config.listen.host, config.listen.port);
|
|
350
353
|
startLevelEditorRequestQueue(state);
|
|
354
|
+
startSkillActivationRequestQueue(state);
|
|
351
355
|
return {
|
|
352
356
|
config: state.config,
|
|
353
357
|
state,
|
|
354
358
|
url: `http://${formatHost(config.listen.host)}:${port}`,
|
|
355
359
|
async close() {
|
|
356
360
|
stopLevelEditorRequestQueue(state);
|
|
361
|
+
await stopSkillActivationRequestQueue(state);
|
|
357
362
|
const serverClosed = closeServer(server);
|
|
358
363
|
await stopParameterVerifications(state);
|
|
359
364
|
await stopModelPreparations(state);
|