@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,567 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CommandHandle = exports.Outage = exports.MAX_BLOCKING_EXEC_MS = exports.DEFAULT_EXEC_TIMEOUT_MS = void 0;
4
+ exports.ridesOut = ridesOut;
5
+ exports.execCommand = execCommand;
6
+ // sandbox.exec: one call for short commands, a detached run for background
7
+ // work, long timeouts and streamed output.
8
+ const errors_js_1 = require("./errors.js");
9
+ const http_js_1 = require("./http.js");
10
+ const shell_js_1 = require("./shell.js");
11
+ const outcome_js_1 = require("./outcome.js");
12
+ const output_js_1 = require("./output.js");
13
+ const toolbox_js_1 = require("./toolbox.js");
14
+ /** The timeout a command gets when the call names none. */
15
+ exports.DEFAULT_EXEC_TIMEOUT_MS = 60_000;
16
+ /**
17
+ * The longest command one blocking call carries. Longer timeouts (and
18
+ * `timeoutMs: 0`) run detached and are polled: Node's fetch gives up on an
19
+ * answer after 300 s, and load balancers drop idle connections not long after.
20
+ */
21
+ exports.MAX_BLOCKING_EXEC_MS = 240_000;
22
+ /** Bytes of each stream one poll brings back. */
23
+ const POLL_CHUNK_BYTES = 1024 * 1024;
24
+ const POLL_MIN_MS = 200;
25
+ const POLL_MAX_MS = 1_000;
26
+ /**
27
+ * The bound on each teardown request the SDK makes on its own (stopping or
28
+ * abandoning a run, removing its files), so it never holds up the caller's
29
+ * result or error for long. The Python SDK's TEARDOWN_SECONDS.
30
+ */
31
+ const TEARDOWN_MS = 10_000;
32
+ /**
33
+ * The bound on abandoning a start (ABANDON_SECONDS): the abandon script
34
+ * waits up to 10 s for a slow start's pid before it KILLs the run, and a
35
+ * request cut off sooner would stop it before the KILL.
36
+ */
37
+ const ABANDON_MS = 20_000;
38
+ /** How long a stopped run's cleanup keeps trying while its group goes (CLEANUP_SECONDS). */
39
+ const CLEANUP_MS = 3_000;
40
+ const CLEANUP_RETRY_MS = 200;
41
+ /**
42
+ * The errors a wait's poll threw (waitRaw) that leave open what became of
43
+ * the run: the run polled, and whether the wait had already seen it end.
44
+ * exec tells them from a callback's own and says what became of the command.
45
+ */
46
+ const failedPolls = new WeakMap();
47
+ /**
48
+ * The poll errors that settle what became of the run: it is gone with its
49
+ * sandbox (deleted, or not running), or there is no such run. Any other (no
50
+ * answer, an answer that could not be read, a 5xx, a rate limit, a refused
51
+ * key) leaves the command perhaps still running. The Python SDK's
52
+ * _RUN_SETTLED.
53
+ */
54
+ const RUN_SETTLED = new Set(['not_found', 'sandbox_deleted', 'sandbox_not_running']);
55
+ /** The poll errors with no answer behind them that an outage window rides out. */
56
+ const NO_ANSWER = new Set(['network_error', 'timeout', 'bad_response']);
57
+ /**
58
+ * Whether a failed poll is one an outage window rides out (sdk/retry-spec.json
59
+ * `pollOutage`): no answer, an answer that could not be read, a rate limit or
60
+ * any 5xx. Any other (a refused key, any other refusal, an answer that
61
+ * settles the run, an abort) ends the wait at once. The Python SDK's
62
+ * rides_out.
63
+ */
64
+ function ridesOut(error) {
65
+ if (RUN_SETTLED.has(error.code))
66
+ return false;
67
+ if (error.status === null)
68
+ return NO_ANSWER.has(error.code);
69
+ return error.status >= 500 || error.status === 429 || error.code === 'bad_response';
70
+ }
71
+ /**
72
+ * The failed polls of a wait that rides out an outage of up to `windowMs`
73
+ * (exec's pollOutageMs), from when the first poll's retries ran out: polled
74
+ * again with backoff, each poll and its retries ending before the last look,
75
+ * which goes out LAST_LOOK_MAX_MS before the window's end (half the window,
76
+ * for one shorter than twice that) with room for its own retries. So an
77
+ * outage that ends inside the window is always seen to end. The Python
78
+ * SDK's Outage.
79
+ */
80
+ class Outage {
81
+ windowMs;
82
+ end = null;
83
+ last = null;
84
+ tries = 0;
85
+ room;
86
+ constructor(windowMs) {
87
+ this.windowMs = windowMs;
88
+ this.room = Math.min(http_js_1.LAST_LOOK_MAX_MS, windowMs / 2);
89
+ }
90
+ /** The bound on a poll sent `now`: undefined unless polls are failing; else the last look's start, or the window's end for the last look. */
91
+ deadline(now) {
92
+ if (this.end === null)
93
+ return undefined;
94
+ // (A millisecond's slack, as Polls has.)
95
+ return now < this.end - this.room - 1 ? this.end - this.room : this.end;
96
+ }
97
+ /** A poll was answered: the outage, if any, is over. */
98
+ answered() {
99
+ this.end = null;
100
+ this.last = null;
101
+ this.tries = 0;
102
+ }
103
+ /**
104
+ * A poll sent at `sentAt` failed `now` with `error` (`cut`: its bound cut
105
+ * it short). Returns the pause before polling again, or null to give up,
106
+ * with the failure to give up with: the last one a poll met on its own,
107
+ * never the cut.
108
+ */
109
+ failed(error, sentAt, now, cut, backoff) {
110
+ if (this.windowMs <= 0 || !ridesOut(error))
111
+ return { pause: null, failure: error };
112
+ this.end ??= now + this.windowMs;
113
+ const met = error.cause instanceof errors_js_1.PrizedError ? error.cause : null;
114
+ if (!cut) {
115
+ this.last = error;
116
+ this.tries += 1;
117
+ }
118
+ else if (met !== null && ridesOut(met) && met.code !== this.last?.code) {
119
+ // What the cut poll met, when it is news (a 503 after lost connections).
120
+ this.last = met;
121
+ }
122
+ const failure = this.last ?? error;
123
+ // The last look has been taken: this poll went out with no more than its room left.
124
+ if (this.end - sentAt <= this.room + 1)
125
+ return { pause: null, failure };
126
+ // 1 s, 2 s, 4 s, then 8 s apart (jittered as retries are), each poll with
127
+ // its own retries, none past the last look's start; straight on to it
128
+ // when the bound cut this one short.
129
+ const toLast = this.end - this.room - now;
130
+ if (cut)
131
+ return { pause: Math.max(0, toLast), failure };
132
+ // A Retry-After the last answer asked for is waited out too (up to MAX_RETRY_AFTER_MS).
133
+ const pause = Math.max(backoff(Math.min(this.tries, 4)), Math.min((error.retryAfter ?? 0) * 1000, http_js_1.MAX_RETRY_AFTER_MS));
134
+ return { pause: Math.max(0, Math.min(pause, toLast)), failure };
135
+ }
136
+ }
137
+ exports.Outage = Outage;
138
+ function randomHex(bytes) {
139
+ const out = new Uint8Array(bytes);
140
+ const webcrypto = globalThis.crypto;
141
+ if (webcrypto?.getRandomValues) {
142
+ webcrypto.getRandomValues(out);
143
+ }
144
+ else {
145
+ for (let i = 0; i < bytes; i += 1)
146
+ out[i] = Math.floor(Math.random() * 256);
147
+ }
148
+ return Array.from(out, (b) => b.toString(16).padStart(2, '0')).join('');
149
+ }
150
+ function base64Bytes(text) {
151
+ if (text === '')
152
+ return new Uint8Array(0);
153
+ const binary = atob(text);
154
+ const out = new Uint8Array(binary.length);
155
+ for (let i = 0; i < binary.length; i += 1)
156
+ out[i] = binary.charCodeAt(i);
157
+ return out;
158
+ }
159
+ const REPORT_RE = /^prized-run (running|done|gone|missing) (-|-?[0-9]+) ([0-9]+) ([0-9]+) ([01]) (-|-?[0-9]+)$/;
160
+ function parseReport(result, toolbox, id) {
161
+ const lines = result.stdout.split('\n');
162
+ const head = REPORT_RE.exec(lines[0] ?? '');
163
+ if (result.exitCode !== 0 || head === null) {
164
+ throw new errors_js_1.PrizedError('bad_response', `Could not read a background command's state: ${(result.stderr || lines[0] || '').slice(0, 200)}`, {
165
+ sandboxId: toolbox.sandboxId,
166
+ });
167
+ }
168
+ let stdout = new Uint8Array(0);
169
+ let stderr = new Uint8Array(0);
170
+ try {
171
+ // A missing run is not_found (poll()), whatever follows its line.
172
+ if (head[1] !== 'missing') {
173
+ stdout = base64Bytes((lines[1] ?? '').trim());
174
+ stderr = base64Bytes((lines[2] ?? '').trim());
175
+ }
176
+ }
177
+ catch (error) {
178
+ // As the Python SDK reads it: an answer that could not be read.
179
+ throw new errors_js_1.PrizedError('bad_response', `Unreadable report for ${id}: ${error instanceof Error ? error.message : String(error)}`, {
180
+ sandboxId: toolbox.sandboxId,
181
+ cause: error,
182
+ });
183
+ }
184
+ return {
185
+ state: head[1],
186
+ exitCode: head[2] === '-' ? null : Number(head[2]),
187
+ outSize: Number(head[3]),
188
+ errSize: Number(head[4]),
189
+ timedOut: head[5] === '1',
190
+ durationMs: head[6] === '-' ? null : Number(head[6]),
191
+ stdout,
192
+ stderr,
193
+ };
194
+ }
195
+ /** One output stream: bytes read so far, decoded as UTF-8 across chunk boundaries. */
196
+ class StreamReader {
197
+ onChunk;
198
+ offset = 0;
199
+ text = '';
200
+ decoder = new TextDecoder('utf-8');
201
+ constructor(onChunk) {
202
+ this.onChunk = onChunk;
203
+ }
204
+ push(bytes) {
205
+ if (bytes.length === 0)
206
+ return;
207
+ this.offset += bytes.length;
208
+ this.emit(this.decoder.decode(bytes, { stream: true }));
209
+ }
210
+ finish() {
211
+ this.emit(this.decoder.decode());
212
+ return this.text;
213
+ }
214
+ emit(chunk) {
215
+ if (chunk === '')
216
+ return;
217
+ this.text += chunk;
218
+ this.onChunk?.(chunk);
219
+ }
220
+ }
221
+ /**
222
+ * A command running in the background (`sandbox.exec(cmd, { background: true })`).
223
+ * It runs in its own session: it keeps running after this process exits, and
224
+ * while it runs, kill() reaches every process it started in its process group.
225
+ */
226
+ class CommandHandle {
227
+ /** The run's id (`run_…`). */
228
+ id;
229
+ /** The process group id of the command inside the sandbox. */
230
+ pid;
231
+ sandboxId;
232
+ toolbox;
233
+ startedAt;
234
+ /** @internal The time limit exec gave the command, in ms; null for none. */
235
+ timeoutMs = null;
236
+ /** @internal */
237
+ constructor(toolbox, id, pid, startedAt) {
238
+ this.toolbox = toolbox;
239
+ this.id = id;
240
+ this.pid = pid;
241
+ this.sandboxId = toolbox.sandboxId;
242
+ this.startedAt = startedAt;
243
+ }
244
+ async poll(outOffset, errOffset, maxChunk, signal, deadline) {
245
+ const result = await (0, toolbox_js_1.helperExec)(this.toolbox, (0, shell_js_1.pollScript)(this.id, outOffset, errOffset, maxChunk), { signal, deadline });
246
+ const report = parseReport(result, this.toolbox, this.id);
247
+ if (report.state === 'missing') {
248
+ throw new errors_js_1.PrizedError('not_found', `No background command ${this.id} in this sandbox.`, {
249
+ sandboxId: this.sandboxId,
250
+ });
251
+ }
252
+ return report;
253
+ }
254
+ /** Whether the command is still running, and its exit code once it is not. */
255
+ async status() {
256
+ const report = await this.poll(0, 0, 0);
257
+ if (report.state === 'running')
258
+ return { running: true, exitCode: null };
259
+ const exitCode = report.timedOut || report.state !== 'done' || report.exitCode === null ? -1 : report.exitCode;
260
+ return { running: false, exitCode };
261
+ }
262
+ /** Everything the command has printed so far. */
263
+ async logs() {
264
+ const out = new StreamReader();
265
+ const err = new StreamReader();
266
+ for (;;) {
267
+ const report = await this.poll(out.offset, err.offset, POLL_CHUNK_BYTES);
268
+ out.push(report.stdout);
269
+ err.push(report.stderr);
270
+ const caughtUp = (report.stdout.length === 0 || out.offset >= report.outSize) &&
271
+ (report.stderr.length === 0 || err.offset >= report.errSize);
272
+ if (caughtUp)
273
+ return { stdout: out.finish(), stderr: err.finish() };
274
+ }
275
+ }
276
+ /**
277
+ * Wait for the command to finish and return everything it printed.
278
+ * `onStdout`/`onStderr` receive the output as it arrives, from the start.
279
+ * A `timeoutMs` here bounds the wait only; the command keeps running. If
280
+ * the command ran past its own timeout (the one given to exec), it was
281
+ * killed and this throws CommandTimeoutError with what it printed.
282
+ */
283
+ async wait(options = {}) {
284
+ return finished(await this.waitRaw(options), this.timeoutMs, this.sandboxId);
285
+ }
286
+ /**
287
+ * @internal wait() without the timeout check: exec cleans up before it
288
+ * throws. `outageMs` (exec's pollOutageMs; a wait with no limit only):
289
+ * how long polls may fail before the wait gives up (Outage).
290
+ */
291
+ async waitRaw(options = {}, outageMs = 0) {
292
+ const config = this.toolbox.config;
293
+ const deadline = options.timeoutMs ? config.now() + options.timeoutMs : null;
294
+ const out = new StreamReader(options.onStdout);
295
+ const err = new StreamReader(options.onStderr);
296
+ let delay = POLL_MIN_MS;
297
+ let finished = false;
298
+ const polls = deadline === null ? null : new http_js_1.Polls(config, deadline);
299
+ const outage = new Outage(deadline === null ? outageMs : 0);
300
+ for (;;) {
301
+ let report;
302
+ const sentAt = config.now();
303
+ polls?.sent();
304
+ try {
305
+ // The wait's deadline bounds each poll and its retries too, so a
306
+ // stalled or rate-limited edge cannot hold a short wait for minutes.
307
+ // Once the command has finished, draining the rest of its output is
308
+ // not held to it. While polls fail, the outage window's end bounds them.
309
+ const until = outage.deadline(sentAt) ?? (finished ? undefined : deadline ?? undefined);
310
+ report = await this.poll(out.offset, err.offset, POLL_CHUNK_BYTES, options.signal, until);
311
+ polls?.answered();
312
+ }
313
+ catch (error) {
314
+ if (deadline !== null && error instanceof errors_js_1.PrizedError && error.code === 'timeout')
315
+ throw this.waitTimeout(options.timeoutMs);
316
+ let failure = error;
317
+ if (error instanceof errors_js_1.PrizedError && !options.signal?.aborted) {
318
+ // A dropped connection, an edge restarting: the command may be
319
+ // fine. Poll again until the window runs out, then give up.
320
+ const step = outage.failed(error, sentAt, config.now(), (0, http_js_1.cutByDeadline)(error), (tries) => (0, http_js_1.backoffMs)(config, tries));
321
+ if (step.pause !== null) {
322
+ await config.sleep(step.pause, options.signal);
323
+ continue;
324
+ }
325
+ failure = step.failure;
326
+ }
327
+ if (failure instanceof errors_js_1.PrizedError && !RUN_SETTLED.has(failure.code))
328
+ failedPolls.set(failure, { runId: this.id, ended: finished });
329
+ throw failure;
330
+ }
331
+ outage.answered();
332
+ if (report.state !== 'running')
333
+ finished = true;
334
+ out.push(report.stdout);
335
+ err.push(report.stderr);
336
+ const more = (report.stdout.length > 0 && out.offset < report.outSize) ||
337
+ (report.stderr.length > 0 && err.offset < report.errSize);
338
+ if (report.state !== 'running' && !more)
339
+ return this.result(report, out.finish(), err.finish());
340
+ // Checked before draining more output, so a chatty command cannot hold
341
+ // the wait past its limit.
342
+ if (report.state === 'running' && deadline !== null && config.now() >= deadline) {
343
+ throw this.waitTimeout(options.timeoutMs);
344
+ }
345
+ if (more)
346
+ continue;
347
+ // Back off while it is quiet; poll briskly again once output flows.
348
+ if (report.stdout.length > 0 || report.stderr.length > 0)
349
+ delay = POLL_MIN_MS;
350
+ // With a limit, the last poll goes out just before it (Polls).
351
+ const pause = polls === null ? delay : polls.pause(delay);
352
+ if (pause === null)
353
+ throw this.waitTimeout(options.timeoutMs);
354
+ if (pause > 0)
355
+ await config.sleep(pause, options.signal);
356
+ delay = Math.min(POLL_MAX_MS, delay * 2);
357
+ }
358
+ }
359
+ waitTimeout(timeoutMs) {
360
+ return new errors_js_1.PrizedError('timeout', `${this.id} was still running after ${timeoutMs} ms`, { sandboxId: this.sandboxId });
361
+ }
362
+ result(report, stdout, stderr) {
363
+ // Both come from the run's own files, not from when the caller looked.
364
+ const durationMs = report.durationMs ?? Math.max(0, this.toolbox.config.now() - this.startedAt);
365
+ const exitCode = report.timedOut || report.state !== 'done' || report.exitCode === null ? -1 : report.exitCode;
366
+ return { stdout, stderr, exitCode, durationMs, timedOut: report.timedOut, truncated: false };
367
+ }
368
+ /**
369
+ * Send a signal (default SIGTERM) to the command and everything it
370
+ * started in its process group. Resolves true if it was sent, false if
371
+ * the run had already ended. Processes that left the group (`setsid`, a
372
+ * daemon) are not reached, nor anything after the run itself has ended.
373
+ */
374
+ async kill(signal = 'SIGTERM') {
375
+ const name = signal.toUpperCase().replace(/^SIG/, '');
376
+ if (!shell_js_1.SIGNALS.has(name)) {
377
+ throw new errors_js_1.PrizedError('invalid_argument', `Unsupported signal ${signal}; use one of ${[...shell_js_1.SIGNALS].map((s) => `SIG${s}`).join(', ')}`);
378
+ }
379
+ // Like a command: a signal whose answer was lost is not sent again (the
380
+ // second would report false for a kill that landed).
381
+ const result = await (0, toolbox_js_1.helperExec)(this.toolbox, (0, shell_js_1.killScript)(this.id, name), { retry: 'exec', effect: (0, outcome_js_1.signalEffect)(this.id, name) });
382
+ if (result.exitCode === 0)
383
+ return true;
384
+ if (result.exitCode === 3) {
385
+ throw new errors_js_1.PrizedError('not_found', `No background command ${this.id} in this sandbox.`, { sandboxId: this.sandboxId });
386
+ }
387
+ return false;
388
+ }
389
+ /**
390
+ * Remove the command's files (its output under `~/.prized/runs/<id>/`)
391
+ * from the sandbox once it has ended: call it after wait() resolves or
392
+ * throws CommandTimeoutError (after a kill(), await wait() first). On a
393
+ * command still running it does nothing, so status(), wait() and kill()
394
+ * keep working. Once its files are gone, status(), logs(), wait() and
395
+ * kill() reject with `not_found`. Best effort: it never rejects.
396
+ */
397
+ async cleanup() {
398
+ try {
399
+ // Exit 5 (still running) is not an error: the files stay on purpose.
400
+ await (0, toolbox_js_1.helperExec)(this.toolbox, (0, shell_js_1.cleanupScript)(this.id));
401
+ }
402
+ catch {
403
+ // Best effort: a leftover run directory costs a few bytes of disk.
404
+ }
405
+ }
406
+ }
407
+ exports.CommandHandle = CommandHandle;
408
+ /**
409
+ * One teardown helper the SDK sends on its own (stopping or abandoning a
410
+ * run, removing its files): best effort, and within `deadline` (TEARDOWN_MS
411
+ * from now by default), so it never holds up the caller's result or error
412
+ * for long. Its exit code, or null when it got no answer.
413
+ */
414
+ async function teardown(toolbox, script, deadline = toolbox.config.now() + TEARDOWN_MS) {
415
+ try {
416
+ return (await (0, toolbox_js_1.helperExec)(toolbox, script, { deadline })).exitCode;
417
+ }
418
+ catch {
419
+ return null;
420
+ }
421
+ }
422
+ /**
423
+ * SIGKILL a run for a caller that is leaving, sent again like a read (a
424
+ * second KILL changes nothing). True once nothing of it is running: sent,
425
+ * or there was nothing left to send it to.
426
+ */
427
+ async function stopRun(toolbox, id) {
428
+ const code = await teardown(toolbox, (0, shell_js_1.killScript)(id, 'KILL'));
429
+ return code === 0 || code === 3 || code === 4;
430
+ }
431
+ /**
432
+ * Remove the files of a run just stopped: its group may take a moment to
433
+ * go, and cleanup keeps a running run's files (exit 5), so it is tried
434
+ * again for CLEANUP_MS.
435
+ */
436
+ async function cleanupOnceGone(toolbox, id) {
437
+ const config = toolbox.config;
438
+ const deadline = config.now() + CLEANUP_MS;
439
+ for (;;) {
440
+ if ((await teardown(toolbox, (0, shell_js_1.cleanupScript)(id), deadline)) !== 5 || config.now() + CLEANUP_RETRY_MS >= deadline)
441
+ return;
442
+ try {
443
+ await config.sleep(CLEANUP_RETRY_MS);
444
+ }
445
+ catch {
446
+ return; // never in place of the error the caller is leaving with
447
+ }
448
+ }
449
+ }
450
+ async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, unbuffered) {
451
+ // Nothing sent yet: nothing to stop.
452
+ if (signal?.aborted)
453
+ throw (0, outcome_js_1.withOutcome)(new errors_js_1.PrizedError('aborted', null, { sandboxId: toolbox.sandboxId }), (0, outcome_js_1.startEffect)(), 'never');
454
+ const id = `run_${randomHex(8)}`;
455
+ if (!shell_js_1.RUN_ID_RE.test(id))
456
+ throw new Error('unreachable: bad run id');
457
+ const startedAt = toolbox.config.now();
458
+ let pid;
459
+ // Launched, but no pid came within the start's own wait: it may yet run.
460
+ let unsettled = false;
461
+ try {
462
+ const result = await (0, toolbox_js_1.rawExec)(toolbox, { command: (0, shell_js_1.startScript)(id, command, timeoutMs === null ? null : timeoutMs / 1000, unbuffered), cwd, env, timeoutMs: 60_000 },
463
+ // Keyed by a run id this call chose, so a resend finds the first start
464
+ // instead of starting the command twice.
465
+ { retry: 'safe', signal, effect: (0, outcome_js_1.startEffect)() });
466
+ pid = Number(result.stdout.trim());
467
+ if (result.exitCode !== 0 || !Number.isInteger(pid) || pid <= 0) {
468
+ unsettled = result.exitCode === shell_js_1.NO_PID_EXIT;
469
+ throw new errors_js_1.PrizedError('command_failed', `Could not start the background command: ${(result.stderr || result.stdout).trim().slice(0, 300)}`, {
470
+ sandboxId: toolbox.sandboxId,
471
+ });
472
+ }
473
+ }
474
+ catch (error) {
475
+ // The start may have run even though its answer never came (or the
476
+ // caller left mid-start). The caller gets no handle, so the run is
477
+ // stopped by its id rather than left going in its own session, and the
478
+ // error says so when it does not settle whether the command started.
479
+ const stopped = (await teardown(toolbox, (0, shell_js_1.abandonScript)(id), toolbox.config.now() + ABANDON_MS)) === 0;
480
+ if ((0, outcome_js_1.mayHaveActed)(error) || (unsettled && error instanceof errors_js_1.PrizedError))
481
+ throw (0, outcome_js_1.startAbandoned)(error, id, stopped);
482
+ throw error;
483
+ }
484
+ return new CommandHandle(toolbox, id, pid, startedAt);
485
+ }
486
+ function validTimeout(value) {
487
+ if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
488
+ throw new errors_js_1.PrizedError('invalid_argument', 'timeoutMs must be a number of ms ≥ 0');
489
+ }
490
+ }
491
+ /**
492
+ * A command's result for the caller: throw CommandTimeoutError when its
493
+ * timeout killed it, so a cut-off run never reads as a finished one. The
494
+ * SDK's own helpers read their results raw.
495
+ */
496
+ function finished(result, timeoutMs, sandboxId) {
497
+ if (result.timedOut)
498
+ throw new errors_js_1.CommandTimeoutError(result, timeoutMs, sandboxId);
499
+ return result;
500
+ }
501
+ /** sandbox.exec's body. */
502
+ async function execCommand(toolbox, command, options = {}) {
503
+ const text = (0, shell_js_1.commandText)(command);
504
+ if (text.trim() === '')
505
+ throw new errors_js_1.PrizedError('invalid_argument', 'The command is empty.');
506
+ validTimeout(options.timeoutMs);
507
+ const outageMs = (0, http_js_1.validOutage)(options.pollOutageMs) ?? toolbox.config.pollOutageMs;
508
+ const cwd = options.cwd === undefined ? undefined : (0, shell_js_1.sandboxPath)(options.cwd);
509
+ if (options.background) {
510
+ const limit = options.timeoutMs ? options.timeoutMs : null;
511
+ const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, true);
512
+ handle.timeoutMs = limit;
513
+ return handle;
514
+ }
515
+ const timeoutMs = options.timeoutMs ?? exports.DEFAULT_EXEC_TIMEOUT_MS;
516
+ const streamed = options.onStdout !== undefined || options.onStderr !== undefined;
517
+ if (streamed || timeoutMs === 0 || timeoutMs > exports.MAX_BLOCKING_EXEC_MS) {
518
+ const limit = timeoutMs === 0 ? null : timeoutMs;
519
+ const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, streamed);
520
+ const waitOptions = {};
521
+ if (options.onStdout)
522
+ waitOptions.onStdout = options.onStdout;
523
+ if (options.onStderr)
524
+ waitOptions.onStderr = options.onStderr;
525
+ if (options.signal)
526
+ waitOptions.signal = options.signal;
527
+ let result;
528
+ try {
529
+ result = await handle.waitRaw(waitOptions, outageMs);
530
+ }
531
+ catch (error) {
532
+ // Leaving the call (an abort, a callback that threw, polls that kept
533
+ // failing past pollOutageMs, or failed in a way waiting cannot mend)
534
+ // stops the command, as a blocking call would: the caller has
535
+ // no handle to it. Its files go only once the stop is confirmed: until
536
+ // then they are how the run is found and stopped by its id.
537
+ const stopped = await stopRun(toolbox, handle.id);
538
+ if (stopped)
539
+ await cleanupOnceGone(toolbox, handle.id);
540
+ // The caller's abort says what became of the command (the Python SDK
541
+ // has no signal), as does a poll that failed: with the network down,
542
+ // the stop may not have got through either. A callback's own error
543
+ // stays as it was thrown.
544
+ if (options.signal?.aborted && error instanceof errors_js_1.PrizedError && error.code === 'aborted') {
545
+ throw (0, outcome_js_1.runAborted)(error, handle.id, stopped, toolbox.sandboxId);
546
+ }
547
+ if (error instanceof errors_js_1.PrizedError) {
548
+ const poll = failedPolls.get(error);
549
+ // A run the wait saw end is not running, whatever became of the stop.
550
+ if (poll?.runId === handle.id)
551
+ throw (0, outcome_js_1.pollFailed)(error, handle.id, stopped || poll.ended, toolbox.sandboxId);
552
+ }
553
+ throw error;
554
+ }
555
+ await teardown(toolbox, (0, shell_js_1.cleanupScript)(handle.id));
556
+ return finished(result, limit, toolbox.sandboxId);
557
+ }
558
+ if (options.fullOutput) {
559
+ const result = await (0, output_js_1.execFullOutput)(toolbox, `exec_${randomHex(8)}`, { command: text, cwd, env: options.env, timeoutMs }, options.signal);
560
+ return finished(result, timeoutMs, toolbox.sandboxId);
561
+ }
562
+ // A command that reached the sandbox may have run even if its answer was
563
+ // lost, so a blocking exec is retried only when the edge proves it never
564
+ // delivered it.
565
+ const result = await (0, toolbox_js_1.rawExec)(toolbox, { command: text, cwd, env: options.env, timeoutMs }, { retry: 'exec', signal: options.signal });
566
+ return finished(result, timeoutMs, toolbox.sandboxId);
567
+ }
@@ -0,0 +1,73 @@
1
+ import type { ExecResult } from './types.js';
2
+ /** Where a member makes an API key. */
3
+ export declare const API_KEYS_URL = "https://prized.dev/dashboard/workspace/api-keys";
4
+ /** Where a newcomer signs up (free credit, no card) and gets one. */
5
+ export declare const SIGN_UP_URL = "https://prized.dev/onboarding/sandboxes";
6
+ /** The free tier's limits, and what lifts them. */
7
+ export declare const FREE_TIER_URL = "https://prized.dev/docs/sandboxes#free-tier";
8
+ /** The sentence an error with this code and no message of its own carries. */
9
+ export declare function defaultMessage(code: string): string;
10
+ export interface PrizedErrorInit {
11
+ /** The HTTP status, when an answer came back. */
12
+ status?: number | null;
13
+ /** The API's finer-grained reason, when it gave one. */
14
+ reason?: string | null;
15
+ sandboxId?: string | null;
16
+ /** The whole error document, when there was one. */
17
+ body?: Record<string, unknown> | null;
18
+ /** Seconds the API asked the caller to wait (Retry-After). */
19
+ retryAfter?: number | null;
20
+ cause?: unknown;
21
+ }
22
+ /**
23
+ * Every failure the SDK throws.
24
+ *
25
+ * - `status`: the HTTP status, or `null` when nothing came back (a network
26
+ * failure, a wait that timed out, a refused argument).
27
+ * - `code`: the API's error string, e.g. `paid_balance_required`,
28
+ * `insufficient_balance`, `sandbox_quota_exceeded`, `not_found`,
29
+ * `rate_limited`; or one of the SDK's own: `missing_api_key`,
30
+ * `invalid_api_key`, `invalid_argument`, `network_error`, `timeout`, `aborted`,
31
+ * `bad_response`, `http_error`, `sandbox_error`, `sandbox_deleted`,
32
+ * `command_failed`, `command_timeout` (thrown as CommandTimeoutError).
33
+ * - `reason`: the finer-grained reason, when there is one (for
34
+ * `sandbox_quota_exceeded` it names the quota: `sandbox_limit`,
35
+ * `paused_limit`, `running_vcpu`, `running_memory`, `retained_disk`,
36
+ * `max_runtime`).
37
+ */
38
+ export declare class PrizedError extends Error {
39
+ readonly status: number | null;
40
+ readonly code: string;
41
+ readonly reason: string | null;
42
+ readonly sandboxId: string | null;
43
+ readonly body: Record<string, unknown> | null;
44
+ readonly retryAfter: number | null;
45
+ constructor(code: string, message?: string | null, init?: PrizedErrorInit);
46
+ toString(): string;
47
+ }
48
+ /**
49
+ * A command ran past its timeout and was killed (`code` is
50
+ * `command_timeout`). The sandbox answered as it does for any command that
51
+ * ends, so nothing is lost: `result` is that answer, with what the command
52
+ * printed before it was killed, `exitCode: -1` and `timedOut: true`.
53
+ * `timeoutMs` is the limit, or null when the SDK did not set one.
54
+ */
55
+ export declare class CommandTimeoutError extends PrizedError {
56
+ readonly result: ExecResult;
57
+ readonly timeoutMs: number | null;
58
+ constructor(result: ExecResult, timeoutMs: number | null, sandboxId?: string | null);
59
+ /** What the command printed to stdout before it was killed. */
60
+ get stdout(): string;
61
+ /** What the command printed to stderr before it was killed. */
62
+ get stderr(): string;
63
+ }
64
+ /** Retry-After as seconds: delta-seconds or an HTTP date. */
65
+ export declare function parseRetryAfter(value: string | null, nowMs: number): number | null;
66
+ /**
67
+ * The error document → a PrizedError. Two envelopes are read: today's flat
68
+ * `{ "error": "code", "message"?, "reason"?, "field"? }` and the nested
69
+ * `{ "error": { "code", "message" } }`. Anything else (a proxy's HTML page)
70
+ * becomes `http_error` with the status. `path` is the request's URL path,
71
+ * which tells a file call's 404 from a sandbox's.
72
+ */
73
+ export declare function errorFromResponse(status: number, text: string, retryAfter: number | null, sandboxId: string | null, path?: string): PrizedError;