@prized/sdk 0.1.0 → 0.1.2

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.
Files changed (48) hide show
  1. package/README.md +76 -21
  2. package/dist/cjs/commands.d.ts +47 -14
  3. package/dist/cjs/commands.js +94 -26
  4. package/dist/cjs/errors.d.ts +43 -3
  5. package/dist/cjs/errors.js +54 -6
  6. package/dist/cjs/files.d.ts +21 -5
  7. package/dist/cjs/files.js +38 -14
  8. package/dist/cjs/http.d.ts +74 -15
  9. package/dist/cjs/http.js +208 -32
  10. package/dist/cjs/index.d.ts +3 -3
  11. package/dist/cjs/index.js +2 -1
  12. package/dist/cjs/outcome.d.ts +11 -1
  13. package/dist/cjs/outcome.js +28 -5
  14. package/dist/cjs/output.d.ts +19 -8
  15. package/dist/cjs/output.js +152 -56
  16. package/dist/cjs/sandbox.d.ts +3 -2
  17. package/dist/cjs/sandbox.js +40 -5
  18. package/dist/cjs/shell.d.ts +2 -1
  19. package/dist/cjs/shell.js +2 -1
  20. package/dist/cjs/toolbox.d.ts +2 -0
  21. package/dist/cjs/toolbox.js +4 -1
  22. package/dist/cjs/types.d.ts +13 -1
  23. package/dist/cjs/version.d.ts +1 -1
  24. package/dist/cjs/version.js +1 -1
  25. package/dist/esm/commands.d.ts +47 -14
  26. package/dist/esm/commands.js +95 -27
  27. package/dist/esm/errors.d.ts +43 -3
  28. package/dist/esm/errors.js +52 -5
  29. package/dist/esm/files.d.ts +21 -5
  30. package/dist/esm/files.js +39 -15
  31. package/dist/esm/http.d.ts +74 -15
  32. package/dist/esm/http.js +207 -31
  33. package/dist/esm/index.d.ts +3 -3
  34. package/dist/esm/index.js +1 -1
  35. package/dist/esm/outcome.d.ts +11 -1
  36. package/dist/esm/outcome.js +27 -5
  37. package/dist/esm/output.d.ts +19 -8
  38. package/dist/esm/output.js +153 -58
  39. package/dist/esm/sandbox.d.ts +3 -2
  40. package/dist/esm/sandbox.js +40 -5
  41. package/dist/esm/shell.d.ts +2 -1
  42. package/dist/esm/shell.js +2 -1
  43. package/dist/esm/toolbox.d.ts +2 -0
  44. package/dist/esm/toolbox.js +4 -1
  45. package/dist/esm/types.d.ts +13 -1
  46. package/dist/esm/version.d.ts +1 -1
  47. package/dist/esm/version.js +1 -1
  48. package/package.json +1 -1
package/dist/esm/http.js CHANGED
@@ -5,6 +5,11 @@ import { commandEffect, restate, withAbort, withOutcome } from './outcome.js';
5
5
  import { VERSION } from './version.js';
6
6
  export const DEFAULT_BASE_URL = 'https://api.prized.dev';
7
7
  export const DEFAULT_EDGE_URL = 'https://term.prized.dev';
