@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.
@@ -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), expiresAt: z.string().datetime(),
15
- configId: z.string().uuid(), model: z.string().min(1).max(128),
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(), userId: z.string(), workspaceId: z.string(), runtimeId: z.string(),
19
- generation: z.number().int(), runtimeEpoch: z.number().int(),
20
- traceOrigin: z.custom(value => workspaceExecutionTraceOriginSchema.safeParse(value).success).optional(),
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 ? `${workspaceId}/${createHash('sha256').update(sessionId).digest('hex').slice(0, 32)}` : workspaceId;
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 ?? (route?.epoch === this.state.runtimeEpoch ? route.selected?.entry.modelConfigId : undefined);
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) ?? [...this.entries.values()].find(item => item.workspaceId === workspaceId && item.epoch === this.state.runtimeEpoch && item.modelConfigId === selected
77
- && item.sessionId === sessionId && item.clientRequestId === clientRequestId
78
- && item.reasoningEffort === reasoningEffort);
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 = { workspaceId, epoch: this.state.runtimeEpoch, cacheKey, modelConfigId: selected, reasoningEffort, sessionId, clientRequestId };
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 = { workspaceId, epoch: this.state.runtimeEpoch, capability: randomBytes(32).toString('base64url'),
88
- generationCapability: randomBytes(32).toString('base64url'), referencesCapability: randomBytes(32).toString('base64url'),
89
- skillsCapability: randomBytes(32).toString('base64url') };
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 = { entry, model: credential.model,
99
- ...(sessionId && clientRequestId ? { feedback: { sessionId, clientRequestId, cwd, boundaryId, readOnly,
100
- ...(originCwd ? { originCwd } : {}) } } : {}) };
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 ? { 'shell_environment_policy.set': {
107
- PLAYCRAFT_AGENT_GENERATION_ENDPOINT: `${this.baseUrl}/${routeKey}/generation-provenance`,
108
- PLAYCRAFT_AGENT_GENERATION_CAPABILITY: route.generationCapability,
109
- PLAYCRAFT_REMIX_REFERENCES_ENDPOINT: `${this.baseUrl}/${routeKey}/remix-references`,
110
- PLAYCRAFT_REMIX_REFERENCES_CAPABILITY: route.referencesCapability,
111
- PLAYCRAFT_SKILLS_ENDPOINT: `${this.baseUrl}/${routeKey}/skill-discovery`,
112
- PLAYCRAFT_SKILLS_CAPABILITY: route.skillsCapability,
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', base_url: `${this.baseUrl}/${routeKey}`,
116
- wire_api: 'responses', requires_openai_auth: false,
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 && !this.state.journal.destroyed()
124
- && this.state.runtimeEpoch === entry.epoch && this.state.journal.getWorkspace(entry.workspaceId)
125
- && this.entries.get(entry.cacheKey) === entry);
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)) ? basename(dirname(cwd)) : 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 || !this.valid(entry) || entry.sessionId !== sessionId || entry.clientRequestId !== clientRequestId)
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 && entry.credential && Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
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', redirect: 'error', signal: AbortSignal.timeout(10_000),
154
- headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
155
- body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation,
156
- runtimeEpoch: entry.epoch, workspaceId: entry.workspaceId,
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(['model_config_required', 'model_config_denied', 'reasoning_effort_denied', 'reasoning_effort_unsupported'].includes(error.reason ?? '') ? error.reason : 'model_credential_unavailable');
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) || claims.workspaceId !== entry.workspaceId || claims.runtimeId !== config.runtimeId
169
- || claims.userId !== config.userId || claims.environment !== config.environment
170
- || claims.generation !== config.generation || claims.runtimeEpoch !== entry.epoch
171
- || (entry.modelConfigId !== undefined && credential.configId !== entry.modelConfigId)
172
- || Date.parse(credential.expiresAt) <= Date.now() + 1000)
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 ? `Bearer ${generationRequest || contextRequest ? route.generationCapability
198
- : referencesRequest ? route.referencesCapability : skillsRequest ? route.skillsCapability : route.capability}` : '');
199
- if (request.method !== 'POST' || !entry || !expected.length || presented.length !== expected.length
200
- || !timingSafeEqual(presented, expected) || !this.valid(entry)) {
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, { 'content-type': 'application/json', 'cache-control': 'no-store' });
244
- response.end(JSON.stringify({ error: { message: 'model_request_too_large',
245
- type: 'invalid_request_error', code: 'model_request_too_large', maxBytes: MAX_MODEL_REQUEST_BYTES } }));
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 && /^\d+$/.test(declaredLength) && Number(declaredLength) > MAX_MODEL_REQUEST_BYTES) {
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' : entry.reasoningEffort;
420
+ ? 'xhigh'
421
+ : entry.reasoningEffort;
292
422
  body.reasoning = {
293
- ...(reasoning && typeof reasoning === 'object' && !Array.isArray(reasoning) ? 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 && candidateOrigin?.clientRequestId === entry.clientRequestId
303
- ? candidateOrigin : undefined;
304
- const broker = { receivedAt, dispatchedAt,
305
- credentialWaitMs, requestReadMs, beforeDispatchMs };
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', redirect: 'error', signal: abort.signal,
311
- headers: { authorization: `Bearer ${credential.token}`, 'content-type': 'application/json',
312
- ...(origin ? { 'x-playcraft-model-journey': JSON.stringify({ version: 1,
313
- runtimeOperationId: origin.runtimeOperationId, sessionId: origin.sessionId, clientRequestId: origin.clientRequestId,
314
- ...(entry.codexDispatchedAt ? { codexDispatchedAt: entry.codexDispatchedAt } : {}),
315
- brokerReceivedAt: receivedAt, brokerDispatchedAt: dispatchedAt, credentialWaitMs, requestReadMs, beforeDispatchMs,
316
- ...(entry.codexDispatchMark === undefined ? {} : { codexDispatchToBrokerMs: Math.max(0, Math.round(receivedMark - entry.codexDispatchMark)) }),
317
- }) } : {}),
318
- ...(selected?.feedback ? {
319
- 'x-playcraft-cloud-session-id': selected.feedback.sessionId,
320
- 'x-playcraft-client-request-id': selected.feedback.clientRequestId,
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, { version: 1, attemptId,
368
- runtimeOperationId: origin.runtimeOperationId, sessionId: origin.sessionId,
369
- clientRequestId: origin.clientRequestId, broker, outcome });
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, { 'content-type': 'application/json', 'cache-control': 'no-store' });
376
- response.end(JSON.stringify({ error: { message: 'model_credential_unavailable', type: 'server_error' } }));
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', redirect: 'error', signal: AbortSignal.timeout(3_000),
396
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(input),
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 { /* Missing observations remain unknown; never replay a model request for telemetry. */ }
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.83",
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.83",
61
- "@playcraft/common": "0.1.83",
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": "50dc53ff94effedf10903df708c1ecdb51bd8145a5a6ec1eac9449cad7424a24",
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: "echo 'No eslint config yet'",
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: {