@miosa/sdk 3.2.2 → 3.2.4

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.
package/README.md CHANGED
@@ -268,6 +268,23 @@ await sbx.pause();
268
268
  await sbx.resume();
269
269
  ```
270
270
 
271
+ ### Cancelling sandbox commands
272
+
273
+ Pass an `AbortSignal` to `sandbox.exec.run(command, { signal, timeoutSec })` or `sandbox.exec.stream(command, { signal, timeoutSec })`.
274
+ Aborting rejects with the signal's abort reason (an `AbortError` by default); it does not return a successful exit code or replay the command.
275
+ Signal-bearing `exec.run` uses the request-owned stream and aggregates its exact stdout, stderr, and terminal exit code.
276
+ If that stream ends without an exit event, `exec.run` rejects with `EXEC_STREAM_INCOMPLETE` (502, not retryable).
277
+ Ordinary execution without a signal keeps the standard exec endpoint.
278
+ An explicit `timeoutSec` sets the guest deadline and allows five additional seconds for its response.
279
+ A guest that overruns that deadline rejects with `TimeoutError`, never with `AbortError`.
280
+ Cancellation requires the server's request-owned streaming implementation and the guest's process-group cancellation support.
281
+
282
+ ### Confirming sandbox deletion
283
+
284
+ `sandbox.destroy()` acknowledges deletion and updates the handle to the server's state.
285
+ If deletion is still in progress, the handle reports `destroying` and exposes `sandbox.data.operation_id` when supplied by the server.
286
+ Use `sandbox.refresh()` to verify completion; an acknowledgement alone does not prove resource cleanup.
287
+
271
288
  ## Publish from sandbox
272
289
 
273
290
  Preview is mutable; publish creates a durable deployment version. Static output
@@ -414,6 +431,7 @@ try {
414
431
  ```
415
432
 
416
433
  The SDK retries `429` and `5xx` automatically (3 retries, exponential backoff + jitter). Set `maxRetries: 0` to disable.
