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