@playcraft/cli 0.1.83 → 0.1.85-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/workspace-runtime/codex/capture-isolation.js +10 -0
- package/dist/workspace-runtime/codex/model-broker.js +269 -82
- package/dist/workspace-runtime/codex/model-capture.js +675 -0
- package/package.json +4 -3
- package/templates/preview-runtime/v1/manifest.json +1 -1
- package/templates/preview-runtime/v1/runtime.mjs +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Matches the root-owned sandbox launcher: control 1500, development children 1501. */
|
|
2
|
+
export function isCaptureIsolationAvailable(input = {
|
|
3
|
+
platform: process.platform,
|
|
4
|
+
uid: process.getuid?.(),
|
|
5
|
+
launcher: process.env.PLAYCRAFT_LAUNCHER_SOCKET,
|
|
6
|
+
}) {
|
|
7
|
+
return (input.platform === 'linux' &&
|
|
8
|
+
input.uid === 1500 &&
|
|
9
|
+
input.launcher === '/run/playcraft/runtime/launcher.sock');
|
|
10
|
+
}
|
|
@@ -1,23 +1,41 @@
|
|
|
1
|
-
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
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, workspaceExecutionTraceOriginSchema } 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
12
|
import { WorkspaceSkillDiscoveryBroker } from './skill-discovery-broker.js';
|
|
13
|
+
import { WorkspaceModelCapture, captureSubmittedResponse, } from './model-capture.js';
|
|
13
14
|
const credentialSchema = z.object({
|
|
14
|
-
token: z.string().min(1),
|
|
15
|
-
|
|
15
|
+
token: z.string().min(1),
|
|
16
|
+
expiresAt: z.string().datetime(),
|
|
17
|
+
configId: z.string().uuid(),
|
|
18
|
+
model: z.string().min(1).max(128),
|
|
16
19
|
supportsReasoning: z.boolean().optional(),
|
|
17
20
|
claims: z.object({
|
|
18
|
-
environment: z.string(),
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
environment: z.string(),
|
|
22
|
+
userId: z.string(),
|
|
23
|
+
workspaceId: z.string(),
|
|
24
|
+
runtimeId: z.string(),
|
|
25
|
+
generation: z.number().int(),
|
|
26
|
+
runtimeEpoch: z.number().int(),
|
|
27
|
+
traceOrigin: z
|
|
28
|
+
.custom((value) => workspaceExecutionTraceOriginSchema.safeParse(value).success)
|
|
29
|
+
.optional(),
|
|
30
|
+
capturePolicy: z
|
|
31
|
+
.object({
|
|
32
|
+
enabled: z.literal(true),
|
|
33
|
+
policyVersion: z.literal('workspace-model-capture-v1'),
|
|
34
|
+
maxRequestBytes: z.number().int().positive(),
|
|
35
|
+
maxResponseBytes: z.number().int().positive(),
|
|
36
|
+
expiresAt: z.string().datetime(),
|
|
37
|
+
})
|
|
38
|
+
.optional(),
|
|
21
39
|
}),
|
|
22
40
|
});
|
|
23
41
|
const MAX_MODEL_REQUEST_BYTES = 128 * 1024 * 1024;
|
|
@@ -35,12 +53,14 @@ export class WorkspaceModelBroker {
|
|
|
35
53
|
references;
|
|
36
54
|
executionContext;
|
|
37
55
|
skills;
|
|
56
|
+
capture;
|
|
38
57
|
constructor(state) {
|
|
39
58
|
this.state = state;
|
|
40
59
|
this.generation = new WorkspaceGenerationProvenanceBroker(state);
|
|
41
60
|
this.references = new WorkspaceRemixReferencesBroker(state);
|
|
42
61
|
this.executionContext = new WorkspaceExecutionContextBroker(state);
|
|
43
62
|
this.skills = new WorkspaceSkillDiscoveryBroker(state);
|
|
63
|
+
this.capture = new WorkspaceModelCapture(state);
|
|
44
64
|
}
|
|
45
65
|
async start() {
|
|
46
66
|
if (this.server)
|
|
@@ -57,6 +77,7 @@ export class WorkspaceModelBroker {
|
|
|
57
77
|
if (!address || typeof address === 'string')
|
|
58
78
|
throw new Error('model_broker_unavailable');
|
|
59
79
|
this.baseUrl = `http://127.0.0.1:${address.port}`;
|
|
80
|
+
await this.capture.start();
|
|
60
81
|
}
|
|
61
82
|
async threadConfig(cwd, modelConfigId, sessionId, reasoningEffort) {
|
|
62
83
|
const { activate, ...settings } = await this.prepareThreadConfig(cwd, modelConfigId, sessionId, reasoningEffort);
|
|
@@ -69,24 +90,44 @@ export class WorkspaceModelBroker {
|
|
|
69
90
|
const { workspaceId } = await workspaceContextFromRoot(this.state, cwd);
|
|
70
91
|
if (modelConfigId !== undefined && !z.string().uuid().safeParse(modelConfigId).success)
|
|
71
92
|
throw new Error('model_config_denied');
|
|
72
|
-
const routeKey = sessionId
|
|
93
|
+
const routeKey = sessionId
|
|
94
|
+
? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`
|
|
95
|
+
: workspaceId;
|
|
73
96
|
let route = this.routes.get(routeKey);
|
|
74
|
-
const selected = modelConfigId ??
|
|
97
|
+
const selected = modelConfigId ??
|
|
98
|
+
(route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
|
|
75
99
|
const cacheKey = `${workspaceId}:${selected ?? 'default'}:${reasoningEffort ?? 'default'}:${sessionId ?? ''}:${clientRequestId ?? ''}`;
|
|
76
|
-
let entry = this.entries.get(cacheKey) ??
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
let entry = this.entries.get(cacheKey) ??
|
|
101
|
+
[...this.entries.values()].find((item) => item.workspaceId === workspaceId &&
|
|
102
|
+
item.epoch === this.state.runtimeEpoch &&
|
|
103
|
+
item.modelConfigId === selected &&
|
|
104
|
+
item.sessionId === sessionId &&
|
|
105
|
+
item.clientRequestId === clientRequestId &&
|
|
106
|
+
item.reasoningEffort === reasoningEffort);
|
|
79
107
|
if (!entry || entry.epoch !== this.state.runtimeEpoch) {
|
|
80
|
-
entry = {
|
|
108
|
+
entry = {
|
|
109
|
+
workspaceId,
|
|
110
|
+
epoch: this.state.runtimeEpoch,
|
|
111
|
+
cacheKey,
|
|
112
|
+
modelConfigId: selected,
|
|
113
|
+
reasoningEffort,
|
|
114
|
+
sessionId,
|
|
115
|
+
clientRequestId,
|
|
116
|
+
};
|
|
81
117
|
this.entries.set(cacheKey, entry);
|
|
82
118
|
}
|
|
83
119
|
// Resolve current model metadata and permission at every native turn boundary.
|
|
84
120
|
const credential = await this.credential(entry, true);
|
|
85
121
|
const boundaryId = sessionId && clientRequestId ? await this.generation.prepare() : undefined;
|
|
86
122
|
if (!route || route.epoch !== this.state.runtimeEpoch) {
|
|
87
|
-
route = {
|
|
88
|
-
|
|
89
|
-
|
|
123
|
+
route = {
|
|
124
|
+
workspaceId,
|
|
125
|
+
epoch: this.state.runtimeEpoch,
|
|
126
|
+
capability: randomBytes(32).toString('base64url'),
|
|
127
|
+
generationCapability: randomBytes(32).toString('base64url'),
|
|
128
|
+
referencesCapability: randomBytes(32).toString('base64url'),
|
|
129
|
+
skillsCapability: randomBytes(32).toString('base64url'),
|
|
130
|
+
};
|
|
90
131
|
this.routes.set(routeKey, route);
|
|
91
132
|
}
|
|
92
133
|
// Each session pins its prepared model; shared credential renewal cannot change an active turn.
|
|
@@ -95,41 +136,67 @@ export class WorkspaceModelBroker {
|
|
|
95
136
|
if (!this.valid(entry) || this.routes.get(routeKey) !== route)
|
|
96
137
|
throw new Error('model_access_denied');
|
|
97
138
|
route.lastAdmissionReason = undefined;
|
|
98
|
-
route.selected = {
|
|
99
|
-
|
|
100
|
-
|
|
139
|
+
route.selected = {
|
|
140
|
+
entry,
|
|
141
|
+
model: credential.model,
|
|
142
|
+
...(sessionId && clientRequestId
|
|
143
|
+
? {
|
|
144
|
+
feedback: {
|
|
145
|
+
sessionId,
|
|
146
|
+
clientRequestId,
|
|
147
|
+
cwd,
|
|
148
|
+
boundaryId,
|
|
149
|
+
readOnly,
|
|
150
|
+
...(originCwd ? { originCwd } : {}),
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
: {}),
|
|
154
|
+
};
|
|
101
155
|
},
|
|
102
156
|
model: credential.model,
|
|
103
157
|
modelConfigId: credential.configId,
|
|
104
158
|
modelProvider: 'playcraft_workspace',
|
|
105
159
|
config: {
|
|
106
|
-
...(sessionId
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
160
|
+
...(sessionId
|
|
161
|
+
? {
|
|
162
|
+
'shell_environment_policy.set': {
|
|
163
|
+
PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
|
|
164
|
+
PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
|
|
165
|
+
PLAYCRAFT_REMIX_REFERENCES_ENDPOINT: `${this.baseUrl}/${routeKey}/remix-references`,
|
|
166
|
+
PLAYCRAFT_REMIX_REFERENCES_CAPABILITY: route.referencesCapability,
|
|
167
|
+
PLAYCRAFT_SKILLS_ENDPOINT: `${this.baseUrl}/${routeKey}/skill-discovery`,
|
|
168
|
+
PLAYCRAFT_SKILLS_CAPABILITY: route.skillsCapability,
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
: {}),
|
|
114
172
|
'model_providers.playcraft_workspace': {
|
|
115
|
-
name: 'PlayCraft Workspace',
|
|
116
|
-
|
|
173
|
+
name: 'PlayCraft Workspace',
|
|
174
|
+
base_url: `${this.baseUrl}/${routeKey}`,
|
|
175
|
+
wire_api: 'responses',
|
|
176
|
+
requires_openai_auth: false,
|
|
117
177
|
http_headers: { Authorization: `Bearer ${route.capability}` },
|
|
118
178
|
},
|
|
119
179
|
},
|
|
120
180
|
};
|
|
121
181
|
}
|
|
122
182
|
valid(entry) {
|
|
123
|
-
return Boolean(this.server &&
|
|
124
|
-
|
|
125
|
-
|
|
183
|
+
return Boolean(this.server &&
|
|
184
|
+
!this.state.journal.destroyed() &&
|
|
185
|
+
this.state.runtimeEpoch === entry.epoch &&
|
|
186
|
+
this.state.journal.getWorkspace(entry.workspaceId) &&
|
|
187
|
+
this.entries.get(entry.cacheKey) === entry);
|
|
126
188
|
}
|
|
127
189
|
/** Called immediately before the native turn/start write, within this broker's process clock. */
|
|
128
190
|
markCodexDispatched(cwd, sessionId, clientRequestId) {
|
|
129
|
-
const workspaceId = /^(basic|remix-)/.test(basename(cwd))
|
|
191
|
+
const workspaceId = /^(basic|remix-)/.test(basename(cwd))
|
|
192
|
+
? basename(dirname(cwd))
|
|
193
|
+
: basename(cwd);
|
|
130
194
|
const route = this.routes.get(`${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`);
|
|
131
195
|
const entry = route?.selected?.entry;
|
|
132
|
-
if (!entry ||
|
|
196
|
+
if (!entry ||
|
|
197
|
+
!this.valid(entry) ||
|
|
198
|
+
entry.sessionId !== sessionId ||
|
|
199
|
+
entry.clientRequestId !== clientRequestId)
|
|
133
200
|
return;
|
|
134
201
|
entry.codexDispatchedAt = new Date().toISOString();
|
|
135
202
|
entry.codexDispatchMark = performance.now();
|
|
@@ -137,7 +204,9 @@ export class WorkspaceModelBroker {
|
|
|
137
204
|
async credential(entry, refresh = false) {
|
|
138
205
|
if (!this.valid(entry))
|
|
139
206
|
throw new Error('model_access_denied');
|
|
140
|
-
if (!refresh &&
|
|
207
|
+
if (!refresh &&
|
|
208
|
+
entry.credential &&
|
|
209
|
+
Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
|
|
141
210
|
return entry.credential;
|
|
142
211
|
let pending = this.pending.get(entry);
|
|
143
212
|
if (!pending) {
|
|
@@ -150,26 +219,48 @@ export class WorkspaceModelBroker {
|
|
|
150
219
|
const config = this.state.config;
|
|
151
220
|
const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.modelCredentials);
|
|
152
221
|
const response = await fetch(endpoint, {
|
|
153
|
-
method: 'POST',
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
222
|
+
method: 'POST',
|
|
223
|
+
redirect: 'error',
|
|
224
|
+
signal: AbortSignal.timeout(10_000),
|
|
225
|
+
headers: {
|
|
226
|
+
authorization: `Bearer ${config.controlToken}`,
|
|
227
|
+
'content-type': 'application/json',
|
|
228
|
+
'playcraft-env': config.environment,
|
|
229
|
+
...(this.capture.supported ? { 'x-playcraft-model-capture-protocol': '1' } : {}),
|
|
230
|
+
},
|
|
231
|
+
body: JSON.stringify({
|
|
232
|
+
runtimeId: config.runtimeId,
|
|
233
|
+
generation: config.generation,
|
|
234
|
+
runtimeEpoch: entry.epoch,
|
|
235
|
+
workspaceId: entry.workspaceId,
|
|
157
236
|
...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
|
|
158
237
|
...(entry.clientRequestId ? { clientRequestId: entry.clientRequestId } : {}),
|
|
159
238
|
...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
|
|
160
|
-
...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {})
|
|
239
|
+
...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}),
|
|
240
|
+
}),
|
|
161
241
|
});
|
|
162
242
|
if (!response.ok) {
|
|
163
|
-
const error = await response.json().catch(() => ({}));
|
|
164
|
-
throw new Error([
|
|
243
|
+
const error = (await response.json().catch(() => ({})));
|
|
244
|
+
throw new Error([
|
|
245
|
+
'model_config_required',
|
|
246
|
+
'model_config_denied',
|
|
247
|
+
'reasoning_effort_denied',
|
|
248
|
+
'reasoning_effort_unsupported',
|
|
249
|
+
].includes(error.reason ?? '')
|
|
250
|
+
? error.reason
|
|
251
|
+
: 'model_credential_unavailable');
|
|
165
252
|
}
|
|
166
253
|
const credential = credentialSchema.parse(await response.json());
|
|
167
254
|
const claims = credential.claims;
|
|
168
|
-
if (!this.valid(entry) ||
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
255
|
+
if (!this.valid(entry) ||
|
|
256
|
+
claims.workspaceId !== entry.workspaceId ||
|
|
257
|
+
claims.runtimeId !== config.runtimeId ||
|
|
258
|
+
claims.userId !== config.userId ||
|
|
259
|
+
claims.environment !== config.environment ||
|
|
260
|
+
claims.generation !== config.generation ||
|
|
261
|
+
claims.runtimeEpoch !== entry.epoch ||
|
|
262
|
+
(entry.modelConfigId !== undefined && credential.configId !== entry.modelConfigId) ||
|
|
263
|
+
Date.parse(credential.expiresAt) <= Date.now() + 1000)
|
|
173
264
|
throw new Error('model_credential_scope_mismatch');
|
|
174
265
|
entry.modelConfigId = credential.configId;
|
|
175
266
|
entry.credential = credential;
|
|
@@ -182,6 +273,9 @@ export class WorkspaceModelBroker {
|
|
|
182
273
|
const abort = new AbortController();
|
|
183
274
|
this.requests.add(abort);
|
|
184
275
|
let activeRoute;
|
|
276
|
+
let capturedRequest;
|
|
277
|
+
let capturedResponse;
|
|
278
|
+
let requestEnded = false;
|
|
185
279
|
const cancel = () => abort.abort();
|
|
186
280
|
response.once('close', cancel);
|
|
187
281
|
try {
|
|
@@ -194,10 +288,21 @@ export class WorkspaceModelBroker {
|
|
|
194
288
|
const contextRequest = match?.[2] === 'execution-context';
|
|
195
289
|
const referencesRequest = match?.[2] === 'remix-references';
|
|
196
290
|
const skillsRequest = match?.[2]?.startsWith('skill-discovery/');
|
|
197
|
-
const expected = Buffer.from(route
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
291
|
+
const expected = Buffer.from(route
|
|
292
|
+
? `Bearer ${generationRequest || contextRequest
|
|
293
|
+
? route.generationCapability
|
|
294
|
+
: referencesRequest
|
|
295
|
+
? route.referencesCapability
|
|
296
|
+
: skillsRequest
|
|
297
|
+
? route.skillsCapability
|
|
298
|
+
: route.capability}`
|
|
299
|
+
: '');
|
|
300
|
+
if (request.method !== 'POST' ||
|
|
301
|
+
!entry ||
|
|
302
|
+
!expected.length ||
|
|
303
|
+
presented.length !== expected.length ||
|
|
304
|
+
!timingSafeEqual(presented, expected) ||
|
|
305
|
+
!this.valid(entry)) {
|
|
201
306
|
response.writeHead(403);
|
|
202
307
|
response.end();
|
|
203
308
|
return;
|
|
@@ -240,12 +345,23 @@ export class WorkspaceModelBroker {
|
|
|
240
345
|
return;
|
|
241
346
|
}
|
|
242
347
|
const rejectOversizedBody = () => {
|
|
243
|
-
response.writeHead(413, {
|
|
244
|
-
|
|
245
|
-
|
|
348
|
+
response.writeHead(413, {
|
|
349
|
+
'content-type': 'application/json',
|
|
350
|
+
'cache-control': 'no-store',
|
|
351
|
+
});
|
|
352
|
+
response.end(JSON.stringify({
|
|
353
|
+
error: {
|
|
354
|
+
message: 'model_request_too_large',
|
|
355
|
+
type: 'invalid_request_error',
|
|
356
|
+
code: 'model_request_too_large',
|
|
357
|
+
maxBytes: MAX_MODEL_REQUEST_BYTES,
|
|
358
|
+
},
|
|
359
|
+
}));
|
|
246
360
|
};
|
|
247
361
|
const declaredLength = request.headers['content-length'];
|
|
248
|
-
if (declaredLength !== undefined &&
|
|
362
|
+
if (declaredLength !== undefined &&
|
|
363
|
+
/^\d+$/.test(declaredLength) &&
|
|
364
|
+
Number(declaredLength) > MAX_MODEL_REQUEST_BYTES) {
|
|
249
365
|
rejectOversizedBody();
|
|
250
366
|
return;
|
|
251
367
|
}
|
|
@@ -256,10 +372,21 @@ export class WorkspaceModelBroker {
|
|
|
256
372
|
const credentialMark = performance.now();
|
|
257
373
|
const credential = await this.credential(entry);
|
|
258
374
|
const credentialWaitMs = elapsed(credentialMark);
|
|
375
|
+
const modelCallId = randomUUID();
|
|
376
|
+
const candidateCaptureOrigin = credential.claims.traceOrigin;
|
|
377
|
+
const captured = candidateCaptureOrigin?.sessionId === entry.sessionId &&
|
|
378
|
+
candidateCaptureOrigin?.clientRequestId === entry.clientRequestId
|
|
379
|
+
? this.capture.begin(modelCallId, credential)
|
|
380
|
+
: undefined;
|
|
381
|
+
capturedRequest = captured?.artifact('codex_request', request.headers['content-type'] ?? 'application/json');
|
|
382
|
+
capturedResponse = captured?.artifact('codex_response');
|
|
383
|
+
if (capturedResponse)
|
|
384
|
+
captureSubmittedResponse(response, capturedResponse);
|
|
259
385
|
const requestReadMark = performance.now();
|
|
260
386
|
const chunks = [];
|
|
261
387
|
let size = 0;
|
|
262
388
|
for await (const chunk of request) {
|
|
389
|
+
capturedRequest?.append(chunk);
|
|
263
390
|
size += chunk.length;
|
|
264
391
|
if (size > MAX_MODEL_REQUEST_BYTES) {
|
|
265
392
|
rejectOversizedBody();
|
|
@@ -268,6 +395,8 @@ export class WorkspaceModelBroker {
|
|
|
268
395
|
chunks.push(Buffer.from(chunk));
|
|
269
396
|
}
|
|
270
397
|
const requestReadMs = elapsed(requestReadMark);
|
|
398
|
+
capturedRequest?.finish({ sourceEnd: 'eof', protocolTerminal: 'not_applicable' });
|
|
399
|
+
requestEnded = true;
|
|
271
400
|
if (!this.valid(entry))
|
|
272
401
|
throw new Error('model_access_denied');
|
|
273
402
|
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
@@ -288,9 +417,12 @@ export class WorkspaceModelBroker {
|
|
|
288
417
|
const reasoning = body.reasoning;
|
|
289
418
|
const modelName = selected.model.split('/').pop() ?? selected.model;
|
|
290
419
|
const effort = entry.reasoningEffort === 'max' && /^o\d|gpt-[5-9]/i.test(modelName)
|
|
291
|
-
? 'xhigh'
|
|
420
|
+
? 'xhigh'
|
|
421
|
+
: entry.reasoningEffort;
|
|
292
422
|
body.reasoning = {
|
|
293
|
-
...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning)
|
|
423
|
+
...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning)
|
|
424
|
+
? reasoning
|
|
425
|
+
: {}),
|
|
294
426
|
effort,
|
|
295
427
|
};
|
|
296
428
|
}
|
|
@@ -299,26 +431,58 @@ export class WorkspaceModelBroker {
|
|
|
299
431
|
const dispatchedMark = performance.now();
|
|
300
432
|
const beforeDispatchMs = elapsed(receivedMark);
|
|
301
433
|
const candidateOrigin = credential.claims.traceOrigin;
|
|
302
|
-
const origin = candidateOrigin?.sessionId === entry.sessionId &&
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
434
|
+
const origin = candidateOrigin?.sessionId === entry.sessionId &&
|
|
435
|
+
candidateOrigin?.clientRequestId === entry.clientRequestId
|
|
436
|
+
? candidateOrigin
|
|
437
|
+
: undefined;
|
|
438
|
+
const broker = {
|
|
439
|
+
receivedAt,
|
|
440
|
+
dispatchedAt,
|
|
441
|
+
credentialWaitMs,
|
|
442
|
+
requestReadMs,
|
|
443
|
+
beforeDispatchMs,
|
|
444
|
+
};
|
|
306
445
|
let attemptId;
|
|
307
446
|
let outcome = 'error';
|
|
308
447
|
try {
|
|
309
448
|
const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
|
|
310
|
-
method: 'POST',
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
449
|
+
method: 'POST',
|
|
450
|
+
redirect: 'error',
|
|
451
|
+
signal: abort.signal,
|
|
452
|
+
headers: {
|
|
453
|
+
authorization: `Bearer ${credential.token}`,
|
|
454
|
+
'content-type': 'application/json',
|
|
455
|
+
'x-playcraft-model-call-id': modelCallId,
|
|
456
|
+
...(origin
|
|
457
|
+
? {
|
|
458
|
+
'x-playcraft-model-journey': JSON.stringify({
|
|
459
|
+
version: 1,
|
|
460
|
+
runtimeOperationId: origin.runtimeOperationId,
|
|
461
|
+
sessionId: origin.sessionId,
|
|
462
|
+
clientRequestId: origin.clientRequestId,
|
|
463
|
+
...(entry.codexDispatchedAt
|
|
464
|
+
? { codexDispatchedAt: entry.codexDispatchedAt }
|
|
465
|
+
: {}),
|
|
466
|
+
brokerReceivedAt: receivedAt,
|
|
467
|
+
brokerDispatchedAt: dispatchedAt,
|
|
468
|
+
credentialWaitMs,
|
|
469
|
+
requestReadMs,
|
|
470
|
+
beforeDispatchMs,
|
|
471
|
+
...(entry.codexDispatchMark === undefined
|
|
472
|
+
? {}
|
|
473
|
+
: {
|
|
474
|
+
codexDispatchToBrokerMs: Math.max(0, Math.round(receivedMark - entry.codexDispatchMark)),
|
|
475
|
+
}),
|
|
476
|
+
}),
|
|
477
|
+
}
|
|
478
|
+
: {}),
|
|
479
|
+
...(selected?.feedback
|
|
480
|
+
? {
|
|
481
|
+
'x-playcraft-cloud-session-id': selected.feedback.sessionId,
|
|
482
|
+
'x-playcraft-client-request-id': selected.feedback.clientRequestId,
|
|
483
|
+
}
|
|
484
|
+
: {}),
|
|
485
|
+
},
|
|
322
486
|
body: JSON.stringify(body),
|
|
323
487
|
});
|
|
324
488
|
broker.responseHeadersAt = new Date().toISOString();
|
|
@@ -343,6 +507,7 @@ export class WorkspaceModelBroker {
|
|
|
343
507
|
if (upstream.body)
|
|
344
508
|
await pipeline(Readable.fromWeb(upstream.body), new Transform({
|
|
345
509
|
transform(chunk, _encoding, callback) {
|
|
510
|
+
capturedResponse?.upstreamObserved(chunk.length);
|
|
346
511
|
if (chunk.length && broker.firstBodyByteAt === undefined) {
|
|
347
512
|
broker.firstBodyByteAt = new Date().toISOString();
|
|
348
513
|
broker.proxyFirstBodyByteMs = elapsed(dispatchedMark);
|
|
@@ -364,21 +529,37 @@ export class WorkspaceModelBroker {
|
|
|
364
529
|
broker.proxyResponseDurationMs = elapsed(dispatchedMark);
|
|
365
530
|
if (origin && attemptId) {
|
|
366
531
|
// Telemetry is sent after transfer and cannot delay or change the Codex model response.
|
|
367
|
-
void this.reportObservation(credential.token, {
|
|
368
|
-
|
|
369
|
-
|
|
532
|
+
void this.reportObservation(credential.token, {
|
|
533
|
+
version: 1,
|
|
534
|
+
attemptId,
|
|
535
|
+
runtimeOperationId: origin.runtimeOperationId,
|
|
536
|
+
sessionId: origin.sessionId,
|
|
537
|
+
clientRequestId: origin.clientRequestId,
|
|
538
|
+
broker,
|
|
539
|
+
outcome,
|
|
540
|
+
});
|
|
370
541
|
}
|
|
371
542
|
}
|
|
372
543
|
}
|
|
373
544
|
catch {
|
|
374
545
|
if (!response.headersSent) {
|
|
375
|
-
response.writeHead(503, {
|
|
376
|
-
|
|
546
|
+
response.writeHead(503, {
|
|
547
|
+
'content-type': 'application/json',
|
|
548
|
+
'cache-control': 'no-store',
|
|
549
|
+
});
|
|
550
|
+
response.end(JSON.stringify({
|
|
551
|
+
error: { message: 'model_credential_unavailable', type: 'server_error' },
|
|
552
|
+
}));
|
|
377
553
|
}
|
|
378
554
|
else
|
|
379
555
|
response.destroy();
|
|
380
556
|
}
|
|
381
557
|
finally {
|
|
558
|
+
if (!requestEnded)
|
|
559
|
+
capturedRequest?.finish({
|
|
560
|
+
sourceEnd: abort.signal.aborted ? 'aborted' : 'io_error',
|
|
561
|
+
protocolTerminal: 'unknown',
|
|
562
|
+
});
|
|
382
563
|
response.off('close', cancel);
|
|
383
564
|
this.requests.delete(abort);
|
|
384
565
|
if (activeRoute) {
|
|
@@ -392,12 +573,17 @@ export class WorkspaceModelBroker {
|
|
|
392
573
|
async reportObservation(token, input) {
|
|
393
574
|
try {
|
|
394
575
|
const response = await fetch(platformEndpoint(this.state.config.publicBaseUrl, '/api/llm-proxy/runtime-observations'), {
|
|
395
|
-
method: 'POST',
|
|
396
|
-
|
|
576
|
+
method: 'POST',
|
|
577
|
+
redirect: 'error',
|
|
578
|
+
signal: AbortSignal.timeout(3_000),
|
|
579
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
580
|
+
body: JSON.stringify(input),
|
|
397
581
|
});
|
|
398
582
|
await response.body?.cancel();
|
|
399
583
|
}
|
|
400
|
-
catch {
|
|
584
|
+
catch {
|
|
585
|
+
/* Missing observations remain unknown; never replay a model request for telemetry. */
|
|
586
|
+
}
|
|
401
587
|
}
|
|
402
588
|
cancelSession(cwd, sessionId) {
|
|
403
589
|
const routeKey = `${/^(basic|remix-)/.test(basename(cwd)) ? basename(dirname(cwd)) : basename(cwd)}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}`;
|
|
@@ -424,7 +610,8 @@ export class WorkspaceModelBroker {
|
|
|
424
610
|
request.abort();
|
|
425
611
|
if (server) {
|
|
426
612
|
server.closeAllConnections();
|
|
427
|
-
await new Promise(resolve => server.close(() => resolve()));
|
|
613
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
428
614
|
}
|
|
615
|
+
await this.capture.close();
|
|
429
616
|
}
|
|
430
617
|
}
|
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, writeFile, readFile, readdir, rm, rename, lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve, relative, isAbsolute } from 'node:path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { platformEndpoint, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { MODEL_CAPTURE_CHUNK_BYTES, } from '@playcraft/common/model-proxy';
|
|
7
|
+
import { isCaptureIsolationAvailable } from './capture-isolation.js';
|
|
8
|
+
const QUEUE_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
const SPOOL_BYTES = 512 * 1024 * 1024;
|
|
10
|
+
const MAX_CAPTURE_CALLS = 128;
|
|
11
|
+
const manifestSchema = z.object({
|
|
12
|
+
version: z.literal(1),
|
|
13
|
+
identity: z.object({
|
|
14
|
+
modelCallId: z.string().uuid(),
|
|
15
|
+
workspaceId: z.string().uuid(),
|
|
16
|
+
sessionId: z.string().min(1),
|
|
17
|
+
clientRequestId: z.string().min(1),
|
|
18
|
+
directoryName: z.string().min(1).optional(),
|
|
19
|
+
runtimeId: z.string().min(1),
|
|
20
|
+
generation: z.number().int().min(0),
|
|
21
|
+
runtimeEpoch: z.number().int().min(0),
|
|
22
|
+
configId: z.string().uuid(),
|
|
23
|
+
captureCallRowId: z.string().uuid().optional(),
|
|
24
|
+
expiresAt: z.string().datetime(),
|
|
25
|
+
}),
|
|
26
|
+
artifacts: z
|
|
27
|
+
.array(z.object({
|
|
28
|
+
stage: z.enum(['codex_request', 'codex_response']),
|
|
29
|
+
contentType: z.string().max(256),
|
|
30
|
+
representation: z.enum(['raw-http-body', 'decoded_body']),
|
|
31
|
+
parts: z.number().int().min(0).max(128),
|
|
32
|
+
rawBytes: z.number().int().min(0).max(SPOOL_BYTES),
|
|
33
|
+
end: z
|
|
34
|
+
.object({
|
|
35
|
+
sourceEnd: z.enum([
|
|
36
|
+
'eof',
|
|
37
|
+
'aborted',
|
|
38
|
+
'cancelled_by_failover',
|
|
39
|
+
'process_lost',
|
|
40
|
+
'limit_exceeded',
|
|
41
|
+
'io_error',
|
|
42
|
+
'not_started',
|
|
43
|
+
]),
|
|
44
|
+
protocolTerminal: z.enum(['observed', 'absent', 'not_applicable', 'unknown']),
|
|
45
|
+
downstreamFinished: z.boolean().optional(),
|
|
46
|
+
dispatchObserved: z.boolean().optional(),
|
|
47
|
+
responseHeadersObserved: z.boolean().optional(),
|
|
48
|
+
responseStatus: z.number().int().min(100).max(599).optional(),
|
|
49
|
+
upstreamObservedBytes: z.number().int().min(0).optional(),
|
|
50
|
+
downstreamSubmittedBytes: z.number().int().min(0).optional(),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
53
|
+
}))
|
|
54
|
+
.max(2),
|
|
55
|
+
});
|
|
56
|
+
function within(parent, child) {
|
|
57
|
+
const path = relative(parent, child);
|
|
58
|
+
return !path || (!path.startsWith('..') && !isAbsolute(path));
|
|
59
|
+
}
|
|
60
|
+
let queuedBytes = 0;
|
|
61
|
+
let uploadCount = 0;
|
|
62
|
+
const uploadWaiters = [];
|
|
63
|
+
async function uploadSlot(operation) {
|
|
64
|
+
if (uploadCount >= 2)
|
|
65
|
+
await new Promise((resolve) => uploadWaiters.push(resolve));
|
|
66
|
+
else
|
|
67
|
+
uploadCount++;
|
|
68
|
+
try {
|
|
69
|
+
return await operation();
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
const next = uploadWaiters.shift();
|
|
73
|
+
if (next)
|
|
74
|
+
next();
|
|
75
|
+
else
|
|
76
|
+
uploadCount--;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Captures are control-process state. Only immutable scope and raw parts reach disk. */
|
|
80
|
+
export class WorkspaceModelCapture {
|
|
81
|
+
state;
|
|
82
|
+
root;
|
|
83
|
+
diskBytes = 0;
|
|
84
|
+
closed = false;
|
|
85
|
+
ready = false;
|
|
86
|
+
calls = new Set();
|
|
87
|
+
constructor(state) {
|
|
88
|
+
this.state = state;
|
|
89
|
+
this.root = join(state.config.stateDir, 'model-captures');
|
|
90
|
+
}
|
|
91
|
+
async start() {
|
|
92
|
+
if (!isCaptureIsolationAvailable())
|
|
93
|
+
return;
|
|
94
|
+
try {
|
|
95
|
+
await mkdir(this.root, { recursive: true, mode: 0o700 }); // private
|
|
96
|
+
const info = await lstat(this.root);
|
|
97
|
+
const rootPath = await realpath(this.root);
|
|
98
|
+
const workspacePath = await realpath(this.state.config.workspaceRoot).catch(() => resolve(this.state.config.workspaceRoot));
|
|
99
|
+
const codexPath = await realpath(process.env.PLAYCRAFT_CODEX_HOME || '/var/lib/playcraft/codex').catch(() => resolve(process.env.PLAYCRAFT_CODEX_HOME || '/var/lib/playcraft/codex'));
|
|
100
|
+
if (!info.isDirectory() ||
|
|
101
|
+
info.isSymbolicLink() ||
|
|
102
|
+
(info.mode & 0o077) !== 0 ||
|
|
103
|
+
(process.getuid && info.uid !== process.getuid()) ||
|
|
104
|
+
within(workspacePath, rootPath) ||
|
|
105
|
+
within(codexPath, rootPath))
|
|
106
|
+
return;
|
|
107
|
+
this.ready = true;
|
|
108
|
+
for (const name of await readdir(this.root)) {
|
|
109
|
+
if (!z.string().uuid().safeParse(name).success)
|
|
110
|
+
continue;
|
|
111
|
+
const directory = join(this.root, name);
|
|
112
|
+
try {
|
|
113
|
+
const entry = await lstat(directory);
|
|
114
|
+
if (!entry.isDirectory() ||
|
|
115
|
+
entry.isSymbolicLink() ||
|
|
116
|
+
entry.uid !== info.uid ||
|
|
117
|
+
entry.mode & 0o077)
|
|
118
|
+
throw new Error('capture_private_directory_required');
|
|
119
|
+
const manifest = manifestSchema.parse(JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')));
|
|
120
|
+
if (manifest.version !== 1 ||
|
|
121
|
+
manifest.identity.modelCallId !== name ||
|
|
122
|
+
manifest.identity.runtimeId !== this.state.config.runtimeId ||
|
|
123
|
+
manifest.identity.generation !== this.state.config.generation ||
|
|
124
|
+
manifest.identity.runtimeEpoch !== this.state.runtimeEpoch ||
|
|
125
|
+
Date.parse(manifest.identity.expiresAt) <= Date.now() ||
|
|
126
|
+
!manifest.identity.captureCallRowId ||
|
|
127
|
+
this.calls.size >= MAX_CAPTURE_CALLS) {
|
|
128
|
+
await rm(directory, { recursive: true, force: true });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
let existingBytes = 0;
|
|
132
|
+
const stages = new Set();
|
|
133
|
+
for (const artifact of manifest.artifacts) {
|
|
134
|
+
if (stages.has(artifact.stage))
|
|
135
|
+
throw new Error('capture_duplicate_artifact');
|
|
136
|
+
stages.add(artifact.stage);
|
|
137
|
+
let actualBytes = 0;
|
|
138
|
+
for (let partNo = 0; partNo < artifact.parts; partNo++) {
|
|
139
|
+
const part = await lstat(join(directory, `${artifact.stage}.${partNo}.part`));
|
|
140
|
+
if (!part.isFile() ||
|
|
141
|
+
part.isSymbolicLink() ||
|
|
142
|
+
part.uid !== info.uid ||
|
|
143
|
+
part.mode & 0o077 ||
|
|
144
|
+
part.size > MODEL_CAPTURE_CHUNK_BYTES)
|
|
145
|
+
throw new Error('capture_private_part_required');
|
|
146
|
+
actualBytes += part.size;
|
|
147
|
+
}
|
|
148
|
+
if (actualBytes !== artifact.rawBytes)
|
|
149
|
+
throw new Error('capture_part_manifest_mismatch');
|
|
150
|
+
existingBytes += actualBytes;
|
|
151
|
+
if (!artifact.end)
|
|
152
|
+
artifact.end = {
|
|
153
|
+
sourceEnd: 'process_lost',
|
|
154
|
+
protocolTerminal: 'unknown',
|
|
155
|
+
...(artifact.stage === 'codex_response' ? { downstreamFinished: false } : {}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (this.diskBytes + existingBytes > SPOOL_BYTES)
|
|
159
|
+
throw new Error('capture_spool_limit');
|
|
160
|
+
this.diskBytes += existingBytes;
|
|
161
|
+
const call = new CaptureCall(this, directory, manifest);
|
|
162
|
+
this.calls.add(call);
|
|
163
|
+
void call.upload();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
await rm(directory, { recursive: true, force: true }).catch(() => { });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
/* Capture setup failure cannot prevent the loopback broker from starting. */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
begin(modelCallId, credential) {
|
|
175
|
+
const policy = credential.claims.capturePolicy;
|
|
176
|
+
const origin = credential.claims.traceOrigin;
|
|
177
|
+
if (this.closed ||
|
|
178
|
+
!this.ready ||
|
|
179
|
+
this.calls.size >= MAX_CAPTURE_CALLS ||
|
|
180
|
+
!policy?.enabled ||
|
|
181
|
+
!origin ||
|
|
182
|
+
!('directory' in origin) ||
|
|
183
|
+
Date.parse(policy.expiresAt) <= Date.now())
|
|
184
|
+
return undefined;
|
|
185
|
+
const identity = {
|
|
186
|
+
modelCallId,
|
|
187
|
+
workspaceId: credential.claims.workspaceId,
|
|
188
|
+
runtimeId: credential.claims.runtimeId,
|
|
189
|
+
generation: credential.claims.generation,
|
|
190
|
+
runtimeEpoch: credential.claims.runtimeEpoch,
|
|
191
|
+
sessionId: origin.sessionId,
|
|
192
|
+
directoryName: typeof origin.directory === 'string' ? origin.directory : undefined,
|
|
193
|
+
clientRequestId: origin.clientRequestId,
|
|
194
|
+
configId: credential.configId,
|
|
195
|
+
expiresAt: new Date(Math.min(Date.parse(policy.expiresAt), Date.now() + 3_600_000)).toISOString(),
|
|
196
|
+
};
|
|
197
|
+
const call = new CaptureCall(this, join(this.root, modelCallId), { version: 1, identity, artifacts: [] }, credential.token, {
|
|
198
|
+
request: Math.min(policy.maxRequestBytes, 128 * 1024 * 1024),
|
|
199
|
+
response: Math.min(policy.maxResponseBytes, 512 * 1024 * 1024),
|
|
200
|
+
});
|
|
201
|
+
this.calls.add(call);
|
|
202
|
+
return call;
|
|
203
|
+
}
|
|
204
|
+
reserve(bytes) {
|
|
205
|
+
if (this.diskBytes + bytes > SPOOL_BYTES)
|
|
206
|
+
return false;
|
|
207
|
+
this.diskBytes += bytes;
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
release(bytes) {
|
|
211
|
+
this.diskBytes = Math.max(0, this.diskBytes - bytes);
|
|
212
|
+
}
|
|
213
|
+
done(call) {
|
|
214
|
+
this.calls.delete(call);
|
|
215
|
+
}
|
|
216
|
+
get runtime() {
|
|
217
|
+
return this.state;
|
|
218
|
+
}
|
|
219
|
+
get supported() {
|
|
220
|
+
return this.ready && !this.closed;
|
|
221
|
+
}
|
|
222
|
+
async close() {
|
|
223
|
+
this.closed = true;
|
|
224
|
+
// Only local writes are drained. Network/storage availability never delays shutdown.
|
|
225
|
+
await Promise.allSettled([...this.calls].map((call) => call.flush()));
|
|
226
|
+
for (const call of this.calls)
|
|
227
|
+
call.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
export class CaptureCall {
|
|
231
|
+
owner;
|
|
232
|
+
directory;
|
|
233
|
+
manifest;
|
|
234
|
+
limits;
|
|
235
|
+
writes = Promise.resolve();
|
|
236
|
+
manifests = Promise.resolve();
|
|
237
|
+
token;
|
|
238
|
+
tokenExpires = 0;
|
|
239
|
+
uploading = false;
|
|
240
|
+
stopped = false;
|
|
241
|
+
reservedBytes = 0;
|
|
242
|
+
removing;
|
|
243
|
+
retry;
|
|
244
|
+
artifacts = new Map();
|
|
245
|
+
nextParts = new Map();
|
|
246
|
+
failedStages = new Set();
|
|
247
|
+
constructor(owner, directory, manifest, token, limits = { request: 128 * 1024 * 1024, response: 512 * 1024 * 1024 }) {
|
|
248
|
+
this.owner = owner;
|
|
249
|
+
this.directory = directory;
|
|
250
|
+
this.manifest = manifest;
|
|
251
|
+
this.limits = limits;
|
|
252
|
+
this.token = token;
|
|
253
|
+
this.reservedBytes = manifest.artifacts.reduce((sum, artifact) => sum + artifact.rawBytes, 0);
|
|
254
|
+
}
|
|
255
|
+
artifact(stage, contentType = 'application/octet-stream', representation = 'raw-http-body') {
|
|
256
|
+
let artifact = this.artifacts.get(stage);
|
|
257
|
+
if (!artifact) {
|
|
258
|
+
const record = { stage, contentType, representation, parts: 0, rawBytes: 0 };
|
|
259
|
+
this.manifest.artifacts.push(record);
|
|
260
|
+
artifact = new CaptureArtifact(this, record, stage === 'codex_request' ? this.limits.request : this.limits.response);
|
|
261
|
+
this.artifacts.set(stage, artifact);
|
|
262
|
+
this.schedule(async () => {
|
|
263
|
+
if (this.stopped)
|
|
264
|
+
return;
|
|
265
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 }); // private
|
|
266
|
+
await this.persist();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return artifact;
|
|
270
|
+
}
|
|
271
|
+
schedule(operation) {
|
|
272
|
+
this.writes = this.writes.then(operation).catch(() => {
|
|
273
|
+
this.stopped = true;
|
|
274
|
+
// Run after this chain's rejection handler returns, so cleanup cannot await itself.
|
|
275
|
+
setImmediate(() => {
|
|
276
|
+
void this.remove();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
part(record, bytes) {
|
|
281
|
+
const partNo = this.nextParts.get(record.stage) ?? record.parts;
|
|
282
|
+
this.nextParts.set(record.stage, partNo + 1);
|
|
283
|
+
if (!this.owner.reserve(bytes.length)) {
|
|
284
|
+
throw new Error('capture_spool_limit');
|
|
285
|
+
}
|
|
286
|
+
this.reservedBytes += bytes.length;
|
|
287
|
+
this.schedule(async () => {
|
|
288
|
+
let written = false;
|
|
289
|
+
try {
|
|
290
|
+
if (this.stopped || this.failedStages.has(record.stage)) {
|
|
291
|
+
this.release(bytes.length);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
await writeFile(this.partPath(record.stage, partNo), bytes, { flag: 'wx', mode: 0o600 }); // private
|
|
295
|
+
written = true;
|
|
296
|
+
record.rawBytes += bytes.length;
|
|
297
|
+
record.parts = partNo + 1;
|
|
298
|
+
await this.persist();
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (!written)
|
|
302
|
+
this.release(bytes.length);
|
|
303
|
+
this.failedStages.add(record.stage);
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
queuedBytes -= bytes.length;
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
finish(record, end) {
|
|
311
|
+
this.schedule(async () => {
|
|
312
|
+
if (this.stopped)
|
|
313
|
+
return;
|
|
314
|
+
record.end = {
|
|
315
|
+
...end,
|
|
316
|
+
sourceEnd: this.failedStages.has(record.stage) ? 'io_error' : end.sourceEnd,
|
|
317
|
+
};
|
|
318
|
+
await this.persist();
|
|
319
|
+
});
|
|
320
|
+
void this.upload();
|
|
321
|
+
}
|
|
322
|
+
flush() {
|
|
323
|
+
return this.writes;
|
|
324
|
+
}
|
|
325
|
+
close() {
|
|
326
|
+
this.stopped = true;
|
|
327
|
+
if (this.retry)
|
|
328
|
+
clearTimeout(this.retry);
|
|
329
|
+
}
|
|
330
|
+
get active() {
|
|
331
|
+
return !this.stopped;
|
|
332
|
+
}
|
|
333
|
+
release(bytes) {
|
|
334
|
+
this.owner.release(bytes);
|
|
335
|
+
this.reservedBytes = Math.max(0, this.reservedBytes - bytes);
|
|
336
|
+
}
|
|
337
|
+
remove() {
|
|
338
|
+
if (this.removing)
|
|
339
|
+
return this.removing;
|
|
340
|
+
this.stopped = true;
|
|
341
|
+
for (const artifact of this.artifacts.values())
|
|
342
|
+
artifact.finish({ sourceEnd: 'io_error', protocolTerminal: 'unknown' });
|
|
343
|
+
if (this.retry)
|
|
344
|
+
clearTimeout(this.retry);
|
|
345
|
+
this.removing = (async () => {
|
|
346
|
+
await this.writes;
|
|
347
|
+
await this.manifests.catch(() => { });
|
|
348
|
+
try {
|
|
349
|
+
await rm(this.directory, { recursive: true, force: true });
|
|
350
|
+
this.release(this.reservedBytes);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
/* Keep quota reserved while files cannot be removed. */
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
this.owner.done(this);
|
|
357
|
+
}
|
|
358
|
+
})();
|
|
359
|
+
return this.removing;
|
|
360
|
+
}
|
|
361
|
+
partPath(stage, part) {
|
|
362
|
+
return join(this.directory, `${stage}.${part}.part`);
|
|
363
|
+
}
|
|
364
|
+
persist() {
|
|
365
|
+
// Registration and body writes share one manifest writer, including snapshot creation.
|
|
366
|
+
const next = this.manifests
|
|
367
|
+
.catch(() => { })
|
|
368
|
+
.then(async () => {
|
|
369
|
+
const temporary = join(this.directory, `${randomUUID()}.tmp`);
|
|
370
|
+
await writeFile(temporary, JSON.stringify(this.manifest), { mode: 0o600 }); // private
|
|
371
|
+
await rename(temporary, join(this.directory, 'manifest.json'));
|
|
372
|
+
});
|
|
373
|
+
this.manifests = next;
|
|
374
|
+
return next;
|
|
375
|
+
}
|
|
376
|
+
async authorize() {
|
|
377
|
+
if (this.token && (!this.tokenExpires || this.tokenExpires > Date.now() + 10_000))
|
|
378
|
+
return this.token;
|
|
379
|
+
const config = this.owner.runtime.config;
|
|
380
|
+
const response = await fetch(platformEndpoint(config.publicBaseUrl, '/runtime/internal/model-capture-upload-grants'), {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
redirect: 'error',
|
|
383
|
+
signal: AbortSignal.timeout(10_000),
|
|
384
|
+
headers: {
|
|
385
|
+
authorization: `Bearer ${config.controlToken}`,
|
|
386
|
+
'content-type': 'application/json',
|
|
387
|
+
'playcraft-env': config.environment,
|
|
388
|
+
},
|
|
389
|
+
body: JSON.stringify({
|
|
390
|
+
runtimeId: this.manifest.identity.runtimeId,
|
|
391
|
+
generation: this.manifest.identity.generation,
|
|
392
|
+
runtimeEpoch: this.manifest.identity.runtimeEpoch,
|
|
393
|
+
workspaceId: this.manifest.identity.workspaceId,
|
|
394
|
+
sessionId: this.manifest.identity.sessionId,
|
|
395
|
+
directoryName: this.manifest.identity.directoryName,
|
|
396
|
+
clientRequestId: this.manifest.identity.clientRequestId,
|
|
397
|
+
modelCallId: this.manifest.identity.modelCallId,
|
|
398
|
+
captureCallRowId: this.manifest.identity.captureCallRowId,
|
|
399
|
+
}),
|
|
400
|
+
});
|
|
401
|
+
if (!response.ok) {
|
|
402
|
+
await response.body?.cancel();
|
|
403
|
+
if ([401, 403, 404, 410].includes(response.status))
|
|
404
|
+
await this.remove();
|
|
405
|
+
throw new Error('capture_authority_unavailable');
|
|
406
|
+
}
|
|
407
|
+
const grant = z
|
|
408
|
+
.object({ token: z.string().min(1), expiresAt: z.string().datetime() })
|
|
409
|
+
.parse(await response.json());
|
|
410
|
+
this.token = grant.token;
|
|
411
|
+
this.tokenExpires = Date.parse(grant.expiresAt);
|
|
412
|
+
return grant.token;
|
|
413
|
+
}
|
|
414
|
+
async send(path, init) {
|
|
415
|
+
const token = await this.authorize();
|
|
416
|
+
const base = `/api/llm-proxy/workspace-captures/${this.manifest.identity.modelCallId}`;
|
|
417
|
+
const response = await fetch(platformEndpoint(this.owner.runtime.config.publicBaseUrl, `${base}${path}`), {
|
|
418
|
+
...init,
|
|
419
|
+
redirect: 'error',
|
|
420
|
+
signal: AbortSignal.timeout(15_000),
|
|
421
|
+
headers: { ...init.headers, authorization: `Bearer ${token}` },
|
|
422
|
+
});
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
const terminal = response.status === 410 ||
|
|
425
|
+
((response.status === 401 || response.status === 403) && this.tokenExpires > 0);
|
|
426
|
+
if (response.status === 401 || response.status === 403) {
|
|
427
|
+
this.token = undefined;
|
|
428
|
+
this.tokenExpires = 0;
|
|
429
|
+
}
|
|
430
|
+
await response.body?.cancel();
|
|
431
|
+
if (terminal)
|
|
432
|
+
await this.remove();
|
|
433
|
+
throw new Error('capture_upload_unavailable');
|
|
434
|
+
}
|
|
435
|
+
return response;
|
|
436
|
+
}
|
|
437
|
+
verifyArchive(record, sha256, remote) {
|
|
438
|
+
if (remote.stage !== record.stage ||
|
|
439
|
+
remote.archiveState !== 'archived' ||
|
|
440
|
+
remote.rawBytes !== record.rawBytes ||
|
|
441
|
+
remote.partCount !== record.parts ||
|
|
442
|
+
remote.sha256 !== sha256 ||
|
|
443
|
+
Object.entries(record.end ?? {}).some(([key, value]) => remote[key] !== value)) {
|
|
444
|
+
throw new Error('capture_archive_confirmation_mismatch');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
async upload() {
|
|
448
|
+
if (this.uploading || this.stopped)
|
|
449
|
+
return;
|
|
450
|
+
this.uploading = true;
|
|
451
|
+
try {
|
|
452
|
+
await this.writes;
|
|
453
|
+
if (this.stopped)
|
|
454
|
+
return;
|
|
455
|
+
if (Date.parse(this.manifest.identity.expiresAt) <= Date.now()) {
|
|
456
|
+
await this.remove();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
await uploadSlot(async () => {
|
|
460
|
+
if (!this.manifest.identity.captureCallRowId) {
|
|
461
|
+
const response = await this.send('/register', {
|
|
462
|
+
method: 'POST',
|
|
463
|
+
headers: { 'content-type': 'application/json' },
|
|
464
|
+
body: JSON.stringify({ configId: this.manifest.identity.configId }),
|
|
465
|
+
});
|
|
466
|
+
const registration = (await response.json());
|
|
467
|
+
if (registration.capability !== 'available' || !registration.call) {
|
|
468
|
+
await this.remove();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.manifest.identity.captureCallRowId = registration.call.callRowId;
|
|
472
|
+
await this.persist();
|
|
473
|
+
}
|
|
474
|
+
const statusResponse = await this.send('/upload-status', { method: 'GET' });
|
|
475
|
+
const status = (await statusResponse.json());
|
|
476
|
+
if (status.call?.callRowId !== this.manifest.identity.captureCallRowId ||
|
|
477
|
+
status.call.modelCallId !== this.manifest.identity.modelCallId ||
|
|
478
|
+
!Array.isArray(status.artifacts)) {
|
|
479
|
+
throw new Error('capture_status_identity_mismatch');
|
|
480
|
+
}
|
|
481
|
+
for (const record of this.manifest.artifacts) {
|
|
482
|
+
if (!record.end)
|
|
483
|
+
continue;
|
|
484
|
+
const remote = status.artifacts.find((item) => item.stage === record.stage);
|
|
485
|
+
const confirmed = new Map((remote?.confirmedParts ?? []).map((part) => [part.partNo, part]));
|
|
486
|
+
const hash = createHash('sha256');
|
|
487
|
+
for (let partNo = 0; partNo < record.parts; partNo++) {
|
|
488
|
+
const bytes = await readFile(this.partPath(record.stage, partNo));
|
|
489
|
+
hash.update(bytes);
|
|
490
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
491
|
+
const part = confirmed.get(partNo);
|
|
492
|
+
if (part) {
|
|
493
|
+
if (part.sha256 !== sha256 || part.rawBytes !== bytes.length)
|
|
494
|
+
throw new Error('capture_part_confirmation_mismatch');
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const response = await this.send(`/artifacts/${record.stage}/chunks/${partNo}`, {
|
|
498
|
+
method: 'PUT',
|
|
499
|
+
headers: {
|
|
500
|
+
'content-type': 'application/octet-stream',
|
|
501
|
+
'x-capture-sha256': sha256,
|
|
502
|
+
'x-capture-content-type': record.contentType,
|
|
503
|
+
'x-capture-representation': record.representation,
|
|
504
|
+
},
|
|
505
|
+
body: bytes,
|
|
506
|
+
});
|
|
507
|
+
await response.body?.cancel();
|
|
508
|
+
}
|
|
509
|
+
const sha256 = hash.digest('hex');
|
|
510
|
+
if (remote?.archiveState === 'archived') {
|
|
511
|
+
this.verifyArchive(record, sha256, remote);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
const response = await this.send(`/artifacts/${record.stage}/finalize`, {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
headers: { 'content-type': 'application/json' },
|
|
517
|
+
body: JSON.stringify({
|
|
518
|
+
...record.end,
|
|
519
|
+
partCount: record.parts,
|
|
520
|
+
rawBytes: record.rawBytes,
|
|
521
|
+
sha256,
|
|
522
|
+
}),
|
|
523
|
+
});
|
|
524
|
+
this.verifyArchive(record, sha256, (await response.json()));
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
if ((this.manifest.artifacts.length === 2 || this.artifacts.size === 0) &&
|
|
528
|
+
this.manifest.artifacts.every((item) => item.end)) {
|
|
529
|
+
await this.remove();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
/* Retry only archival transport, never model execution. */
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
this.uploading = false;
|
|
538
|
+
if (!this.stopped) {
|
|
539
|
+
this.retry = setTimeout(() => {
|
|
540
|
+
void this.upload();
|
|
541
|
+
}, 5_000);
|
|
542
|
+
this.retry.unref();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
export class CaptureArtifact {
|
|
548
|
+
call;
|
|
549
|
+
record;
|
|
550
|
+
limit;
|
|
551
|
+
pending = [];
|
|
552
|
+
pendingBytes = 0;
|
|
553
|
+
receivedBytes = 0;
|
|
554
|
+
ended = false;
|
|
555
|
+
failure;
|
|
556
|
+
observedBytes = 0;
|
|
557
|
+
submittedBytes = 0;
|
|
558
|
+
constructor(call, record, limit) {
|
|
559
|
+
this.call = call;
|
|
560
|
+
this.record = record;
|
|
561
|
+
this.limit = limit;
|
|
562
|
+
}
|
|
563
|
+
contentType(value) {
|
|
564
|
+
this.record.contentType = value.slice(0, 256);
|
|
565
|
+
}
|
|
566
|
+
upstreamObserved(bytes) {
|
|
567
|
+
this.observedBytes += bytes;
|
|
568
|
+
}
|
|
569
|
+
append(bytes) {
|
|
570
|
+
if (this.ended || !bytes.byteLength)
|
|
571
|
+
return;
|
|
572
|
+
this.submittedBytes += bytes.byteLength;
|
|
573
|
+
if (this.failure || !this.call.active)
|
|
574
|
+
return;
|
|
575
|
+
if (this.receivedBytes + bytes.byteLength > this.limit ||
|
|
576
|
+
queuedBytes + bytes.byteLength > QUEUE_BYTES) {
|
|
577
|
+
this.failure = 'limit_exceeded';
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
this.receivedBytes += bytes.byteLength;
|
|
581
|
+
queuedBytes += bytes.byteLength;
|
|
582
|
+
let offset = 0;
|
|
583
|
+
while (offset < bytes.byteLength) {
|
|
584
|
+
const length = Math.min(MODEL_CAPTURE_CHUNK_BYTES - this.pendingBytes, bytes.byteLength - offset);
|
|
585
|
+
this.pending.push(Buffer.from(bytes.subarray(offset, offset + length)));
|
|
586
|
+
this.pendingBytes += length;
|
|
587
|
+
offset += length;
|
|
588
|
+
if (this.pendingBytes === MODEL_CAPTURE_CHUNK_BYTES)
|
|
589
|
+
this.flushPart();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
finish(end) {
|
|
593
|
+
if (this.ended)
|
|
594
|
+
return;
|
|
595
|
+
this.ended = true;
|
|
596
|
+
if (!this.call.active) {
|
|
597
|
+
queuedBytes -= this.pendingBytes;
|
|
598
|
+
this.pending = [];
|
|
599
|
+
this.pendingBytes = 0;
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
this.flushPart();
|
|
603
|
+
this.call.finish(this.record, {
|
|
604
|
+
...end,
|
|
605
|
+
sourceEnd: this.failure ?? end.sourceEnd,
|
|
606
|
+
...(this.record.stage === 'codex_response'
|
|
607
|
+
? {
|
|
608
|
+
upstreamObservedBytes: this.observedBytes,
|
|
609
|
+
downstreamSubmittedBytes: this.submittedBytes,
|
|
610
|
+
}
|
|
611
|
+
: {}),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
flushPart() {
|
|
615
|
+
if (!this.pendingBytes)
|
|
616
|
+
return;
|
|
617
|
+
const bytes = Buffer.concat(this.pending, this.pendingBytes);
|
|
618
|
+
this.pending = [];
|
|
619
|
+
this.pendingBytes = 0;
|
|
620
|
+
try {
|
|
621
|
+
this.call.part(this.record, bytes);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
queuedBytes -= bytes.length;
|
|
625
|
+
this.failure = 'limit_exceeded';
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/** Observes Node's accepted bytes without changing the return value or drain handling. */
|
|
630
|
+
export function captureSubmittedResponse(response, artifact) {
|
|
631
|
+
const append = (chunk, encoding) => {
|
|
632
|
+
try {
|
|
633
|
+
if (typeof chunk === 'string')
|
|
634
|
+
artifact.append(Buffer.from(chunk, typeof encoding === 'string' ? encoding : undefined));
|
|
635
|
+
else if (chunk instanceof Uint8Array)
|
|
636
|
+
artifact.append(chunk);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
/* Archival must never turn a successful write into an inference failure. */
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
const write = response.write;
|
|
643
|
+
response.write = function (...args) {
|
|
644
|
+
const writable = !this.destroyed && !this.writableEnded && !this.socket?.destroyed;
|
|
645
|
+
const accepted = Reflect.apply(write, this, args);
|
|
646
|
+
if (writable && !this.destroyed && !this.socket?.destroyed)
|
|
647
|
+
append(args[0], args[1]);
|
|
648
|
+
return accepted;
|
|
649
|
+
};
|
|
650
|
+
const end = response.end;
|
|
651
|
+
response.end = function (chunk, encoding, callback) {
|
|
652
|
+
const writable = !this.destroyed && !this.writableEnded && !this.socket?.destroyed;
|
|
653
|
+
const accepted = Reflect.apply(end, this, [chunk, encoding, callback]);
|
|
654
|
+
if (writable && !this.destroyed && !this.socket?.destroyed)
|
|
655
|
+
append(chunk, encoding);
|
|
656
|
+
return accepted;
|
|
657
|
+
};
|
|
658
|
+
response.once('finish', () => queueMicrotask(() => {
|
|
659
|
+
artifact.contentType(String(response.getHeader('content-type') ?? 'application/octet-stream'));
|
|
660
|
+
artifact.finish({
|
|
661
|
+
sourceEnd: 'eof',
|
|
662
|
+
protocolTerminal: /event-stream|ndjson/i.test(String(response.getHeader('content-type') ?? ''))
|
|
663
|
+
? 'unknown'
|
|
664
|
+
: 'not_applicable',
|
|
665
|
+
downstreamFinished: true,
|
|
666
|
+
responseStatus: response.statusCode,
|
|
667
|
+
});
|
|
668
|
+
}));
|
|
669
|
+
response.once('close', () => queueMicrotask(() => artifact.finish({
|
|
670
|
+
sourceEnd: response.writableFinished ? 'eof' : 'aborted',
|
|
671
|
+
protocolTerminal: 'unknown',
|
|
672
|
+
downstreamFinished: response.writableFinished,
|
|
673
|
+
responseStatus: response.statusCode,
|
|
674
|
+
})));
|
|
675
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.85-beta",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"README.md"
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
|
+
"lint": "pnpm -w run lint:eslint packages/cli",
|
|
25
26
|
"dev": "tsc -w",
|
|
26
27
|
"build": "node scripts/generate-asset-skills.mjs && node scripts/generate-video-skills.mjs && node scripts/generate-parameter-skill.mjs && node scripts/generate-level-editor-skill.mjs && node scripts/generate-preview-runtime.mjs && tsc",
|
|
27
28
|
"start": "node dist/index.js",
|
|
@@ -57,8 +58,8 @@
|
|
|
57
58
|
"@gltf-transform/core": "^4.3.0",
|
|
58
59
|
"@gltf-transform/extensions": "^4.3.0",
|
|
59
60
|
"@gltf-transform/functions": "^4.3.0",
|
|
60
|
-
"@playcraft/build": "0.1.
|
|
61
|
-
"@playcraft/common": "0.1.
|
|
61
|
+
"@playcraft/build": "0.1.85-beta",
|
|
62
|
+
"@playcraft/common": "0.1.85-beta",
|
|
62
63
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
63
64
|
"chokidar": "^4.0.3",
|
|
64
65
|
"commander": "^13.1.0",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"release-guard.mjs": "728ac088f8d885a50a1c9bcdddad724fa80afd06e66a6dd3658756034e563055",
|
|
9
9
|
"runtime.d.mts": "a90c69a8b72105e8dc8fe0b3bcd272f038ed255f7daf5ec6c1aefc293a1e84b2",
|
|
10
10
|
"runtime.d.ts": "a90c69a8b72105e8dc8fe0b3bcd272f038ed255f7daf5ec6c1aefc293a1e84b2",
|
|
11
|
-
"runtime.mjs": "
|
|
11
|
+
"runtime.mjs": "fefc50ed3246b6d33934179c04952c48f424fcce4d8d45b35bbcd083b644e41e",
|
|
12
12
|
"types/adapters.d.ts": "f5e70c4bb9043de1c590eada14266f613286360c809faf26680b0bbbd1cc7b2c",
|
|
13
13
|
"types/host.d.ts": "9ea867d5485a66aba7da86ea7e5583bca6b9a7a89618afd0eb70ace58e06faf9",
|
|
14
14
|
"types/index.d.ts": "210d24a767f260bc8944d917b50dae1357cba31b8b5d499bea7bcc99b1051e6c",
|
|
@@ -257,7 +257,7 @@ var package_default = {
|
|
|
257
257
|
test: "vitest run",
|
|
258
258
|
"test:watch": "vitest",
|
|
259
259
|
playground: 'pnpm --filter "@playcraft/iframe-bridge-playground-*" --parallel dev',
|
|
260
|
-
lint: "
|
|
260
|
+
lint: "pnpm -w run lint:eslint packages/iframe-bridge",
|
|
261
261
|
release: "node ../../scripts/release-npm.mjs --packages=iframe-bridge --skip-install --skip-tests --skip-git-push"
|
|
262
262
|
},
|
|
263
263
|
devDependencies: {
|