434
+ Sandbox `exec` requests are never retried, because a replayed command would run twice; see [Cancelling sandbox commands](#cancelling-sandbox-commands).
417
435
  Every `MiosaError` carries `retryable` (server-supplied when present, otherwise `true` for 408/429/5xx and transport failures) and `requestId` for support correlation.
418
436
 
419
437
  ## Configuration
package/dist/index.d.ts CHANGED
@@ -6,6 +6,10 @@ interface RequestOptions {
6
6
  headers?: Record<string, string>;
7
7
  body?: unknown;
8
8
  timeout?: number;
9
+ /** Cancel this request and its response stream without retrying. */
10
+ signal?: AbortSignal | undefined;
11
+ /** Override retries for operations which cannot safely be replayed. */
12
+ maxRetries?: number;
9
13
  /** Return raw Response instead of parsing JSON */
10
14
  rawResponse?: boolean;
11
15
  /** Send body as multipart/form-data */
@@ -6728,7 +6732,7 @@ type SandboxSize = "xs" | "small" | "medium" | "large" | "xl";
6728
6732
  type SandboxId = string & {
6729
6733
  readonly __brand: "SandboxId";
6730
6734
  };
6731
- type SandboxState = "provisioning" | "running" | "paused" | "destroyed" | "error";
6735
+ type SandboxState = "provisioning" | "running" | "paused" | "destroying" | "destroyed" | "error";
6732
6736
  interface SandboxCreateParams {
6733
6737
  templateId?: string;
6734
6738
  template_id?: string;
@@ -6848,6 +6852,8 @@ interface SandboxListParams {
6848
6852
  external_project_id?: string;
6849
6853
  }
6850
6854
  interface SandboxExecOptions {
6855
+ /** Abort the request-scoped execution, including its response stream. */
6856
+ signal?: AbortSignal;
6851
6857
  cwd?: string;
6852
6858
  workingDir?: string;
6853
6859
  working_dir?: string;
@@ -6917,6 +6923,8 @@ interface SandboxExecRunner {
6917
6923
  stream(command: string, options?: SandboxExecOptions): AsyncIterableIterator<SandboxExecEvent>;
6918
6924
  }
6919
6925
  interface SandboxData {
6926
+ operation_id?: string | null;
6927
+ request_id?: string | null;
6920
6928
  id: SandboxId;
6921
6929
  state: SandboxState;
6922
6930
  slug?: string;
@@ -7372,6 +7380,7 @@ declare class Sandbox {
7372
7380
  */
7373
7381
  prompt(prompt: string, options?: SandboxPromptOptions): Promise<AgentRun>;
7374
7382
  private runExec;
7383
+ private runCancellableExec;
7375
7384
  private execStream;
7376
7385
  writeFile(path: string, content: string | Uint8Array): Promise<void>;
7377
7386
  download(path: string): Promise<Uint8Array>;
@@ -7459,30 +7468,39 @@ declare class Sandbox {
7459
7468
  /** Check readiness of the sandbox (GET /sandboxes/:id/readiness). */
7460
7469
  readiness(): Promise<Record<string, unknown>>;
7461
7470
  /**
7462
- * Block until the sandbox reports ready, or *timeout* seconds elapse.
7471
+ * Block until the sandbox reports ready, or the caller's *timeout* seconds
7472
+ * (the full deadline budget) elapse.
7463
7473
  *
7464
- * When `stream` is `true` (the default) this opens an SSE connection
7465
- * to `GET /sandboxes/:id/readiness/stream` and waits for an
7466
- * `event: ready` frame. The server emits `ready` immediately if the
7467
- * sandbox is already ready, otherwise as soon as the readiness PubSub
7468
- * message fires.
7474
+ * When `stream` is `true` (the default) this opens an SSE connection to
7475
+ * `GET /sandboxes/:id/readiness/stream?timeout=<window>` and waits for an
7476
+ * `event: ready` frame. `<window>` is the caller's remaining budget clamped
7477
+ * to {@link READINESS_STREAM_MAX_TIMEOUT_SEC}, the server's own maximum
7478
+ * lease for a single stream connection — a long-restoring persistent
7479
+ * sandbox (e.g. Docker restore, 60-90 s) legitimately outlives one lease.
7480
+ * The server emits `ready` immediately if the sandbox is already ready,
7481
+ * `event: timeout` when *that connection's* window elapses without a
7482
+ * verdict, and `event: error` on a terminal boot failure.
7469
7483
  *
7470
- * Returns `true` once the sandbox is ready, `false` on `event: timeout`
7471
- * or when the local timeout elapses before ready.
7484
+ * An `event: timeout` (or the stream closing without a verdict) is NOT
7485
+ * treated as the final answer while the caller's overall deadline still has
7486
+ * budget left — that would silently truncate a 180 s wait down to the
7487
+ * server's lease window and return `false` while the sandbox was still
7488
+ * mid-restore. Instead this reconnects the stream (or falls back to
7489
+ * polling) for the remaining budget. `false` is only returned once the
7490
+ * caller's full deadline has elapsed.
7472
7491
  *
7473
7492
  * Throws {@link MiosaError} with code `SANDBOX_BOOT_FAILED` as soon as the
7474
7493
  * sandbox is observed in a terminal failure state (`error` or `destroyed`)
7475
7494
  * while waiting, instead of silently exhausting the timeout and returning
7476
7495
  * `false` — a permanent boot failure and "still booting, keep waiting" must
7477
- * never look the same to the caller. The readiness SSE stream itself has no
7478
- * dedicated failure event today (the server only ever emits
7479
- * `ready`/`status`/`timeout`), so on `event: timeout` this makes one
7480
- * authoritative {@link readiness} check before giving up, to catch a
7481
- * failure that landed right at the boundary.
7496
+ * never look the same to the caller. On each `event: timeout` boundary this
7497
+ * also makes one authoritative {@link readiness} check before deciding to
7498
+ * reconnect or stop, to catch a failure that landed right at the boundary.
7482
7499
  *
7483
7500
  * If the SSE endpoint returns 404 (server pre-dates the streaming
7484
7501
  * endpoint) this transparently falls back to polling {@link readiness}
7485
- * every 10 ms until ready, timeout, or terminal failure.
7502
+ * every 10 ms until ready, the caller's deadline elapses, or terminal
7503
+ * failure.
7486
7504
  */
7487
7505
  waitUntilReady(options?: {
7488
7506
  timeout?: number;
@@ -7513,6 +7531,15 @@ declare class Sandbox {
7513
7531
  * stream endpoint is unavailable (404 or transport error) so callers
7514
7532
  * can fall back to polling.
7515
7533
  */
7534
+ /**
7535
+ * Opens one readiness SSE connection for at most *windowSec* seconds (this
7536
+ * is a single lease, not the caller's overall {@link waitUntilReady}
7537
+ * budget — see there for how leases are chained). *windowSec* is sent to
7538
+ * the server as `?timeout=<seconds>` so the server's own stream deadline
7539
+ * matches what was actually requested instead of always falling back to
7540
+ * its 30 s default, which is what let a 180 s `waitUntilReady` call get
7541
+ * cut short at 30 s.
7542
+ */
7516
7543
  private tryReadinessStream;
7517
7544
  destroy(): Promise<void>;
7518
7545
  delete(): Promise<void>;
package/dist/index.js CHANGED
@@ -174,7 +174,7 @@ var TokenRefreshFailedError = class extends MiosaError {
174
174
  };
175
175
 
176
176
  // src/version.ts
177
- var SDK_VERSION = "3.2.2";
177
+ var SDK_VERSION = "3.2.4";
178
178
  var SDK_USER_AGENT = `@miosa/sdk/${SDK_VERSION}`;
179
179
 
180
180
  // src/http.ts
@@ -271,8 +271,12 @@ var HttpClient = class {
271
271
  fetchBody = JSON.stringify(body5);
272
272
  }
273
273
  let lastError;
274
- for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
274
+ const maxRetries = options.maxRetries ?? this.maxRetries;
275
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
276
+ options.signal?.throwIfAborted();
275
277
  const controller = new AbortController();
278
+ const cancel = () => controller.abort(options.signal?.reason);
279
+ options.signal?.addEventListener("abort", cancel, { once: true });
276
280
  const timer = setTimeout(() => controller.abort(), timeout);
277
281
  try {
278
282
  const response = await fetch(this.buildUrl(path), {
@@ -296,7 +300,7 @@ var HttpClient = class {
296
300
  errorBody,
297
301
  requestId
298
302
  );
299
- if (RETRY_STATUS.has(response.status) && attempt < this.maxRetries) {
303
+ if (RETRY_STATUS.has(response.status) && attempt < maxRetries) {
300
304
  lastError = error;
301
305
  await sleep(retryDelay(attempt, retryAfter));
302
306
  continue;
@@ -313,7 +317,7 @@ var HttpClient = class {
313
317
  if (options.rawResponse) return response;
314
318
  return await response.json();
315
319
  } catch (err) {
316
- clearTimeout(timer);
320
+ options.signal?.throwIfAborted();
317
321
  if (err instanceof MiosaError) {
318
322
  throw err;
319
323
  }
@@ -327,7 +331,7 @@ var HttpClient = class {
327
331
  `Network error reaching ${path}: ${err.message}`,
328
332
  err
329
333
  );
330
- if (attempt < this.maxRetries) {
334
+ if (attempt < maxRetries) {
331
335
  lastError = networkErr;
332
336
  await sleep(retryDelay(attempt, null));
333
337
  continue;
@@ -335,6 +339,9 @@ var HttpClient = class {
335
339
  throw networkErr;
336
340
  }
337
341
  throw err;
342
+ } finally {
343
+ clearTimeout(timer);
344
+ options.signal?.removeEventListener("abort", cancel);
338
345
  }
339
346
  }
340
347
  throw lastError ?? new MiosaError(
@@ -394,44 +401,52 @@ var HttpClient = class {
394
401
  headers = { ...headers, "Content-Type": "application/json" };
395
402
  body5 = JSON.stringify(options.body);
396
403
  }
404
+ options.signal?.throwIfAborted();
397
405
  const controller = new AbortController();
398
- let response;
406
+ const cancel = () => controller.abort(options.signal?.reason);
407
+ options.signal?.addEventListener("abort", cancel, { once: true });
408
+ const deadline = options.timeout === void 0 ? void 0 : new TimeoutError(
409
+ `Stream ${path} timed out after ${options.timeout}ms`
410
+ );
411
+ const timer = deadline === void 0 ? void 0 : setTimeout(() => controller.abort(deadline), options.timeout);
412
+ let reader;
399
413
  try {
400
- response = await fetch(`${this.baseUrl}${path}`, {
401
- method,
402
- headers,
403
- body: body5,
404
- signal: controller.signal
405
- });
406
- } catch (err) {
407
- if (err instanceof TypeError) {
408
- throw new NetworkError(
409
- `Network error opening SSE stream: ${err.message}`,
410
- err
414
+ let response;
415
+ try {
416
+ response = await fetch(this.buildUrl(path), {
417
+ method,
418
+ headers,
419
+ body: body5,
420
+ signal: controller.signal
421
+ });
422
+ } catch (err) {
423
+ if (err instanceof TypeError) {
424
+ throw new NetworkError(
425
+ `Network error opening SSE stream: ${err.message}`,
426
+ err
427
+ );
428
+ }
429
+ throw err;
430
+ }
431
+ if (!response.ok) {
432
+ let errorBody = {};
433
+ try {
434
+ errorBody = await response.json();
435
+ } catch {
436
+ }
437
+ throw MiosaError.fromResponse(
438
+ response.status,
439
+ errorBody,
440
+ response.headers.get("x-request-id") ?? void 0
411
441
  );
412
442
  }
413
- throw err;
414
- }
415
- if (!response.ok) {
416
- let errorBody = {};
417
- try {
418
- errorBody = await response.json();
419
- } catch {
443
+ if (!response.body) {
444
+ throw new MiosaError("SSE stream has no body", 0, "NO_STREAM_BODY");
420
445
  }
421
- throw MiosaError.fromResponse(
422
- response.status,
423
- errorBody,
424
- response.headers.get("x-request-id") ?? void 0
425
- );
426
- }
427
- if (!response.body) {
428
- throw new MiosaError("SSE stream has no body", 0, "NO_STREAM_BODY");
429
- }
430
- const reader = response.body.getReader();
431
- const decoder = new TextDecoder();
432
- let buffer = "";
433
- let event = null;
434
- try {
446
+ reader = response.body.getReader();
447
+ const decoder = new TextDecoder();
448
+ let buffer = "";
449
+ let event = null;
435
450
  while (true) {
436
451
  const { done, value } = await reader.read();
437
452
  if (done) break;
@@ -459,9 +474,17 @@ var HttpClient = class {
459
474
  }
460
475
  }
461
476
  }
477
+ } catch (err) {
478
+ options.signal?.throwIfAborted();
479
+ if (deadline !== void 0 && controller.signal.reason === deadline) {
480
+ throw deadline;
481
+ }
482
+ throw err;
462
483
  } finally {
484
+ clearTimeout(timer);
485
+ options.signal?.removeEventListener("abort", cancel);
463
486
  controller.abort();
464
- reader.releaseLock();
487
+ reader?.releaseLock();
465
488
  }
466
489
  }
467
490
  /**
@@ -8494,6 +8517,7 @@ function isCommandReady(payload) {
8494
8517
  return status === "ready";
8495
8518
  });
8496
8519
  }
8520
+ var READINESS_STREAM_MAX_TIMEOUT_SEC = 120;
8497
8521
  var SANDBOX_TEMPLATE = "miosa-sandbox";
8498
8522
  var SANDBOX_TIER_BY_CPU = {
8499
8523
  1: { size: "xs", memoryMb: 2048 },
@@ -8628,6 +8652,16 @@ function raiseUnresolvableShape(cpuCount, memoryMb, tier) {
8628
8652
  `Unsupported cpu/memory combination (${supplied}). Supported tiers: xs (1/2048), small (2/4096), medium (4/8192), large (8/16384), xl (16/32768). Pass a matching cpuCount + memoryMb (any diskSizeMb is allowed), use size, or supply cpuCount, memoryMb, and diskSizeMb together for a custom shape.`
8629
8653
  );
8630
8654
  }
8655
+ function execDeadline(options) {
8656
+ const seconds = options?.timeout ?? options?.timeoutSec ?? options?.timeout_sec;
8657
+ if (seconds === void 0) return {};
8658
+ if (!Number.isFinite(seconds) || seconds <= 0 || seconds > 86400) {
8659
+ throw new RangeError(
8660
+ "Exec timeout must be greater than zero and at most 86400 seconds"
8661
+ );
8662
+ }
8663
+ return { timeout: seconds * 1e3 + 5e3 };
8664
+ }
8631
8665
  function execBody(command, options = {}) {
8632
8666
  return stripUndefined26({
8633
8667
  command,
@@ -9087,10 +9121,17 @@ var Sandbox = class _Sandbox {
9087
9121
  }
9088
9122
  async runExec(command, options) {
9089
9123
  this.assertRunning("exec");
9124
+ if (options?.signal) return this.runCancellableExec(command, options);
9090
9125
  const response = unwrap48(
9091
- await this.http.post(
9126
+ await this.http.request(
9092
9127
  `/sandboxes/${this.id}/exec`,
9093
- execBody(command, options)
9128
+ {
9129
+ method: "POST",
9130
+ body: execBody(command, options),
9131
+ signal: options?.signal,
9132
+ maxRetries: 0,
9133
+ ...execDeadline(options)
9134
+ }
9094
9135
  )
9095
9136
  );
9096
9137
  const exitCode = Number(response.exit_code ?? response.exitCode ?? 0);
@@ -9106,13 +9147,42 @@ var Sandbox = class _Sandbox {
9106
9147
  }
9107
9148
  return result;
9108
9149
  }
9150
+ async runCancellableExec(command, options) {
9151
+ let stdout = "";
9152
+ let stderr = "";
9153
+ let exitCode;
9154
+ for await (const event of this.execStream(command, options)) {
9155
+ if (event.type === "stdout" && typeof event.line === "string")
9156
+ stdout += event.line;
9157
+ if (event.type === "stderr" && typeof event.line === "string")
9158
+ stderr += event.line;
9159
+ if (event.type === "exit" && typeof event.exit_code === "number") {
9160
+ exitCode = event.exit_code;
9161
+ break;
9162
+ }
9163
+ }
9164
+ options.signal?.throwIfAborted();
9165
+ if (exitCode === void 0) {
9166
+ throw new MiosaError(
9167
+ "Execution stream ended without a terminal result",
9168
+ 502,
9169
+ "EXEC_STREAM_INCOMPLETE",
9170
+ void 0,
9171
+ void 0,
9172
+ false
9173
+ );
9174
+ }
9175
+ return { stdout, stderr, exitCode, exit_code: exitCode };
9176
+ }
9109
9177
  execStream(command, options) {
9110
9178
  this.assertRunning("exec.stream");
9111
9179
  const frames = this.http.streamFrames(
9112
9180
  `/sandboxes/${this.id}/exec/stream`,
9113
9181
  {
9114
9182
  method: "POST",
9115
- body: execBody(command, options)
9183
+ body: execBody(command, options),
9184
+ signal: options?.signal,
9185
+ ...execDeadline(options)
9116
9186
  }
9117
9187
  );
9118
9188
  return (async function* () {
@@ -9496,47 +9566,66 @@ var Sandbox = class _Sandbox {
9496
9566
  );
9497
9567
  }
9498
9568
  /**
9499
- * Block until the sandbox reports ready, or *timeout* seconds elapse.
9569
+ * Block until the sandbox reports ready, or the caller's *timeout* seconds
9570
+ * (the full deadline budget) elapse.
9500
9571
  *
9501
- * When `stream` is `true` (the default) this opens an SSE connection
9502
- * to `GET /sandboxes/:id/readiness/stream` and waits for an
9503
- * `event: ready` frame. The server emits `ready` immediately if the
9504
- * sandbox is already ready, otherwise as soon as the readiness PubSub
9505
- * message fires.
9572
+ * When `stream` is `true` (the default) this opens an SSE connection to
9573
+ * `GET /sandboxes/:id/readiness/stream?timeout=<window>` and waits for an
9574
+ * `event: ready` frame. `<window>` is the caller's remaining budget clamped
9575
+ * to {@link READINESS_STREAM_MAX_TIMEOUT_SEC}, the server's own maximum
9576
+ * lease for a single stream connection — a long-restoring persistent
9577
+ * sandbox (e.g. Docker restore, 60-90 s) legitimately outlives one lease.
9578
+ * The server emits `ready` immediately if the sandbox is already ready,
9579
+ * `event: timeout` when *that connection's* window elapses without a
9580
+ * verdict, and `event: error` on a terminal boot failure.
9506
9581
  *
9507
- * Returns `true` once the sandbox is ready, `false` on `event: timeout`
9508
- * or when the local timeout elapses before ready.
9582
+ * An `event: timeout` (or the stream closing without a verdict) is NOT
9583
+ * treated as the final answer while the caller's overall deadline still has
9584
+ * budget left — that would silently truncate a 180 s wait down to the
9585
+ * server's lease window and return `false` while the sandbox was still
9586
+ * mid-restore. Instead this reconnects the stream (or falls back to
9587
+ * polling) for the remaining budget. `false` is only returned once the
9588
+ * caller's full deadline has elapsed.
9509
9589
  *
9510
9590
  * Throws {@link MiosaError} with code `SANDBOX_BOOT_FAILED` as soon as the
9511
9591
  * sandbox is observed in a terminal failure state (`error` or `destroyed`)
9512
9592
  * while waiting, instead of silently exhausting the timeout and returning
9513
9593
  * `false` — a permanent boot failure and "still booting, keep waiting" must
9514
- * never look the same to the caller. The readiness SSE stream itself has no
9515
- * dedicated failure event today (the server only ever emits
9516
- * `ready`/`status`/`timeout`), so on `event: timeout` this makes one
9517
- * authoritative {@link readiness} check before giving up, to catch a
9518
- * failure that landed right at the boundary.
9594
+ * never look the same to the caller. On each `event: timeout` boundary this
9595
+ * also makes one authoritative {@link readiness} check before deciding to
9596
+ * reconnect or stop, to catch a failure that landed right at the boundary.
9519
9597
  *
9520
9598
  * If the SSE endpoint returns 404 (server pre-dates the streaming
9521
9599
  * endpoint) this transparently falls back to polling {@link readiness}
9522
- * every 10 ms until ready, timeout, or terminal failure.
9600
+ * every 10 ms until ready, the caller's deadline elapses, or terminal
9601
+ * failure.
9523
9602
  */
9524
9603
  async waitUntilReady(options = {}) {
9525
9604
  const timeout = options.timeout ?? 30;
9526
9605
  const stream = options.stream ?? true;
9527
9606
  const requireCommandReady = options.requireCommandReady ?? true;
9607
+ const deadlineMs = Date.now() + timeout * 1e3;
9528
9608
  if (stream) {
9529
- const sseResult = await this.tryReadinessStream(timeout);
9530
- if (sseResult === true) {
9531
- await this.adoptReadyState();
9532
- return true;
9533
- }
9534
- if (sseResult === false) {
9535
- await this.throwIfTerminallyFailed();
9536
- return false;
9609
+ while (true) {
9610
+ const remainingMs = deadlineMs - Date.now();
9611
+ if (remainingMs <= 0) return false;
9612
+ const remainingSec = remainingMs / 1e3;
9613
+ const clamped = remainingSec > READINESS_STREAM_MAX_TIMEOUT_SEC;
9614
+ const windowSec = clamped ? READINESS_STREAM_MAX_TIMEOUT_SEC : remainingSec;
9615
+ const sseResult = await this.tryReadinessStream(windowSec);
9616
+ if (sseResult === true) {
9617
+ await this.adoptReadyState();
9618
+ return true;
9619
+ }
9620
+ if (sseResult === false) {
9621
+ await this.throwIfTerminallyFailed();
9622
+ if (!clamped || Date.now() >= deadlineMs) return false;
9623
+ continue;
9624
+ }
9625
+ break;
9537
9626
  }
9627
+ if (Date.now() >= deadlineMs) return false;
9538
9628
  }
9539
- const deadlineMs = Date.now() + timeout * 1e3;
9540
9629
  while (Date.now() < deadlineMs) {
9541
9630
  try {
9542
9631
  const data = await this.readiness();
@@ -9600,9 +9689,25 @@ var Sandbox = class _Sandbox {
9600
9689
  * stream endpoint is unavailable (404 or transport error) so callers
9601
9690
  * can fall back to polling.
9602
9691
  */
9603
- async tryReadinessStream(timeoutSec) {
9692
+ /**
9693
+ * Opens one readiness SSE connection for at most *windowSec* seconds (this
9694
+ * is a single lease, not the caller's overall {@link waitUntilReady}
9695
+ * budget — see there for how leases are chained). *windowSec* is sent to
9696
+ * the server as `?timeout=<seconds>` so the server's own stream deadline
9697
+ * matches what was actually requested instead of always falling back to
9698
+ * its 30 s default, which is what let a 180 s `waitUntilReady` call get
9699
+ * cut short at 30 s.
9700
+ */
9701
+ async tryReadinessStream(windowSec) {
9702
+ const clampedWindowSec = Math.max(
9703
+ 1,
9704
+ Math.min(windowSec, READINESS_STREAM_MAX_TIMEOUT_SEC)
9705
+ );
9604
9706
  const abort = new AbortController();
9605
- const timer = setTimeout(() => abort.abort(), (timeoutSec + 5) * 1e3);
9707
+ const timer = setTimeout(
9708
+ () => abort.abort(),
9709
+ (clampedWindowSec + 5) * 1e3
9710
+ );
9606
9711
  try {
9607
9712
  const headers = {
9608
9713
  Authorization: `Bearer ${this.http.apiKey}`,
@@ -9614,7 +9719,7 @@ var Sandbox = class _Sandbox {
9614
9719
  "User-Agent": SDK_USER_AGENT
9615
9720
  };
9616
9721
  const response = await fetch(
9617
- `${this.http.baseUrl}/sandboxes/${this.id}/readiness/stream`,
9722
+ `${this.http.baseUrl}/sandboxes/${this.id}/readiness/stream?timeout=${clampedWindowSec}`,
9618
9723
  { method: "GET", headers, signal: abort.signal }
9619
9724
  );
9620
9725
  if (response.status === 404) return null;
@@ -9635,6 +9740,7 @@ var Sandbox = class _Sandbox {
9635
9740
  const evt = line.slice(6).trim();
9636
9741
  if (evt === "ready") return true;
9637
9742
  if (evt === "timeout") return false;
9743
+ if (evt === "error") throw this.bootFailedError("error");
9638
9744
  }
9639
9745
  }
9640
9746
  }
@@ -9645,7 +9751,10 @@ var Sandbox = class _Sandbox {
9645
9751
  }
9646
9752
  }
9647
9753
  return null;
9648
- } catch {
9754
+ } catch (err) {
9755
+ if (err instanceof MiosaError && err.code === "SANDBOX_BOOT_FAILED") {
9756
+ throw err;
9757
+ }
9649
9758
  return null;
9650
9759
  } finally {
9651
9760
  clearTimeout(timer);
@@ -9656,12 +9765,21 @@ var Sandbox = class _Sandbox {
9656
9765
  }
9657
9766
  }
9658
9767
  async destroy() {
9659
- if (this.data.state === "destroyed") return;
9660
- await this.http.delete(`/sandboxes/${this.id}`);
9768
+ if (this.data.state === "destroyed" || this.data.state === "destroying") {
9769
+ return;
9770
+ }
9771
+ const receipt = unwrap48(
9772
+ await this.http.delete(
9773
+ `/sandboxes/${this.id}`
9774
+ )
9775
+ ) ?? {};
9776
+ const settled = receipt.state === "destroyed" || receipt.state === void 0 && receipt.operation_id == null;
9661
9777
  this.data = {
9662
9778
  ...this.data,
9663
- state: "destroyed",
9664
- destroyed_at: (/* @__PURE__ */ new Date()).toISOString()
9779
+ state: settled ? "destroyed" : "destroying",
9780
+ ready: false,
9781
+ operation_id: receipt.operation_id ?? this.data.operation_id ?? null,
9782
+ destroyed_at: receipt.destroyed_at ?? this.data.destroyed_at ?? null
9665
9783
  };
9666
9784
  }
9667
9785
  delete() {