@prized/sdk 0.1.0 → 0.1.1

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.
@@ -65,22 +65,42 @@ export function ridesOut(error) {
65
65
  }
66
66
  /**
67
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.
68
+ * (pollOutageMs), from when the first poll's retries ran out, and, while the
69
+ * command runs, never past the wait's own deadline (`waitDeadline`, epoch
70
+ * ms; null for none): polled again with backoff, each poll and its retries
71
+ * ending before the last look, which goes out LAST_LOOK_MAX_MS before the
72
+ * window's end (half the window, for one shorter than twice that) with room
73
+ * for its own retries. So an outage that ends inside the window is always
74
+ * seen to end. The Python SDK's Outage.
74
75
  */
75
76
  export class Outage {
76
77
  windowMs;
78
+ waitDeadline;
77
79
  end = null;
78
80
  last = null;
79
81
  tries = 0;
80
- room;
81
- constructor(windowMs) {
82
+ room = 0;
83
+ finished = false;
84
+ constructor(windowMs, waitDeadline = null) {
82
85
  this.windowMs = windowMs;
83
- this.room = Math.min(LAST_LOOK_MAX_MS, windowMs / 2);
86
+ this.waitDeadline = waitDeadline;
87
+ }
88
+ /** The command has ended: draining its output, and an outage meanwhile, are not held to the deadline. */
89
+ finish() {
90
+ this.finished = true;
91
+ }
92
+ /**
93
+ * Whether a wait's failed poll (`error`, at `now`; `cut`: its bound cut it
94
+ * short) means the wait's own time ran out: its deadline cut the poll
95
+ * short while the command ran, with no outage being ridden out, so the
96
+ * command was still running when last seen. With no window, any timeout
97
+ * while it runs says so, as it always has. Never once the command has
98
+ * ended. The Python SDK's Outage.ran_out.
99
+ */
100
+ ranOut(error, now, cut) {
101
+ if (this.waitDeadline === null || this.finished || error.code !== 'timeout')
102
+ return false;
103
+ return this.windowMs <= 0 || (this.end === null && (cut || now >= this.waitDeadline));
84
104
  }
85
105
  /** 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
106
  deadline(now) {
@@ -104,7 +124,10 @@ export class Outage {
104
124
  failed(error, sentAt, now, cut, backoff) {
105
125
  if (this.windowMs <= 0 || !ridesOut(error))
106
126
  return { pause: null, failure: error };
107
- this.end ??= now + this.windowMs;
127
+ if (this.end === null) {
128
+ this.end = Math.min(now + this.windowMs, this.finished || this.waitDeadline === null ? Infinity : this.waitDeadline);
129
+ this.room = Math.max(0, Math.min(LAST_LOOK_MAX_MS, (this.end - now) / 2));
130
+ }
108
131
  const met = error.cause instanceof PrizedError ? error.cause : null;
109
132
  if (!cut) {
110
133
  this.last = error;
@@ -272,17 +295,20 @@ export class CommandHandle {
272
295
  * `onStdout`/`onStderr` receive the output as it arrives, from the start.
273
296
  * A `timeoutMs` here bounds the wait only; the command keeps running. If
274
297
  * the command ran past its own timeout (the one given to exec), it was
275
- * killed and this throws CommandTimeoutError with what it printed.
298
+ * killed and this throws CommandTimeoutError with what it printed. While
299
+ * polls fail (a dropped connection, a 5xx), it keeps polling for up to
300
+ * `pollOutageMs` (the client's, 120 000 by default) before it throws.
276
301
  */
277
302
  async wait(options = {}) {
278
- return finished(await this.waitRaw(options), this.timeoutMs, this.sandboxId);
303
+ const outageMs = validOutage(options.pollOutageMs) ?? this.toolbox.config.pollOutageMs;
304
+ return finished(await this.waitRaw(options, outageMs), this.timeoutMs, this.sandboxId);
279
305
  }
280
306
  /**
281
307
  * @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).
308
+ * throws. `outageMs` (pollOutageMs): how long polls may fail before the
309
+ * wait gives up (Outage), never past the wait's own deadline.
284
310
  */
285
- async waitRaw(options = {}, outageMs = 0) {
311
+ async waitRaw(options, outageMs) {
286
312
  const config = this.toolbox.config;
287
313
  const deadline = options.timeoutMs ? config.now() + options.timeoutMs : null;
288
314
  const out = new StreamReader(options.onStdout);
@@ -290,7 +316,7 @@ export class CommandHandle {
290
316
  let delay = POLL_MIN_MS;
291
317
  let finished = false;
292
318
  const polls = deadline === null ? null : new Polls(config, deadline);
293
- const outage = new Outage(deadline === null ? outageMs : 0);
319
+ const outage = new Outage(outageMs, deadline);
294
320
  for (;;) {
295
321
  let report;
296
322
  const sentAt = config.now();
@@ -305,13 +331,15 @@ export class CommandHandle {
305
331
  polls?.answered();
306
332
  }
307
333
  catch (error) {
308
- if (deadline !== null && error instanceof PrizedError && error.code === 'timeout')
334
+ const now = config.now();
335
+ if (error instanceof PrizedError && outage.ranOut(error, now, cutByDeadline(error))) {
309
336
  throw this.waitTimeout(options.timeoutMs);
337
+ }
310
338
  let failure = error;
311
339
  if (error instanceof PrizedError && !options.signal?.aborted) {
312
340
  // A dropped connection, an edge restarting: the command may be
313
341
  // 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));
342
+ const step = outage.failed(error, sentAt, now, cutByDeadline(error), (tries) => backoffMs(config, tries));
315
343
  if (step.pause !== null) {
316
344
  await config.sleep(step.pause, options.signal);
317
345
  continue;
@@ -323,8 +351,10 @@ export class CommandHandle {
323
351
  throw failure;
324
352
  }
325
353
  outage.answered();
326
- if (report.state !== 'running')
354
+ if (report.state !== 'running') {
327
355
  finished = true;
356
+ outage.finish();
357
+ }
328
358
  out.push(report.stdout);
329
359
  err.push(report.stderr);
330
360
  const more = (report.stdout.length > 0 && out.offset < report.outSize) ||
@@ -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
  /**
@@ -2,15 +2,22 @@
2
2
  export const API_KEYS_URL = 'https://prized.dev/dashboard/workspace/api-keys';
3
3
  /** Where a newcomer signs up (free credit, no card) and gets one. */
4
4
  export const SIGN_UP_URL = 'https://prized.dev/onboarding/sandboxes';
5
+ /** What a coding agent follows to sign its person up and get a key itself:
6
+ * their email address, then the 6-digit code Prized emails them. The SDK's
7
+ * own key errors name it; a refused key's 401 carries the API's own line
8
+ * (`signUp`), which follows the deployment. */
9
+ export const AGENT_SIGN_UP_URL = 'https://prized.dev/auth.md';
10
+ const AGENT_SIGN_UP = `No Prized account yet? If you are an agent, you can sign your person up with their email and a 6-digit code: follow ${AGENT_SIGN_UP_URL}`;
5
11
  /** The free tier's limits, and what lifts them. */
6
12
  export const FREE_TIER_URL = 'https://prized.dev/docs/sandboxes#free-tier';
7
13
  /** A sentence for the codes a caller is most likely to meet, used when the
8
14
  * answer carries no `message` of its own. */
9
15
  const MESSAGES = {
10
16
  // The SDK's own codes.
11
- missing_api_key: `No API key: pass apiKey or set PRIZED_API_KEY. New to Prized? ${SIGN_UP_URL} signs you up and makes one; otherwise make one at ${API_KEYS_URL}.`,
12
- invalid_api_key: `The API key has characters no key has (the docs' placeholder, or a stray character?); copy it again from ${API_KEYS_URL}.`,
17
+ missing_api_key: `No API key: pass apiKey or set PRIZED_API_KEY. New to Prized? ${SIGN_UP_URL} signs you up and makes one, or, if you are an agent, you can sign your person up with their email and a 6-digit code: follow ${AGENT_SIGN_UP_URL}, keep the key file it writes, and pass that key as apiKey at run time, never in source. Otherwise make one at ${API_KEYS_URL}.`,
18
+ invalid_api_key: `The API key has characters no key has (the docs' placeholder, or a stray character?); copy it again from ${API_KEYS_URL}. ${AGENT_SIGN_UP}`,
13
19
  invalid_argument: 'An argument was not valid.',
20
+ edge_unknown: 'The API named no command endpoint this SDK trusts; pass edgeUrl for that deployment.',
14
21
  network_error: 'Could not reach the Prized API.',
15
22
  timeout: 'Timed out.',
16
23
  aborted: 'The operation was aborted.',
@@ -21,6 +28,7 @@ const MESSAGES = {
21
28
  sandbox_deleted: 'The sandbox was deleted.',
22
29
  command_failed: 'The command failed in the sandbox.',
23
30
  command_timeout: 'The command ran past its timeout and was killed.',
31
+ output_read_failed: 'The command ran, but its output could not be read back from the sandbox.',
24
32
  not_directory: 'Not a directory.',
25
33
  // The API's and the exec edge's codes.
26
34
  unauthorized: `The API key was refused. Check that it is a live key from ${API_KEYS_URL}.`,
@@ -77,13 +85,21 @@ export function defaultMessage(code) {
77
85
  * - `code`: the API's error string, e.g. `paid_balance_required`,
78
86
  * `insufficient_balance`, `sandbox_quota_exceeded`, `not_found`,
79
87
  * `rate_limited`; or one of the SDK's own: `missing_api_key`,
80
- * `invalid_api_key`, `invalid_argument`, `network_error`, `timeout`, `aborted`,
88
+ * `invalid_api_key`, `invalid_argument`, `edge_unknown` (another
89
+ * deployment's API named no command endpoint this SDK sends the key to),
90
+ * `network_error`, `timeout`, `aborted`,
81
91
  * `bad_response`, `http_error`, `sandbox_error`, `sandbox_deleted`,
82
- * `command_failed`, `command_timeout` (thrown as CommandTimeoutError).
92
+ * `command_failed`, `command_timeout` (thrown as CommandTimeoutError),
93
+ * `output_read_failed` (thrown as OutputReadError).
83
94
  * - `reason`: the finer-grained reason, when there is one (for
84
95
  * `sandbox_quota_exceeded` it names the quota: `sandbox_limit`,
85
96
  * `paused_limit`, `running_vcpu`, `running_memory`, `retained_disk`,
86
- * `max_runtime`).
97
+ * `max_runtime`; for `output_read_failed`, the code of the failure that
98
+ * stopped the read).
99
+ * - `exitCode`: the command's exit code when the error came after it ran: a
100
+ * `fullOutput` command whose output could not be read back
101
+ * (`output_read_failed`, or `sandbox_deleted` when the sandbox was deleted
102
+ * first). Don't run such a command again blindly. Null otherwise.
87
103
  */
88
104
  export class PrizedError extends Error {
89
105
  status;
@@ -92,6 +108,7 @@ export class PrizedError extends Error {
92
108
  sandboxId;
93
109
  body;
94
110
  retryAfter;
111
+ exitCode;
95
112
  constructor(code, message, init = {}) {
96
113
  super(message || defaultMessage(code), init.cause === undefined ? undefined : { cause: init.cause });
97
114
  this.name = 'PrizedError';
@@ -101,6 +118,7 @@ export class PrizedError extends Error {
101
118
  this.sandboxId = init.sandboxId ?? null;
102
119
  this.body = init.body ?? null;
103
120
  this.retryAfter = init.retryAfter ?? null;
121
+ this.exitCode = init.exitCode ?? null;
104
122
  }
105
123
  toString() {
106
124
  const head = this.status === null ? this.code : `${this.status} ${this.code}`;
@@ -135,6 +153,30 @@ export class CommandTimeoutError extends PrizedError {
135
153
  return this.result.stderr;
136
154
  }
137
155
  }
156
+ /**
157
+ * A `fullOutput` command ran, but its output could not be read back from the
158
+ * files it was copied to (`code` is `output_read_failed`). The command did
159
+ * run (`exitCode` is its exit code), so do not run it again blindly; and the
160
+ * sandbox is not known to be gone (a deleted one throws `sandbox_deleted`
161
+ * instead). `reason` is the code of the failure that stopped the read (say
162
+ * `not_found`, `network_error`), and `cause` is that error, with its HTTP
163
+ * status; `status` is null, as the command's own call succeeded. The output
164
+ * stays in the sandbox for an hour at `stdoutPath` and `stderrPath`: read it
165
+ * with files.read(), then remove their directory.
166
+ */
167
+ export class OutputReadError extends PrizedError {
168
+ exitCode;
169
+ stdoutPath;
170
+ stderrPath;
171
+ constructor(message, init) {
172
+ const { cause } = init;
173
+ super('output_read_failed', message, { reason: cause.code, sandboxId: cause.sandboxId, cause, exitCode: init.exitCode });
174
+ this.name = 'OutputReadError';
175
+ this.exitCode = init.exitCode;
176
+ this.stdoutPath = init.stdoutPath;
177
+ this.stderrPath = init.stderrPath;
178
+ }
179
+ }
138
180
  function stringField(record, key) {
139
181
  const value = record[key];
140
182
  return typeof value === 'string' && value !== '' ? value : null;
@@ -246,5 +288,10 @@ export function errorFromResponse(status, text, retryAfter, sandboxId, path = ''
246
288
  const reason = doc ? stringField(doc, 'reason') ?? stringField(doc, 'field') ?? stringField(doc, 'detail') : null;
247
289
  if (doc && doc.tier === 'free' && !(message ?? '').includes('free tier'))
248
290
  message = freeTierMessage(code, message, reason, doc.limit);
291
+ // A refused key's 401 may say how an agent signs its person up (the API
292
+ // sends it where agent sign-in answers, with that deployment's address).
293
+ const signUp = code === 'unauthorized' && doc ? stringField(doc, 'signUp') : null;
294
+ if (signUp !== null)
295
+ message = `${message ?? defaultMessage(code)} ${signUp.slice(0, 300)}`;
249
296
  return new PrizedError(code, message, { status, reason, sandboxId, body: doc, retryAfter });
250
297
  }
@@ -11,7 +11,12 @@ export interface WriteOptions {
11
11
  */
12
12
  mode?: number;
13
13
  }
14
- /** The sandbox's file transfers take at most this long, retries included. */
14
+ /**
15
+ * The sandbox's file transfers take at most this long, retries included:
16
+ * the edge's own limit on one transfer. A write's upload sent again (a
17
+ * staged one is, after a lost answer or a 502, 503 or 504) is sent only
18
+ * while time is left, so a transfer that timed out is not.
19
+ */
15
20
  export declare const FILE_TIMEOUT_MS: number;
16
21
  /** The publish script's own exits (the Python SDK's too). */
17
22
  export declare const EXIT_TARGET_IS_LINK = 47;
@@ -54,12 +59,22 @@ export declare class Files {
54
59
  * A file that is replaced keeps its mode, so a script made executable
55
60
  * stays executable; a new file gets the sandbox's default (0600 in
56
61
  * prized-base-v1 and prized-agent-v1). `mode` sets the permission bits either way.
57
- * Sent again only if it provably never arrived: a write whose answer was
58
- * lost throws, since it may have landed and the sandbox written the file
59
- * since (read it back, or write again, as suits the file).
62
+ * The bytes go up under a temporary name beside the file, sent again if
63
+ * need be (a lost answer, a 502, 503 or 504; all copies within the
64
+ * transfer's 10 minutes),
65
+ * and one command that is never sent again puts them in place: a write
66
+ * whose answer to that command was lost throws, since it may have landed
67
+ * and the sandbox written the file since (read it back, or write again,
68
+ * as suits the file).
60
69
  */
61
70
  write(path: string, data: FileData, options?: WriteOptions): Promise<WriteInfo>;
62
- /** 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. */
71
+ /**
72
+ * One upload to `path`, as the files API takes it; `target` is the file the
73
+ * caller asked for, and `effect` what the upload does to it. `exec` for an
74
+ * upload straight to the file: like a command, a resend after the answer
75
+ * was lost could overwrite what the sandbox wrote to the path since.
76
+ * `staged` for one to a fresh name: sent again like a read.
77
+ */
63
78
  private send;
64
79
  /**
65
80
  * Remove a staged upload that was not put in place (best effort: the
package/dist/esm/files.js CHANGED
@@ -4,7 +4,12 @@ import { request } from './http.js';
4
4
  import { mayHaveActed, removeEffect, withOutcome, writeEffect } from './outcome.js';
5
5
  import { existsScript, listScript, removeScript, sandboxPath, shellQuote, WORKSPACE_DIR } from './shell.js';
6
6
  import { helperExec, MAX_FILE_BYTES, rawExec } from './toolbox.js';
7
- /** The sandbox's file transfers take at most this long, retries included. */
7
+ /**
8
+ * The sandbox's file transfers take at most this long, retries included:
9
+ * the edge's own limit on one transfer. A write's upload sent again (a
10
+ * staged one is, after a lost answer or a 502, 503 or 504) is sent only
11
+ * while time is left, so a transfer that timed out is not.
12
+ */
8
13
  export const FILE_TIMEOUT_MS = 10 * 60_000;
9
14
  function byteSize(data) {
10
15
  if (typeof data === 'string')
@@ -40,12 +45,16 @@ export const EXIT_TARGET_NOT_REGULAR = 48;
40
45
  const DISCARD_MS = 10_000;
41
46
  /** A write's own commands: a rename, a chmod or an rm. */
42
47
  const WRITE_COMMAND_MS = 60_000;
43
- /** For the staged upload: the target is changed only by the command that follows it. */
48
+ /**
49
+ * For the staged upload: the target is changed only by the command that
50
+ * follows it. The upload may have been sent again, so it says only what to
51
+ * do (langchain-prized's staged_upload_effect words it the same).
52
+ */
44
53
  function stagedEffect(path) {
45
54
  return {
46
55
  maybe: `${path} was not changed (its new bytes may have reached the sandbox, but were not put in place)`,
47
56
  did: `${path} was not changed yet`,
48
- check: 'It was not sent again: write it again.',
57
+ check: 'Write it again.',
49
58
  };
50
59
  }
51
60
  /** For the command that puts the staged upload in place: a 2xx proves it ran, not how it ended. */
@@ -165,9 +174,13 @@ export class Files {
165
174
  * A file that is replaced keeps its mode, so a script made executable
166
175
  * stays executable; a new file gets the sandbox's default (0600 in
167
176
  * 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).
177
+ * The bytes go up under a temporary name beside the file, sent again if
178
+ * need be (a lost answer, a 502, 503 or 504; all copies within the
179
+ * transfer's 10 minutes),
180
+ * and one command that is never sent again puts them in place: a write
181
+ * whose answer to that command was lost throws, since it may have landed
182
+ * and the sandbox written the file since (read it back, or write again,
183
+ * as suits the file).
171
184
  */
172
185
  async write(path, data, options = {}) {
173
186
  const sandboxId = this.toolbox.sandboxId;
@@ -179,14 +192,18 @@ export class Files {
179
192
  const target = sandboxPath(path);
180
193
  const plan = stagingFor(target);
181
194
  if (plan === null)
182
- return this.send(target, data, size, target, writeEffect(target));
195
+ return this.send(target, data, size, target, writeEffect(target), 'exec');
183
196
  const { clean, staging } = plan;
184
197
  try {
185
- await this.send(staging, data, size, clean, stagedEffect(clean));
198
+ // Every copy to the same fresh name, which only the command below puts in place.
199
+ await this.send(staging, data, size, clean, stagedEffect(clean), 'staged');
186
200
  }
187
201
  catch (error) {
188
202
  // A 2xx whose body could not be read: the staged bytes are all there.
189
203
  if (!landed(error)) {
204
+ // Removed only if a copy may have landed: a refusal, or copies that
205
+ // never arrived, left nothing, and a command would wake a sandbox
206
+ // the edge's refusal left paused.
190
207
  if (mayHaveActed(error))
191
208
  await this.discard(staging);
192
209
  throw error;
@@ -212,7 +229,7 @@ export class Files {
212
229
  return { path: clean, size };
213
230
  if (result.exitCode === EXIT_TARGET_IS_LINK) {
214
231
  // 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));
232
+ const info = await this.send(clean, data, size, clean, writeEffect(clean), 'exec');
216
233
  if (mode !== null)
217
234
  await this.chmod(clean, mode);
218
235
  return info;
@@ -223,16 +240,20 @@ export class Files {
223
240
  const denied = /Permission denied|Operation not permitted/.test(result.stderr);
224
241
  throw new PrizedError(denied ? 'permission_denied' : 'command_failed', `Could not write ${clean}: ${commandDetail(result)}`, { sandboxId });
225
242
  }
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) {
243
+ /**
244
+ * One upload to `path`, as the files API takes it; `target` is the file the
245
+ * caller asked for, and `effect` what the upload does to it. `exec` for an
246
+ * upload straight to the file: like a command, a resend after the answer
247
+ * was lost could overwrite what the sandbox wrote to the path since.
248
+ * `staged` for one to a fresh name: sent again like a read.
249
+ */
250
+ async send(path, data, size, target, effect, retry) {
228
251
  const result = await request(this.toolbox.config, {
229
252
  method: 'POST',
230
253
  url: this.url('upload', path),
231
254
  body: data,
232
255
  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',
256
+ retry,
236
257
  effect,
237
258
  timeoutMs: FILE_TIMEOUT_MS,
238
259
  deadline: this.toolbox.config.now() + FILE_TIMEOUT_MS,
@@ -6,11 +6,16 @@ export declare const DEFAULT_EDGE_URL = "https://term.prized.dev";
6
6
  export interface ClientOptions {
7
7
  /** Your API key (`dcp_…`). Default: the `PRIZED_API_KEY` environment variable. */
8
8
  apiKey?: string;
9
- /** The API origin. Default: `https://api.prized.dev`. */
9
+ /**
10
+ * The API origin: https, or plain http only on this machine (`localhost`,
11
+ * `127.0.0.1`, `[::1]`). Default: `https://api.prized.dev`.
12
+ */
10
13
  baseUrl?: string;
11
14
  /**
12
- * The origin that serves exec and files. Default: the `toolboxBase` the API
13
- * returns for each sandbox (today `https://term.prized.dev`).
15
+ * The origin that serves exec and files, by the same rule as `baseUrl`.
16
+ * Default: the `toolboxBase` the API returns for each sandbox (today
17
+ * `https://term.prized.dev`), on a host trusted beside the API's
18
+ * (toolboxBaseFor).
14
19
  */
15
20
  edgeUrl?: string;
16
21
  /** A fetch implementation. Default: the global `fetch`. */
@@ -23,10 +28,11 @@ export interface ClientOptions {
23
28
  * the request itself and the API's hold of a create, pause or resume. Default 500. */
24
29
  pollIntervalMs?: number;
25
30
  /**
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.
31
+ * How long a wait keeps polling while its polls fail (no answer, a 5xx, a
32
+ * rate limit), in ms, before it gives up: a streamed or long `exec` then
33
+ * stops the command, a handle's `wait()` throws. Default 120 000. `0`
34
+ * gives up once one poll's retries run out. `exec` and `wait()` take it
35
+ * per call too.
30
36
  */
31
37
  pollOutageMs?: number;
32
38
  /** Sleep seam (tests inject one that never really waits). */
@@ -51,10 +57,10 @@ export interface Config {
51
57
  }
52
58
  export declare function abortableSleep(ms: number, signal?: AbortSignal): Promise<void>;
53
59
  /**
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.
60
+ * How long a wait (a streamed or long exec, a handle's wait()) keeps polling
61
+ * while its polls fail before it gives up (pollOutageMs), and a streamed or
62
+ * long exec stops its command: long enough to ride out a dropped connection
63
+ * or an edge that restarts. The Python SDK's DEFAULT_POLL_OUTAGE.
58
64
  */
59
65
  export declare const DEFAULT_POLL_OUTAGE_MS = 120000;
60
66
  /** A pollOutageMs given (a number of ms ≥ 0), or undefined. */
@@ -63,7 +69,10 @@ export declare function resolveConfig(options?: ClientOptions): Config;
63
69
  /**
64
70
  * Where exec and files go for one sandbox. An explicit edgeUrl wins; else the
65
71
  * API's own `toolboxBase` when it has the expected shape (https, or plain
66
- * http on loopback for local development); else the default edge.
72
+ * http on loopback for local development, ending /v1/sandbox/<id>) on a host
73
+ * trusted beside the API's (term.prized.dev beside api.prized.dev; see
74
+ * trustedEdge); else the default edge, for Prized's own API only. The API key
75
+ * is never sent anywhere else: discovery is routing, not authority.
67
76
  */
68
77
  export declare function toolboxBaseFor(config: Config, sandboxId: string, toolboxBase: string | null): string;
69
78
  /**
@@ -93,8 +102,15 @@ export declare function warmEdge(config: Config): Promise<void>;
93
102
  * provably never arrived: a connection that never opened, or an answer
94
103
  * saying so (the edge's rate limit, its lookup failing). A lost answer
95
104
  * is never retried: it may already have run.
105
+ * - `staged`: a write's upload to a fresh name beside its target, which only
106
+ * a command after it puts in place (files.ts). Retried as `safe` (a lost
107
+ * answer, a timeout, 429/502/503/504) and `exec` both, a GET's 500 aside:
108
+ * every copy writes the same bytes under the same name, which nothing else
109
+ * writes, so a second one changes no file of the caller's. A 2xx whose body
110
+ * broke off is not sent again: the bytes are there. Its error says whether
111
+ * a copy may have reached the sandbox.
96
112
  */
97
- export type RetryMode = 'safe' | 'exec';
113
+ export type RetryMode = 'safe' | 'exec' | 'staged';
98
114
  /**
99
115
  * Whether a fetch that threw never sent its request: the connection was
100
116
  * refused, the name did not resolve, or connecting timed out. Anything else