@prized/sdk 0.0.0-stage → 0.1.0

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 (52) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +625 -2
  3. package/dist/cjs/commands.d.ts +168 -0
  4. package/dist/cjs/commands.js +567 -0
  5. package/dist/cjs/errors.d.ts +73 -0
  6. package/dist/cjs/errors.js +258 -0
  7. package/dist/cjs/files.d.ts +86 -0
  8. package/dist/cjs/files.js +366 -0
  9. package/dist/cjs/http.d.ts +210 -0
  10. package/dist/cjs/http.js +570 -0
  11. package/dist/cjs/index.d.ts +12 -0
  12. package/dist/cjs/index.js +22 -0
  13. package/dist/cjs/outcome.d.ts +75 -0
  14. package/dist/cjs/outcome.js +167 -0
  15. package/dist/cjs/output.d.ts +59 -0
  16. package/dist/cjs/output.js +288 -0
  17. package/dist/cjs/package.json +1 -0
  18. package/dist/cjs/sandbox.d.ts +218 -0
  19. package/dist/cjs/sandbox.js +810 -0
  20. package/dist/cjs/shell.d.ts +152 -0
  21. package/dist/cjs/shell.js +393 -0
  22. package/dist/cjs/toolbox.d.ts +48 -0
  23. package/dist/cjs/toolbox.js +86 -0
  24. package/dist/cjs/types.d.ts +99 -0
  25. package/dist/cjs/types.js +102 -0
  26. package/dist/cjs/version.d.ts +2 -0
  27. package/dist/cjs/version.js +5 -0
  28. package/dist/esm/commands.d.ts +168 -0
  29. package/dist/esm/commands.js +560 -0
  30. package/dist/esm/errors.d.ts +73 -0
  31. package/dist/esm/errors.js +250 -0
  32. package/dist/esm/files.d.ts +86 -0
  33. package/dist/esm/files.js +360 -0
  34. package/dist/esm/http.d.ts +210 -0
  35. package/dist/esm/http.js +553 -0
  36. package/dist/esm/index.d.ts +12 -0
  37. package/dist/esm/index.js +7 -0
  38. package/dist/esm/outcome.d.ts +75 -0
  39. package/dist/esm/outcome.js +152 -0
  40. package/dist/esm/output.d.ts +59 -0
  41. package/dist/esm/output.js +280 -0
  42. package/dist/esm/sandbox.d.ts +218 -0
  43. package/dist/esm/sandbox.js +806 -0
  44. package/dist/esm/shell.d.ts +152 -0
  45. package/dist/esm/shell.js +379 -0
  46. package/dist/esm/toolbox.d.ts +48 -0
  47. package/dist/esm/toolbox.js +81 -0
  48. package/dist/esm/types.d.ts +99 -0
  49. package/dist/esm/types.js +95 -0
  50. package/dist/esm/version.d.ts +2 -0
  51. package/dist/esm/version.js +2 -0
  52. package/package.json +53 -4
