@prized/sdk 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +76 -21
  2. package/dist/cjs/commands.d.ts +47 -14
  3. package/dist/cjs/commands.js +94 -26
  4. package/dist/cjs/errors.d.ts +43 -3
  5. package/dist/cjs/errors.js +54 -6
  6. package/dist/cjs/files.d.ts +21 -5
  7. package/dist/cjs/files.js +38 -14
  8. package/dist/cjs/http.d.ts +74 -15
  9. package/dist/cjs/http.js +208 -32
  10. package/dist/cjs/index.d.ts +3 -3
  11. package/dist/cjs/index.js +2 -1
  12. package/dist/cjs/outcome.d.ts +11 -1
  13. package/dist/cjs/outcome.js +28 -5
  14. package/dist/cjs/output.d.ts +19 -8
  15. package/dist/cjs/output.js +152 -56
  16. package/dist/cjs/sandbox.d.ts +3 -2
  17. package/dist/cjs/sandbox.js +40 -5
  18. package/dist/cjs/shell.d.ts +2 -1
  19. package/dist/cjs/shell.js +2 -1
  20. package/dist/cjs/toolbox.d.ts +2 -0
  21. package/dist/cjs/toolbox.js +4 -1
  22. package/dist/cjs/types.d.ts +13 -1
  23. package/dist/cjs/version.d.ts +1 -1
  24. package/dist/cjs/version.js +1 -1
  25. package/dist/esm/commands.d.ts +47 -14
  26. package/dist/esm/commands.js +95 -27
  27. package/dist/esm/errors.d.ts +43 -3
  28. package/dist/esm/errors.js +52 -5
  29. package/dist/esm/files.d.ts +21 -5
  30. package/dist/esm/files.js +39 -15
  31. package/dist/esm/http.d.ts +74 -15
  32. package/dist/esm/http.js +207 -31
  33. package/dist/esm/index.d.ts +3 -3
  34. package/dist/esm/index.js +1 -1
  35. package/dist/esm/outcome.d.ts +11 -1
  36. package/dist/esm/outcome.js +27 -5
  37. package/dist/esm/output.d.ts +19 -8
  38. package/dist/esm/output.js +153 -58
  39. package/dist/esm/sandbox.d.ts +3 -2
  40. package/dist/esm/sandbox.js +40 -5
  41. package/dist/esm/shell.d.ts +2 -1
  42. package/dist/esm/shell.js +2 -1
  43. package/dist/esm/toolbox.d.ts +2 -0
  44. package/dist/esm/toolbox.js +4 -1
  45. package/dist/esm/types.d.ts +13 -1
  46. package/dist/esm/version.d.ts +1 -1
  47. package/dist/esm/version.js +1 -1
  48. package/package.json +1 -1
