@playcraft/cli 0.1.85-beta → 0.1.87

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.
@@ -1,7 +1,7 @@
1
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
- import { pipeline } from 'node:stream/promises';
4
+ import { finished, 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';
@@ -10,6 +10,8 @@ import { WorkspaceGenerationProvenanceBroker } from './generation-provenance-bro
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 { ModelBrokerDiagnostics } from './model-broker-diagnostics.js';
14
+ import { MODEL_REQUEST_BUDGET_MS, ModelBrokerRequestError, modelBrokerFailure, readModelRequestBody, } from './model-broker-request.js';
13
15
  import { WorkspaceModelCapture, captureSubmittedResponse, } from './model-capture.js';
14
16
  const credentialSchema = z.object({
15
17
  token: z.string().min(1),
@@ -42,6 +44,7 @@ const MAX_MODEL_REQUEST_BYTES = 128 * 1024 * 1024;
42
44
  /** Keeps renewable execution credentials in control memory, outside Codex and project files. */
43
45
  export class WorkspaceModelBroker {
44
46
  state;
47
+ requestBudgetMs;
45
48
  server;
46
49
  baseUrl = '';
47
50
  entries = new Map();
@@ -54,8 +57,9 @@ export class WorkspaceModelBroker {
54
57
  executionContext;
55
58
  skills;
56
59
  capture;
57
- constructor(state) {
60
+ constructor(state, requestBudgetMs = MODEL_REQUEST_BUDGET_MS) {
58
61
  this.state = state;
62
+ this.requestBudgetMs = requestBudgetMs;
59
63
  this.generation = new WorkspaceGenerationProvenanceBroker(state);
60
64
  this.references = new WorkspaceRemixReferencesBroker(state);
61
65
  this.executionContext = new WorkspaceExecutionContextBroker(state);
@@ -174,8 +178,13 @@ export class WorkspaceModelBroker {
174
178
  base_url: `${this.baseUrl}/${routeKey}`,
175
179
  wire_api: 'responses',
176
180
  requires_openai_auth: false,
181
+ request_max_retries: 0,
182
+ stream_max_retries: 0,
183
+ stream_idle_timeout_ms: MODEL_REQUEST_BUDGET_MS,
184
+ supports_websockets: false,
177
185
  http_headers: { Authorization: `Bearer ${route.capability}` },
178
186
  },
187
+ 'features.unbounded_connection_retries': false,
179
188
  },
180
189
  };
181
190
  }
@@ -201,13 +210,17 @@ export class WorkspaceModelBroker {
201
210
  entry.codexDispatchedAt = new Date().toISOString();
202
211
  entry.codexDispatchMark = performance.now();
203
212
  }
204
- async credential(entry, refresh = false) {
213
+ async credential(entry, refresh = false, signal) {
214
+ signal?.throwIfAborted();
205
215
  if (!this.valid(entry))
206
216
  throw new Error('model_access_denied');
207
217
  if (!refresh &&
208
218
  entry.credential &&
209
219
  Date.parse(entry.credential.expiresAt) > Date.now() + 60_000)
210
220
  return entry.credential;
221
+ // A model request owns its renewal cancellation; it must not abort another caller's renewal.
222
+ if (signal)
223
+ return this.renew(entry, signal);
211
224
  let pending = this.pending.get(entry);
212
225
  if (!pending) {
213
226
  pending = this.renew(entry).finally(() => this.pending.delete(entry));
@@ -215,56 +228,76 @@ export class WorkspaceModelBroker {
215
228
  }
216
229
  return pending;
217
230
  }
218
- async renew(entry) {
231
+ async renew(entry, signal) {
219
232
  const config = this.state.config;
220
233
  const endpoint = platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.modelCredentials);
221
- const response = await fetch(endpoint, {
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,
236
- ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
237
- ...(entry.clientRequestId ? { clientRequestId: entry.clientRequestId } : {}),
238
- ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
239
- ...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}),
240
- }),
241
- });
242
- if (!response.ok) {
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');
234
+ const timeout = new AbortController();
235
+ const timer = setTimeout(() => timeout.abort(new ModelBrokerRequestError(504, 'model_credential_timeout')), 10_000);
236
+ timer.unref();
237
+ try {
238
+ const response = await fetch(endpoint, {
239
+ method: 'POST',
240
+ redirect: 'error',
241
+ signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
242
+ headers: {
243
+ authorization: `Bearer ${config.controlToken}`,
244
+ 'content-type': 'application/json',
245
+ 'playcraft-env': config.environment,
246
+ ...(this.capture.supported ? { 'x-playcraft-model-capture-protocol': '1' } : {}),
247
+ },
248
+ body: JSON.stringify({
249
+ runtimeId: config.runtimeId,
250
+ generation: config.generation,
251
+ runtimeEpoch: entry.epoch,
252
+ workspaceId: entry.workspaceId,
253
+ ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
254
+ ...(entry.clientRequestId ? { clientRequestId: entry.clientRequestId } : {}),
255
+ ...(entry.modelConfigId ? { modelConfigId: entry.modelConfigId } : {}),
256
+ ...(entry.reasoningEffort ? { reasoningEffort: entry.reasoningEffort } : {}),
257
+ }),
258
+ });
259
+ if (!response.ok) {
260
+ const error = (await response.json().catch(() => ({})));
261
+ signal?.throwIfAborted();
262
+ timeout.signal.throwIfAborted();
263
+ throw new ModelBrokerRequestError(response.status, [
264
+ 'model_config_required',
265
+ 'model_config_denied',
266
+ 'reasoning_effort_denied',
267
+ 'reasoning_effort_unsupported',
268
+ ].includes(error.reason ?? '')
269
+ ? error.reason
270
+ : 'model_credential_unavailable');
271
+ }
272
+ const parsed = credentialSchema.safeParse(await response.json());
273
+ if (!parsed.success)
274
+ throw new ModelBrokerRequestError(502, 'model_credential_invalid');
275
+ const credential = parsed.data;
276
+ const claims = credential.claims;
277
+ if (!this.valid(entry) ||
278
+ claims.workspaceId !== entry.workspaceId ||
279
+ claims.runtimeId !== config.runtimeId ||
280
+ claims.userId !== config.userId ||
281
+ claims.environment !== config.environment ||
282
+ claims.generation !== config.generation ||
283
+ claims.runtimeEpoch !== entry.epoch ||
284
+ (entry.modelConfigId !== undefined && credential.configId !== entry.modelConfigId) ||
285
+ Date.parse(credential.expiresAt) <= Date.now() + 1000)
286
+ throw new ModelBrokerRequestError(403, 'model_credential_scope_mismatch');
287
+ entry.modelConfigId = credential.configId;
288
+ entry.credential = credential;
289
+ return credential;
290
+ }
291
+ catch (error) {
292
+ signal?.throwIfAborted();
293
+ timeout.signal.throwIfAborted();
294
+ if (error instanceof ModelBrokerRequestError)
295
+ throw error;
296
+ throw new ModelBrokerRequestError(503, 'model_credential_unavailable', error);
297
+ }
298
+ finally {
299
+ clearTimeout(timer);
252
300
  }
253
- const credential = credentialSchema.parse(await response.json());
254
- const claims = credential.claims;
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)
264
- throw new Error('model_credential_scope_mismatch');
265
- entry.modelConfigId = credential.configId;
266
- entry.credential = credential;
267
- return credential;
268
301
  }
269
302
  async forward(request, response) {
270
303
  const receivedMark = performance.now();
@@ -276,7 +309,16 @@ export class WorkspaceModelBroker {
276
309
  let capturedRequest;
277
310
  let capturedResponse;
278
311
  let requestEnded = false;
279
- const cancel = () => abort.abort();
312
+ let diagnostics;
313
+ let failure;
314
+ let deadlineTimer;
315
+ let credentialTimer;
316
+ const cancel = () => {
317
+ if (response.writableFinished)
318
+ return;
319
+ diagnostics?.abort('downstream_closed');
320
+ abort.abort();
321
+ };
280
322
  response.once('close', cancel);
281
323
  try {
282
324
  const match = /^\/([0-9a-f-]{36}(?:\/[0-9a-f]{32})?)\/(responses|generation-provenance|remix-references|execution-context|skill-discovery\/(?:match|selection|artifact|activate))$/.exec(request.url ?? '');
@@ -344,6 +386,32 @@ export class WorkspaceModelBroker {
344
386
  await this.skills.forward(request, response, selected.feedback, () => this.valid(entry) && route?.selected === selected, action);
345
387
  return;
346
388
  }
389
+ const modelCallId = randomUUID();
390
+ diagnostics = new ModelBrokerDiagnostics({
391
+ environment: this.state.config.environment,
392
+ runtimeId: this.state.config.runtimeId,
393
+ generation: this.state.config.generation,
394
+ runtimeEpoch: entry.epoch,
395
+ workspaceId: entry.workspaceId,
396
+ sessionId: entry.sessionId,
397
+ clientRequestId: entry.clientRequestId,
398
+ configId: entry.modelConfigId,
399
+ modelCallId,
400
+ requestBudgetMs: this.requestBudgetMs,
401
+ });
402
+ const expire = () => {
403
+ if (abort.signal.aborted)
404
+ return;
405
+ diagnostics?.abort('deadline_exceeded');
406
+ abort.abort(new ModelBrokerRequestError(504, 'model_request_deadline_exceeded'));
407
+ };
408
+ const checkDeadline = () => {
409
+ if (performance.now() - receivedMark >= this.requestBudgetMs)
410
+ expire();
411
+ abort.signal.throwIfAborted();
412
+ };
413
+ deadlineTimer = setTimeout(expire, Math.max(0, this.requestBudgetMs - (performance.now() - receivedMark)));
414
+ deadlineTimer.unref();
347
415
  const rejectOversizedBody = () => {
348
416
  response.writeHead(413, {
349
417
  'content-type': 'application/json',
@@ -370,36 +438,52 @@ export class WorkspaceModelBroker {
370
438
  active.add(abort);
371
439
  this.sessionRequests.set(route, active);
372
440
  const credentialMark = performance.now();
373
- const credential = await this.credential(entry);
441
+ const credential = await this.credential(entry, false, abort.signal);
442
+ checkDeadline();
374
443
  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)
444
+ const candidateOrigin = credential.claims.traceOrigin;
445
+ const origin = candidateOrigin?.sessionId === entry.sessionId &&
446
+ candidateOrigin?.clientRequestId === entry.clientRequestId
447
+ ? candidateOrigin
380
448
  : undefined;
449
+ diagnostics.bind({
450
+ configId: credential.configId,
451
+ runtimeOperationId: origin?.runtimeOperationId,
452
+ });
453
+ const captured = origin ? this.capture.begin(modelCallId, credential) : undefined;
381
454
  capturedRequest = captured?.artifact('codex_request', request.headers['content-type'] ?? 'application/json');
382
455
  capturedResponse = captured?.artifact('codex_response');
383
456
  if (capturedResponse)
384
457
  captureSubmittedResponse(response, capturedResponse);
458
+ diagnostics.enter('request_body');
385
459
  const requestReadMark = performance.now();
386
460
  const chunks = [];
387
461
  let size = 0;
388
- for await (const chunk of request) {
462
+ await readModelRequestBody(request, abort.signal, (chunk) => {
463
+ diagnostics?.requestChunk(chunk.length);
389
464
  capturedRequest?.append(chunk);
390
465
  size += chunk.length;
391
466
  if (size > MAX_MODEL_REQUEST_BYTES) {
392
- rejectOversizedBody();
393
- return;
467
+ throw new ModelBrokerRequestError(413, 'model_request_too_large');
394
468
  }
395
469
  chunks.push(Buffer.from(chunk));
396
- }
470
+ });
471
+ checkDeadline();
397
472
  const requestReadMs = elapsed(requestReadMark);
398
473
  capturedRequest?.finish({ sourceEnd: 'eof', protocolTerminal: 'not_applicable' });
399
474
  requestEnded = true;
475
+ diagnostics.enter('prepare');
400
476
  if (!this.valid(entry))
401
477
  throw new Error('model_access_denied');
402
- const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
478
+ let body;
479
+ try {
480
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
481
+ }
482
+ catch {
483
+ checkDeadline();
484
+ throw new ModelBrokerRequestError(400, 'model_request_invalid_json');
485
+ }
486
+ checkDeadline();
403
487
  if (!body || typeof body !== 'object' || Array.isArray(body)) {
404
488
  response.writeHead(400);
405
489
  response.end();
@@ -426,15 +510,16 @@ export class WorkspaceModelBroker {
426
510
  effort,
427
511
  };
428
512
  }
429
- const expires = setTimeout(cancel, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
513
+ credentialTimer = setTimeout(() => {
514
+ if (abort.signal.aborted)
515
+ return;
516
+ diagnostics?.abort('credential_expired');
517
+ abort.abort(new ModelBrokerRequestError(401, 'model_credential_expired'));
518
+ }, Math.max(0, Date.parse(credential.expiresAt) - Date.now()));
519
+ credentialTimer.unref();
430
520
  const dispatchedAt = new Date().toISOString();
431
521
  const dispatchedMark = performance.now();
432
522
  const beforeDispatchMs = elapsed(receivedMark);
433
- const candidateOrigin = credential.claims.traceOrigin;
434
- const origin = candidateOrigin?.sessionId === entry.sessionId &&
435
- candidateOrigin?.clientRequestId === entry.clientRequestId
436
- ? candidateOrigin
437
- : undefined;
438
523
  const broker = {
439
524
  receivedAt,
440
525
  dispatchedAt,
@@ -445,6 +530,9 @@ export class WorkspaceModelBroker {
445
530
  let attemptId;
446
531
  let outcome = 'error';
447
532
  try {
533
+ const requestBody = JSON.stringify(body);
534
+ checkDeadline();
535
+ diagnostics.enter('upstream_headers');
448
536
  const upstream = await fetch(platformEndpoint(this.state.config.publicBaseUrl, modelResponsesPath(credential.configId)), {
449
537
  method: 'POST',
450
538
  redirect: 'error',
@@ -453,6 +541,7 @@ export class WorkspaceModelBroker {
453
541
  authorization: `Bearer ${credential.token}`,
454
542
  'content-type': 'application/json',
455
543
  'x-playcraft-model-call-id': modelCallId,
544
+ 'x-request-id': modelCallId,
456
545
  ...(origin
457
546
  ? {
458
547
  'x-playcraft-model-journey': JSON.stringify({
@@ -483,10 +572,11 @@ export class WorkspaceModelBroker {
483
572
  }
484
573
  : {}),
485
574
  },
486
- body: JSON.stringify(body),
575
+ body: requestBody,
487
576
  });
488
577
  broker.responseHeadersAt = new Date().toISOString();
489
578
  broker.proxyResponseHeadersMs = elapsed(dispatchedMark);
579
+ diagnostics.headers(upstream.status, upstream.headers);
490
580
  const reference = upstream.headers.get('x-model-attempt-id');
491
581
  if (reference && z.string().uuid().safeParse(reference).success)
492
582
  attemptId = reference;
@@ -498,15 +588,22 @@ export class WorkspaceModelBroker {
498
588
  route.lastAdmissionReason = undefined;
499
589
  else if (admissionReason)
500
590
  route.lastAdmissionReason = admissionReason;
501
- response.writeHead(upstream.status, {
502
- 'content-type': upstream.headers.get('content-type') ?? 'application/json',
503
- 'cache-control': 'no-store',
504
- ...(admissionReason ? { 'x-model-admission-reason': admissionReason } : {}),
505
- ...(retryAfter ? { 'retry-after': retryAfter } : {}),
506
- });
507
- if (upstream.body)
508
- await pipeline(Readable.fromWeb(upstream.body), new Transform({
591
+ const forwardHeaders = () => {
592
+ if (response.headersSent)
593
+ return;
594
+ response.writeHead(upstream.status, {
595
+ 'content-type': upstream.headers.get('content-type') ?? 'application/json',
596
+ 'cache-control': 'no-store',
597
+ ...(admissionReason ? { 'x-model-admission-reason': admissionReason } : {}),
598
+ ...(retryAfter ? { 'retry-after': retryAfter } : {}),
599
+ });
600
+ };
601
+ const requestDiagnostics = diagnostics;
602
+ if (upstream.body) {
603
+ const observed = new Transform({
509
604
  transform(chunk, _encoding, callback) {
605
+ forwardHeaders();
606
+ requestDiagnostics.responseChunk(chunk.length);
510
607
  capturedResponse?.upstreamObserved(chunk.length);
511
608
  if (chunk.length && broker.firstBodyByteAt === undefined) {
512
609
  broker.firstBodyByteAt = new Date().toISOString();
@@ -514,9 +611,28 @@ export class WorkspaceModelBroker {
514
611
  }
515
612
  callback(null, chunk);
516
613
  },
517
- }), response);
518
- else
614
+ });
615
+ // Keep an uncommitted HTTP response writable so a pre-body deadline can return 504.
616
+ const drained = finished(response, { cleanup: true });
617
+ const source = Readable.fromWeb(upstream.body);
618
+ const transferred = pipeline(source, observed, {
619
+ signal: abort.signal,
620
+ });
621
+ observed.once('end', forwardHeaders);
622
+ observed.pipe(response);
623
+ try {
624
+ await Promise.all([transferred, drained]);
625
+ }
626
+ catch (error) {
627
+ source.destroy();
628
+ observed.destroy();
629
+ throw error;
630
+ }
631
+ }
632
+ else {
633
+ forwardHeaders();
519
634
  response.end();
635
+ }
520
636
  outcome = 'completed';
521
637
  }
522
638
  catch (error) {
@@ -524,7 +640,7 @@ export class WorkspaceModelBroker {
524
640
  throw error;
525
641
  }
526
642
  finally {
527
- clearTimeout(expires);
643
+ clearTimeout(credentialTimer);
528
644
  broker.completedAt = new Date().toISOString();
529
645
  broker.proxyResponseDurationMs = elapsed(dispatchedMark);
530
646
  if (origin && attemptId) {
@@ -541,20 +657,41 @@ export class WorkspaceModelBroker {
541
657
  }
542
658
  }
543
659
  }
544
- catch {
545
- if (!response.headersSent) {
546
- response.writeHead(503, {
660
+ catch (error) {
661
+ if (deadlineTimer &&
662
+ !abort.signal.aborted &&
663
+ performance.now() - receivedMark >= this.requestBudgetMs) {
664
+ diagnostics?.abort('deadline_exceeded');
665
+ abort.abort(new ModelBrokerRequestError(504, 'model_request_deadline_exceeded'));
666
+ }
667
+ failure = error;
668
+ const rejected = modelBrokerFailure(error, abort.signal);
669
+ if (!response.headersSent && !response.destroyed) {
670
+ response.writeHead(rejected.status, {
547
671
  'content-type': 'application/json',
548
672
  'cache-control': 'no-store',
673
+ ...(!request.complete ? { connection: 'close' } : {}),
549
674
  });
550
675
  response.end(JSON.stringify({
551
- error: { message: 'model_credential_unavailable', type: 'server_error' },
676
+ error: {
677
+ message: rejected.code,
678
+ type: rejected.status === 499
679
+ ? 'request_cancelled'
680
+ : rejected.status < 500
681
+ ? 'invalid_request_error'
682
+ : 'server_error',
683
+ code: rejected.code,
684
+ ...(rejected.status === 413 ? { maxBytes: MAX_MODEL_REQUEST_BYTES } : {}),
685
+ },
552
686
  }));
553
687
  }
554
688
  else
555
689
  response.destroy();
556
690
  }
557
691
  finally {
692
+ clearTimeout(deadlineTimer);
693
+ clearTimeout(credentialTimer);
694
+ diagnostics?.finish(response.statusCode, response.writableFinished, abort.signal.aborted, failure);
558
695
  if (!requestEnded)
559
696
  capturedRequest?.finish({
560
697
  sourceEnd: abort.signal.aborted ? 'aborted' : 'io_error',
@@ -237,6 +237,7 @@ export class CaptureCall {
237
237
  token;
238
238
  tokenExpires = 0;
239
239
  uploading = false;
240
+ pendingUpload = false;
240
241
  stopped = false;
241
242
  reservedBytes = 0;
242
243
  removing;
@@ -297,7 +298,7 @@ export class CaptureCall {
297
298
  record.parts = partNo + 1;
298
299
  await this.persist();
299
300
  }
300
- catch (error) {
301
+ catch {
301
302
  if (!written)
302
303
  this.release(bytes.length);
303
304
  this.failedStages.add(record.stage);
@@ -400,7 +401,11 @@ export class CaptureCall {
400
401
  });
401
402
  if (!response.ok) {
402
403
  await response.body?.cancel();
403
- if ([401, 403, 404, 410].includes(response.status))
404
+ if ([401, 403, 404, 410].includes(response.status) ||
405
+ (response.status >= 400 &&
406
+ response.status < 500 &&
407
+ response.status !== 408 &&
408
+ response.status !== 429))
404
409
  await this.remove();
405
410
  throw new Error('capture_authority_unavailable');
406
411
  }
@@ -422,6 +427,12 @@ export class CaptureCall {
422
427
  });
423
428
  if (!response.ok) {
424
429
  const terminal = response.status === 410 ||
430
+ (response.status >= 400 &&
431
+ response.status < 500 &&
432
+ response.status !== 401 &&
433
+ response.status !== 403 &&
434
+ response.status !== 408 &&
435
+ response.status !== 429) ||
425
436
  ((response.status === 401 || response.status === 403) && this.tokenExpires > 0);
426
437
  if (response.status === 401 || response.status === 403) {
427
438
  this.token = undefined;
@@ -445,9 +456,15 @@ export class CaptureCall {
445
456
  }
446
457
  }
447
458
  async upload() {
448
- if (this.uploading || this.stopped)
459
+ if (this.stopped)
460
+ return;
461
+ if (this.uploading) {
462
+ this.pendingUpload = true;
449
463
  return;
464
+ }
450
465
  this.uploading = true;
466
+ this.pendingUpload = false;
467
+ let shouldRetry = false;
451
468
  try {
452
469
  await this.writes;
453
470
  if (this.stopped)
@@ -456,6 +473,7 @@ export class CaptureCall {
456
473
  await this.remove();
457
474
  return;
458
475
  }
476
+ let completedStages = 0;
459
477
  await uploadSlot(async () => {
460
478
  if (!this.manifest.identity.captureCallRowId) {
461
479
  const response = await this.send('/register', {
@@ -476,12 +494,18 @@ export class CaptureCall {
476
494
  if (status.call?.callRowId !== this.manifest.identity.captureCallRowId ||
477
495
  status.call.modelCallId !== this.manifest.identity.modelCallId ||
478
496
  !Array.isArray(status.artifacts)) {
497
+ await this.remove();
479
498
  throw new Error('capture_status_identity_mismatch');
480
499
  }
481
500
  for (const record of this.manifest.artifacts) {
482
501
  if (!record.end)
483
502
  continue;
484
503
  const remote = status.artifacts.find((item) => item.stage === record.stage);
504
+ if (remote?.archiveState &&
505
+ ['failed', 'expired', 'deleted'].includes(remote.archiveState)) {
506
+ completedStages++;
507
+ continue;
508
+ }
485
509
  const confirmed = new Map((remote?.confirmedParts ?? []).map((part) => [part.partNo, part]));
486
510
  const hash = createHash('sha256');
487
511
  for (let partNo = 0; partNo < record.parts; partNo++) {
@@ -490,8 +514,10 @@ export class CaptureCall {
490
514
  const sha256 = createHash('sha256').update(bytes).digest('hex');
491
515
  const part = confirmed.get(partNo);
492
516
  if (part) {
493
- if (part.sha256 !== sha256 || part.rawBytes !== bytes.length)
517
+ if (part.sha256 !== sha256 || part.rawBytes !== bytes.length) {
518
+ await this.remove();
494
519
  throw new Error('capture_part_confirmation_mismatch');
520
+ }
495
521
  continue;
496
522
  }
497
523
  const response = await this.send(`/artifacts/${record.stage}/chunks/${partNo}`, {
@@ -508,7 +534,14 @@ export class CaptureCall {
508
534
  }
509
535
  const sha256 = hash.digest('hex');
510
536
  if (remote?.archiveState === 'archived') {
511
- this.verifyArchive(record, sha256, remote);
537
+ try {
538
+ this.verifyArchive(record, sha256, remote);
539
+ }
540
+ catch (error) {
541
+ await this.remove();
542
+ throw error;
543
+ }
544
+ completedStages++;
512
545
  continue;
513
546
  }
514
547
  const response = await this.send(`/artifacts/${record.stage}/finalize`, {
@@ -521,10 +554,20 @@ export class CaptureCall {
521
554
  sha256,
522
555
  }),
523
556
  });
524
- this.verifyArchive(record, sha256, (await response.json()));
557
+ const finalized = (await response.json());
558
+ try {
559
+ this.verifyArchive(record, sha256, finalized);
560
+ }
561
+ catch (error) {
562
+ await this.remove();
563
+ throw error;
564
+ }
565
+ completedStages++;
525
566
  }
526
567
  });
527
- if ((this.manifest.artifacts.length === 2 || this.artifacts.size === 0) &&
568
+ if (!this.pendingUpload &&
569
+ (this.manifest.artifacts.length === 2 || this.artifacts.size === 0) &&
570
+ completedStages === this.manifest.artifacts.length &&
528
571
  this.manifest.artifacts.every((item) => item.end)) {
529
572
  await this.remove();
530
573
  return;
@@ -532,14 +575,20 @@ export class CaptureCall {
532
575
  }
533
576
  catch {
534
577
  /* Retry only archival transport, never model execution. */
578
+ shouldRetry = !this.stopped;
535
579
  }
536
580
  finally {
537
581
  this.uploading = false;
538
582
  if (!this.stopped) {
539
- this.retry = setTimeout(() => {
583
+ if (this.pendingUpload && !shouldRetry) {
540
584
  void this.upload();
541
- }, 5_000);
542
- this.retry.unref();
585
+ }
586
+ else if (shouldRetry) {
587
+ this.retry = setTimeout(() => {
588
+ void this.upload();
589
+ }, 5_000);
590
+ this.retry.unref();
591
+ }
543
592
  }
544
593
  }
545
594
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.85-beta",
3
+ "version": "0.1.87",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -58,8 +58,8 @@
58
58
  "@gltf-transform/core": "^4.3.0",
59
59
  "@gltf-transform/extensions": "^4.3.0",
60
60
  "@gltf-transform/functions": "^4.3.0",
61
- "@playcraft/build": "0.1.85-beta",
62
- "@playcraft/common": "0.1.85-beta",
61
+ "@playcraft/build": "0.1.87",
62
+ "@playcraft/common": "0.1.87",
63
63
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
64
64
  "chokidar": "^4.0.3",
65
65
  "commander": "^13.1.0",