@@ -0,0 +1,806 @@
1
+ // The Sandbox class: create, connect, list, templates, and the lifecycle
2
+ // verbs (pause, resume, fork, delete, refresh) over the REST API.
3
+ import { execCommand } from './commands.js';
4
+ import { PrizedError } from './errors.js';
5
+ import { Files } from './files.js';
6
+ import { callDeadline, cutByDeadline, MAX_RETRY_AFTER_MS, Polls, request, resolveConfig, toolboxBaseFor, warmEdge, } from './http.js';
7
+ import { parseSandbox, parseTemplate, resourcesToWire, } from './types.js';
8
+ /** The header a request to a sandbox port's URL carries the API key in. */
9
+ export const PREVIEW_KEY_HEADER = 'Prized-Key';
10
+ /** The template a create uses when it names none. */
11
+ export const DEFAULT_TEMPLATE = 'prized-base-v1';
12
+ /** How long create, resume and fork wait for `running` by default. */
13
+ export const DEFAULT_START_TIMEOUT_MS = 180_000;
14
+ /** How long pause and delete wait by default. */
15
+ export const DEFAULT_SETTLE_TIMEOUT_MS = 120_000;
16
+ /** Fork copies a failed or lost fork deletes at once. */
17
+ const DELETE_CONCURRENCY = 8;
18
+ /**
19
+ * The longest the API holds a create, pause or resume for `?wait=` (its
20
+ * SANDBOX_WAIT_MAX_MS). An API that predates `wait` ignores it and answers
21
+ * at once, and the SDK polls as before.
22
+ */
23
+ const SERVER_HOLD_MAX_MS = 8_000;
24
+ /** Room a held request leaves inside requestTimeoutMs for the round trip. */
25
+ const HOLD_MARGIN_MS = 5_000;
26
+ /** Room it leaves inside the caller's own budget for the answer to arrive. */
27
+ const HOLD_ANSWER_MS = 1_000;
28
+ /** The first poll after an answer that has not settled waits this long, doubling up to pollIntervalMs. */
29
+ const FIRST_POLL_MS = 100;
30
+ /** The bound on deleting the sandbox a create with no usable answer made (replay + delete). */
31
+ const RECLAIM_MS = 10_000;
32
+ /** What a fork's cleanup adds to RECLAIM_MS per copy, so a large fork's copies are all reached. */
33
+ const RECLAIM_PER_COPY_MS = 500;
34
+ /** The most one DELETE of a cleanup may take, so a stalled one leaves time for the rest. */
35
+ const RECLAIM_DELETE_MS = 5_000;
36
+ /** The 5xx answers the API gives before it creates anything. */
37
+ const REFUSED_BEFORE_CREATE = new Set(['sandboxes_disabled', 'sandbox_create_disabled', 'sandbox_fork_disabled', 'sandbox_capacity']);
38
+ /** Cleanups still running after the call that started them has answered.
39
+ * Not exported from the package; the tests await it. */
40
+ export const backgroundCleanups = new Set();
41
+ /** Edge warm-ups (warmEdge) not yet settled. Not exported from the package;
42
+ * the tests await it. */
43
+ export const backgroundWarmups = new Set();
44
+ function randomKey() {
45
+ const webcrypto = globalThis.crypto;
46
+ if (webcrypto?.randomUUID)
47
+ return `prized-sdk-${webcrypto.randomUUID()}`;
48
+ const bytes = new Uint8Array(16);
49
+ if (webcrypto?.getRandomValues)
50
+ webcrypto.getRandomValues(bytes);
51
+ else
52
+ for (let i = 0; i < bytes.length; i += 1)
53
+ bytes[i] = Math.floor(Math.random() * 256);
54
+ return `prized-sdk-${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
55
+ }
56
+ /** A deep copy of a request body, as it goes on the wire. */
57
+ function snapshot(body) {
58
+ return JSON.parse(JSON.stringify(body));
59
+ }
60
+ /** A call's timeoutMs: the default when not given, else a number of ms > 0. */
61
+ function budgetMs(value, fallback) {
62
+ if (value === undefined)
63
+ return fallback;
64
+ if (!(Number.isFinite(value) && value > 0))
65
+ throw new PrizedError('invalid_argument', 'timeoutMs must be a number of ms > 0');
66
+ return value;
67
+ }
68
+ function wholeMinutes(name, value, allowZero) {
69
+ if (value === undefined)
70
+ return undefined;
71
+ if (!Number.isInteger(value) || value < 0 || (!allowZero && value === 0)) {
72
+ throw new PrizedError('invalid_argument', `${name} must be a whole number of minutes${allowZero ? ' ≥ 0' : ' > 0'}`);
73
+ }
74
+ return value;
75
+ }
76
+ function apiUrl(config, path) {
77
+ return `${config.baseUrl}/api/v1${path}`;
78
+ }
79
+ /**
80
+ * The `?wait=` query asking the API to answer once the sandbox is `want`:
81
+ * held no longer than the caller has left (`leftMs`, less room for the
82
+ * answer), the API's bound, or what the request timeout leaves room for.
83
+ * Empty when there is no time to hold. Built per attempt (RequestSpec.url),
84
+ * so a retry after a lost answer asks only for what is left.
85
+ */
86
+ function holdQuery(config, want, leftMs) {
87
+ const ms = Math.floor(Math.min(SERVER_HOLD_MAX_MS, leftMs - HOLD_ANSWER_MS, config.requestTimeoutMs - HOLD_MARGIN_MS));
88
+ return ms > 0 ? `?wait=${want}&waitTimeoutMs=${ms}` : '';
89
+ }
90
+ /** The most labels one selector may name (create's own limit). */
91
+ const MAX_SELECTOR_LABELS = 32;
92
+ /** How many times deleteByLabels asks again while the API says `more` (it deletes up to 250 a request). */
93
+ const MAX_BULK_DELETE_CALLS = 100;
94
+ /** A UTF-16 half with no partner: URLSearchParams would send it as U+FFFD, another label. */
95
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
96
+ /** Can the API be asked for this pair? It reads a key up to its first `=` and refuses a pair no sandbox could carry. */
97
+ function selectable(key, value) {
98
+ return (typeof value === 'string' &&
99
+ key !== '' &&
100
+ !key.includes('=') &&
101
+ !(key + value).includes('\0') &&
102
+ key.length <= 64 &&
103
+ value.length <= 256 &&
104
+ !LONE_SURROGATE.test(key + value));
105
+ }
106
+ /**
107
+ * `?label=key=value&…` for a label selector, in key order ('' for none). A
108
+ * pair the API cannot be asked for, or more than 32: `strict` (a delete)
109
+ * refuses it, and a list leaves it to its own filter, as it always filtered.
110
+ */
111
+ function labelQuery(labels, strict) {
112
+ const pairs = [];
113
+ for (const key of Object.keys(labels ?? {}).sort()) {
114
+ const value = labels[key];
115
+ if (selectable(key, value))
116
+ pairs.push(['label', `${key}=${value}`]);
117
+ else if (strict) {
118
+ throw new PrizedError('invalid_argument', `Cannot select on label ${JSON.stringify(key)}: a key is 1 to 64 characters without '=', a value a string of up to 256.`);
119
+ }
120
+ }
121
+ if (pairs.length > MAX_SELECTOR_LABELS) {
122
+ if (strict)
123
+ throw new PrizedError('invalid_argument', `At most ${MAX_SELECTOR_LABELS} labels can be selected on.`);
124
+ return '';
125
+ }
126
+ return pairs.length > 0 ? `?${new URLSearchParams(pairs).toString()}` : '';
127
+ }
128
+ function sandboxPathFor(id) {
129
+ return `/sandboxes/${encodeURIComponent(id)}`;
130
+ }
131
+ /**
132
+ * Whether one failed attempt at a create or fork may have made sandboxes
133
+ * anyway: no answer came (it timed out, the connection was lost, it was
134
+ * cancelled mid-request), the answer was a 5xx other than the API's
135
+ * refusals made before it creates anything (a gateway's, or the API failing
136
+ * after it committed), or a 2xx the SDK could not read. Every other answer
137
+ * is the API's (or a proxy's) refusal, after which that attempt made nothing.
138
+ */
139
+ function outcomeUnknown(error) {
140
+ if (error.status === null)
141
+ return error.code === 'timeout' || error.code === 'network_error' || error.code === 'aborted';
142
+ if (error.status >= 500)
143
+ return !REFUSED_BEFORE_CREATE.has(error.code);
144
+ return error.status >= 200 && error.status < 300;
145
+ }
146
+ /**
147
+ * Every attempt of a keyed create or fork, judged together. Its retries
148
+ * carry the same key, but one refused before the API looks the key up (a
149
+ * rate limit, a switched-off create) says nothing about an earlier attempt
150
+ * that got no answer: the call's outcome is unknown if any attempt's was,
151
+ * whatever the last one answered.
152
+ */
153
+ class Attempts {
154
+ failures = [];
155
+ /** A 2xx came back (its body may still have been unusable). */
156
+ accepted = false;
157
+ onAttemptFailed = (error) => {
158
+ this.failures.push(error);
159
+ };
160
+ /** Whether sandboxes may exist that the caller has no handle to. */
161
+ get unresolved() {
162
+ return this.accepted || this.failures.some(outcomeUnknown);
163
+ }
164
+ /** The wait the last refusal asked for (Retry-After), in ms, within the SDK's cap. */
165
+ get retryAfterMs() {
166
+ const seconds = this.failures[this.failures.length - 1]?.retryAfter ?? null;
167
+ return seconds === null ? 0 : Math.min(MAX_RETRY_AFTER_MS, Math.ceil(seconds * 1000));
168
+ }
169
+ }
170
+ /**
171
+ * Find what a create or fork with no usable answer made, by sending its key
172
+ * again (`replay`, without a hold: the API answers at once with what the
173
+ * key made, or makes it now if no attempt ever arrived), and delete it. In
174
+ * the background, so the caller's rejection never waits on it, and bounded:
175
+ * the wait the last refusal asked for (a replay sent sooner would be
176
+ * refused again), then RECLAIM_MS, plus RECLAIM_PER_COPY_MS a copy. Only
177
+ * under the SDK's own key, which no other call holds. The sandboxes' own
178
+ * timers (maxRuntimeMin) are the backstop.
179
+ */
180
+ function reclaim(config, attempts, copies, replay) {
181
+ const cleanup = (async () => {
182
+ const waitMs = attempts.retryAfterMs;
183
+ if (waitMs > 0)
184
+ await config.sleep(waitMs);
185
+ const deadline = config.now() + RECLAIM_MS + RECLAIM_PER_COPY_MS * Math.max(0, copies - 1);
186
+ const ids = await replay(deadline);
187
+ // DELETE_CONCURRENCY deletes at a time, each taking at most its share of
188
+ // what is left for the rounds still to go (and RECLAIM_DELETE_MS), so a
189
+ // stalled one never leaves later copies without an attempt.
190
+ let next = 0;
191
+ const worker = async () => {
192
+ while (next < ids.length) {
193
+ const i = next;
194
+ next += 1;
195
+ const id = ids[i];
196
+ const rounds = Math.ceil((ids.length - i) / DELETE_CONCURRENCY);
197
+ const share = Math.min(RECLAIM_DELETE_MS, (deadline - config.now()) / rounds);
198
+ await request(config, {
199
+ method: 'DELETE',
200
+ url: apiUrl(config, sandboxPathFor(id)),
201
+ retry: 'safe',
202
+ deadline: Math.min(deadline, config.now() + share),
203
+ sandboxId: id,
204
+ }).catch(() => undefined);
205
+ }
206
+ };
207
+ await Promise.all(Array.from({ length: Math.min(DELETE_CONCURRENCY, ids.length) }, worker));
208
+ })()
209
+ .catch(() => {
210
+ // The sandboxes' own timers (maxRuntimeMin) are the backstop.
211
+ })
212
+ .finally(() => backgroundCleanups.delete(cleanup));
213
+ backgroundCleanups.add(cleanup);
214
+ }
215
+ /** The error a failed or deleted sandbox answers a wait with; null while it can still arrive. */
216
+ function failedState(info) {
217
+ if (info.state === 'error') {
218
+ return new PrizedError('sandbox_error', info.errorReason ? `The sandbox failed: ${info.errorReason}` : null, {
219
+ sandboxId: info.id,
220
+ reason: info.errorReason,
221
+ });
222
+ }
223
+ if (info.state === 'deleting' || info.state === 'deleted') {
224
+ return new PrizedError('sandbox_deleted', null, { sandboxId: info.id });
225
+ }
226
+ return null;
227
+ }
228
+ /**
229
+ * A wait's timeout. One ended by a read its deadline cut short (the retry it
230
+ * was due would have ended past it: a 503's Retry-After, say) may come
231
+ * before the whole budget has passed, so it says so, and what the last
232
+ * attempt met; `cause` is the read's own error.
233
+ */
234
+ function waitTimeout(what, budgetMs, sandboxId, read) {
235
+ const last = read?.cause instanceof PrizedError ? read.cause : null;
236
+ if (last === null)
237
+ return new PrizedError('timeout', `${what} after ${budgetMs} ms`, { sandboxId, cause: read });
238
+ const met = last.status === null ? last.code : `${last.status} ${last.code}`;
239
+ return new PrizedError('timeout', `${what}: a read could not get an answer within the ${budgetMs} ms allowed (the last failed with ${met}).`, {
240
+ sandboxId,
241
+ cause: read,
242
+ });
243
+ }
244
+ /**
245
+ * A Prized sandbox: a microVM your code creates, runs commands in, and
246
+ * pauses, resumes, forks and deletes.
247
+ *
248
+ * const sandbox = await Sandbox.create({ template: 'prized-base-v1' });
249
+ * const { stdout } = await sandbox.exec('python3 --version');
250
+ * await sandbox.delete();
251
+ */
252
+ export class Sandbox {
253
+ /** The sandbox id (`sbx_…`). */
254
+ id;
255
+ /** Read, write, list, remove, upload and download files. */
256
+ files;
257
+ config;
258
+ current;
259
+ toolbox;
260
+ constructor(config, info) {
261
+ this.config = config;
262
+ this.id = info.id;
263
+ this.current = info;
264
+ this.toolbox = {
265
+ config,
266
+ sandboxId: info.id,
267
+ base: () => toolboxBaseFor(config, this.id, this.current.toolboxBase),
268
+ };
269
+ this.files = new Files(this.toolbox);
270
+ }
271
+ // --- what the SDK last heard ---------------------------------------------
272
+ /** Everything the API said about this sandbox, as of the last call. */
273
+ get info() {
274
+ return this.current;
275
+ }
276
+ /** The state as of the last call; `refresh()` reads it fresh. */
277
+ get state() {
278
+ return this.current.state;
279
+ }
280
+ get template() {
281
+ return this.current.template;
282
+ }
283
+ get resources() {
284
+ return this.current.resources;
285
+ }
286
+ get labels() {
287
+ return this.current.labels;
288
+ }
289
+ get createdAt() {
290
+ return this.current.createdAt;
291
+ }
292
+ get expiresAt() {
293
+ return this.current.expiresAt;
294
+ }
295
+ get errorReason() {
296
+ return this.current.errorReason;
297
+ }
298
+ toJSON() {
299
+ const { raw: _raw, ...rest } = this.current;
300
+ return rest;
301
+ }
302
+ // --- entry points ----------------------------------------------------------
303
+ /**
304
+ * Create a sandbox and wait until it is running, ready for commands.
305
+ * If it fails or the wait runs out, the sandbox is deleted and the error
306
+ * thrown. Throws PrizedError: `paid_balance_required`, `insufficient_balance`,
307
+ * `sandbox_quota_exceeded` (see `reason`), `template_not_found`,
308
+ * `invalid_resources`, `sandbox_error`, `timeout`, …
309
+ */
310
+ static async create(options = {}) {
311
+ const config = resolveConfig(options);
312
+ const body = { template: options.template ?? DEFAULT_TEMPLATE };
313
+ if (options.resources !== undefined)
314
+ body.resources = resourcesToWire(options.resources);
315
+ if (options.envVars !== undefined)
316
+ body.envVars = options.envVars;
317
+ if (options.labels !== undefined)
318
+ body.labels = options.labels;
319
+ const maxRuntimeMin = wholeMinutes('maxRuntimeMin', options.maxRuntimeMin, false);
320
+ if (maxRuntimeMin !== undefined)
321
+ body.maxRuntimeMin = maxRuntimeMin;
322
+ const autoPauseMin = wholeMinutes('autoPauseMin', options.autoPauseMin, true);
323
+ if (autoPauseMin !== undefined)
324
+ body.autoPauseMin = autoPauseMin;
325
+ const autoDeleteMin = wholeMinutes('autoDeleteMin', options.autoDeleteMin, true);
326
+ if (autoDeleteMin !== undefined)
327
+ body.autoDeleteMin = autoDeleteMin;
328
+ const timeoutMs = budgetMs(options.timeoutMs, DEFAULT_START_TIMEOUT_MS);
329
+ const started = config.now();
330
+ const idempotencyKey = options.idempotencyKey ?? randomKey();
331
+ const held = options.wait !== false;
332
+ // Every attempt and the cleanup's replay send this copy, so a caller
333
+ // that changes its labels or envVars meanwhile cannot make the replay
334
+ // a different request (idempotency_conflict) that finds nothing.
335
+ const wire = snapshot(body);
336
+ const send = (hold, signal, deadline, attempts) => request(config, {
337
+ method: 'POST',
338
+ url: (leftMs) => apiUrl(config, `/sandboxes${hold ? holdQuery(config, 'running', leftMs) : ''}`),
339
+ json: wire,
340
+ headers: { 'idempotency-key': idempotencyKey },
341
+ // Keyed, so a resend returns the first sandbox rather than a second.
342
+ retry: 'safe',
343
+ signal,
344
+ deadline,
345
+ onAttemptFailed: attempts?.onAttemptFailed,
346
+ });
347
+ // The caller's first command goes to the edge: open that connection
348
+ // while the API holds the create, not after the sandbox runs.
349
+ // A signal aborted before the call sends nothing: nothing to warm.
350
+ if (held && options.signal?.aborted !== true) {
351
+ const warm = warmEdge(config).finally(() => backgroundWarmups.delete(warm));
352
+ backgroundWarmups.add(warm);
353
+ }
354
+ const attempts = new Attempts();
355
+ let sandbox;
356
+ try {
357
+ // Held by the API until running (or its bound), instead of polled;
358
+ // with its retries, within the caller's own timeoutMs (wait: false too).
359
+ const result = await send(held, options.signal, started + timeoutMs, attempts);
360
+ attempts.accepted = true;
361
+ sandbox = new Sandbox(config, parseSandbox(result.json?.sandbox));
362
+ }
363
+ catch (error) {
364
+ // Cancelled mid-request, no answer at all (timed out or the connection
365
+ // lost), most likely during the API's hold (it commits the sandbox,
366
+ // then holds), or a 2xx with no usable sandbox in it: on any attempt,
367
+ // even when a later one was refused. One may be billing that the
368
+ // caller has no handle to. Under the SDK's own key it can only be this
369
+ // call's: replay the key and delete what it answers with. Under the
370
+ // caller's key it may be an earlier call's: left, and resending the
371
+ // key returns it.
372
+ if (attempts.unresolved && options.idempotencyKey === undefined) {
373
+ reclaim(config, attempts, 1, async (deadline) => {
374
+ const replay = await send(false, undefined, deadline);
375
+ return [parseSandbox(replay.json?.sandbox).id];
376
+ });
377
+ }
378
+ throw error;
379
+ }
380
+ if (options.wait === false)
381
+ return sandbox;
382
+ try {
383
+ await sandbox.waitFor('running', timeoutMs - (config.now() - started), options.signal, timeoutMs);
384
+ }
385
+ catch (error) {
386
+ // The caller gets no handle to a sandbox that failed or never came up,
387
+ // so it is deleted rather than left running (and billing).
388
+ await sandbox.discard();
389
+ throw error;
390
+ }
391
+ return sandbox;
392
+ }
393
+ /** An existing sandbox by id, in whatever state it is in. */
394
+ static async connect(id, options = {}) {
395
+ if (typeof id !== 'string' || id === '')
396
+ throw new PrizedError('invalid_argument', 'A sandbox id is required.');
397
+ const config = resolveConfig(options);
398
+ return new Sandbox(config, await fetchInfo(config, id, undefined, callDeadline(config)));
399
+ }
400
+ /** The workspace's sandboxes (deleted ones excluded), newest first; `labels` is filtered by the API. */
401
+ static async list(options = {}) {
402
+ const config = resolveConfig(options);
403
+ const infos = await listInfos(config, undefined, callDeadline(config), labelQuery(options.labels, false));
404
+ const states = options.state === undefined ? null : new Set(Array.isArray(options.state) ? options.state : [options.state]);
405
+ const labels = Object.entries(options.labels ?? {});
406
+ return infos
407
+ .filter((info) => states === null || states.has(info.state))
408
+ .filter((info) => labels.every(([key, value]) => info.labels[key] === value))
409
+ .map((info) => new Sandbox(config, info));
410
+ }
411
+ /**
412
+ * Delete every sandbox carrying all of `labels` (at least one) and resolve
413
+ * with their ids (the API takes up to 250 a request; this repeats it until
414
+ * none are left). Safe to repeat: sandboxes already being
415
+ * deleted are in the answer again, and none matching resolves `[]`. Each
416
+ * counts toward the workspace's quotas until it is gone;
417
+ * `Sandbox.list({ labels })` coming back empty means they all are.
418
+ */
419
+ static async deleteByLabels(labels, options = {}) {
420
+ if (labels === null || typeof labels !== 'object' || Object.keys(labels).length === 0) {
421
+ throw new PrizedError('invalid_argument', 'deleteByLabels needs at least one label.');
422
+ }
423
+ const query = labelQuery(labels, true);
424
+ const config = resolveConfig(options);
425
+ const deadline = callDeadline(config);
426
+ const deleted = new Set();
427
+ const stuck = (calls) => new PrizedError('delete_incomplete', `Deleted ${deleted.size} sandboxes in ${calls} requests and some are left; call again to delete the rest.`);
428
+ for (let call = 1; call <= MAX_BULK_DELETE_CALLS; call += 1) {
429
+ const result = await request(config, { method: 'DELETE', url: apiUrl(config, `/sandboxes${query}`), retry: 'safe', deadline });
430
+ const answer = result.json;
431
+ const ids = answer?.ids;
432
+ if (!Array.isArray(ids) || !ids.every((id) => typeof id === 'string')) {
433
+ throw new PrizedError('bad_response', "The API answered without the deleted sandboxes' ids.");
434
+ }
435
+ const before = deleted.size;
436
+ for (const id of ids)
437
+ deleted.add(id);
438
+ if (answer?.more !== true)
439
+ return [...deleted];
440
+ if (deleted.size === before)
441
+ throw stuck(call);
442
+ }
443
+ throw stuck(MAX_BULK_DELETE_CALLS);
444
+ }
445
+ /** The templates you can create sandboxes from, with their default, minimum and maximum shapes. */
446
+ static async templates(options = {}) {
447
+ const config = resolveConfig(options);
448
+ const result = await request(config, {
449
+ method: 'GET',
450
+ url: apiUrl(config, '/sandbox-templates'),
451
+ retry: 'safe',
452
+ deadline: callDeadline(config),
453
+ });
454
+ const list = result.json?.templates;
455
+ if (!Array.isArray(list))
456
+ throw new PrizedError('bad_response', 'The API answered without a template list.');
457
+ return list.map(parseTemplate).filter((t) => t !== null);
458
+ }
459
+ exec(command, options = {}) {
460
+ return execCommand(this.toolbox, command, options);
461
+ }
462
+ // --- lifecycle ----------------------------------------------------------------
463
+ /** Read the sandbox again: `state` and the other fields update. Does not count as activity. */
464
+ async refresh() {
465
+ this.current = await fetchInfo(this.config, this.id, undefined, callDeadline(this.config));
466
+ return this;
467
+ }
468
+ /**
469
+ * Count as activity now: the `autoPauseMin` idle clock starts over.
470
+ * Commands and file transfers already count; this is for idle stretches.
471
+ */
472
+ async keepAlive() {
473
+ await request(this.config, {
474
+ method: 'POST',
475
+ url: apiUrl(this.config, `${sandboxPathFor(this.id)}/refresh`),
476
+ retry: 'safe',
477
+ deadline: callDeadline(this.config),
478
+ read: 'none',
479
+ sandboxId: this.id,
480
+ });
481
+ }
482
+ /**
483
+ * Pause: memory and disk are kept, and only storage is billed. Waits for
484
+ * `paused` unless `wait: false`.
485
+ */
486
+ async pause(options = {}) {
487
+ const timeoutMs = budgetMs(options.timeoutMs, DEFAULT_SETTLE_TIMEOUT_MS);
488
+ const deadline = this.config.now() + timeoutMs;
489
+ await this.verb('pause', options.signal, deadline, options.wait === false ? null : 'paused');
490
+ if (options.wait === false)
491
+ return;
492
+ await this.waitFor('paused', deadline - this.config.now(), options.signal, timeoutMs);
493
+ }
494
+ /** Resume a paused sandbox, with every process where it left off. Waits for `running` unless `wait: false`. */
495
+ async resume(options = {}) {
496
+ const timeoutMs = budgetMs(options.timeoutMs, DEFAULT_START_TIMEOUT_MS);
497
+ const deadline = this.config.now() + timeoutMs;
498
+ await this.verb('resume', options.signal, deadline, options.wait === false ? null : 'running');
499
+ if (options.wait === false)
500
+ return;
501
+ await this.waitFor('running', deadline - this.config.now(), options.signal, timeoutMs);
502
+ }
503
+ /**
504
+ * Fork a running or paused sandbox into `count` new sandboxes, each with
505
+ * every process of this one running. Resolves once every copy is running,
506
+ * ready for commands (unless `wait: false`); if any copy fails or the
507
+ * wait runs out, every copy is deleted and the error thrown. The parent is
508
+ * left as it was: running, or paused. `timeoutMs` bounds the request and
509
+ * the wait together.
510
+ */
511
+ async fork(options = {}) {
512
+ const count = options.count ?? 1;
513
+ if (!Number.isInteger(count) || count < 1)
514
+ throw new PrizedError('invalid_argument', 'count must be a whole number ≥ 1');
515
+ const timeoutMs = budgetMs(options.timeoutMs, DEFAULT_START_TIMEOUT_MS);
516
+ // One deadline for the POST, its retries and the wait for the copies.
517
+ const deadline = this.config.now() + timeoutMs;
518
+ const body = { count };
519
+ if (options.labels !== undefined)
520
+ body.labels = options.labels;
521
+ const maxRuntimeMin = wholeMinutes('maxRuntimeMin', options.maxRuntimeMin, false);
522
+ if (maxRuntimeMin !== undefined)
523
+ body.maxRuntimeMin = maxRuntimeMin;
524
+ const idempotencyKey = options.idempotencyKey ?? randomKey();
525
+ const wire = snapshot(body); // as in create
526
+ const send = (deadline, attempts) => request(this.config, {
527
+ method: 'POST',
528
+ url: apiUrl(this.config, `${sandboxPathFor(this.id)}/fork`),
529
+ json: wire,
530
+ headers: { 'idempotency-key': idempotencyKey },
531
+ retry: 'safe',
532
+ signal: attempts === undefined ? undefined : options.signal,
533
+ deadline,
534
+ sandboxId: this.id,
535
+ onAttemptFailed: attempts?.onAttemptFailed,
536
+ });
537
+ const copies = (result) => {
538
+ const docs = result.json?.sandboxes;
539
+ if (!Array.isArray(docs))
540
+ throw new PrizedError('bad_response', 'The fork answer carried no sandboxes.', { sandboxId: this.id });
541
+ return docs.map((doc) => new Sandbox(this.config, parseSandbox(doc)));
542
+ };
543
+ const attempts = new Attempts();
544
+ let children;
545
+ try {
546
+ const result = await send(deadline, attempts);
547
+ attempts.accepted = true;
548
+ children = copies(result);
549
+ }
550
+ catch (error) {
551
+ // As for create: copies may exist with no handle to them. The API
552
+ // answers a replayed fork key before it checks the parent, so they are
553
+ // found even if the parent stopped meanwhile.
554
+ if (attempts.unresolved && options.idempotencyKey === undefined) {
555
+ reclaim(this.config, attempts, count, async (deadline) => copies(await send(deadline)).map((copy) => copy.id));
556
+ }
557
+ throw error;
558
+ }
559
+ if (options.wait === false || children.length === 0)
560
+ return children;
561
+ try {
562
+ await Sandbox.settle(this.config, children, deadline, timeoutMs, options.signal, this.id);
563
+ const failed = children.find((child) => child.state !== 'running');
564
+ if (failed)
565
+ throw failedState(failed.current) ?? new PrizedError('sandbox_error', null, { sandboxId: failed.id });
566
+ }
567
+ catch (error) {
568
+ // All or nothing: copies the caller will never get are not left billing.
569
+ for (let i = 0; i < children.length; i += DELETE_CONCURRENCY) {
570
+ await Promise.all(children.slice(i, i + DELETE_CONCURRENCY).map((child) => child.discard()));
571
+ }
572
+ throw error;
573
+ }
574
+ return children;
575
+ }
576
+ /**
577
+ * Delete the sandbox and everything in it. Returns once the API accepts;
578
+ * `wait: true` also waits until it is gone (a deleting sandbox still
579
+ * counts toward the workspace's quota). `timeoutMs` (default 120 000)
580
+ * bounds the whole call, its retries and the wait included.
581
+ */
582
+ async delete(options = {}) {
583
+ const timeoutMs = budgetMs(options.timeoutMs, DEFAULT_SETTLE_TIMEOUT_MS);
584
+ // One deadline for the whole call: the DELETE, its retries and the wait.
585
+ const deadline = this.config.now() + timeoutMs;
586
+ const result = await requestOr404(this.config, {
587
+ method: 'DELETE',
588
+ url: apiUrl(this.config, sandboxPathFor(this.id)),
589
+ retry: 'safe',
590
+ signal: options.signal,
591
+ deadline,
592
+ sandboxId: this.id,
593
+ });
594
+ if (result === null) {
595
+ this.current = { ...this.current, state: 'deleted' };
596
+ return;
597
+ }
598
+ const doc = result.json?.sandbox;
599
+ if (doc !== undefined)
600
+ this.current = parseSandbox(doc);
601
+ if (options.wait !== true)
602
+ return;
603
+ const timedOut = (read) => waitTimeout(`${this.id} was still ${this.current.state}`, timeoutMs, this.id, read);
604
+ const polls = new Polls(this.config, deadline);
605
+ for (;;) {
606
+ let fresh;
607
+ polls.sent();
608
+ try {
609
+ // As in waitFor: the deadline bounds each read and its retries.
610
+ fresh = await requestOr404(this.config, {
611
+ method: 'GET',
612
+ url: apiUrl(this.config, sandboxPathFor(this.id)),
613
+ retry: 'safe',
614
+ signal: options.signal,
615
+ deadline,
616
+ sandboxId: this.id,
617
+ });
618
+ }
619
+ catch (error) {
620
+ throw cutByDeadline(error) ? timedOut(error) : error;
621
+ }
622
+ polls.answered();
623
+ if (fresh === null) {
624
+ this.current = { ...this.current, state: 'deleted' };
625
+ return;
626
+ }
627
+ this.current = parseSandbox(fresh.json?.sandbox);
628
+ if (this.current.state === 'deleted')
629
+ return;
630
+ // As in waitFor: the last read goes out just before the deadline.
631
+ const pause = polls.pause(this.config.pollIntervalMs);
632
+ if (pause === null)
633
+ throw timedOut();
634
+ if (pause > 0)
635
+ await this.config.sleep(pause, options.signal);
636
+ if (deadline - this.config.now() <= 0)
637
+ throw timedOut();
638
+ }
639
+ }
640
+ // --- ports at a URL -----------------------------------------------------------
641
+ /**
642
+ * The https URL `port` of the sandbox answers at, for a request that
643
+ * carries a workspace-wide API key with Sandboxes: Write in the
644
+ * `Prized-Key` header (`previewHeaders()`): a web app, an API or a dev
645
+ * server listening on that port in the sandbox on 127.0.0.1 or 0.0.0.0. A
646
+ * request to a paused sandbox resumes it and waits up to 10 seconds, as
647
+ * `exec` does. Nothing is created: the URL is the sandbox's own. Throws
648
+ * `preview_unavailable` when the deployment has sandbox ingress off.
649
+ */
650
+ previewUrl(port) {
651
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 22 || port === 81) {
652
+ throw new PrizedError('bad_request', `port must be a whole number from 1 to 65535 other than 22 and 81, not ${String(port)}`, { sandboxId: this.id });
653
+ }
654
+ const template = this.current.raw.previewUrlTemplate;
655
+ if (typeof template !== 'string' || !template.includes('{port}')) {
656
+ throw new PrizedError('preview_unavailable', 'sandbox ports at a URL are not turned on for this deployment', { sandboxId: this.id });
657
+ }
658
+ return template.replace('{port}', String(port));
659
+ }
660
+ /** The headers a request to `previewUrl(port)` sends: this client's API key in
661
+ * `Prized-Key`. The header never reaches the app. Send them with
662
+ * `redirect: 'manual'`: fetch resends custom headers to whatever host a
663
+ * redirect names, so only ever send them to the sandbox's preview URL. */
664
+ previewHeaders() {
665
+ return { [PREVIEW_KEY_HEADER]: this.config.apiKey };
666
+ }
667
+ // --- internals ------------------------------------------------------------------
668
+ /** POST the verb, the call and its retries within `deadline`; with `hold`,
669
+ * held by the API until the sandbox is that (each attempt asks only for
670
+ * what is left). */
671
+ async verb(name, signal, deadline, hold) {
672
+ const path = `${sandboxPathFor(this.id)}/${name}`;
673
+ const result = await request(this.config, {
674
+ method: 'POST',
675
+ url: (leftMs) => apiUrl(this.config, hold ? `${path}${holdQuery(this.config, hold, leftMs)}` : path),
676
+ retry: 'safe',
677
+ deadline,
678
+ signal,
679
+ sandboxId: this.id,
680
+ });
681
+ const doc = result.json?.sandbox;
682
+ if (doc !== undefined)
683
+ this.current = parseSandbox(doc);
684
+ }
685
+ /**
686
+ * Poll until the sandbox reaches `want`; throws on error/deleted or the
687
+ * deadline. The first read goes out at once (a held answer that has not
688
+ * settled is usually a breath away), then 100 ms, doubling to
689
+ * pollIntervalMs, and the last just before the deadline (Polls).
690
+ * `reportedMs` is the caller's whole budget, for the error.
691
+ */
692
+ async waitFor(want, timeoutMs, signal, reportedMs = timeoutMs) {
693
+ const deadline = this.config.now() + timeoutMs;
694
+ const timedOut = (read) => waitTimeout(`${this.id} was ${this.current.state}, not ${want}`, reportedMs, this.id, read);
695
+ let delay = Math.min(FIRST_POLL_MS, this.config.pollIntervalMs);
696
+ const polls = new Polls(this.config, deadline);
697
+ for (let first = true;; first = false) {
698
+ if (this.current.state === want)
699
+ return;
700
+ const failed = failedState(this.current);
701
+ if (failed)
702
+ throw failed;
703
+ if (deadline - this.config.now() <= 0)
704
+ throw timedOut();
705
+ if (!first) {
706
+ // null once the last look, just before the deadline, has been taken.
707
+ const pause = polls.pause(delay);
708
+ if (pause === null)
709
+ throw timedOut();
710
+ if (pause > 0)
711
+ await this.config.sleep(pause, signal);
712
+ delay = Math.min(delay * 2, this.config.pollIntervalMs);
713
+ // Overslept past the deadline (a laptop lid closed, say): no read is sent.
714
+ if (deadline - this.config.now() <= 0)
715
+ throw timedOut();
716
+ }
717
+ polls.sent();
718
+ try {
719
+ // The wait's deadline bounds each read and its retries too, so a
720
+ // read that meets a 5xx near the end cannot carry the wait past it.
721
+ this.current = await fetchInfo(this.config, this.id, signal, deadline);
722
+ polls.answered();
723
+ }
724
+ catch (error) {
725
+ throw cutByDeadline(error) ? timedOut(error) : error;
726
+ }
727
+ }
728
+ }
729
+ /** Best-effort delete after a failed wait, bounded (as the Python SDK's); never throws. */
730
+ async discard() {
731
+ try {
732
+ await this.delete({ timeoutMs: RECLAIM_DELETE_MS });
733
+ }
734
+ catch {
735
+ // The sandbox's own timers (maxRuntimeMin) are the backstop.
736
+ }
737
+ }
738
+ /**
739
+ * Poll the list until every fork copy is running or has failed, by
740
+ * `deadline`: one list call per poll, however many copies, every
741
+ * pollIntervalMs, and the last just before the deadline (Polls),
742
+ * so even a budget shorter than one interval polls once. `reportedMs` is
743
+ * the caller's whole budget, for the error.
744
+ */
745
+ static async settle(config, children, deadline, reportedMs, signal, parentId) {
746
+ const settled = (child) => child.state === 'running' || child.state === 'error' || child.state === 'deleting' || child.state === 'deleted';
747
+ const timedOut = (pending, read) => waitTimeout(`Fork copies not running (${pending.map((c) => `${c.id} ${c.state}`).join(', ')})`, reportedMs, parentId, read);
748
+ const polls = new Polls(config, deadline);
749
+ for (;;) {
750
+ const pending = children.filter((child) => !settled(child));
751
+ if (pending.length === 0)
752
+ return;
753
+ const pause = polls.pause(config.pollIntervalMs);
754
+ if (pause === null)
755
+ throw timedOut(pending);
756
+ if (pause > 0)
757
+ await config.sleep(pause, signal);
758
+ if (deadline - config.now() <= 0)
759
+ throw timedOut(pending); // as in waitFor
760
+ let infos;
761
+ polls.sent();
762
+ try {
763
+ // As in waitFor: the wait's deadline bounds the read and its retries.
764
+ infos = await listInfos(config, signal, deadline);
765
+ polls.answered();
766
+ }
767
+ catch (error) {
768
+ throw cutByDeadline(error) ? timedOut(pending, error) : error;
769
+ }
770
+ const byId = new Map(infos.map((info) => [info.id, info]));
771
+ for (const child of pending) {
772
+ // The list leaves deleted sandboxes out.
773
+ child.current = byId.get(child.id) ?? { ...child.current, state: 'deleted' };
774
+ }
775
+ }
776
+ }
777
+ }
778
+ async function fetchInfo(config, id, signal, deadline) {
779
+ const result = await request(config, {
780
+ method: 'GET',
781
+ url: apiUrl(config, sandboxPathFor(id)),
782
+ retry: 'safe',
783
+ signal,
784
+ deadline,
785
+ sandboxId: id,
786
+ });
787
+ return parseSandbox(result.json?.sandbox);
788
+ }
789
+ async function listInfos(config, signal, deadline, query = '') {
790
+ const result = await request(config, { method: 'GET', url: apiUrl(config, `/sandboxes${query}`), retry: 'safe', signal, deadline });
791
+ const list = result.json?.sandboxes;
792
+ if (!Array.isArray(list))
793
+ throw new PrizedError('bad_response', 'The API answered without a sandbox list.');
794
+ return list.map(parseSandbox);
795
+ }
796
+ /** request(), with a 404 not_found answered as null. */
797
+ async function requestOr404(config, spec) {
798
+ try {
799
+ return await request(config, spec);
800
+ }
801
+ catch (error) {
802
+ if (error instanceof PrizedError && error.status === 404 && error.code === 'not_found')
803
+ return null;
804
+ throw error;
805
+ }
806
+ }