8
+ /** Node's own fetch (not Bun's or Deno's, which also say they are Node), with web streams. */
9
+ function nodeFetch() {
10
+ const versions = globalThis.process?.versions;
11
+ return typeof versions?.node === 'string' && versions.bun === undefined && versions.deno === undefined && typeof ReadableStream === 'function';
12
+ }
8
13
  function envApiKey() {
9
14
  try {
10
15
  const env = globalThis.process?.env;
@@ -17,7 +22,15 @@ function envApiKey() {
17
22
  function isLoopback(hostname) {
18
23
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
19
24
  }
20
- /** An http(s) origin, trailing slashes and a trailing /api/v1 dropped. */
25
+ /**
26
+ * https anywhere, plain http only to this machine: the API key rides in
27
+ * every request's Authorization header. The same rule for the options and
28
+ * for an edge the API names.
29
+ */
30
+ function secureEndpoint(url) {
31
+ return url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback(url.hostname));
32
+ }
33
+ /** An https (or loopback http) origin, trailing slashes and a trailing /api/v1 dropped. */
21
34
  function normalizeOrigin(name, raw) {
22
35
  let url;
23
36
  try {
@@ -29,6 +42,9 @@ function normalizeOrigin(name, raw) {
29
42
  if (url.protocol !== 'https:' && url.protocol !== 'http:') {
30
43
  throw new PrizedError('invalid_argument', `${name} must be an http(s) URL`);
31
44
  }
45
+ if (!secureEndpoint(url)) {
46
+ throw new PrizedError('invalid_argument', `${name} must use https (plain http only for localhost, 127.0.0.1 or [::1])`);
47
+ }
32
48
  if (url.username || url.password || url.search || url.hash) {
33
49
  throw new PrizedError('invalid_argument', `${name} must not carry credentials, a query or a fragment`);
34
50
  }
@@ -53,10 +69,11 @@ export function abortableSleep(ms, signal) {
53
69
  });
54
70
  }
55
71
  /**
56
- * How long a streamed or long exec keeps polling while its polls fail
57
- * before it stops the command (pollOutageMs): long enough to ride out a
58
- * dropped connection or an edge that restarts. The Python SDK's
59
- * DEFAULT_POLL_OUTAGE.
72
+ * How long a wait (a streamed or long exec, a handle's wait()) keeps polling
73
+ * while its polls fail before it gives up (pollOutageMs), and a streamed or
74
+ * long exec stops its command, and how long a start is sent again: long
75
+ * enough to ride out a dropped connection or an edge that restarts (about
76
+ * 70 s, the last time it crashed). The Python SDK's DEFAULT_POLL_OUTAGE.
60
77
  */
61
78
  export const DEFAULT_POLL_OUTAGE_MS = 120_000;
62
79
  /** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
@@ -98,22 +115,54 @@ export function resolveConfig(options = {}) {
98
115
  sleep: options.sleep ?? abortableSleep,
99
116
  now: options.now ?? Date.now,
100
117
  random: options.random ?? Math.random,
118
+ streamBodies: options.fetch === undefined && nodeFetch(),
101
119
  };
102
120
  }
121
+ /** Prized's own domain: its API and its edges are hosts under it. */
122
+ const PRIZED_DOMAIN = 'prized.dev';
123
+ function under(host, domain) {
124
+ return host === domain || host.endsWith(`.${domain}`);
125
+ }
126
+ /** A URL's hostname (lowercase, as URL gives it), or '' if it does not parse. */
127
+ function hostOf(raw) {
128
+ try {
129
+ return new URL(raw).hostname;
130
+ }
131
+ catch {
132
+ return '';
133
+ }
134
+ }
135
+ /**
136
+ * Where a toolboxBase may point, given the API's host: the API host itself;
137
+ * prized.dev's own hosts when the API is one of them; loopback when the API
138
+ * is. Never a domain guessed from the API host's name (a public suffix such
139
+ * as co.uk would trust everyone under it). The Python SDK's _trusted_edge.
140
+ */
141
+ function trustedEdge(edgeHost, apiHost) {
142
+ if (edgeHost === '' || apiHost === '')
143
+ return false;
144
+ if (isLoopback(apiHost) || isLoopback(edgeHost))
145
+ return isLoopback(apiHost) && isLoopback(edgeHost);
146
+ return edgeHost === apiHost || (under(apiHost, PRIZED_DOMAIN) && under(edgeHost, PRIZED_DOMAIN));
147
+ }
103
148
  /**
104
149
  * Where exec and files go for one sandbox. An explicit edgeUrl wins; else the
105
150
  * API's own `toolboxBase` when it has the expected shape (https, or plain
106
- * http on loopback for local development); else the default edge.
151
+ * http on loopback for local development, ending /v1/sandbox/<id>) on a host
152
+ * trusted beside the API's (term.prized.dev beside api.prized.dev; see
153
+ * trustedEdge); else the default edge, for Prized's own API only. The API key
154
+ * is never sent anywhere else: discovery is routing, not authority.
107
155
  */
108
156
  export function toolboxBaseFor(config, sandboxId, toolboxBase) {
109
157
  const path = `/v1/sandbox/${encodeURIComponent(sandboxId)}`;
110
158
  if (config.edgeUrl !== null)
111
159
  return `${config.edgeUrl}${path}`;
160
+ const apiHost = hostOf(config.baseUrl);
112
161
  if (toolboxBase) {
113
162
  try {
114
163
  const url = new URL(toolboxBase);
115
- const secure = url.protocol === 'https:' || (url.protocol === 'http:' && isLoopback(url.hostname));
116
- if (secure &&
164
+ if (secureEndpoint(url) &&
165
+ trustedEdge(url.hostname, apiHost) &&
117
166
  !url.username &&
118
167
  !url.password &&
119
168
  !url.search &&
@@ -126,7 +175,11 @@ export function toolboxBaseFor(config, sandboxId, toolboxBase) {
126
175
  // fall through to the default
127
176
  }
128
177
  }
129
- return `${DEFAULT_EDGE_URL}${path}`;
178
+ // Prized's own edge only for Prized's own API: a key for another
179
+ // deployment is never sent to it. That deployment names its edge.
180
+ if (under(apiHost, PRIZED_DOMAIN))
181
+ return `${DEFAULT_EDGE_URL}${path}`;
182
+ throw new PrizedError('edge_unknown', `The API at ${config.baseUrl} named no command endpoint this SDK trusts for ${sandboxId}; pass edgeUrl for that deployment.`, { sandboxId });
130
183
  }
131
184
  /**
132
185
  * The edge this config's sandboxes answer on, before the API has named one:
@@ -223,7 +276,7 @@ export function retryable(error, mode, method) {
223
276
  // have done a write's work, so a write is left to its caller (a create
224
277
  // that may have made a sandbox finds and deletes it). A GET did nothing:
225
278
  // a poll that meets one is sent again rather than failing its create.
226
- if (error.status === 500 && method === 'GET')
279
+ if (mode === 'safe' && error.status === 500 && method === 'GET')
227
280
  return true;
228
281
  return error.status === 429 || error.status === 502 || error.status === 503 || error.status === 504;
229
282
  }
@@ -309,6 +362,84 @@ export function backoffMs(config, attempt) {
309
362
  const base = Math.min(MAX_BACKOFF_MS, BACKOFF_FIRST_MS * 2 ** attempt);
310
363
  return Math.round(base / 2 + (config.random() * base) / 2);
311
364
  }
365
+ /**
366
+ * How long a file transfer may move no bytes before the attempt is given up
367
+ * (sdk/retry-spec.json transferStallMs; the Python SDK's TRANSFER_STALL).
368
+ * The edge holds a request about 20 s at most before it reads the body (its
369
+ * admission queue, then waking a paused sandbox), and ends a body that brings
370
+ * nothing for 60 s itself: kept above that, a stalled copy is over at the
371
+ * edge before the next one goes to the same staged name, so the two never
372
+ * race. Once an upload's last byte is handed over, its answer is waited for
373
+ * that and a second more for every uploadAnswerBytesPerSecond it sent: what
374
+ * the socket's buffers, or a proxy that takes a whole body before it sends it
375
+ * on, still hold moves unseen, and must not read as a stall (the Python SDK's
376
+ * UPLOAD_ANSWER_RATE). Mutable for tests only.
377
+ */
378
+ export const timing = { transferStallMs: 120_000, uploadAnswerBytesPerSecond: 128 * 1024 };
379
+ /** A streamed upload goes out this many bytes at a time. */
380
+ export const UPLOAD_CHUNK = 64 * 1024;
381
+ /**
382
+ * A body's bytes, to stream; null for one to send as it is. By shape, not
383
+ * instanceof: a Uint8Array or Buffer from another realm (a test runner's vm
384
+ * context, say) is one too.
385
+ */
386
+ async function bodyBytes(body) {
387
+ if (typeof body === 'string')
388
+ return new TextEncoder().encode(body);
389
+ if (ArrayBuffer.isView(body))
390
+ return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
391
+ if (Object.prototype.toString.call(body) === '[object ArrayBuffer]')
392
+ return new Uint8Array(body);
393
+ if (typeof body.arrayBuffer === 'function')
394
+ return new Uint8Array(await body.arrayBuffer());
395
+ return null;
396
+ }
397
+ /**
398
+ * `bytes` as a stream fed UPLOAD_CHUNK at a time, `moved` told each time
399
+ * fetch asks for more, and at the end given the answer's allowance
400
+ * (timing.uploadAnswerBytesPerSecond).
401
+ */
402
+ function inPieces(bytes, moved) {
403
+ let at = 0;
404
+ return new ReadableStream({
405
+ pull(controller) {
406
+ if (at >= bytes.byteLength) {
407
+ moved((bytes.byteLength / timing.uploadAnswerBytesPerSecond) * 1000);
408
+ controller.close();
409
+ return;
410
+ }
411
+ moved();
412
+ // A copy: a runtime may take the piece's buffer as it sends it.
413
+ controller.enqueue(bytes.slice(at, at + UPLOAD_CHUNK));
414
+ at += UPLOAD_CHUNK;
415
+ },
416
+ },
417
+ // Nothing read ahead: a pull is the socket taking the last piece.
418
+ { highWaterMark: 0 });
419
+ }
420
+ /** A body read whole, `moved` told of every piece. */
421
+ async function readAll(response, moved) {
422
+ if (response.body === null)
423
+ return new Uint8Array(0);
424
+ const reader = response.body.getReader();
425
+ const parts = [];
426
+ let size = 0;
427
+ for (;;) {
428
+ const { done, value } = await reader.read();
429
+ if (done)
430
+ break;
431
+ moved();
432
+ parts.push(value);
433
+ size += value.byteLength;
434
+ }
435
+ const whole = new Uint8Array(size);
436
+ let at = 0;
437
+ for (const part of parts) {
438
+ whole.set(part, at);
439
+ at += part.byteLength;
440
+ }
441
+ return whole;
442
+ }
312
443
  /**
313
444
  * One fetch, body included, under one timeout and the caller's signal. A
314
445
  * body that stalls or breaks after the headers fails the attempt like a
@@ -317,10 +448,24 @@ export function backoffMs(config, attempt) {
317
448
  async function attemptOnce(config, spec, url, timeoutMs) {
318
449
  const controller = new AbortController();
319
450
  let timedOut = false;
451
+ let stalled = false;
320
452
  const timer = setTimeout(() => {
321
453
  timedOut = true;
322
454
  controller.abort();
323
455
  }, timeoutMs);
456
+ // The stall bound starts over whenever bytes move (stallMs), until the attempt is over.
457
+ let stall;
458
+ let over = false;
459
+ const moved = (extraMs = 0) => {
460
+ if (spec.stallMs === undefined || over)
461
+ return;
462
+ clearTimeout(stall);
463
+ stall = setTimeout(() => {
464
+ stalled = true;
465
+ timedOut = true;
466
+ controller.abort();
467
+ }, spec.stallMs + extraMs);
468
+ };
324
469
  const onAbort = () => controller.abort();
325
470
  spec.signal?.addEventListener('abort', onAbort, { once: true });
326
471
  const headers = {
@@ -330,30 +475,52 @@ async function attemptOnce(config, spec, url, timeoutMs) {
330
475
  ...spec.headers,
331
476
  };
332
477
  let body;
333
- if (spec.json !== undefined) {
334
- headers['content-type'] = 'application/json';
335
- body = JSON.stringify(spec.json);
336
- }
337
- else if (spec.body !== undefined) {
338
- headers['content-type'] = spec.contentType ?? 'application/octet-stream';
339
- body = spec.body;
340
- }
478
+ // Watched from the start unless an upload goes out unwatched (Config.streamBodies).
479
+ let watched = spec.stallMs !== undefined && spec.body === undefined;
480
+ let streamed = false;
341
481
  let status = null;
342
482
  try {
483
+ if (spec.json !== undefined) {
484
+ headers['content-type'] = 'application/json';
485
+ body = JSON.stringify(spec.json);
486
+ }
487
+ else if (spec.body !== undefined) {
488
+ headers['content-type'] = spec.contentType ?? 'application/octet-stream';
489
+ body = spec.body;
490
+ const bytes = spec.stallMs !== undefined && config.streamBodies ? await bodyBytes(spec.body) : null;
491
+ if (bytes !== null) {
492
+ headers['content-length'] = String(bytes.byteLength);
493
+ body = inPieces(bytes, moved);
494
+ streamed = true;
495
+ watched = true;
496
+ }
497
+ }
498
+ if (watched)
499
+ moved();
343
500
  const response = await config.fetch(url, {
344
501
  method: spec.method,
345
502
  headers,
346
503
  body: body,
504
+ // A streamed body (Node's fetch asks for it): sent before the answer is read.
505
+ ...(streamed ? { duplex: 'half' } : {}),
347
506
  // Never follow a redirect with the key attached.
348
507
  redirect: 'manual',
349
508
  signal: controller.signal,
350
509
  });
351
510
  status = response.status;
511
+ if (spec.stallMs !== undefined)
512
+ moved();
352
513
  const ok = response.status >= 200 && response.status < 300;
353
514
  if (response.status >= 300 && response.status < 400) {
354
515
  await response.body?.cancel().catch(() => undefined);
355
516
  return { answer: { status: response.status, headers: response.headers, text: '', bytes: null } };
356
517
  }
518
+ if (spec.stallMs !== undefined) {
519
+ const whole = await readAll(response, moved);
520
+ if (ok && spec.read === 'bytes')
521
+ return { answer: { status: response.status, headers: response.headers, text: '', bytes: whole } };
522
+ return { answer: { status: response.status, headers: response.headers, text: new TextDecoder('utf-8').decode(whole), bytes: null } };
523
+ }
357
524
  if (ok && spec.read === 'bytes') {
358
525
  const bytes = new Uint8Array(await response.arrayBuffer());
359
526
  return { answer: { status: response.status, headers: response.headers, text: '', bytes } };
@@ -361,10 +528,12 @@ async function attemptOnce(config, spec, url, timeoutMs) {
361
528
  return { answer: { status: response.status, headers: response.headers, text: await response.text(), bytes: null } };
362
529
  }
363
530
  catch (error) {
364
- return { error, timedOut, status };
531
+ return { error, timedOut, stalled, status };
365
532
  }
366
533
  finally {
534
+ over = true;
367
535
  clearTimeout(timer);
536
+ clearTimeout(stall);
368
537
  spec.signal?.removeEventListener('abort', onAbort);
369
538
  }
370
539
  }
@@ -410,8 +579,9 @@ export function outcomeOf(error) {
410
579
  /**
411
580
  * The error a request throws. With an `effect`, it says whether the sandbox
412
581
  * acted on the request: by this attempt, or by an earlier one that was sent
413
- * again (`acted`: only a keyed request, the background start, is sent again
414
- * after it may have arrived). `cutStatus`: the 2xx whose body broke off.
582
+ * again (`acted`: only a keyed request, the background start, and a staged
583
+ * upload are sent again after they may have arrived). `cutStatus`: the 2xx
584
+ * whose body broke off.
415
585
  */
416
586
  function raised(failure, outcome, acted, effect, cutStatus = null) {
417
587
  if (effect === undefined)
@@ -426,7 +596,8 @@ export async function request(config, spec) {
426
596
  const timeoutMs = spec.timeoutMs ?? config.requestTimeoutMs;
427
597
  const sandboxId = spec.sandboxId ?? null;
428
598
  const effect = spec.effect ?? (spec.retry === 'exec' ? commandEffect() : undefined);
429
- const unsent = spec.retry === 'exec' ? effect : undefined;
599
+ // Whether an attempt that was sent again (or an earlier copy) may have reached the sandbox.
600
+ let acted = spec.acted ?? false;
430
601
  // Names the last attempt's failure, if any, so a call cut short by its
431
602
  // deadline still says what it was waiting out (a 503, a rate limit).
432
603
  const pastDeadline = (last) => {
@@ -434,9 +605,11 @@ export async function request(config, spec) {
434
605
  const tail = what === null ? '' : ` The last attempt failed with ${what}.`;
435
606
  let error = new PrizedError('timeout', `The time allowed for this call ran out.${tail}`, { sandboxId, cause: last });
436
607
  // A command is sent again only when it provably never arrived, so every
437
- // attempt of one cut short this way never reached the sandbox.
438
- if (unsent !== undefined)
439
- error = withOutcome(error, unsent, 'never');
608
+ // attempt of one cut short this way never reached the sandbox. A staged
609
+ // upload's copies may have, unless each provably never arrived, and so
610
+ // may a keyed start's (the one `safe` request that names an effect).
611
+ if (effect !== undefined)
612
+ error = withOutcome(error, effect, spec.retry !== 'exec' && acted ? 'maybe' : 'never');
440
613
  cutShort.add(error);
441
614
  return error;
442
615
  };
@@ -445,8 +618,6 @@ export async function request(config, spec) {
445
618
  return error;
446
619
  };
447
620
  let last;
448
- // Whether an attempt that was sent again may have reached the sandbox.
449
- let acted = false;
450
621
  // An abort with an `effect` says whether the sandbox may have acted:
451
622
  // before an attempt, or while waiting to send one again, nothing was sent
452
623
  // that might have, unless an earlier attempt of a keyed request (`acted`)
@@ -476,15 +647,19 @@ export async function request(config, spec) {
476
647
  }
477
648
  const unsentAttempt = !outcome.timedOut && neverSent(outcome.error);
478
649
  failure = failed(outcome.timedOut
479
- ? new PrizedError('timeout', `No answer within ${attemptMs} ms`, { sandboxId, cause: outcome.error })
650
+ ? new PrizedError('timeout', outcome.stalled ? `No bytes moved for ${spec.stallMs} ms` : `No answer within ${attemptMs} ms`, {
651
+ sandboxId,
652
+ cause: outcome.error,
653
+ })
480
654
  : unsentAttempt
481
655
  ? new PrizedError('network_error', null, { sandboxId, cause: outcome.error })
482
656
  : new PrizedError('network_error', lostMessage(spec.method, url, outcome.error), { sandboxId, cause: outcome.error }));
483
657
  // The status came and the body did not: the sandbox acted on it.
484
658
  const cut = outcome.status !== null && outcome.status >= 200 && outcome.status < 300;
485
659
  const said = cut ? 'did' : unsentAttempt ? 'never' : 'maybe';
486
- // A command or upload is sent again only if it never left.
487
- const resend = spec.retry === 'safe' || (spec.retry === 'exec' && unsentAttempt);
660
+ // A command is sent again only if it never left; a staged upload
661
+ // unless its 2xx came (its bytes are there).
662
+ const resend = spec.retry === 'safe' || (spec.retry === 'exec' && unsentAttempt) || (spec.retry === 'staged' && !cut);
488
663
  if (!resend || attempt >= config.maxRetries)
489
664
  throw raised(failure, said, acted, effect, cut ? outcome.status : null);
490
665
  acted = acted || said !== 'never';
@@ -500,7 +675,8 @@ export async function request(config, spec) {
500
675
  }
501
676
  }
502
677
  if (answer.status >= 300 && answer.status < 400) {
503
- throw failed(new PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId }));
678
+ // A refusal; worded only when an earlier copy of a keyed start may have arrived.
679
+ throw raised(failed(new PrizedError('unexpected_redirect', null, { status: answer.status, sandboxId })), null, acted, effect);
504
680
  }
505
681
  const retryAfter = parseRetryAfter(answer.headers.get('retry-after'), config.now());
506
682
  failure = failed(errorFromResponse(answer.status, answer.text, retryAfter, sandboxId, urlPath(url)));
@@ -4,9 +4,9 @@ export { CommandHandle, DEFAULT_EXEC_TIMEOUT_MS, MAX_BLOCKING_EXEC_MS } from './
4
4
  export type { CommandStatus, ExecOptions, WaitOptions } from './commands.js';
5
5
  export { Files } from './files.js';
6
6
  export type { FileData, WriteOptions } from './files.js';
7
- export { CommandTimeoutError, PrizedError } from './errors.js';
8
- export type { PrizedErrorInit } from './errors.js';
7
+ export { CommandTimeoutError, OutputReadError, PrizedError } from './errors.js';
8
+ export type { OutputReadErrorInit, PrizedErrorInit } from './errors.js';
9
9
  export { DEFAULT_BASE_URL, DEFAULT_EDGE_URL } from './http.js';
10
10
  export type { ClientOptions } from './http.js';
11
- export type { ExecResult, FileInfo, Resources, ResourcesInput, SandboxInfo, SandboxState, Template, WriteInfo, } from './types.js';
11
+ export type { ExecResult, FileInfo, Resources, ResourcesInput, SandboxInfo, SandboxState, Template, TruncatedReason, WriteInfo, } from './types.js';
12
12
  export { VERSION } from './version.js';
package/dist/esm/index.js CHANGED
@@ -2,6 +2,6 @@
2
2
  export { Sandbox, DEFAULT_TEMPLATE, PREVIEW_KEY_HEADER } from './sandbox.js';
3
3
  export { CommandHandle, DEFAULT_EXEC_TIMEOUT_MS, MAX_BLOCKING_EXEC_MS } from './commands.js';
4
4
  export { Files } from './files.js';
5
- export { CommandTimeoutError, PrizedError } from './errors.js';
5
+ export { CommandTimeoutError, OutputReadError, PrizedError } from './errors.js';
6
6
  export { DEFAULT_BASE_URL, DEFAULT_EDGE_URL } from './http.js';
7
7
  export { VERSION } from './version.js';
@@ -30,11 +30,12 @@ export declare function helperEffect(): Effect;
30
30
  export type Outcome = 'never' | 'maybe' | 'did' | null;
31
31
  /** Whether withOutcome said the sandbox may have acted on the request. */
32
32
  export declare function mayHaveActed(error: unknown): error is PrizedError;
33
- /** `error` with another message (and, given, another status, code, sandbox or cause), every other field kept. */
33
+ /** `error` with another message (and, given, another status, code, sandbox, exit code or cause), every other field kept. */
34
34
  export declare function restate(error: PrizedError, message: string, init?: {
35
35
  status?: number;
36
36
  code?: string;
37
37
  sandboxId?: string;
38
+ exitCode?: number;
38
39
  cause?: unknown;
39
40
  }): PrizedError;
40
41
  /** `error` (same code, status and body), its message saying what it means for `effect`. */
@@ -48,6 +49,15 @@ export declare function withOutcome(error: PrizedError, effect: Effect, outcome:
48
49
  * connection closes. TypeScript only: the Python SDK takes no signal.
49
50
  */
50
51
  export declare function withAbort(error: PrizedError, effect: Effect, outcome: 'never' | 'maybe' | 'did'): PrizedError;
52
+ /**
53
+ * What a start that gave up throws when the failure it gives up with
54
+ * (`failure`, Outage's pick) is not the last copy's own: an earlier copy's,
55
+ * worded before a later copy may have reached the sandbox, or what a copy
56
+ * cut short by the window met, not yet worded. Worded for the start: as one
57
+ * that may have started when any copy may have reached the sandbox
58
+ * (`anyActed`), else as one that never did. The Python SDK's start_gave_up.
59
+ */
60
+ export declare function startGaveUp(failure: PrizedError, anyActed: boolean): PrizedError;
51
61
  /**
52
62
  * A background start that may have started (`error`, from withOutcome or
53
63
  * withAbort) and that the SDK then stopped by its run id (`stopped`: the
@@ -1,7 +1,9 @@
1
1
  // What an error says about a command that may have run. A command, a file
2
- // write, a remove or a signal (`exec` retry mode) is never sent again once it
3
- // may have reached the sandbox, so its error has to say what the caller cannot
4
- // see: whether it happened. The Python SDK words these the same (_core.py).
2
+ // write's rename, a remove or a signal (`exec` retry mode) is never sent again
3
+ // once it may have reached the sandbox, so its error has to say what the
4
+ // caller cannot see: whether it happened. A write's staged upload (`staged`)
5
+ // may have been sent again, and its error says whether a copy may have
6
+ // reached the sandbox. The Python SDK words these the same (_core.py).
5
7
  import { defaultMessage, PrizedError } from './errors.js';
6
8
  const NOT_SENT_AGAIN = 'It was not sent again';
7
9
  export function commandEffect() {
@@ -50,7 +52,7 @@ const acted = new WeakSet();
50
52
  export function mayHaveActed(error) {
51
53
  return error instanceof PrizedError && acted.has(error);
52
54
  }
53
- /** `error` with another message (and, given, another status, code, sandbox or cause), every other field kept. */
55
+ /** `error` with another message (and, given, another status, code, sandbox, exit code or cause), every other field kept. */
54
56
  export function restate(error, message, init = {}) {
55
57
  return new PrizedError(init.code ?? error.code, message, {
56
58
  status: init.status ?? error.status,
@@ -58,6 +60,7 @@ export function restate(error, message, init = {}) {
58
60
  sandboxId: error.sandboxId ?? init.sandboxId ?? null,
59
61
  body: error.body,
60
62
  retryAfter: error.retryAfter,
63
+ exitCode: init.exitCode ?? error.exitCode,
61
64
  cause: 'cause' in init ? init.cause : error.cause,
62
65
  });
63
66
  }
@@ -87,12 +90,14 @@ function maybeTail(effect, closed) {
87
90
  tail += ` ${effect.check}`;
88
91
  return tail;
89
92
  }
93
+ /** How an error says its request never reached the sandbox (withOutcome). */
94
+ const NEVER_TAIL = ' It never reached the sandbox, so it had no effect.';
90
95
  /** `error` (same code, status and body), its message saying what it means for `effect`. */
91
96
  export function withOutcome(error, effect, outcome) {
92
97
  if (outcome === null)
93
98
  return error;
94
99
  if (outcome === 'never')
95
- return restate(error, `${trimmed(error.message)}. It never reached the sandbox, so it had no effect.`);
100
+ return restate(error, `${trimmed(error.message)}.${NEVER_TAIL}`);
96
101
  if (outcome === 'did')
97
102
  return acting(error, `${effect.did}, but the answer could not be read (${detail(error)}).`);
98
103
  return acting(error, `No clear answer came back (${detail(error)}), so ${maybeTail(effect, false)}`);
@@ -116,6 +121,23 @@ function stopTail(runId, stopped) {
116
121
  ? ` The SDK stopped ${runId}, or it had already ended.`
117
122
  : ` The SDK could not confirm it stopped: it may still be running as ${runId} (exec('ps -ef | grep ${runId}') finds it).`;
118
123
  }
124
+ /**
125
+ * What a start that gave up throws when the failure it gives up with
126
+ * (`failure`, Outage's pick) is not the last copy's own: an earlier copy's,
127
+ * worded before a later copy may have reached the sandbox, or what a copy
128
+ * cut short by the window met, not yet worded. Worded for the start: as one
129
+ * that may have started when any copy may have reached the sandbox
130
+ * (`anyActed`), else as one that never did. The Python SDK's start_gave_up.
131
+ */
132
+ export function startGaveUp(failure, anyActed) {
133
+ if (acted.has(failure))
134
+ return failure;
135
+ const never = failure.message.endsWith(NEVER_TAIL);
136
+ if (never && !anyActed)
137
+ return failure;
138
+ const plain = never ? restate(failure, failure.message.slice(0, -NEVER_TAIL.length)) : failure;
139
+ return withOutcome(plain, startEffect(), anyActed ? 'maybe' : 'never');
140
+ }
119
141
  /**
120
142
  * A background start that may have started (`error`, from withOutcome or
121
143
  * withAbort) and that the SDK then stopped by its run id (`stopped`: the
@@ -1,5 +1,5 @@
1
1
  import { type Toolbox } from './toolbox.js';
2
- import type { ExecResult } from './types.js';
2
+ import type { ExecResult, TruncatedReason } from './types.js';
3
3
  export declare const OUTPUT_DIR = "/home/prized/.prized/exec";
4
4
  export declare const OUTPUT_ID_RE: RegExp;
5
5
  /** Each stream of a fullOutput command keeps at most this many bytes: one download. */
@@ -22,17 +22,22 @@ export declare const RUN_SCRIPT = "eval \"${PRIZED_EXEC_SCRIPT:?}\"";
22
22
  * Nothing is assigned before it runs but the positional parameters, so every
23
23
  * variable the caller's env sets reaches it unchanged, PATH included; and
24
24
  * every step holds under an errexit the env may switch on (SHELLOPTS). As in
25
- * a plain exec, the readers stop 5 s after the command ends if a process it
25
+ * a plain exec, the relays stop 5 s after the command ends if a process it
26
26
  * left running still holds its output, keeping what came through. A command
27
- * a signal ends exits 128+n, as a background command does. Without tee,
28
- * setsid or a directory to write to, the command runs as a plain exec.
27
+ * a signal ends exits 128+n, as a background command does. A reader that
28
+ * could not write its file (failedLines) sends the output to the files API
29
+ * whatever its size, with the marker saying which. Without /bin/sh, setsid,
30
+ * cat or a directory to write to, the command runs as a plain exec.
29
31
  */
30
32
  export declare function captureScript(id: string): string;
31
33
  /**
32
34
  * What a fullOutput command left when the script could not print it (its
33
35
  * time limit, or a signal, ended the script): the marker line, or `gone`,
34
36
  * with the directory removed, when there is nothing to read (it was never
35
- * made, or its output was already printed back).
37
+ * made, or its output was already printed back). A failed write is read from
38
+ * the reader's flag, before the size, which is final once the flag is there
39
+ * (the script's wait for the reader's exit status went with the script); a
40
+ * reader killed, or one that could not make its flag, is not seen here.
36
41
  */
37
42
  export declare function collectScript(id: string): string;
38
43
  /**
@@ -41,11 +46,17 @@ export declare function collectScript(id: string): string;
41
46
  * answer to remove its own.
42
47
  */
43
48
  export declare function removeOutputScript(id: string): string;
44
- /** The marker's byte counts (a stream cut at FULL_OUTPUT_BYTES reads more), `gone`, or null for no marker. */
45
- export declare function parseMarker(stdout: string, id: string): {
49
+ /** What the marker line says: each stream's byte count (one cut at FULL_OUTPUT_BYTES reads more), and whether its reader failed to write it. */
50
+ export interface Marker {
46
51
  out: number;
47
52
  err: number;
48
- } | 'gone' | null;
53
+ outFailed: boolean;
54
+ errFailed: boolean;
55
+ }
56
+ /** The marker (one without failure flags, as an earlier SDK's collect printed, has none), `gone`, or null for no marker. */
57
+ export declare function parseMarker(stdout: string, id: string): Marker | 'gone' | null;
58
+ /** Why the read-back output is cut, if it is: a failed write first, since it is the one the caller cannot foresee. */
59
+ export declare function markerTruncation(marker: Marker): TruncatedReason | null;
49
60
  /**
50
61
  * A blocking exec that keeps the whole output (up to FULL_OUTPUT_BYTES of
51
62
  * each stream). The result is a plain exec's, timeouts included: the caller