@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,360 @@
1
+ // sandbox.files: read, write, list, remove, upload, download.
2
+ import { PrizedError } from './errors.js';
3
+ import { request } from './http.js';
4
+ import { mayHaveActed, removeEffect, withOutcome, writeEffect } from './outcome.js';
5
+ import { existsScript, listScript, removeScript, sandboxPath, shellQuote, WORKSPACE_DIR } from './shell.js';
6
+ import { helperExec, MAX_FILE_BYTES, rawExec } from './toolbox.js';
7
+ /** The sandbox's file transfers take at most this long, retries included. */
8
+ export const FILE_TIMEOUT_MS = 10 * 60_000;
9
+ function byteSize(data) {
10
+ if (typeof data === 'string')
11
+ return new TextEncoder().encode(data).length;
12
+ if (data instanceof Uint8Array)
13
+ return data.byteLength;
14
+ if (data instanceof ArrayBuffer)
15
+ return data.byteLength;
16
+ return data.size;
17
+ }
18
+ async function nodeFs() {
19
+ try {
20
+ return (await import('node:fs/promises'));
21
+ }
22
+ catch (cause) {
23
+ throw new PrizedError('invalid_argument', 'upload() and download() need a local file system (Node, Bun or Deno); use write() and readBytes() instead.', { cause });
24
+ }
25
+ }
26
+ // A write goes up under a fresh name beside its target, and one command then
27
+ // renames it over the target, giving it the replaced file's permission bits
28
+ // first: uploaded straight over a file, the bytes would land 0600 on some
29
+ // sandbox images, and a 0755 script would stop running (exit 126). A new file
30
+ // keeps the mode the files API gave the staged one, its own rule for new
31
+ // files, so a write follows the same rules on every image. The Python SDK
32
+ // renders the same script (_core.py publish_command; sdk/file-scripts).
33
+ const HOME_DIR = '/home/prized';
34
+ /** A staged write's name, beside its target. */
35
+ const STAGING_PREFIX = '.prized-write-';
36
+ /** The publish script's own exits (the Python SDK's too). */
37
+ export const EXIT_TARGET_IS_LINK = 47;
38
+ export const EXIT_TARGET_NOT_REGULAR = 48;
39
+ /** How long removing a staged file that was never put in place may take. */
40
+ const DISCARD_MS = 10_000;
41
+ /** A write's own commands: a rename, a chmod or an rm. */
42
+ const WRITE_COMMAND_MS = 60_000;
43
+ /** For the staged upload: the target is changed only by the command that follows it. */
44
+ function stagedEffect(path) {
45
+ return {
46
+ maybe: `${path} was not changed (its new bytes may have reached the sandbox, but were not put in place)`,
47
+ did: `${path} was not changed yet`,
48
+ check: 'It was not sent again: write it again.',
49
+ };
50
+ }
51
+ /** For the command that puts the staged upload in place: a 2xx proves it ran, not how it ended. */
52
+ function publishEffect(path) {
53
+ return { ...writeEffect(path), did: `${path} may or may not have been written: the command that puts it in place ran` };
54
+ }
55
+ function randomHex(bytes) {
56
+ const out = new Uint8Array(bytes);
57
+ const webcrypto = globalThis.crypto;
58
+ if (webcrypto?.getRandomValues) {
59
+ webcrypto.getRandomValues(out);
60
+ }
61
+ else {
62
+ for (let i = 0; i < bytes; i += 1)
63
+ out[i] = Math.floor(Math.random() * 256);
64
+ }
65
+ return Array.from(out, (b) => b.toString(16).padStart(2, '0')).join('');
66
+ }
67
+ /**
68
+ * Where a write to `target` is staged: `clean` is the target as the files
69
+ * API reads it (`.`, `//` and a trailing `/` gone), and `staging` a fresh
70
+ * name in the same directory. Null when the files API has to judge the path
71
+ * itself (outside /home/prized, `..`, NUL): it is then uploaded as it is, and
72
+ * refused as it always was.
73
+ */
74
+ export function stagingFor(target, suffix = randomHex(16)) {
75
+ if (!target.startsWith('/') || target.includes('\0'))
76
+ return null;
77
+ const parts = target.split('/').filter((part) => part !== '' && part !== '.');
78
+ if (parts.includes('..'))
79
+ return null;
80
+ const clean = `/${parts.join('/')}`;
81
+ if (!clean.startsWith(`${HOME_DIR}/`))
82
+ return null;
83
+ return { clean, staging: `${clean.slice(0, clean.lastIndexOf('/') + 1)}${STAGING_PREFIX}${suffix}` };
84
+ }
85
+ /**
86
+ * The command that puts the staged upload `staging` in place at `target`
87
+ * (both absolute, in one directory) with one rename. A regular file there
88
+ * lends its permission bits (setuid, setgid and sticky are not carried) and,
89
+ * where the sandbox user may, its owner and group; `mode`, when given, sets
90
+ * the bits instead. With nothing there the staged file keeps the files API's
91
+ * mode for a new file. A link there exits 47 and a directory or other
92
+ * non-regular file exits 48, changing nothing. The command removes the
93
+ * staged file as it exits (unless it is killed).
94
+ */
95
+ export function publishScript(staging, target, mode) {
96
+ const chmod = (bits) => `chmod ${bits} -- "$src" || exit`;
97
+ return [
98
+ `src=${shellQuote(staging)}`,
99
+ `dst=${shellQuote(target)}`,
100
+ `trap 'rm -f -- "$src"' EXIT`,
101
+ // One lstat: the type, the bits and the owner come from the same file.
102
+ // `+`: the ids as numbers, never looked up as names.
103
+ `if s=$(stat -c '%f +%u:+%g' -- "$dst" 2> /dev/null); then`,
104
+ ` t=$((0x\${s%% *} & 0170000))`,
105
+ ` [ "$t" -eq $((0120000)) ] && exit ${EXIT_TARGET_IS_LINK}`,
106
+ ` [ "$t" -eq $((0100000)) ] || exit ${EXIT_TARGET_NOT_REGULAR}`,
107
+ ` chown -- "\${s#* }" "$src" 2> /dev/null || chgrp -- "\${s##*:}" "$src" 2> /dev/null`,
108
+ ...(mode === null ? [` ${chmod('"$(printf %o $((0x${s%% *} & 0777)))"')}`, 'fi'] : ['fi', chmod(mode.toString(8))]),
109
+ `mv -fT -- "$src" "$dst" || exit`,
110
+ // The rename is on disk before the answer, as an upload's is.
111
+ `sync -- "\${dst%/*}" 2> /dev/null`,
112
+ 'exit 0',
113
+ ].join('\n');
114
+ }
115
+ /** A command's stderr in one line, for an error message. */
116
+ function commandDetail(result) {
117
+ return result.stderr.trim().replace(/\s+/g, ' ').slice(0, 300) || `exit ${result.exitCode}`;
118
+ }
119
+ /** An upload or command answered 2xx whose body could not be read: it took effect. */
120
+ function landed(error) {
121
+ return error instanceof PrizedError && error.status !== null && error.status >= 200 && error.status < 300;
122
+ }
123
+ function checkMode(mode, sandboxId) {
124
+ if (mode === undefined)
125
+ return null;
126
+ if (typeof mode !== 'number' || !Number.isInteger(mode) || mode < 0 || mode > 0o777) {
127
+ throw new PrizedError('invalid_argument', `mode must be permission bits from 0 to 0o777 (0o755 for a script), got ${String(mode)}.`, { sandboxId });
128
+ }
129
+ return mode;
130
+ }
131
+ function parentDir(path) {
132
+ const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
133
+ return cut > 0 ? path.slice(0, cut) : null;
134
+ }
135
+ /** File operations on one sandbox. Paths are absolute, or relative to /home/prized/work. */
136
+ export class Files {
137
+ toolbox;
138
+ /** @internal */
139
+ constructor(toolbox) {
140
+ this.toolbox = toolbox;
141
+ }
142
+ url(op, path) {
143
+ return `${this.toolbox.base()}/files/${op}?path=${encodeURIComponent(sandboxPath(path))}`;
144
+ }
145
+ /** A file's contents as UTF-8 text. */
146
+ async read(path) {
147
+ return new TextDecoder('utf-8').decode(await this.readBytes(path));
148
+ }
149
+ /** A file's contents as bytes (at most 100 MiB). */
150
+ async readBytes(path) {
151
+ const result = await request(this.toolbox.config, {
152
+ method: 'GET',
153
+ url: this.url('download', path),
154
+ retry: 'safe',
155
+ read: 'bytes',
156
+ timeoutMs: FILE_TIMEOUT_MS,
157
+ deadline: this.toolbox.config.now() + FILE_TIMEOUT_MS,
158
+ sandboxId: this.toolbox.sandboxId,
159
+ });
160
+ return result.bytes ?? new Uint8Array(0);
161
+ }
162
+ /**
163
+ * Write a file (at most 100 MiB), replacing it whole if it exists. Missing
164
+ * parent directories are created. The path must be inside /home/prized.
165
+ * A file that is replaced keeps its mode, so a script made executable
166
+ * stays executable; a new file gets the sandbox's default (0600 in
167
+ * prized-base-v1 and prized-agent-v1). `mode` sets the permission bits either way.
168
+ * Sent again only if it provably never arrived: a write whose answer was
169
+ * lost throws, since it may have landed and the sandbox written the file
170
+ * since (read it back, or write again, as suits the file).
171
+ */
172
+ async write(path, data, options = {}) {
173
+ const sandboxId = this.toolbox.sandboxId;
174
+ const size = byteSize(data);
175
+ if (size > MAX_FILE_BYTES) {
176
+ throw new PrizedError('file_too_large', null, { sandboxId });
177
+ }
178
+ const mode = checkMode(options.mode, sandboxId);
179
+ const target = sandboxPath(path);
180
+ const plan = stagingFor(target);
181
+ if (plan === null)
182
+ return this.send(target, data, size, target, writeEffect(target));
183
+ const { clean, staging } = plan;
184
+ try {
185
+ await this.send(staging, data, size, clean, stagedEffect(clean));
186
+ }
187
+ catch (error) {
188
+ // A 2xx whose body could not be read: the staged bytes are all there.
189
+ if (!landed(error)) {
190
+ if (mayHaveActed(error))
191
+ await this.discard(staging);
192
+ throw error;
193
+ }
194
+ }
195
+ let result;
196
+ try {
197
+ // Like any write: a lost answer is not sent again (it may have renamed).
198
+ result = await this.run(publishScript(staging, clean, mode), { retry: 'exec', effect: publishEffect(clean) });
199
+ }
200
+ catch (error) {
201
+ // Unless it ran (a 2xx), the staged file may still be there; once it
202
+ // ran, the command removed it itself.
203
+ if (!landed(error))
204
+ await this.discard(staging);
205
+ throw error;
206
+ }
207
+ if (result.timedOut) {
208
+ await this.discard(staging); // killed, perhaps before it could remove it
209
+ throw withOutcome(new PrizedError('command_timeout', `Putting ${clean} in place timed out.`, { sandboxId }), writeEffect(clean), 'maybe');
210
+ }
211
+ if (result.exitCode === 0)
212
+ return { path: clean, size };
213
+ if (result.exitCode === EXIT_TARGET_IS_LINK) {
214
+ // A link: the files API follows it or refuses it, as for any upload.
215
+ const info = await this.send(clean, data, size, clean, writeEffect(clean));
216
+ if (mode !== null)
217
+ await this.chmod(clean, mode);
218
+ return info;
219
+ }
220
+ if (result.exitCode === EXIT_TARGET_NOT_REGULAR) {
221
+ throw new PrizedError('unsupported_file_type', `${clean} is a directory or another file that is not a regular file; only a regular file can be written.`, { sandboxId });
222
+ }
223
+ const denied = /Permission denied|Operation not permitted/.test(result.stderr);
224
+ throw new PrizedError(denied ? 'permission_denied' : 'command_failed', `Could not write ${clean}: ${commandDetail(result)}`, { sandboxId });
225
+ }
226
+ /** One upload to `path`, as the files API takes it; `target` is the file the caller asked for, and `effect` what the upload does to it. */
227
+ async send(path, data, size, target, effect) {
228
+ const result = await request(this.toolbox.config, {
229
+ method: 'POST',
230
+ url: this.url('upload', path),
231
+ body: data,
232
+ contentType: 'application/octet-stream',
233
+ // Like a command: a resend after the answer was lost could overwrite
234
+ // what the sandbox wrote to the path since.
235
+ retry: 'exec',
236
+ effect,
237
+ timeoutMs: FILE_TIMEOUT_MS,
238
+ deadline: this.toolbox.config.now() + FILE_TIMEOUT_MS,
239
+ sandboxId: this.toolbox.sandboxId,
240
+ });
241
+ const doc = (result.json ?? {});
242
+ return {
243
+ path: path === target && typeof doc.path === 'string' ? doc.path : target,
244
+ size: typeof doc.size === 'number' ? doc.size : size,
245
+ };
246
+ }
247
+ /**
248
+ * Remove a staged upload that was not put in place (best effort: the
249
+ * write's own error matters more). Sent again like a read, unlike other
250
+ * removes: the name is this call's own, so a second rm finds nothing
251
+ * anyone else made.
252
+ */
253
+ async discard(staging) {
254
+ try {
255
+ await this.run(removeScript(staging, false), { retry: 'safe', deadline: this.toolbox.config.now() + DISCARD_MS });
256
+ }
257
+ catch {
258
+ // The write's error is the one to report.
259
+ }
260
+ }
261
+ async chmod(path, mode) {
262
+ const result = await this.run(`chmod ${mode.toString(8)} -- ${shellQuote(path)}`, { retry: 'safe' });
263
+ if (result.exitCode !== 0) {
264
+ throw new PrizedError('command_failed', `${path} was written, but its mode could not be set: ${commandDetail(result)}`, {
265
+ sandboxId: this.toolbox.sandboxId,
266
+ });
267
+ }
268
+ }
269
+ /** One of a write's own commands, run in the home, which is always there (the workspace may not be). */
270
+ run(script, options) {
271
+ return rawExec(this.toolbox, { command: script, cwd: HOME_DIR, timeoutMs: WRITE_COMMAND_MS }, options);
272
+ }
273
+ /** A directory's entries, sorted by name (default: /home/prized/work). */
274
+ async list(path = WORKSPACE_DIR) {
275
+ const dir = sandboxPath(path).replace(/(.)\/+$/, '$1');
276
+ const result = await helperExec(this.toolbox, listScript(dir));
277
+ if (result.exitCode === 20) {
278
+ throw new PrizedError('not_directory', `${dir} is not a directory.`, { sandboxId: this.toolbox.sandboxId });
279
+ }
280
+ if (result.exitCode === 21) {
281
+ throw new PrizedError('not_found', `${dir} does not exist.`, { sandboxId: this.toolbox.sandboxId });
282
+ }
283
+ if (result.exitCode !== 0) {
284
+ throw new PrizedError('command_failed', result.stderr.trim() || `list exited ${result.exitCode}`, {
285
+ sandboxId: this.toolbox.sandboxId,
286
+ });
287
+ }
288
+ if (result.truncated) {
289
+ // Past the per-call output cap the listing is incomplete: say so rather
290
+ // than return a short array that looks whole.
291
+ throw new PrizedError('command_failed', `${dir} has too many entries to list in one call; list a narrower directory.`, {
292
+ sandboxId: this.toolbox.sandboxId,
293
+ });
294
+ }
295
+ const entries = [];
296
+ for (const rec of result.stdout.split('\0')) {
297
+ if (rec === '')
298
+ continue;
299
+ const parts = rec.split('\t');
300
+ if (parts.length < 5)
301
+ continue;
302
+ const [type, size, mode, mtime] = parts;
303
+ const name = parts.slice(4).join('\t');
304
+ const seconds = Number(mtime);
305
+ entries.push({
306
+ name,
307
+ path: dir === '/' ? `/${name}` : `${dir}/${name}`,
308
+ size: Number(size) || 0,
309
+ isDir: type === 'd',
310
+ isSymlink: type === 'l',
311
+ mode,
312
+ modified: Number.isFinite(seconds) ? new Date(Math.round(seconds * 1000)) : null,
313
+ });
314
+ }
315
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
316
+ return entries;
317
+ }
318
+ /** Whether a path exists (a file, a directory, or a link, even a dangling one). */
319
+ async exists(path) {
320
+ const result = await helperExec(this.toolbox, existsScript(sandboxPath(path)));
321
+ if (result.exitCode === 0)
322
+ return true;
323
+ if (result.exitCode === 1)
324
+ return false;
325
+ throw new PrizedError('command_failed', result.stderr.trim() || `exists exited ${result.exitCode}`, {
326
+ sandboxId: this.toolbox.sandboxId,
327
+ });
328
+ }
329
+ /** Remove a file, or a directory tree with `recursive: true`. A missing path is not an error. */
330
+ async remove(path, options = {}) {
331
+ const target = sandboxPath(path);
332
+ // Command retry rules: a lost answer is not sent again, or a path the
333
+ // sandbox recreated in the meantime would be deleted too.
334
+ const result = await helperExec(this.toolbox, removeScript(target, options.recursive === true), {
335
+ retry: 'exec',
336
+ effect: removeEffect(target),
337
+ });
338
+ if (result.exitCode !== 0) {
339
+ throw new PrizedError('command_failed', result.stderr.trim() || `rm exited ${result.exitCode}`, {
340
+ sandboxId: this.toolbox.sandboxId,
341
+ });
342
+ }
343
+ }
344
+ /** Copy a local file into the sandbox (Node, Bun, Deno), as write() does. */
345
+ async upload(localPath, remotePath, options = {}) {
346
+ const fs = await nodeFs();
347
+ const data = await fs.readFile(localPath);
348
+ return this.write(remotePath, new Uint8Array(data.buffer, data.byteOffset, data.byteLength), options);
349
+ }
350
+ /** Copy a file out of the sandbox to a local path (Node, Bun, Deno). Returns the byte count. */
351
+ async download(remotePath, localPath) {
352
+ const fs = await nodeFs();
353
+ const bytes = await this.readBytes(remotePath);
354
+ const parent = parentDir(localPath);
355
+ if (parent !== null)
356
+ await fs.mkdir(parent, { recursive: true });
357
+ await fs.writeFile(localPath, bytes);
358
+ return bytes.byteLength;
359
+ }
360
+ }
@@ -0,0 +1,210 @@
1
+ import { PrizedError } from './errors.js';
2
+ import { type Effect, type Outcome } from './outcome.js';
3
+ export declare const DEFAULT_BASE_URL = "https://api.prized.dev";
4
+ export declare const DEFAULT_EDGE_URL = "https://term.prized.dev";
5
+ /** Options every entry point accepts. All optional. */
6
+ export interface ClientOptions {
7
+ /** Your API key (`dcp_…`). Default: the `PRIZED_API_KEY` environment variable. */
8
+ apiKey?: string;
9
+ /** The API origin. Default: `https://api.prized.dev`. */
10
+ baseUrl?: string;
11
+ /**
12
+ * The origin that serves exec and files. Default: the `toolboxBase` the API
13
+ * returns for each sandbox (today `https://term.prized.dev`).
14
+ */
15
+ edgeUrl?: string;
16
+ /** A fetch implementation. Default: the global `fetch`. */
17
+ fetch?: typeof fetch;
18
+ /** Retries after the first attempt for retryable failures. Default 3. */
19
+ maxRetries?: number;
20
+ /** Per-request timeout for API calls, in ms. Default 60 000. */
21
+ requestTimeoutMs?: number;
22
+ /** The longest the SDK's own polling goes between checks, in ms (the first come sooner); excludes
23
+ * the request itself and the API's hold of a create, pause or resume. Default 500. */
24
+ pollIntervalMs?: number;
25
+ /**
26
+ * How long a streamed or long `exec` keeps polling while its polls fail
27
+ * (no answer, a 5xx, a rate limit), in ms, before it gives up and stops
28
+ * the command. Default 120 000. `0` gives up once one poll's retries run
29
+ * out. `exec` takes it per call too.
30
+ */
31
+ pollOutageMs?: number;
32
+ /** Sleep seam (tests inject one that never really waits). */
33
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
34
+ /** Clock seam: epoch milliseconds. Default `Date.now`. */
35
+ now?: () => number;
36
+ /** Jitter seam: a number in [0, 1). Default `Math.random`. */
37
+ random?: () => number;
38
+ }
39
+ export interface Config {
40
+ apiKey: string;
41
+ baseUrl: string;
42
+ edgeUrl: string | null;
43
+ fetch: typeof fetch;
44
+ maxRetries: number;
45
+ requestTimeoutMs: number;
46
+ pollIntervalMs: number;
47
+ pollOutageMs: number;
48
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
49
+ now: () => number;
50
+ random: () => number;
51
+ }
52
+ export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
53
+ /**
54
+ * How long a streamed or long exec keeps polling while its polls fail
55
+ * before it stops the command (pollOutageMs): long enough to ride out a
56
+ * dropped connection or an edge that restarts. The Python SDK's
57
+ * DEFAULT_POLL_OUTAGE.
58
+ */
59
+ export declare const DEFAULT_POLL_OUTAGE_MS = 120000;
60
+ /** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
61
+ export declare function validOutage(value: number | undefined): number | undefined;
62
+ export declare function resolveConfig(options?: ClientOptions): Config;
63
+ /**
64
+ * Where exec and files go for one sandbox. An explicit edgeUrl wins; else the
65
+ * API's own `toolboxBase` when it has the expected shape (https, or plain
66
+ * http on loopback for local development); else the default edge.
67
+ */
68
+ export declare function toolboxBaseFor(config: Config, sandboxId: string, toolboxBase: string | null): string;
69
+ /**
70
+ * The edge this config's sandboxes answer on, before the API has named one:
71
+ * an explicit edgeUrl, else the default edge for the default API. null for
72
+ * any other API, whose edge is known only from its answer.
73
+ */
74
+ export declare function expectedEdge(config: Config): string | null;
75
+ /**
76
+ * Open a connection to the edge before the first command needs it: one
77
+ * `GET /healthz` without the API key (the route needs none), its body read
78
+ * so the connection goes back to the pool. Sent beside a held create, it
79
+ * takes DNS, TCP and TLS off the first command after the sandbox runs. Best
80
+ * effort: never throws, and an edge it cannot reach costs only itself.
81
+ */
82
+ export declare function warmEdge(config: Config): Promise<void>;
83
+ /**
84
+ * Which failures a request may retry (sdk/retry-spec.json, which the
85
+ * Python SDK shares, lists which calls take which):
86
+ * - `safe`: the request can be repeated without a second effect (reads,
87
+ * state verbs, creates and forks that carry an Idempotency-Key).
88
+ * Retries network failures, 429/502/503/504, and everything `exec`
89
+ * retries (the edge's 500 control_plane_unavailable among them). A GET
90
+ * retries any 500 too (the API's `internal`).
91
+ * - `exec`: a command, an upload or a remove: what the sandbox does
92
+ * meanwhile could be undone by a second copy. Retried only when it
93
+ * provably never arrived: a connection that never opened, or an answer
94
+ * saying so (the edge's rate limit, its lookup failing). A lost answer
95
+ * is never retried: it may already have run.
96
+ */
97
+ export type RetryMode = 'safe' | 'exec';
98
+ /**
99
+ * Whether a fetch that threw never sent its request: the connection was
100
+ * refused, the name did not resolve, or connecting timed out. Anything else
101
+ * (a reset, a timeout once connected, a runtime that names no cause) may
102
+ * have been delivered.
103
+ */
104
+ export declare function neverSent(error: unknown, depth?: number): boolean;
105
+ export declare function retryable(error: PrizedError, mode: RetryMode, method: RequestSpec['method']): boolean;
106
+ export interface RequestSpec {
107
+ method: 'GET' | 'POST' | 'DELETE';
108
+ /** The URL, or one built per attempt from the ms left before `deadline`
109
+ * (Infinity without one): a held request asks only for what is left. */
110
+ url: string | ((leftMs: number) => string);
111
+ json?: unknown;
112
+ body?: string | Uint8Array | ArrayBuffer | Blob;
113
+ contentType?: string;
114
+ headers?: Record<string, string>;
115
+ retry: RetryMode;
116
+ timeoutMs?: number;
117
+ /**
118
+ * Epoch ms (config.now()) bounding the whole call: each attempt's timeout
119
+ * is cut to what is left, and no retry starts past it.
120
+ */
121
+ deadline?: number | undefined;
122
+ signal?: AbortSignal | undefined;
123
+ sandboxId?: string | null;
124
+ /** How to read a 2xx body. Default `json`. */
125
+ read?: 'json' | 'bytes' | 'none';
126
+ /**
127
+ * Told of every attempt that failed (no answer, an error answer, or a 2xx
128
+ * that could not be read), before it is retried or thrown. A create or
129
+ * fork judges by them all whether it may have made sandboxes: the error
130
+ * thrown is only the last attempt's.
131
+ */
132
+ onAttemptFailed?: (error: PrizedError) => void;
133
+ /**
134
+ * What the request does in the sandbox (`exec`: a command unless given), so
135
+ * the error thrown once it may have arrived says whether that happened
136
+ * (outcome.ts). Given for a `safe` request (the keyed background start),
137
+ * its error says so too.
138
+ */
139
+ effect?: Effect;
140
+ }
141
+ export interface HttpResult {
142
+ status: number;
143
+ json: unknown;
144
+ bytes: Uint8Array | null;
145
+ }
146
+ /** The first retry's backoff; each later one doubles, up to MAX_BACKOFF_MS (jittered down to half). */
147
+ export declare const BACKOFF_FIRST_MS = 500;
148
+ export declare const MAX_BACKOFF_MS = 8000;
149
+ /**
150
+ * The longest Retry-After the SDK waits for, the Python SDK's too: twice the
151
+ * longest the API asks for on a route the SDK calls (30 s, with 503
152
+ * sandbox_capacity; the edge asks for 1 s). A longer one is still on the
153
+ * error (`retryAfter`) if the retries run out.
154
+ */
155
+ export declare const MAX_RETRY_AFTER_MS = 60000;
156
+ /**
157
+ * The bound on a call the caller gives no timeout (connect, list, templates,
158
+ * refresh, keepAlive), retries included: twice requestTimeoutMs, room for one
159
+ * attempt that runs out and the retries after it.
160
+ */
161
+ export declare function callDeadline(config: Config): number;
162
+ /**
163
+ * Whether request() ended because the call's deadline came first: no time
164
+ * was left, or the retry it was due could not start in time. A wait relabels
165
+ * only these as its own timeout; an attempt that got no answer while time
166
+ * was left is the API not answering, and is thrown as it is.
167
+ */
168
+ export declare function cutByDeadline(error: unknown): error is PrizedError;
169
+ /** The least room a wait leaves before its deadline for its last poll to be answered. */
170
+ export declare const LAST_LOOK_MS = 100;
171
+ /** The most: a slow read never makes a wait give up its last seconds. */
172
+ export declare const LAST_LOOK_MAX_MS = 2000;
173
+ /**
174
+ * A wait's polls, paced so the last goes out just before the deadline, with
175
+ * room for its answer: the slowest read of this wait so far plus
176
+ * LAST_LOOK_MS (at most LAST_LOOK_MAX_MS). Without it a wait whose last
177
+ * pause ran to the deadline gave up without looking, and one shorter than a
178
+ * poll interval never looked at all.
179
+ */
180
+ export declare class Polls {
181
+ private readonly config;
182
+ private readonly deadline;
183
+ private lastLeft;
184
+ private sentAt;
185
+ private slowest;
186
+ constructor(config: Config, deadline: number);
187
+ private room;
188
+ /**
189
+ * How long to pause before the next poll: `delayMs`, cut so the poll goes
190
+ * out no later than the room it needs before the deadline (at once when
191
+ * less is left). null when the wait is over: the time is up, or the last
192
+ * look was taken (the previous poll went out with no more than that room).
193
+ */
194
+ pause(delayMs: number): number | null;
195
+ /** A poll is going out now. */
196
+ sent(): void;
197
+ /** Its answer came back. */
198
+ answered(): void;
199
+ }
200
+ /** Delay before retry number `attempt` (0-based): exponential with jitter. */
201
+ export declare function backoffMs(config: Config, attempt: number): number;
202
+ /**
203
+ * What an error answer (not a 2xx: request() judges those) says about
204
+ * whether the sandbox acted: `never` for the codes that prove the request
205
+ * never arrived, `maybe` for any other 5xx, null for a refusal (4xx), whose
206
+ * own words say what happened.
207
+ */
208
+ export declare function outcomeOf(error: PrizedError): Outcome;
209
+ /** One API or edge call, retried per `spec.retry`. Throws PrizedError. */
210
+ export declare function request(config: Config, spec: RequestSpec): Promise<HttpResult>;