package/README.md CHANGED
@@ -47,7 +47,9 @@ No runtime dependencies. ESM and CommonJS, with type declarations.
47
47
  Make an API key on the [API keys page](https://prized.dev/dashboard/workspace/api-keys)
48
48
  and put it in `PRIZED_API_KEY`, or pass it in. New to Prized?
49
49
  [Open Sandboxes](https://prized.dev/onboarding/sandboxes) signs you up (free
50
- credit, no card) and makes one.
50
+ credit, no card) and makes one. Or your coding agent can sign you up itself
51
+ with your email and a 6-digit code: have it follow
52
+ [prized.dev/auth.md](https://prized.dev/auth.md).
51
53
 
52
54
  ```ts
53
55
  const sandbox = await Sandbox.create({ apiKey: 'dcp_…' });
@@ -58,19 +60,31 @@ to fewer permissions needs **Sandboxes: Write** to create, run commands and
58
60
  move files; **Sandboxes: Read** is enough for `list`, `connect`, `refresh` and
59
61
  `templates`.
60
62
 
63
+ Your coding agent can install the Prized skill with `npx skills add prized-dev/skills -g -y` (it should ask you first), so it knows when and how to use Prized the next time you ask for a sandbox.
64
+
61
65
  Every entry point (`create`, `connect`, `list`, `deleteByLabels`, `templates`) takes the same
62
66
  client options:
63
67
 
64
68
  | Option | Default | |
65
69
  |---|---|---|
66
70
  | `apiKey` | `process.env.PRIZED_API_KEY` | |
67
- | `baseUrl` | `https://api.prized.dev` | The API origin. |
68
- | `edgeUrl` | the sandbox's own `toolboxBase` | Where commands and files go. |
71
+ | `baseUrl` | `https://api.prized.dev` | The API origin: `https`, or plain `http` on `localhost`, `127.0.0.1` or `[::1]`. |
72
+ | `edgeUrl` | the sandbox's own `toolboxBase` | Where commands and files go, by the same rule. Pass it for another deployment whose API names its command endpoint on another host. |
69
73
  | `fetch` | global `fetch` | Bring your own (for a proxy, custom agent, tests). |
70
74
  | `maxRetries` | `3` | See [Retries](#retries-and-timeouts). |
71
75
  | `requestTimeoutMs` | `60000` | Per API request. |
72
76
  | `pollIntervalMs` | `500` | The longest the SDK's own polling goes between checks of the sandbox (the first come sooner). It does not cover the request itself, or the time the API holds a create, pause or resume for you (up to 8 seconds). |
73
- | `pollOutageMs` | `120000` | How long a streamed or long `exec` keeps polling through a dropped connection (or a 5xx, or a rate limit) before it stops the command. `exec` takes it per call too. |
77
+ | `pollOutageMs` | `120000` | How long a wait keeps polling through a dropped connection (or a 5xx, or a rate limit) before it gives up: a streamed or long `exec` then stops the command, a handle's `wait()` throws. A background, streamed or long `exec` keeps sending its start for as long. `exec` and `wait()` take it per call too. |
78
+
79
+ Your API key goes with every request, so a plain `http` URL to a host other
80
+ than this machine is refused with `invalid_argument` before anything is sent,
81
+ whatever `fetch` you pass. Without `edgeUrl`, a sandbox's `toolboxBase` is used
82
+ only on the API's own host, on Prized's own hosts beside Prized's API, or on
83
+ this machine beside a local API. Otherwise commands and files go to Prized's
84
+ edge for Prized's API; for any other API they throw `edge_unknown`, so its key
85
+ never reaches Prized's edge. Take `baseUrl`, `edgeUrl` and `fetch` only from
86
+ configuration you trust: what they name gets your key, your commands and your
87
+ files.
74
88
 
75
89
  A sandbox remembers the options it was made with: its methods need none.
76
90
 
@@ -169,8 +183,10 @@ await Promise.all(mine.map((s) => s.delete()));
169
183
  ### `Sandbox.deleteByLabels(labels, options?)`
170
184
 
171
185
  Deletes every sandbox carrying all of `labels` (at least one) in one call and
172
- resolves with their ids. It is safe to repeat (one already deleting is listed
173
- again; none matching resolves `[]`). A sandbox being deleted still counts
186
+ resolves with their ids. Each request selects the current matches, including
187
+ sandboxes created since an earlier request. If an answer is lost, the SDK
188
+ throws without repeating the delete; list the matches before deleting again.
189
+ A sandbox being deleted still counts
174
190
  toward your quota until `Sandbox.list({ labels })` no longer returns it.
175
191
 
176
192
  ```ts
@@ -212,6 +228,7 @@ r.stderr; // string, kept separate
212
228
  r.exitCode; // number
213
229
  r.durationMs;
214
230
  r.truncated; // true if stdout or stderr hit the 4 MiB per-call cap
231
+ r.truncatedReason; // why, when truncated: 'output_limit' or 'write_failed'
215
232
 
216
233
  await sandbox.exec(['git', 'commit', '-m', "it's done"]); // quoted for you
217
234
  ```
@@ -225,6 +242,14 @@ small; past 4 MiB the SDK reads it back from files in
225
242
  ends gets `exitCode` 128+n (a plain exec may give -1), as a background command
226
243
  does.
227
244
 
245
+ `truncatedReason` says why a result is cut: `output_limit` for a stream
246
+ that reached its cap, `write_failed` when the sandbox could not save a
247
+ stream as the command printed it (most often a full disk), so it stops where
248
+ the write failed. If the output can't be read back afterwards, the call
249
+ throws `OutputReadError` (`code` `output_read_failed`): the command ran
250
+ (`err.exitCode`), `err.reason` says what stopped the read, and the output
251
+ stays at `err.stdoutPath` and `err.stderrPath` for an hour.
252
+
228
253
  ```ts
229
254
  const { stdout, exitCode } = await sandbox.exec('npm test', { timeoutMs: 180_000, fullOutput: true });
230
255
  ```
@@ -266,7 +291,9 @@ off by an idle connection and its output is never truncated. The result is
266
291
  the same either way. If the connection drops meanwhile, the SDK keeps
267
292
  polling for up to 2 minutes (`pollOutageMs`, on the call or the client)
268
293
  before it gives up and stops the command; a refused key, or an answer that
269
- settles it (the sandbox deleted or not running), stops it at once.
294
+ settles it (the sandbox deleted or not running), stops it at once. Its
295
+ start rides one out the same way: it is sent again for up to `pollOutageMs`,
296
+ and a resend finds the first start, so the command runs once.
270
297
  Background and streamed commands run with `PYTHONUNBUFFERED=1` unless
271
298
  their environment already sets it (`env: { PYTHONUNBUFFERED: '' }` keeps
272
299
  Python's buffering), so a Python program's output arrives as it prints.
@@ -280,7 +307,11 @@ you need into your home: Python packages into a venv
280
307
 
281
308
  ## Background commands
282
309
 
283
- `background: true` starts the command and returns a `CommandHandle` at once.
310
+ `background: true` starts the command and returns a `CommandHandle` once it
311
+ has started. A start that meets a dropped connection or a 5xx is sent again
312
+ for up to `pollOutageMs` once its own retries run out (a resend finds the
313
+ first start, so the command runs once), so the call can take that long while
314
+ the connection is down.
284
315
  The command runs in its own session: it keeps running after your process
285
316
  exits, survives a pause and resume, and while it runs, a kill reaches every
286
317
  process it started (not one that left its process group, such as a daemon).
@@ -306,6 +337,13 @@ result.exitCode;
306
337
  await job.cleanup(); // remove its output files from the sandbox
307
338
  ```
308
339
 
340
+ `wait()` rides out a dropped connection too: while its polls fail (no
341
+ answer, a 5xx, a rate limit) it keeps polling for up to `pollOutageMs` (2
342
+ minutes by default, on the call or the client; while the command runs,
343
+ never past its `timeoutMs`) before it throws what they met. A refused key,
344
+ or an answer that settles the run, throws at once. Either way the command
345
+ keeps running and the handle stays good: call `wait()` again.
346
+
309
347
  A background command has no time limit unless you give `timeoutMs`
310
348
  (enforced to the second: the command gets SIGTERM, then SIGKILL five seconds
311
349
  later, and `wait()` throws `CommandTimeoutError`). Its output is kept under
@@ -321,9 +359,10 @@ sandbox's disk there, so redirect such output yourself.
321
359
  ## Files
322
360
 
323
361
  `sandbox.files` reads and writes the sandbox's disk. Paths are absolute or
324
- relative to `/home/prized/work`. Writes, reads, uploads and downloads must
325
- stay inside `/home/prized`, are at most 100 MiB each, and do not follow
326
- symbolic links. A write replaces the file whole, and a file it replaces keeps
362
+ relative to `/home/prized/work`. Writes, reads, uploads and downloads are at
363
+ most 100 MiB each, and their paths in the sandbox must stay inside
364
+ `/home/prized` and not go through a symbolic link (`symlink_not_allowed`).
365
+ A write replaces the file whole, and a file it replaces keeps
327
366
  its mode, so a script made executable stays executable; a new file gets the
328
367
  sandbox's default (`0600` in `prized-base-v1` and `prized-agent-v1`). `mode` sets the permission
329
368
  bits.
@@ -511,12 +550,14 @@ try {
511
550
  | `file_too_large` | 413 | Over the 100 MiB transfer limit. |
512
551
  | `idempotency_conflict` | 409 | The idempotency key was used for a different request. |
513
552
  | `sandbox_error` | | The sandbox failed while you waited; `reason` says why. |
514
- | `sandbox_deleted` | 410 | The sandbox was deleted before or while a command or file call ran; a command that was running stopped with it. `pause()` and `resume()` of a sandbox being deleted throw it too. Not retried. A wait that finds the sandbox deleted throws it too, with no status. |
553
+ | `sandbox_deleted` | 410 | The sandbox was deleted before or while a command or file call ran; a command that was running stopped with it. `pause()` and `resume()` of a sandbox being deleted throw it too. Not retried. A wait that finds the sandbox deleted throws it too, with no status. When a `fullOutput` command ran and the sandbox went before its output was read back, `err.exitCode` is the command's exit code (null otherwise): it ran, so don't run it again blindly in a new sandbox. |
515
554
  | `command_timeout` | | A command ran past its own `timeoutMs` and was killed. Thrown as `CommandTimeoutError`; `err.result` has what it printed. |
555
+ | `output_read_failed` | | A `fullOutput` command ran, but its output could not be read back. Thrown as `OutputReadError`: `err.exitCode` is the command's, `err.reason` the code of what stopped the read (also in `cause`), and the output stays at `err.stdoutPath` / `err.stderrPath` for an hour. The sandbox is not known to be gone (a deleted one throws `sandbox_deleted`); don't run the command again blindly. |
516
556
  | `timeout` | | A wait ran past its `timeoutMs`, or a call past its bound ([Retries](#retries-and-timeouts)). One that ended because a retry could not start in time says what the last attempt met (also in `cause`). |
517
557
  | `network_error` | | No answer: the API or the sandbox could not be reached, or the connection dropped. |
518
558
  | `bad_response` | 2xx | An answer the SDK could not read. For a command or file write it did happen, and the message says so. |
519
559
  | `missing_api_key` / `invalid_api_key` / `invalid_argument` | | Fix the call. `invalid_api_key`: the key has characters no key has (the docs' placeholder, a stray character); it is never quoted back. |
560
+ | `edge_unknown` | | A `baseUrl` other than Prized's whose API named no command endpoint the SDK trusts (on the API's own host, or on this machine beside a local API): pass `edgeUrl`. The command or file call was not sent. |
520
561
  | `command_failed` | | A file helper (`list`, `remove`, `exists`) failed in the sandbox; `message` has its stderr. |
521
562
 
522
563
  ## Retries and timeouts
@@ -534,15 +575,23 @@ try {
534
575
  `resume`, `fork` and `delete` (the request, its retries and the wait, its
535
576
  polls included; with `wait: false` too), twice `requestTimeoutMs` (2
536
577
  minutes by default) for `connect`, `list`, `templates`, `refresh` and
537
- `keepAlive`, and 10 minutes for a file transfer. `Retry-After` is honoured
578
+ `keepAlive`, and 10 minutes for a file transfer, whose attempt is given up
579
+ after 2 minutes with no bytes moving (a dropped connection) and sent again
580
+ by the rules below (on Node's own `fetch`; elsewhere, or with a `fetch` you
581
+ pass, an upload is watched only once its answer starts). `Retry-After` is honoured
538
582
  up to a minute. A retry that could not start in time is not sent: the call
539
583
  throws `timeout`. A wait's last poll goes out just before its deadline.
540
- - A command, an upload (`files.write`, `files.upload`), a `files.remove` or
541
- a `kill` is sent again only when it provably never arrived: the connection never
542
- opened, or the answer says so (rate limited, the sandbox not ready). If an
543
- answer is lost after it was delivered, the SDK throws
544
- (`sandbox_unreachable`, `network_error`, `timeout`) rather than risk running a command
545
- twice or overwriting what the sandbox wrote to the file since.
584
+ - A command, a `files.remove`, a `kill` or `deleteByLabels` is sent again only when it
585
+ provably never arrived: the connection never opened, or the answer says so
586
+ (rate limited, the sandbox not ready). If an answer is lost after it was
587
+ delivered, the SDK throws (`sandbox_unreachable`, `network_error`,
588
+ `timeout`) rather than risk repeating the operation on work that changed.
589
+ - A file write (`files.write`, `files.upload`) goes up under a temporary name
590
+ beside the file, which is sent again like a read (a lost answer, a 502, 503
591
+ or 504, 2 minutes with no bytes moving), every copy to the same name and
592
+ none after the transfer's 10 minutes. One command that is never
593
+ sent again then puts it in place: if that command's answer is lost, the SDK
594
+ throws rather than risk overwriting what the sandbox wrote to the file since.
546
595
  - Did it run? When a command answered in one call (`exec` without
547
596
  `background`, `onStdout` or `onStderr`, and a `timeoutMs` other than 0 and
548
597
  at most 4 minutes), a file write or a `files.remove` fails without a plain refusal,
@@ -552,10 +601,14 @@ try {
552
601
  clear answer came back (a dropped connection, a timeout, or any other 5xx):
553
602
  it may or may not have run, and the message says how to check (read the
554
603
  file back, `files.exists()`). A 2xx came back that could not be read
555
- (`bad_response`): it ran. A `kill` says the same of its signal
604
+ (`bad_response`): it ran. A write whose upload got no clear answer says the file was
605
+ not changed (its new bytes may have reached the sandbox under the
606
+ temporary name, which the SDK then removes): write it again. A `kill`
607
+ says the same of its signal
556
608
  (`status()` tells you whether the command is still running). A background
557
609
  command (or a streamed or long `exec`, which runs as one) whose start got
558
- no clear answer is stopped by the SDK, and the error says so, or, when the
610
+ no clear answer for longer than `pollOutageMs` is stopped by the SDK, and
611
+ the error says so, or, when the
559
612
  SDK could not confirm the stop, that it may still be running and under
560
613
  which run id. So is a streamed or long `exec` whose polling fails while
561
614
  it runs (a dropped connection or a 5xx for longer than `pollOutageMs`, a
@@ -563,6 +616,8 @@ try {
563
616
  poll's own error as its `cause`, and says whether the SDK stopped the
564
617
  command or it may still be running. An error that settles it (the
565
618
  sandbox deleted or not running, the run not found) is thrown as it came.
619
+ A handle's `wait()` whose polls fail for longer than `pollOutageMs` throws
620
+ the poll's own error; it stops nothing.
566
621
  - Deleting what a failed create or fork made (a few seconds) comes on top
567
622
  of `timeoutMs`.
568
623
  - `create`, `exec` and waits take an `AbortSignal` (`signal`). Aborting a
@@ -19,20 +19,34 @@ export declare const MAX_BLOCKING_EXEC_MS = 240000;
19
19
  export declare function ridesOut(error: PrizedError): boolean;
20
20
  /**
21
21
  * The failed polls of a wait that rides out an outage of up to `windowMs`
22
- * (exec's pollOutageMs), from when the first poll's retries ran out: polled
23
- * again with backoff, each poll and its retries ending before the last look,
24
- * which goes out LAST_LOOK_MAX_MS before the window's end (half the window,
25
- * for one shorter than twice that) with room for its own retries. So an
26
- * outage that ends inside the window is always seen to end. The Python
27
- * SDK's Outage.
22
+ * (pollOutageMs), from when the first poll's retries ran out, and, while the
23
+ * command runs, never past the wait's own deadline (`waitDeadline`, epoch
24
+ * ms; null for none): polled again with backoff, each poll and its retries
25
+ * ending before the last look, which goes out LAST_LOOK_MAX_MS before the
26
+ * window's end (half the window, for one shorter than twice that) with room
27
+ * for its own retries. So an outage that ends inside the window is always
28
+ * seen to end. The Python SDK's Outage.
28
29
  */
29
30
  export declare class Outage {
30
31
  private readonly windowMs;
32
+ private readonly waitDeadline;
31
33
  private end;
32
34
  private last;
33
35
  private tries;
34
- private readonly room;
35
- constructor(windowMs: number);
36
+ private room;
37
+ private finished;
38
+ constructor(windowMs: number, waitDeadline?: number | null);
39
+ /** The command has ended: draining its output, and an outage meanwhile, are not held to the deadline. */
40
+ finish(): void;
41
+ /**
42
+ * Whether a wait's failed poll (`error`, at `now`; `cut`: its bound cut it
43
+ * short) means the wait's own time ran out: its deadline cut the poll
44
+ * short while the command ran, with no outage being ridden out, so the
45
+ * command was still running when last seen. With no window, any timeout
46
+ * while it runs says so, as it always has. Never once the command has
47
+ * ended. The Python SDK's Outage.ran_out.
48
+ */
49
+ ranOut(error: PrizedError, now: number, cut: boolean): boolean;
36
50
  /** 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. */
37
51
  deadline(now: number): number | undefined;
38
52
  /** A poll was answered: the outage, if any, is over. */
@@ -90,16 +104,33 @@ export interface ExecOptions {
90
104
  * its polls fail (no answer, a 5xx, a rate limit), in ms, before it gives
91
105
  * up and stops the command. Default: the client's `pollOutageMs`
92
106
  * (120 000). A refused key, or an answer that settles the run (the
93
- * sandbox deleted or not running, the run gone), stops it at once.
107
+ * sandbox deleted or not running, the run gone), stops it at once. The
108
+ * start of a background, streamed or long command is sent again for as
109
+ * long (a resend finds the first start, so the command runs once).
94
110
  */
95
111
  pollOutageMs?: number;
96
112
  }
97
113
  export interface WaitOptions {
98
- /** Stop waiting after this many ms (the command keeps running). Default: no limit. */
114
+ /**
115
+ * Stop waiting after this many ms (the command keeps running). Default: no
116
+ * limit. Once the command has ended, reading the rest of its output is not
117
+ * held to it.
118
+ */
99
119
  timeoutMs?: number;
100
120
  onStdout?: (chunk: string) => void;
101
121
  onStderr?: (chunk: string) => void;
102
122
  signal?: AbortSignal;
123
+ /**
124
+ * How long the wait keeps polling while its polls fail (no answer, a 5xx,
125
+ * a rate limit), in ms, before it gives up and throws what the polls met.
126
+ * While the command runs, never past `timeoutMs`. Default: the client's
127
+ * `pollOutageMs` (120 000).
128
+ * `0` gives up once one poll's retries run out. A refused key, or an
129
+ * answer that settles the run (the sandbox deleted or not running, the
130
+ * run gone), ends the wait at once. Giving up stops nothing: the command
131
+ * keeps running, and wait() can be called again.
132
+ */
133
+ pollOutageMs?: number;
103
134
  }
104
135
  export interface CommandStatus {
105
136
  running: boolean;
@@ -136,15 +167,17 @@ export declare class CommandHandle {
136
167
  * `onStdout`/`onStderr` receive the output as it arrives, from the start.
137
168
  * A `timeoutMs` here bounds the wait only; the command keeps running. If
138
169
  * the command ran past its own timeout (the one given to exec), it was
139
- * killed and this throws CommandTimeoutError with what it printed.
170
+ * killed and this throws CommandTimeoutError with what it printed. While
171
+ * polls fail (a dropped connection, a 5xx), it keeps polling for up to
172
+ * `pollOutageMs` (the client's, 120 000 by default) before it throws.
140
173
  */
141
174
  wait(options?: WaitOptions): Promise<ExecResult>;
142
175
  /**
143
176
  * @internal wait() without the timeout check: exec cleans up before it
144
- * throws. `outageMs` (exec's pollOutageMs; a wait with no limit only):
145
- * how long polls may fail before the wait gives up (Outage).
177
+ * throws. `outageMs` (pollOutageMs): how long polls may fail before the
178
+ * wait gives up (Outage), never past the wait's own deadline.
146
179
  */
147
- waitRaw(options?: WaitOptions, outageMs?: number): Promise<ExecResult>;
180
+ waitRaw(options: WaitOptions, outageMs: number): Promise<ExecResult>;
148
181
  private waitTimeout;
149
182
  private result;
150
183
  /**
@@ -70,22 +70,42 @@ function ridesOut(error) {
70
70
  }
71
71
  /**
72
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.
73
+ * (pollOutageMs), from when the first poll's retries ran out, and, while the
74
+ * command runs, never past the wait's own deadline (`waitDeadline`, epoch
75
+ * ms; null for none): polled again with backoff, each poll and its retries
76
+ * ending before the last look, which goes out LAST_LOOK_MAX_MS before the
77
+ * window's end (half the window, for one shorter than twice that) with room
78
+ * for its own retries. So an outage that ends inside the window is always
79
+ * seen to end. The Python SDK's Outage.
79
80
  */
80
81
  class Outage {
81
82
  windowMs;
83
+ waitDeadline;
82
84
  end = null;
83
85
  last = null;
84
86
  tries = 0;
85
- room;
86
- constructor(windowMs) {
87
+ room = 0;
88
+ finished = false;
89
+ constructor(windowMs, waitDeadline = null) {
87
90
  this.windowMs = windowMs;
88
- this.room = Math.min(http_js_1.LAST_LOOK_MAX_MS, windowMs / 2);
91
+ this.waitDeadline = waitDeadline;
92
+ }
93
+ /** The command has ended: draining its output, and an outage meanwhile, are not held to the deadline. */
94
+ finish() {
95
+ this.finished = true;
96
+ }
97
+ /**
98
+ * Whether a wait's failed poll (`error`, at `now`; `cut`: its bound cut it
99
+ * short) means the wait's own time ran out: its deadline cut the poll
100
+ * short while the command ran, with no outage being ridden out, so the
101
+ * command was still running when last seen. With no window, any timeout
102
+ * while it runs says so, as it always has. Never once the command has
103
+ * ended. The Python SDK's Outage.ran_out.
104
+ */
105
+ ranOut(error, now, cut) {
106
+ if (this.waitDeadline === null || this.finished || error.code !== 'timeout')
107
+ return false;
108
+ return this.windowMs <= 0 || (this.end === null && (cut || now >= this.waitDeadline));
89
109
  }
90
110
  /** 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
111
  deadline(now) {
@@ -109,7 +129,10 @@ class Outage {
109
129
  failed(error, sentAt, now, cut, backoff) {
110
130
  if (this.windowMs <= 0 || !ridesOut(error))
111
131
  return { pause: null, failure: error };
112
- this.end ??= now + this.windowMs;
132
+ if (this.end === null) {
133
+ this.end = Math.min(now + this.windowMs, this.finished || this.waitDeadline === null ? Infinity : this.waitDeadline);
134
+ this.room = Math.max(0, Math.min(http_js_1.LAST_LOOK_MAX_MS, (this.end - now) / 2));
135
+ }
113
136
  const met = error.cause instanceof errors_js_1.PrizedError ? error.cause : null;
114
137
  if (!cut) {
115
138
  this.last = error;
@@ -278,17 +301,20 @@ class CommandHandle {
278
301
  * `onStdout`/`onStderr` receive the output as it arrives, from the start.
279
302
  * A `timeoutMs` here bounds the wait only; the command keeps running. If
280
303
  * the command ran past its own timeout (the one given to exec), it was
281
- * killed and this throws CommandTimeoutError with what it printed.
304
+ * killed and this throws CommandTimeoutError with what it printed. While
305
+ * polls fail (a dropped connection, a 5xx), it keeps polling for up to
306
+ * `pollOutageMs` (the client's, 120 000 by default) before it throws.
282
307
  */
283
308
  async wait(options = {}) {
284
- return finished(await this.waitRaw(options), this.timeoutMs, this.sandboxId);
309
+ const outageMs = (0, http_js_1.validOutage)(options.pollOutageMs) ?? this.toolbox.config.pollOutageMs;
310
+ return finished(await this.waitRaw(options, outageMs), this.timeoutMs, this.sandboxId);
285
311
  }
286
312
  /**
287
313
  * @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).
314
+ * throws. `outageMs` (pollOutageMs): how long polls may fail before the
315
+ * wait gives up (Outage), never past the wait's own deadline.
290
316
  */
291
- async waitRaw(options = {}, outageMs = 0) {
317
+ async waitRaw(options, outageMs) {
292
318
  const config = this.toolbox.config;
293
319
  const deadline = options.timeoutMs ? config.now() + options.timeoutMs : null;
294
320
  const out = new StreamReader(options.onStdout);
@@ -296,7 +322,7 @@ class CommandHandle {
296
322
  let delay = POLL_MIN_MS;
297
323
  let finished = false;
298
324
  const polls = deadline === null ? null : new http_js_1.Polls(config, deadline);
299
- const outage = new Outage(deadline === null ? outageMs : 0);
325
+ const outage = new Outage(outageMs, deadline);
300
326
  for (;;) {
301
327
  let report;
302
328
  const sentAt = config.now();
@@ -311,13 +337,15 @@ class CommandHandle {
311
337
  polls?.answered();
312
338
  }
313
339
  catch (error) {
314
- if (deadline !== null && error instanceof errors_js_1.PrizedError && error.code === 'timeout')
340
+ const now = config.now();
341
+ if (error instanceof errors_js_1.PrizedError && outage.ranOut(error, now, (0, http_js_1.cutByDeadline)(error))) {
315
342
  throw this.waitTimeout(options.timeoutMs);
343
+ }
316
344
  let failure = error;
317
345
  if (error instanceof errors_js_1.PrizedError && !options.signal?.aborted) {
318
346
  // A dropped connection, an edge restarting: the command may be
319
347
  // 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));
348
+ const step = outage.failed(error, sentAt, now, (0, http_js_1.cutByDeadline)(error), (tries) => (0, http_js_1.backoffMs)(config, tries));
321
349
  if (step.pause !== null) {
322
350
  await config.sleep(step.pause, options.signal);
323
351
  continue;
@@ -329,8 +357,10 @@ class CommandHandle {
329
357
  throw failure;
330
358
  }
331
359
  outage.answered();
332
- if (report.state !== 'running')
360
+ if (report.state !== 'running') {
333
361
  finished = true;
362
+ outage.finish();
363
+ }
334
364
  out.push(report.stdout);
335
365
  err.push(report.stderr);
336
366
  const more = (report.stdout.length > 0 && out.offset < report.outSize) ||
@@ -447,7 +477,48 @@ async function cleanupOnceGone(toolbox, id) {
447
477
  }
448
478
  }
449
479
  }
450
- async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, unbuffered) {
480
+ /**
481
+ * Send a start script. It is keyed by a run id the SDK chose, so a resend
482
+ * finds the first start instead of starting the command twice. Once one
483
+ * copy's retries run out, it is sent again through an outage of up to
484
+ * `outageMs` (pollOutageMs), paced as a wait's polls are (Outage): an edge
485
+ * that restarts mid-start does not cost the command, as it does not cost a
486
+ * running one its wait. Each copy's error counts the copies before it
487
+ * (`acted`), so one that gives up says the command may have started when any
488
+ * copy may have reached the sandbox. The Python SDK's _send_start.
489
+ */
490
+ async function sendStart(toolbox, req, signal, outageMs) {
491
+ const config = toolbox.config;
492
+ const outage = new Outage(outageMs);
493
+ // Whether an earlier copy may have reached the sandbox: an abort between
494
+ // copies then says the command may have started, else that it never did.
495
+ let acted = false;
496
+ const aborted = (cause) => (0, outcome_js_1.withAbort)(new errors_js_1.PrizedError('aborted', null, { sandboxId: toolbox.sandboxId, cause }), (0, outcome_js_1.startEffect)(), acted ? 'maybe' : 'never');
497
+ for (;;) {
498
+ const sentAt = config.now();
499
+ try {
500
+ return await (0, toolbox_js_1.rawExec)(toolbox, req, { retry: 'safe', signal, deadline: outage.deadline(sentAt), effect: (0, outcome_js_1.startEffect)(), acted });
501
+ }
502
+ catch (error) {
503
+ if (!(error instanceof errors_js_1.PrizedError))
504
+ throw error;
505
+ acted = acted || (0, outcome_js_1.mayHaveActed)(error);
506
+ if (signal?.aborted)
507
+ throw error;
508
+ const step = outage.failed(error, sentAt, config.now(), (0, http_js_1.cutByDeadline)(error), (tries) => (0, http_js_1.backoffMs)(config, tries));
509
+ // What a copy met, not the window's cut.
510
+ if (step.pause === null)
511
+ throw step.failure === error ? error : (0, outcome_js_1.startGaveUp)(step.failure, acted);
512
+ try {
513
+ await config.sleep(step.pause, signal);
514
+ }
515
+ catch (abort) {
516
+ throw abort instanceof errors_js_1.PrizedError && abort.code === 'aborted' ? aborted(step.failure) : abort;
517
+ }
518
+ }
519
+ }
520
+ }
521
+ async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, unbuffered, outageMs) {
451
522
  // Nothing sent yet: nothing to stop.
452
523
  if (signal?.aborted)
453
524
  throw (0, outcome_js_1.withOutcome)(new errors_js_1.PrizedError('aborted', null, { sandboxId: toolbox.sandboxId }), (0, outcome_js_1.startEffect)(), 'never');
@@ -459,10 +530,7 @@ async function startBackground(toolbox, command, cwd, env, timeoutMs, signal, un
459
530
  // Launched, but no pid came within the start's own wait: it may yet run.
460
531
  let unsettled = false;
461
532
  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)() });
533
+ const result = await sendStart(toolbox, { command: (0, shell_js_1.startScript)(id, command, timeoutMs === null ? null : timeoutMs / 1000, unbuffered), cwd, env, timeoutMs: 60_000 }, signal, outageMs);
466
534
  pid = Number(result.stdout.trim());
467
535
  if (result.exitCode !== 0 || !Number.isInteger(pid) || pid <= 0) {
468
536
  unsettled = result.exitCode === shell_js_1.NO_PID_EXIT;
@@ -508,7 +576,7 @@ async function execCommand(toolbox, command, options = {}) {
508
576
  const cwd = options.cwd === undefined ? undefined : (0, shell_js_1.sandboxPath)(options.cwd);
509
577
  if (options.background) {
510
578
  const limit = options.timeoutMs ? options.timeoutMs : null;
511
- const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, true);
579
+ const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, true, outageMs);
512
580
  handle.timeoutMs = limit;
513
581
  return handle;
514
582
  }
@@ -516,7 +584,7 @@ async function execCommand(toolbox, command, options = {}) {
516
584
  const streamed = options.onStdout !== undefined || options.onStderr !== undefined;
517
585
  if (streamed || timeoutMs === 0 || timeoutMs > exports.MAX_BLOCKING_EXEC_MS) {
518
586
  const limit = timeoutMs === 0 ? null : timeoutMs;
519
- const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, streamed);
587
+ const handle = await startBackground(toolbox, text, cwd, options.env, limit, options.signal, streamed, outageMs);
520
588
  const waitOptions = {};
521
589
  if (options.onStdout)
522
590
  waitOptions.onStdout = options.onStdout;
@@ -3,6 +3,11 @@ import type { ExecResult } from './types.js';
3
3
  export declare const API_KEYS_URL = "https://prized.dev/dashboard/workspace/api-keys";
4
4
  /** Where a newcomer signs up (free credit, no card) and gets one. */
5
5
  export declare const SIGN_UP_URL = "https://prized.dev/onboarding/sandboxes";
6
+ /** What a coding agent follows to sign its person up and get a key itself:
7
+ * their email address, then the 6-digit code Prized emails them. The SDK's
8
+ * own key errors name it; a refused key's 401 carries the API's own line
9
+ * (`signUp`), which follows the deployment. */
10
+ export declare const AGENT_SIGN_UP_URL = "https://prized.dev/auth.md";
6
11
  /** The free tier's limits, and what lifts them. */
7
12
  export declare const FREE_TIER_URL = "https://prized.dev/docs/sandboxes#free-tier";
8
13
  /** The sentence an error with this code and no message of its own carries. */
@@ -17,6 +22,8 @@ export interface PrizedErrorInit {
17
22
  body?: Record<string, unknown> | null;
18
23
  /** Seconds the API asked the caller to wait (Retry-After). */
19
24
  retryAfter?: number | null;
25
+ /** The command's exit code, for an error that came after it ran. */
26
+ exitCode?: number | null;
20
27
  cause?: unknown;
21
28
  }
22
29
  /**
@@ -27,13 +34,21 @@ export interface PrizedErrorInit {
27
34
  * - `code`: the API's error string, e.g. `paid_balance_required`,
28
35
  * `insufficient_balance`, `sandbox_quota_exceeded`, `not_found`,
29
36
  * `rate_limited`; or one of the SDK's own: `missing_api_key`,
30
- * `invalid_api_key`, `invalid_argument`, `network_error`, `timeout`, `aborted`,
37
+ * `invalid_api_key`, `invalid_argument`, `edge_unknown` (another
38
+ * deployment's API named no command endpoint this SDK sends the key to),
39
+ * `network_error`, `timeout`, `aborted`,
31
40
  * `bad_response`, `http_error`, `sandbox_error`, `sandbox_deleted`,
32
- * `command_failed`, `command_timeout` (thrown as CommandTimeoutError).
41
+ * `command_failed`, `command_timeout` (thrown as CommandTimeoutError),
42
+ * `output_read_failed` (thrown as OutputReadError).
33
43
  * - `reason`: the finer-grained reason, when there is one (for
34
44
  * `sandbox_quota_exceeded` it names the quota: `sandbox_limit`,
35
45
  * `paused_limit`, `running_vcpu`, `running_memory`, `retained_disk`,
36
- * `max_runtime`).
46
+ * `max_runtime`; for `output_read_failed`, the code of the failure that
47
+ * stopped the read).
48
+ * - `exitCode`: the command's exit code when the error came after it ran: a
49
+ * `fullOutput` command whose output could not be read back
50
+ * (`output_read_failed`, or `sandbox_deleted` when the sandbox was deleted
51
+ * first). Don't run such a command again blindly. Null otherwise.
37
52
  */
38
53
  export declare class PrizedError extends Error {
39
54
  readonly status: number | null;
@@ -42,6 +57,7 @@ export declare class PrizedError extends Error {
42
57
  readonly sandboxId: string | null;
43
58
  readonly body: Record<string, unknown> | null;
44
59
  readonly retryAfter: number | null;
60
+ readonly exitCode: number | null;
45
61
  constructor(code: string, message?: string | null, init?: PrizedErrorInit);
46
62
  toString(): string;
47
63
  }
@@ -61,6 +77,30 @@ export declare class CommandTimeoutError extends PrizedError {
61
77
  /** What the command printed to stderr before it was killed. */
62
78
  get stderr(): string;
63
79
  }
80
+ export interface OutputReadErrorInit {
81
+ /** The failure that stopped the read: a download's error. */
82
+ cause: PrizedError;
83
+ exitCode: number;
84
+ stdoutPath: string;
85
+ stderrPath: string;
86
+ }
87
+ /**
88
+ * A `fullOutput` command ran, but its output could not be read back from the
89
+ * files it was copied to (`code` is `output_read_failed`). The command did
90
+ * run (`exitCode` is its exit code), so do not run it again blindly; and the
91
+ * sandbox is not known to be gone (a deleted one throws `sandbox_deleted`
92
+ * instead). `reason` is the code of the failure that stopped the read (say
93
+ * `not_found`, `network_error`), and `cause` is that error, with its HTTP
94
+ * status; `status` is null, as the command's own call succeeded. The output
95
+ * stays in the sandbox for an hour at `stdoutPath` and `stderrPath`: read it
96
+ * with files.read(), then remove their directory.
97
+ */
98
+ export declare class OutputReadError extends PrizedError {
99
+ readonly exitCode: number;
100
+ readonly stdoutPath: string;
101
+ readonly stderrPath: string;
102
+ constructor(message: string, init: OutputReadErrorInit);
103
+ }
64
104
  /** Retry-After as seconds: delta-seconds or an HTTP date. */
65
105
  export declare function parseRetryAfter(value: string | null, nowMs: number): number | null;
66
106
  /**